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 ClaimedLinkingMails, DiscoveredPerson, claim_linking_mails, retire_capped_mails,
13};
14use headless_lms_models::verified_student_numbers;
15use sqlx::{Connection, PgPool};
16use std::collections::BTreeSet;
17use uuid::Uuid;
18
19use crate::error::CreditRegistrationResult;
20use crate::registry::{CourseCode, InteractiveStudyRegistry, StudentNumber};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum ResendOutcome {
25 Queued,
27 AlreadyMailedToEveryKnownAddress,
29 RefusedByRateCap,
31 NoAddressInStudyRegistry,
32 NotOnTheCourseRoster,
34 AlreadyLinked,
36 StudyRegistryUnavailable,
38}
39
40pub struct ResendAttempt {
42 pub outcome: ResendOutcome,
43 pub retired_mail_count: i64,
45}
46
47pub struct RateCapOverride<'a> {
50 pub actor_user_id: Uuid,
51 pub actor_role: &'a str,
52 pub reason: &'a str,
53}
54
55pub(crate) async fn resend_for_target<R: InteractiveStudyRegistry>(
57 pool: &PgPool,
58 registry: &R,
59 course_id: Uuid,
60 student_number: &SecretString,
61 rate_cap_override: Option<RateCapOverride<'_>>,
62) -> CreditRegistrationResult<ResendAttempt> {
63 let already_linked = {
64 let mut conn = pool.acquire().await?;
65 verified_student_numbers::get_by_student_number(&mut conn, student_number.expose_secret())
66 .await?
67 .is_some()
68 };
69 if already_linked {
70 return Ok(ResendAttempt {
71 outcome: ResendOutcome::AlreadyLinked,
72 retired_mail_count: 0,
73 });
74 }
75 resend_linking_mail(
76 pool,
77 registry,
78 course_id,
79 student_number,
80 rate_cap_override.as_ref(),
81 )
82 .await
83}
84
85async fn resend_linking_mail<R: InteractiveStudyRegistry>(
90 pool: &PgPool,
91 registry: &R,
92 course_id: Uuid,
93 student_number: &SecretString,
94 rate_cap_override: Option<&RateCapOverride<'_>>,
95) -> CreditRegistrationResult<ResendAttempt> {
96 let not_retired = |outcome| ResendAttempt {
97 outcome,
98 retired_mail_count: 0,
99 };
100 let course_codes: Vec<CourseCode> = {
101 let mut conn = pool.acquire().await?;
102 get_active_modules_for_course(&mut conn, course_id)
103 .await?
104 .iter()
105 .filter_map(|module| CourseCode::parse(&module.uh_course_code))
106 .collect::<BTreeSet<_>>()
107 .into_iter()
108 .collect()
109 };
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.addresses.is_empty() {
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_mail_count = 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 => 0,
145 };
146 let ClaimedLinkingMails {
147 claimed,
148 suppressed_by_dedup,
149 suppressed_by_rate_cap,
150 } = claim_linking_mails(&mut tx, &discovered).await?;
151 tx.commit().await?;
152 debug!(
153 course_id = %course_id,
154 claimed,
155 suppressed_by_dedup,
156 suppressed_by_rate_cap,
157 "Linking mail resend claim result"
158 );
159 let outcome = if claimed > 0 {
160 ResendOutcome::Queued
161 } else if suppressed_by_rate_cap > 0 {
162 ResendOutcome::RefusedByRateCap
163 } else if suppressed_by_dedup > 0 {
164 ResendOutcome::AlreadyMailedToEveryKnownAddress
165 } else {
166 ResendOutcome::NoAddressInStudyRegistry
167 };
168 Ok(ResendAttempt {
169 outcome,
170 retired_mail_count,
171 })
172}