headless_lms_credit_registration/use_cases/
account_linking.rs1use secrecy::{ExposeSecret, SecretString};
9
10use headless_lms_models::course_module_suotar_configurations::get_active_modules_for_course;
11use headless_lms_models::library::credit_registration::account_linking::{
12 DiscoveredPerson, LinkingMailClaim, RetiredMails, claim_linking_mail, retire_capped_mails,
13};
14use headless_lms_models::library::credit_registration::study_registry::RosterPerson;
15use headless_lms_models::verified_student_numbers;
16use headless_lms_utils::prelude::{DateTime, Utc};
17use sqlx::{Connection, PgPool};
18use std::collections::BTreeSet;
19use uuid::Uuid;
20
21use crate::error::CreditRegistrationResult;
22use crate::registry::{CourseCode, InteractiveStudyRegistry, StudentNumber};
23use crate::use_cases::enrolment_discovery::{list_unlinked, list_unlinked_enrolled_before};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ResendOutcome {
28 Queued,
30 AlreadyMailedToEveryKnownAddress,
32 RefusedByRateCap,
34 JustSent,
36 NoAddressInStudyRegistry,
37 NotOnTheCourseRoster,
39 AlreadyLinked,
41 StudyRegistryUnavailable,
43}
44
45pub struct ResendAttempt {
47 pub outcome: ResendOutcome,
48 pub retired_mail_count: i64,
50 pub emailed_to: Option<SecretString>,
52}
53
54pub struct RateCapOverride<'a> {
57 pub actor_user_id: Uuid,
58 pub actor_role: &'a str,
59 pub reason: &'a str,
60}
61
62pub(crate) async fn resend_for_target<R: InteractiveStudyRegistry>(
64 pool: &PgPool,
65 registry: &R,
66 course_id: Uuid,
67 student_number: &SecretString,
68 rate_cap_override: Option<RateCapOverride<'_>>,
69) -> CreditRegistrationResult<ResendAttempt> {
70 let already_linked = {
71 let mut conn = pool.acquire().await?;
72 verified_student_numbers::get_by_student_number(&mut conn, student_number.expose_secret())
73 .await?
74 .is_some()
75 };
76 if already_linked {
77 return Ok(ResendAttempt {
78 outcome: ResendOutcome::AlreadyLinked,
79 retired_mail_count: 0,
80 emailed_to: None,
81 });
82 }
83 resend_linking_mail(
84 pool,
85 registry,
86 course_id,
87 student_number,
88 rate_cap_override.as_ref(),
89 )
90 .await
91}
92
93async fn resend_linking_mail<R: InteractiveStudyRegistry>(
98 pool: &PgPool,
99 registry: &R,
100 course_id: Uuid,
101 student_number: &SecretString,
102 rate_cap_override: Option<&RateCapOverride<'_>>,
103) -> CreditRegistrationResult<ResendAttempt> {
104 let not_retired = |outcome| ResendAttempt {
105 outcome,
106 retired_mail_count: 0,
107 emailed_to: None,
108 };
109 let course_codes = active_course_codes(pool, course_id).await?;
110 if course_codes.is_empty() {
111 return Ok(not_retired(ResendOutcome::NotOnTheCourseRoster));
112 }
113
114 let search = registry
115 .search_course_rosters(&course_codes, &StudentNumber::new(student_number.clone()))
116 .await;
117 let Some(person) = search.person else {
118 return Ok(not_retired(if search.has_unanswered_code {
119 ResendOutcome::StudyRegistryUnavailable
120 } else {
121 ResendOutcome::NotOnTheCourseRoster
122 }));
123 };
124
125 let discovered = DiscoveredPerson::listed(&person, course_id);
126 if discovered.address.is_none() {
127 return Ok(not_retired(ResendOutcome::NoAddressInStudyRegistry));
128 }
129
130 let mut conn = pool.acquire().await?;
131 let mut tx = conn.begin().await?;
132 let retired = match rate_cap_override {
133 Some(rate_cap_override) => {
134 retire_capped_mails(
135 &mut tx,
136 rate_cap_override.actor_user_id,
137 rate_cap_override.actor_role,
138 course_id,
139 student_number.expose_secret(),
140 rate_cap_override.reason,
141 )
142 .await?
143 }
144 None => RetiredMails {
145 retired_count: 0,
146 kept_just_claimed: false,
147 },
148 };
149 let claim = claim_linking_mail(&mut tx, &discovered).await?;
150 tx.commit().await?;
151 debug!(course_id = %course_id, ?claim, "Linking mail resend claim result");
152 let outcome = match claim {
153 LinkingMailClaim::Claimed => ResendOutcome::Queued,
154 _ if retired.kept_just_claimed => ResendOutcome::JustSent,
155 LinkingMailClaim::SuppressedByRateCap => ResendOutcome::RefusedByRateCap,
156 LinkingMailClaim::SuppressedByDedup => ResendOutcome::AlreadyMailedToEveryKnownAddress,
157 LinkingMailClaim::NoAddress => ResendOutcome::NoAddressInStudyRegistry,
158 };
159 let emailed_to = (outcome == ResendOutcome::Queued)
160 .then(|| discovered.address.clone().map(SecretString::from))
161 .flatten();
162 Ok(ResendAttempt {
163 outcome,
164 retired_mail_count: retired.retired_count,
165 emailed_to,
166 })
167}
168
169async fn active_course_codes(
171 pool: &PgPool,
172 course_id: Uuid,
173) -> CreditRegistrationResult<Vec<CourseCode>> {
174 let mut conn = pool.acquire().await?;
175 Ok(get_active_modules_for_course(&mut conn, course_id)
176 .await?
177 .iter()
178 .filter_map(|module| CourseCode::parse(&module.uh_course_code))
179 .collect::<BTreeSet<_>>()
180 .into_iter()
181 .collect())
182}
183
184pub(crate) async fn unlinked_on_course_rosters<R: InteractiveStudyRegistry>(
186 pool: &PgPool,
187 registry: &R,
188 course_id: Uuid,
189) -> CreditRegistrationResult<Option<Vec<RosterPerson>>> {
190 let mut people = Vec::new();
191 for code in active_course_codes(pool, course_id).await? {
192 let Some(listed) = registry.fetch_course_roster(&code).await else {
193 return Ok(None);
194 };
195 people.extend(listed);
196 }
197 let mut conn = pool.acquire().await?;
198 Ok(Some(list_unlinked(&mut conn, &people).await?))
199}
200
201pub(crate) async fn unlinked_enrolled_before<R: InteractiveStudyRegistry>(
203 pool: &PgPool,
204 registry: &R,
205 course_code: &str,
206 since: DateTime<Utc>,
207) -> CreditRegistrationResult<Option<Vec<RosterPerson>>> {
208 let Some(code) = CourseCode::parse(course_code) else {
209 return Ok(Some(Vec::new()));
210 };
211 let Some(people) = registry.fetch_course_roster(&code).await else {
212 return Ok(None);
213 };
214 let mut conn = pool.acquire().await?;
215 Ok(Some(
216 list_unlinked_enrolled_before(&mut conn, &people, since).await?,
217 ))
218}