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_mails`], 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    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/// What one resend attempt came to.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum ResendOutcome {
25    /// A slot was claimed; the `link-emails` phase queues the message on its next run.
26    Queued,
27    /// Every address the study registry holds for them has already had its mail for this course.
28    AlreadyMailedToEveryKnownAddress,
29    /// A cap refused it: either the quiet period or the per-course lifetime limit.
30    RefusedByRateCap,
31    NoAddressInStudyRegistry,
32    /// The study registry does not list them under any course code of this course.
33    NotOnTheCourseRoster,
34    /// The number is already linked to an account, so no linking mail is owed.
35    AlreadyLinked,
36    /// We could not ask the study registry, so nothing was decided.
37    StudyRegistryUnavailable,
38}
39
40/// The outcome of one resend attempt, plus how many capped mails an override retired to get there.
41pub struct ResendAttempt {
42    pub outcome: ResendOutcome,
43    /// Always zero without an override; only the admin-facing endpoint can pass one.
44    pub retired_mail_count: i64,
45}
46
47/// An admin's go-ahead to get past the linking-mail caps by retiring the mails they count; see
48/// [`retire_capped_mails`].
49pub struct RateCapOverride<'a> {
50    pub actor_user_id: Uuid,
51    pub actor_role: &'a str,
52    pub reason: &'a str,
53}
54
55/// [`crate::account_linking::resend_linking_mail_for_target`] through `registry`.
56pub(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
85/// Claims a linking mail for the one person on the course's roster with this student number.
86///
87/// The override retires the capped mails only once the registry has named an address to mail, in
88/// the claim's transaction, so a lookup that decides nothing leaves the caps in place.
89async 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}