Skip to main content

headless_lms_credit_registration/use_cases/resolve_enrolments/persons/
mod.rs

1//! The first half of a `resolve-enrolments` iteration: the Sisu person behind a registrar-reported
2//! link, which arrives with a student number only.
3//!
4//! Runs before the enrolment lookup freezes the payload, so the frozen row carries the person id
5//! that `uq_credit_registrations_person_module` and enrolment discovery key on. Rows wait out the
6//! call as the enrolment lookup's do (see [`super::Lookup`]), and a found person leaves the row
7//! claimable for the second half of the same iteration.
8
9mod decide;
10
11use std::collections::HashMap;
12
13use headless_lms_models::credit_registrations::claim_due_for_person_lookup;
14use headless_lms_models::secret::DbSecret;
15use headless_lms_models::{study_registry_student_number_conflicts, verified_student_numbers};
16use headless_lms_utils::prelude::Utc;
17use secrecy::ExposeSecret;
18use sqlx::PgConnection;
19use uuid::Uuid;
20
21use super::{claim_for_lookup, hold_in_flight, keep_lookups_in_flight};
22use crate::error::CreditRegistrationResult;
23use crate::registry::{
24    ExchangeAudit, FoundPerson, PersonAnswer, PersonLookup, PersonReading, StudentNumber,
25};
26use crate::use_cases::batch_flow::{BatchFlowContext, Prepared, RegistryBatchFlow};
27use crate::workflow::{Applied, Claimed, RefusalPolicy, write_decision};
28
29use decide::{PersonFill, refused_person_decision, unanswered_person_decision};
30
31pub(super) struct ResolvePersonIds;
32
33/// The account's live link, which has no Sisu person id yet.
34pub(super) struct LinkMissingPerson {
35    link_id: Uuid,
36}
37
38impl RegistryBatchFlow for ResolvePersonIds {
39    type Extra = LinkMissingPerson;
40    type Request = PersonLookup;
41
42    const ALL_UNAVAILABLE_ERROR: &'static str = "Every person lookup came back unavailable.";
43    const REFUSAL: RefusalPolicy<LinkMissingPerson> = RefusalPolicy::RequestLevel;
44
45    /// Claims the rows the enrolment lookup would and keeps only those whose link lacks a person
46    /// id; the others are left for the enrolment lookup.
47    async fn claim(
48        ctx: &BatchFlowContext<'_>,
49        conn: &mut PgConnection,
50        limit: usize,
51    ) -> CreditRegistrationResult<Prepared<LinkMissingPerson, PersonLookup>> {
52        let claimed =
53            claim_due_for_person_lookup(conn, ctx.scope, i64::try_from(limit).unwrap_or(i64::MAX))
54                .await?;
55        let user_ids: Vec<Uuid> = claimed.iter().map(|row| row.user_id).collect();
56        let links: HashMap<Uuid, _> = verified_student_numbers::get_by_user_ids(conn, &user_ids)
57            .await?
58            .into_iter()
59            .filter(|link| link.sisu_person_id.is_none())
60            .map(|link| (link.user_id, link))
61            .collect();
62
63        let mut prepared = Prepared::new();
64        for row in claimed {
65            let Some(link) = links.get(&row.user_id) else {
66                continue;
67            };
68            let person_lookup = PersonLookup {
69                student_number: StudentNumber::new(link.student_number.clone()),
70            };
71            let row = Claimed {
72                claim: claim_for_lookup(row),
73                extra: LinkMissingPerson { link_id: link.id },
74            };
75            prepared.send(row, person_lookup);
76        }
77        hold_in_flight(
78            conn,
79            prepared.sendable().iter().map(|entry| &entry.row.claim),
80        )
81        .await?;
82        Ok(prepared)
83    }
84
85    async fn apply_answer(
86        conn: &mut PgConnection,
87        row: &Claimed<LinkMissingPerson>,
88        answer: Option<&PersonAnswer>,
89        audit: &ExchangeAudit,
90    ) -> CreditRegistrationResult<Applied> {
91        let claim = &row.claim;
92        let facts = claim.facts(Utc::now());
93        let decision = match answer.map(|answer| &answer.reading) {
94            None => unanswered_person_decision(claim.registration().state, &facts),
95            Some(PersonReading::Refused { code }) => refused_person_decision(*code, &facts),
96            // Written before the move and kept even if the row moved on: the link, not the row,
97            // is what the person belongs to.
98            Some(PersonReading::Found(person)) => fill_person_id(conn, &row.extra, person)
99                .await?
100                .decision(claim),
101        };
102        let row_error = answer.and_then(|answer| answer.error_message.as_deref());
103        write_decision(conn, claim, decision.with_row_error(row_error), audit).await
104    }
105
106    async fn keep_in_flight(
107        conn: &mut PgConnection,
108        rows: &[&Claimed<LinkMissingPerson>],
109    ) -> CreditRegistrationResult<()> {
110        keep_lookups_in_flight(conn, rows.iter().map(|row| &row.claim)).await
111    }
112}
113
114/// Fills the found person in on the link, or records the conflict with the link that holds it
115/// already.
116async fn fill_person_id(
117    conn: &mut PgConnection,
118    link: &LinkMissingPerson,
119    person: &FoundPerson,
120) -> CreditRegistrationResult<PersonFill> {
121    let sisu_person_id = DbSecret::from(person.person_id.clone());
122    let first_names = person.first_names.clone().map(DbSecret::from);
123    let last_name = person.last_name.clone().map(DbSecret::from);
124    if verified_student_numbers::fill_sisu_person_id(
125        conn,
126        link.link_id,
127        &sisu_person_id,
128        first_names.as_ref(),
129        last_name.as_ref(),
130    )
131    .await?
132    {
133        return Ok(PersonFill::Filled);
134    }
135    let blocking =
136        verified_student_numbers::get_by_sisu_person_id(conn, sisu_person_id.expose_secret())
137            .await?
138            .filter(|blocking| blocking.id != link.link_id);
139    let Some(blocking) = blocking else {
140        return Ok(PersonFill::LinkChanged);
141    };
142    study_registry_student_number_conflicts::record_person_conflict(
143        conn,
144        link.link_id,
145        blocking.id,
146    )
147    .await?;
148    Ok(PersonFill::HeldByAnotherLink)
149}