Skip to main content

headless_lms_server/domain/credit_registration_phases/
enrolment_discovery.rs

1//! The `enrolment-discovery` phase: who the study registry says is on the course.
2//!
3//! One listing unparks the registrations of people we already have a link for, links the few whose
4//! registry address is an address one of our accounts has proved it controls, and claims an
5//! account-linking mail for everybody else. That middle branch is terminal, never a filter: the
6//! population the linking mail exists to reach is the people whose two addresses differ, and every
7//! fast-track outcome other than a link falls through to the mail.
8
9use headless_lms_models::course_module_suotar_realisations::{
10    RealisationListingOutcome, RealisationToList, claim_stalest_for_listing,
11    listing_request_item_id, mark_listing_failed, record_listing_outcome,
12};
13use headless_lms_models::credit_registration_events::scrub_text;
14use headless_lms_models::credit_registration_phase_state::PhaseRunOutcome;
15use headless_lms_models::credit_registrations::{
16    CreditRegistrationErrorCode, recheck_no_usable_enrolment_now,
17};
18use headless_lms_models::email_deliveries::insert_email_delivery_with_placeholders;
19use headless_lms_models::email_templates::EmailTemplateType;
20use headless_lms_models::library::credit_registration::account_linking::{
21    DiscoveredPerson, claim_linking_mails_batch,
22};
23use headless_lms_models::library::credit_registration::classification::map_code;
24use headless_lms_models::library::credit_registration::fast_track::{
25    FastTrackCandidate, FastTrackDecision, FastTrackLink, FastTrackLookup, RegistryName,
26    decide_fast_track, find_fast_track_candidate, find_fast_track_candidates, link_by_email_match,
27};
28use headless_lms_models::verified_student_numbers;
29use headless_lms_utils::error::util_error::UtilError;
30use headless_lms_utils::prelude::BackendError;
31use headless_lms_utils::prelude::Utc;
32use headless_lms_utils::services::suotar::{
33    ListByCourseRequestItem, ListedPerson, SuotarCallContext, SuotarEndpoint, SuotarItemStatus,
34};
35use serde_json::json;
36use sqlx::{Connection, PgConnection};
37use std::collections::{HashMap, HashSet};
38
39use super::{
40    CreditRegistrationPhase, PhaseContext, PhaseScope, TemplateCache,
41    every_item_failed_transiently, listed_person_addresses, template_language,
42};
43
44pub async fn run(ctx: &PhaseContext<'_>, scope: &PhaseScope) -> anyhow::Result<PhaseRunOutcome> {
45    let endpoint = SuotarEndpoint::ListByCourse;
46    let mut conn = ctx.pool.acquire().await?;
47    let mut tx = conn.begin().await?;
48    let claimed =
49        claim_stalest_for_listing(&mut tx, endpoint.max_batch_size() as i64, scope.course_id)
50            .await?;
51    tx.commit().await?;
52    let attempted = i32::try_from(claimed.len()).unwrap_or(i32::MAX);
53
54    let mut items = Vec::new();
55    let mut realisations = Vec::new();
56    let mut items_failed = 0;
57    for realisation in claimed {
58        let Some(course_code) = listable_course_code(&realisation) else {
59            // A configuration problem the config check reports on; a call would only earn
60            // `courseCodeNotFound`.
61            warn!(
62                "Course module {} has a Suotar realisation but no course code, so it cannot be listed.",
63                realisation.course_module_id
64            );
65            items_failed += 1;
66            mark_listing_failed(
67                &mut conn,
68                realisation.id,
69                CreditRegistrationErrorCode::MissingUhCourseCode,
70            )
71            .await?;
72            continue;
73        };
74        items.push(ListByCourseRequestItem {
75            request_item_id: listing_request_item_id(realisation.id),
76            course_code,
77            course_unit_realisation_id: Some(realisation.course_unit_realisation_id.clone()),
78        });
79        realisations.push(realisation);
80    }
81    if items.is_empty() {
82        return Ok(PhaseRunOutcome {
83            items_processed: attempted,
84            items_failed,
85            error: None,
86        });
87    }
88    // Held only for the reads above; the Suotar call can pin it for the whole request timeout.
89    drop(conn);
90
91    let response = ctx
92        .suotar_client
93        .list_enrolments_by_course(
94            SuotarCallContext::new(ctx.worker_name(CreditRegistrationPhase::EnrolmentDiscovery)),
95            items,
96        )
97        .await;
98    let response = match response {
99        // No ledger row to move: the realisations keep their old `last_listed_at` and stay first in
100        // line for the next iteration.
101        Err(error) => return Ok(whole_request_failed(attempted, &error)),
102        Ok(response) => response,
103    };
104
105    let mut conn = ctx.pool.acquire().await?;
106    for realisation in &realisations {
107        let item = response.item(&listing_request_item_id(realisation.id));
108        let listed = match item {
109            Some(item) if item.status == SuotarItemStatus::Ok => Ok(item
110                .result
111                .as_ref()
112                .map(|result| result.people.as_slice())
113                .unwrap_or_default()),
114            Some(item) => {
115                warn!(
116                    "Listing realisation {} failed with {}.",
117                    realisation.course_unit_realisation_id, item.code
118                );
119                Err(map_code(endpoint, &item.code).unwrap_or(CreditRegistrationErrorCode::Unknown))
120            }
121            None => {
122                warn!(
123                    "The study registry did not answer for realisation {}.",
124                    realisation.course_unit_realisation_id
125                );
126                Err(CreditRegistrationErrorCode::UnexpectedResponse)
127            }
128        };
129        let people = match listed {
130            Ok(people) => people,
131            Err(error) => {
132                items_failed += 1;
133                mark_listing_failed(&mut conn, realisation.id, error).await?;
134                continue;
135            }
136        };
137        let outcome = reconcile(ctx, &mut conn, realisation, people).await?;
138        record_listing_outcome(&mut conn, realisation.id, &outcome).await?;
139    }
140
141    Ok(PhaseRunOutcome {
142        items_processed: attempted,
143        items_failed,
144        error: every_item_failed_transiently(&response).then(|| {
145            "Every realisation of the batch came back transiently unavailable.".to_string()
146        }),
147    })
148}
149
150/// Applies one realisation's roster and returns the counters the realisation row carries.
151async fn reconcile(
152    ctx: &PhaseContext<'_>,
153    conn: &mut PgConnection,
154    realisation: &RealisationToList,
155    people: &[ListedPerson],
156) -> anyhow::Result<RealisationListingOutcome> {
157    let mut outcome = RealisationListingOutcome {
158        listed_person_count: i32::try_from(people.len()).unwrap_or(i32::MAX),
159        ..RealisationListingOutcome::default()
160    };
161    let person_ids: Vec<String> = people
162        .iter()
163        .map(|person| person.person_id.clone())
164        .collect();
165    let linked = verified_student_numbers::get_by_sisu_person_ids(conn, &person_ids).await?;
166    let linked_person_ids: HashSet<&str> = linked
167        .iter()
168        .map(|row| row.sisu_person_id.as_str())
169        .collect();
170
171    let mut fast_track = FastTrackRun::new(ctx, realisation);
172    fast_track
173        .resolve_accounts(
174            conn,
175            people
176                .iter()
177                .filter(|person| !linked_person_ids.contains(person.person_id.as_str())),
178        )
179        .await?;
180    let mut discovered = Vec::new();
181    for person in people {
182        if linked_person_ids.contains(person.person_id.as_str()) {
183            outcome.already_linked_count += 1;
184            continue;
185        }
186        let addresses = listed_person_addresses(person);
187        if addresses.is_empty() {
188            // The only genuinely unreachable population, and the reason it has a counter of its own.
189            outcome.no_address_count += 1;
190            continue;
191        }
192        if fast_track.try_link(conn, person, &mut outcome).await? {
193            continue;
194        }
195        discovered.push(DiscoveredPerson {
196            sisu_person_id: person.person_id.clone(),
197            student_number: person.student_number.clone(),
198            first_names: Some(person.first_names.clone()),
199            last_name: Some(person.last_name.clone()),
200            course_id: realisation.course_id,
201            addresses,
202        });
203    }
204    if !discovered.is_empty() {
205        for claimed in claim_linking_mails_batch(conn, &discovered).await? {
206            outcome.mailed_count += claimed.claimed;
207            outcome.suppressed_by_dedup_count += claimed.suppressed_by_dedup;
208            outcome.suppressed_by_rate_cap_count += claimed.suppressed_by_rate_cap;
209        }
210    }
211
212    // The fast way back for a row parked without an enrolment, which would otherwise wait out its
213    // own daily recheck.
214    let linked_user_ids: Vec<_> = linked.iter().map(|row| row.user_id).collect();
215    if !linked_user_ids.is_empty() {
216        recheck_no_usable_enrolment_now(conn, realisation.course_id, &linked_user_ids).await?;
217    }
218    Ok(outcome)
219}
220
221/// The fast track over one realisation's roster: the config it reads and the template lookup it
222/// caches, so neither is repeated per person.
223struct FastTrackRun<'a> {
224    ctx: &'a PhaseContext<'a>,
225    realisation: &'a RealisationToList,
226    enabled: bool,
227    max_verification_age: chrono::Duration,
228    templates: TemplateCache,
229    /// The account behind each person's registry address, resolved for the whole roster at once so
230    /// only the few people a link is actually possible for cost a transaction.
231    accounts: HashMap<String, FastTrackCandidate>,
232}
233
234impl<'a> FastTrackRun<'a> {
235    fn new(ctx: &'a PhaseContext<'a>, realisation: &'a RealisationToList) -> Self {
236        let conf = ctx.suotar_conf;
237        Self {
238            ctx,
239            realisation,
240            enabled: conf.fast_track_email_match_enabled,
241            max_verification_age: chrono::Duration::days(
242                conf.fast_track_max_email_verification_age_days.max(0),
243            ),
244            templates: TemplateCache::default(),
245            accounts: HashMap::new(),
246        }
247    }
248
249    async fn resolve_accounts<'p>(
250        &mut self,
251        conn: &mut PgConnection,
252        people: impl Iterator<Item = &'p ListedPerson>,
253    ) -> anyhow::Result<()> {
254        if !self.enabled {
255            return Ok(());
256        }
257        let wanted: Vec<FastTrackLookup> = people
258            .map(|person| FastTrackLookup {
259                primary_email: person.primary_email.clone(),
260                sisu_person_id: person.person_id.clone(),
261            })
262            .collect();
263        self.accounts = find_fast_track_candidates(conn, &wanted).await?;
264        Ok(())
265    }
266
267    fn decide(
268        &self,
269        candidate: Option<&FastTrackCandidate>,
270        person: &ListedPerson,
271    ) -> FastTrackDecision {
272        decide_fast_track(
273            candidate,
274            RegistryName {
275                first_names: Some(&person.first_names),
276                last_name: Some(&person.last_name),
277            },
278            Utc::now(),
279            self.max_verification_age,
280        )
281    }
282
283    /// Whether the person was linked here, in which case no linking mail is owed. `false` for every
284    /// other outcome, including the flag being off, and the caller carries on to the mail.
285    async fn try_link(
286        &mut self,
287        conn: &mut PgConnection,
288        person: &ListedPerson,
289        outcome: &mut RealisationListingOutcome,
290    ) -> anyhow::Result<bool> {
291        if !self.enabled {
292            return Ok(false);
293        }
294        let decision = self.decide(self.accounts.get(&person.person_id), person);
295        if decision != FastTrackDecision::Link {
296            count_decision(outcome, decision);
297            return Ok(false);
298        }
299        // One transaction, and the candidate query locks the account row: a profile edit landing
300        // between reading the proof and writing the link would leave a link resting on an address
301        // the account no longer holds. The decision is taken again under that lock, so the batch
302        // above is only ever a way to skip the people no link is possible for.
303        let mut tx = conn.begin().await?;
304        // The registry's secondary address is self-entered, so anyone could name someone else's
305        // account address there and be handed their student number.
306        let candidate =
307            find_fast_track_candidate(&mut tx, &person.primary_email, &person.person_id).await?;
308        let decision = self.decide(candidate.as_ref(), person);
309        count_decision(outcome, decision);
310        let (FastTrackDecision::Link, Some(candidate)) = (decision, candidate) else {
311            return Ok(false);
312        };
313
314        link_by_email_match(
315            &mut tx,
316            &FastTrackLink {
317                student_number: &person.student_number,
318                sisu_person_id: &person.person_id,
319                first_names: Some(&person.first_names),
320                last_name: Some(&person.last_name),
321                course_id: self.realisation.course_id,
322            },
323            &candidate,
324        )
325        .await?;
326        self.notify(&mut tx, person, &candidate).await?;
327        tx.commit().await?;
328        Ok(true)
329    }
330
331    /// The security notice that makes a wrong link detectable by the one party the link was proved
332    /// against. Off the critical path on purpose: the link is already made and registration proceeds,
333    /// so a missing template is logged and skipped rather than failing the listing.
334    async fn notify(
335        &mut self,
336        conn: &mut PgConnection,
337        person: &ListedPerson,
338        candidate: &FastTrackCandidate,
339    ) -> anyhow::Result<()> {
340        let language = template_language(&self.realisation.course_language_code);
341        let Some(template_id) = self
342            .templates
343            .id_for(
344                conn,
345                EmailTemplateType::CreditRegistrationStudentNumberLinked,
346                &language,
347            )
348            .await?
349        else {
350            warn!(
351                "No credit_registration_student_number_linked email template in {language}, so an automatic link went unannounced."
352            );
353            return Ok(());
354        };
355        insert_email_delivery_with_placeholders(
356            conn,
357            candidate.user_id,
358            template_id,
359            &json!({
360                "NAME": candidate.first_name.clone().unwrap_or_default(),
361                "STUDENT_NUMBER": person.student_number,
362                "LINK": student_number_settings_url(self.ctx.base_url),
363            }),
364        )
365        .await?;
366        Ok(())
367    }
368}
369
370/// Every fast-track outcome has a counter on the realisation row: the skips are what says whether
371/// the flag is worth having on.
372fn count_decision(outcome: &mut RealisationListingOutcome, decision: FastTrackDecision) {
373    match decision {
374        FastTrackDecision::NoAccountMatch => outcome.fast_track_skipped_no_account_count += 1,
375        FastTrackDecision::UnverifiedAccount => outcome.fast_track_skipped_unverified_count += 1,
376        FastTrackDecision::StaleVerification => {
377            outcome.fast_track_skipped_stale_verification_count += 1
378        }
379        FastTrackDecision::NameMismatch => outcome.fast_track_skipped_name_mismatch_count += 1,
380        FastTrackDecision::AccountHasStudentNumber => {
381            outcome.fast_track_skipped_account_has_number_count += 1
382        }
383        FastTrackDecision::UnlinkedBefore => outcome.fast_track_skipped_unlinked_before_count += 1,
384        FastTrackDecision::Link => outcome.fast_tracked_count += 1,
385    }
386}
387
388/// Where the notice's "not you? unlink" link goes.
389fn student_number_settings_url(base_url: &str) -> String {
390    format!(
391        "{}/user-settings/student-number",
392        base_url.trim_end_matches('/')
393    )
394}
395
396pub(super) fn listable_course_code(realisation: &RealisationToList) -> Option<String> {
397    realisation
398        .uh_course_code
399        .clone()
400        .filter(|code| !code.trim().is_empty())
401}
402
403fn whole_request_failed(attempted: i32, error: &UtilError) -> PhaseRunOutcome {
404    PhaseRunOutcome {
405        items_processed: attempted,
406        items_failed: attempted,
407        error: Some(scrub_text(error.message())),
408    }
409}