Skip to main content

headless_lms_credit_registration/use_cases/
account_linking.rs

1//! The account-linking action an admin or a teacher sets off by hand: re-running the linking mail
2//! send path for one person on one course. Not a phase: no schedule and no allowance, and no
3//! breaker learns from its calls, so one click cannot trip the workers'.
4//!
5//! The addresses come from the study registry rather than the ledger, and the claim goes through
6//! [`claim_linking_mail`], so the caps and dedup guard apply exactly as they do to the worker.
7
8use 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/// What one resend attempt came to.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ResendOutcome {
28    /// A slot was claimed; the `link-emails` phase queues the message on its next run.
29    Queued,
30    /// Every address the study registry holds for them has already had its mail for this course.
31    AlreadyMailedToEveryKnownAddress,
32    /// A cap refused it: either the quiet period or the per-course lifetime limit.
33    RefusedByRateCap,
34    /// An override left a mail claimed moments ago in place, and the caps counting it refused.
35    JustSent,
36    NoAddressInStudyRegistry,
37    /// The study registry does not list them under any course code of this course.
38    NotOnTheCourseRoster,
39    /// The number is already linked to an account, so no linking mail is owed.
40    AlreadyLinked,
41    /// We could not ask the study registry, so nothing was decided.
42    StudyRegistryUnavailable,
43}
44
45/// The outcome of one resend attempt, plus how many capped mails an override retired to get there.
46pub struct ResendAttempt {
47    pub outcome: ResendOutcome,
48    /// Always zero without an override; only the admin-facing endpoint can pass one.
49    pub retired_mail_count: i64,
50    /// Where the queued mail goes; `None` unless `outcome` is `Queued`.
51    pub emailed_to: Option<SecretString>,
52}
53
54/// An admin's go-ahead to get past the linking-mail caps by retiring the mails they count; see
55/// [`retire_capped_mails`].
56pub struct RateCapOverride<'a> {
57    pub actor_user_id: Uuid,
58    pub actor_role: &'a str,
59    pub reason: &'a str,
60}
61
62/// [`crate::account_linking::resend_linking_mail_for_target`] through `registry`.
63pub(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
93/// Claims a linking mail for the one person on the course's roster with this student number.
94///
95/// The override retires the capped mails only once the registry has named an address to mail, in
96/// the claim's transaction, so a lookup that decides nothing leaves the caps in place.
97async 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
169/// The distinct course codes of the course's modules that take part in credit registration.
170async 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
184/// [`crate::account_linking::list_unlinked_on_course_rosters`] through `registry`.
185pub(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
201/// [`crate::account_linking::list_unlinked_enrolled_before`] through `registry`.
202pub(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}