Skip to main content

headless_lms_server/domain/credit_registration_phases/
linking_mail_resend.rs

1//! Re-running the account-linking send path for one person on one course.
2//!
3//! Not a phase: no schedule, no heartbeat, no circuit-breaker bookkeeping — one manual click must not
4//! be able to trip the workers' breaker. The addresses come from the study registry rather than the
5//! ledger, and the claim goes through [`claim_linking_mails`], so the caps and dedup guard apply
6//! exactly as they do to the worker.
7
8use std::future::Future;
9use std::pin::Pin;
10
11use serde::{Deserialize, Serialize};
12use utoipa::ToSchema;
13
14use headless_lms_models::course_module_suotar_realisations::{
15    get_active_for_course, listing_request_item_id,
16};
17use headless_lms_models::library::credit_registration::account_linking::{
18    ClaimedLinkingMails, DiscoveredPerson, claim_linking_mails,
19};
20use headless_lms_models::verified_student_numbers;
21use headless_lms_utils::services::suotar::{
22    ListByCourseRequestItem, ResolvePersonRequestItem, SuotarCallContext, SuotarEndpoint,
23    SuotarItemStatus,
24};
25use uuid::Uuid;
26
27use super::{CreditRegistrationPhase, PhaseContext, listed_person_addresses, worker_name};
28
29/// What one resend attempt did.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum LinkingMailResendOutcome {
32    /// A slot was claimed; the `link-emails` phase queues the message on its next run.
33    Claimed,
34    /// Every address the study registry holds for them has already had its mail for this course.
35    AlreadyMailedToEveryKnownAddress,
36    /// A cap refused it: either the quiet period or the per-course lifetime limit.
37    RefusedByRateCap,
38    NoAddressInStudyRegistry,
39    /// The study registry does not list them on any realisation of this course.
40    NotOnTheCourseRoster,
41    /// We could not ask the study registry, so nothing was decided.
42    StudyRegistryUnavailable,
43}
44
45/// Claims a linking mail for the one person on the course's roster with this student number.
46pub async fn resend_linking_mail(
47    ctx: &PhaseContext<'_>,
48    course_id: Uuid,
49    student_number: &str,
50) -> anyhow::Result<LinkingMailResendOutcome> {
51    let mut conn = ctx.pool.acquire().await?;
52    let realisations = get_active_for_course(&mut conn, course_id).await?;
53
54    let mut items = Vec::new();
55    for realisation in &realisations {
56        let Some(course_code) = super::enrolment_discovery::listable_course_code(realisation)
57        else {
58            continue;
59        };
60        items.push(ListByCourseRequestItem {
61            request_item_id: listing_request_item_id(realisation.id),
62            course_code,
63            course_unit_realisation_id: Some(realisation.course_unit_realisation_id.clone()),
64        });
65    }
66    if items.is_empty() {
67        return Ok(LinkingMailResendOutcome::NotOnTheCourseRoster);
68    }
69
70    // A course can hold more realisations than one `list-by-course` request may carry.
71    let mut person = None;
72    for chunk in items.chunks(SuotarEndpoint::ListByCourse.max_batch_size()) {
73        let response = ctx
74            .suotar_client
75            .list_enrolments_by_course(
76                SuotarCallContext::new(worker_name(
77                    ctx.caller,
78                    CreditRegistrationPhase::EnrolmentDiscovery,
79                )),
80                chunk.to_vec(),
81            )
82            .await;
83        let Ok(response) = response else {
84            return Ok(LinkingMailResendOutcome::StudyRegistryUnavailable);
85        };
86        person = response
87            .items
88            .iter()
89            .filter(|item| item.status == SuotarItemStatus::Ok)
90            .filter_map(|item| item.result.as_ref())
91            .flat_map(|result| result.people.iter())
92            .find(|candidate| candidate.student_number == student_number)
93            .cloned();
94        if person.is_some() {
95            break;
96        }
97    }
98    let Some(person) = person else {
99        return Ok(LinkingMailResendOutcome::NotOnTheCourseRoster);
100    };
101
102    let discovered = DiscoveredPerson {
103        sisu_person_id: person.person_id.clone(),
104        student_number: person.student_number.clone(),
105        first_names: Some(person.first_names.clone()),
106        last_name: Some(person.last_name.clone()),
107        course_id,
108        addresses: listed_person_addresses(&person),
109    };
110    if discovered.addresses.is_empty() {
111        return Ok(LinkingMailResendOutcome::NoAddressInStudyRegistry);
112    }
113
114    let ClaimedLinkingMails {
115        claimed,
116        suppressed_by_dedup,
117        suppressed_by_rate_cap,
118    } = claim_linking_mails(&mut conn, &discovered).await?;
119    if claimed > 0 {
120        return Ok(LinkingMailResendOutcome::Claimed);
121    }
122    if suppressed_by_rate_cap > 0 {
123        return Ok(LinkingMailResendOutcome::RefusedByRateCap);
124    }
125    if suppressed_by_dedup > 0 {
126        return Ok(LinkingMailResendOutcome::AlreadyMailedToEveryKnownAddress);
127    }
128    Ok(LinkingMailResendOutcome::NoAddressInStudyRegistry)
129}
130
131/// What [`resend_linking_mail_for_target`] decided.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum ResendDecision {
134    /// The number is already linked to an account, so no linking mail is owed.
135    AlreadyLinked,
136    Attempted(LinkingMailResendOutcome),
137}
138
139/// The outcome of one resend attempt, plus how many capped mails an override retired to get there.
140pub struct ResendAttempt {
141    pub decision: ResendDecision,
142    /// Always zero without an override; only the admin-facing endpoint can pass one.
143    pub retired_mail_count: i64,
144}
145
146/// What a resend endpoint tells its caller. Shared wire shape for the teacher- and admin-facing
147/// resend endpoints; `NoStudentNumberKnown` is only ever emitted by the teacher endpoint, whose
148/// target may be an account that has never held a number.
149#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
150#[serde(rename_all = "snake_case")]
151pub enum ResendOutcome {
152    /// A mail is owed and will be handed to the sender on the next run.
153    Queued,
154    AlreadyMailedToEveryKnownAddress,
155    /// A cap refused it.
156    RefusedByRateCap,
157    NoAddressInStudyRegistry,
158    NotOnTheCourseRoster,
159    /// The number is already linked to an account, so no linking mail is owed.
160    AlreadyLinked,
161    StudyRegistryUnavailable,
162    /// This account has never had a student number, so there is nothing to look up.
163    NoStudentNumberKnown,
164}
165
166impl From<ResendDecision> for ResendOutcome {
167    fn from(decision: ResendDecision) -> Self {
168        match decision {
169            ResendDecision::AlreadyLinked => Self::AlreadyLinked,
170            ResendDecision::Attempted(LinkingMailResendOutcome::Claimed) => Self::Queued,
171            ResendDecision::Attempted(
172                LinkingMailResendOutcome::AlreadyMailedToEveryKnownAddress,
173            ) => Self::AlreadyMailedToEveryKnownAddress,
174            ResendDecision::Attempted(LinkingMailResendOutcome::RefusedByRateCap) => {
175                Self::RefusedByRateCap
176            }
177            ResendDecision::Attempted(LinkingMailResendOutcome::NoAddressInStudyRegistry) => {
178                Self::NoAddressInStudyRegistry
179            }
180            ResendDecision::Attempted(LinkingMailResendOutcome::NotOnTheCourseRoster) => {
181                Self::NotOnTheCourseRoster
182            }
183            ResendDecision::Attempted(LinkingMailResendOutcome::StudyRegistryUnavailable) => {
184                Self::StudyRegistryUnavailable
185            }
186        }
187    }
188}
189
190/// Shared by the teacher- and admin-facing resend endpoints: refuses a target that is already linked,
191/// otherwise runs `before_send` (the admin path's rate-cap override; the teacher path passes a no-op)
192/// and reruns the send path exactly as the worker would.
193///
194/// `before_send` runs strictly after the already-linked check and before [`resend_linking_mail`], so an
195/// override never retires mails for a number that turns out to already be linked.
196pub async fn resend_linking_mail_for_target<'a>(
197    ctx: &PhaseContext<'_>,
198    course_id: Uuid,
199    student_number: &str,
200    before_send: Pin<Box<dyn Future<Output = anyhow::Result<i64>> + 'a>>,
201) -> anyhow::Result<ResendAttempt> {
202    let already_linked = {
203        let mut conn = ctx.pool.acquire().await?;
204        verified_student_numbers::get_by_student_number(&mut conn, student_number)
205            .await?
206            .is_some()
207    };
208    if already_linked {
209        return Ok(ResendAttempt {
210            decision: ResendDecision::AlreadyLinked,
211            retired_mail_count: 0,
212        });
213    }
214    let retired_mail_count = before_send.await?;
215    let decision =
216        ResendDecision::Attempted(resend_linking_mail(ctx, course_id, student_number).await?);
217    Ok(ResendAttempt {
218        decision,
219        retired_mail_count,
220    })
221}
222
223pub struct ResolvedPerson {
224    pub sisu_person_id: String,
225    pub first_names: String,
226    pub last_name: String,
227    /// The registry's own per-item code, an identifier rather than prose.
228    pub code: String,
229}
230
231/// Why [`resolve_person`] could not say whether the number exists. Both cases read the same to a
232/// caller: the registry could not be asked.
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub enum ResolvePersonError {
235    /// The request itself failed: network, auth, or a request-level error from the registry.
236    StudyRegistryUnavailable,
237    /// The registry answered but its response did not include this item.
238    ItemMissingFromResponse,
239}
240
241/// Looks one student number up in the study registry without changing anything: no ledger row, no
242/// claimed mail slot, just the call log row every study registry call writes.
243///
244/// `Ok(None)` means the registry answered and does not know the number; `Err` means we could not ask.
245pub async fn resolve_person(
246    ctx: &PhaseContext<'_>,
247    student_number: &str,
248) -> Result<Option<ResolvedPerson>, ResolvePersonError> {
249    let request_item_id = format!("admin-{student_number}");
250    let response = ctx
251        .suotar_client
252        .resolve_persons(
253            SuotarCallContext::new(ctx.caller),
254            vec![ResolvePersonRequestItem {
255                request_item_id: request_item_id.clone(),
256                student_number: student_number.to_string(),
257            }],
258        )
259        .await
260        .map_err(|_| ResolvePersonError::StudyRegistryUnavailable)?;
261    let Some(item) = response.item(&request_item_id) else {
262        return Err(ResolvePersonError::ItemMissingFromResponse);
263    };
264    let Some(result) = item.result.as_ref() else {
265        return Ok(None);
266    };
267    Ok(Some(ResolvedPerson {
268        sisu_person_id: result.person_id.clone(),
269        first_names: result.first_names.clone(),
270        last_name: result.last_name.clone(),
271        code: item.code.clone(),
272    }))
273}