Skip to main content

headless_lms_credit_registration/use_cases/enrolment_discovery/
reconcile.rs

1//! What one code's roster does to the modules on it: waking linked students' registrations and
2//! claiming linking mails for everybody else.
3
4use std::collections::{HashMap, HashSet};
5
6use headless_lms_models::course_module_suotar_configurations::{
7    ModuleListingOutcome, ModuleToList, mark_listing_succeeded_without_linking,
8    record_listing_outcome,
9};
10use headless_lms_models::library::credit_registration::account_linking::{
11    DiscoveredPerson, claim_linking_mails_batch,
12};
13use headless_lms_models::library::credit_registration::enrolment_checks::{
14    RosterEnrolee, wake_for_roster_listing,
15};
16use headless_lms_models::library::credit_registration::study_registry::RosterPerson;
17use headless_lms_models::verified_student_numbers::{self, VerifiedStudentNumber};
18use headless_lms_utils::secret_string::expose_option;
19use secrecy::ExposeSecret;
20use sqlx::PgConnection;
21
22use super::CodeListing;
23use crate::error::CreditRegistrationResult;
24
25/// Wakes and mails for one code's roster, module by module: mails and links are per course. Returns
26/// the linking mails claimed across the listing's modules, for the caller's roster-fetch summary.
27pub(super) async fn reconcile_roster(
28    conn: &mut PgConnection,
29    listing: &CodeListing,
30    people: &[RosterPerson],
31    is_account_linking_enabled: bool,
32) -> CreditRegistrationResult<i32> {
33    let distinct = distinct_people(people);
34    let linked_rows = load_linked_accounts(conn, &distinct).await?;
35    let linked = LinkedAccounts::new(&linked_rows);
36    let enrolees = roster_enrolees(people, &linked);
37    let mut mailed_count = 0;
38    for module in &listing.modules {
39        if !enrolees.is_empty() {
40            wake_for_roster_listing(conn, module.course_module_id, &enrolees).await?;
41        }
42        if is_account_linking_enabled {
43            let outcome = claim_linking_mails(conn, module, &distinct, &linked).await?;
44            mailed_count += outcome.mailed_count;
45            record_listing_outcome(conn, module.course_module_id, &outcome).await?;
46        } else {
47            mark_listing_succeeded_without_linking(conn, module.course_module_id).await?;
48        }
49    }
50    Ok(mailed_count)
51}
52
53/// A person enrolled on several realisations of the code is listed once per realisation; keeps the
54/// most recent enrolment of each, one with no enrolment time counting as the oldest.
55fn distinct_people(people: &[RosterPerson]) -> Vec<&RosterPerson> {
56    let enrolled_at = |person: &RosterPerson| {
57        person
58            .enrolment
59            .as_ref()
60            .and_then(|enrolment| enrolment.enrolment_date_time)
61    };
62    let mut kept: Vec<&RosterPerson> = Vec::new();
63    let mut index_by_person: HashMap<&str, usize> = HashMap::new();
64    for person in people {
65        match index_by_person.get(person.person_id.expose_secret()) {
66            Some(&index) => {
67                if enrolled_at(person) > enrolled_at(kept[index]) {
68                    kept[index] = person;
69                }
70            }
71            None => {
72                index_by_person.insert(person.person_id.expose_secret(), kept.len());
73                kept.push(person);
74            }
75        }
76    }
77    kept
78}
79
80/// The accounts already linked to someone on the roster, by Sisu person id or student number.
81async fn load_linked_accounts(
82    conn: &mut PgConnection,
83    people: &[&RosterPerson],
84) -> CreditRegistrationResult<Vec<VerifiedStudentNumber>> {
85    let person_ids: Vec<String> = people
86        .iter()
87        .map(|person| person.person_id.expose_secret().to_owned())
88        .collect();
89    let student_numbers: Vec<String> = people
90        .iter()
91        .map(|person| person.student_number.expose_secret().to_owned())
92        .collect();
93    let mut linked = verified_student_numbers::get_by_sisu_person_ids(conn, &person_ids).await?;
94    // A study_registry link has no person id until resolve-person-ids reaches it.
95    let linked_ids: HashSet<_> = linked.iter().map(|row| row.id).collect();
96    let linked_by_number = verified_student_numbers::get_by_student_numbers(conn, &student_numbers)
97        .await?
98        .into_iter()
99        .filter(|row| !linked_ids.contains(&row.id))
100        .collect::<Vec<_>>();
101    linked.extend(linked_by_number);
102    Ok(linked)
103}
104
105/// The accounts linked to someone on one code's roster, indexed once for every module on it.
106struct LinkedAccounts<'a> {
107    rows: &'a [VerifiedStudentNumber],
108    person_ids: HashSet<&'a str>,
109    student_numbers: HashSet<&'a str>,
110}
111
112impl<'a> LinkedAccounts<'a> {
113    fn new(rows: &'a [VerifiedStudentNumber]) -> Self {
114        Self {
115            rows,
116            person_ids: rows
117                .iter()
118                .filter_map(|row| expose_option(&row.sisu_person_id))
119                .collect(),
120            student_numbers: rows
121                .iter()
122                .map(|row| row.student_number.expose_secret())
123                .collect(),
124        }
125    }
126
127    /// Whether some account is linked to `person`, by Sisu person id or student number.
128    fn is_linked(&self, person: &RosterPerson) -> bool {
129        self.person_ids.contains(person.person_id.expose_secret())
130            || self
131                .student_numbers
132                .contains(person.student_number.expose_secret())
133    }
134}
135
136/// The linked accounts on the roster, each with every enrolment id the registry lists them under.
137fn roster_enrolees(people: &[RosterPerson], linked: &LinkedAccounts<'_>) -> Vec<RosterEnrolee> {
138    let mut ids_by_person_id: HashMap<&str, Vec<String>> = HashMap::new();
139    let mut person_id_by_student_number: HashMap<&str, &str> = HashMap::new();
140    for person in people {
141        let person_id = person.person_id.expose_secret();
142        person_id_by_student_number.insert(person.student_number.expose_secret(), person_id);
143        let ids = ids_by_person_id.entry(person_id).or_default();
144        if let Some(id) = person
145            .enrolment
146            .as_ref()
147            .and_then(|enrolment| enrolment.id.clone())
148        {
149            ids.push(id);
150        }
151    }
152    linked
153        .rows
154        .iter()
155        .filter_map(|row| {
156            let person_id = expose_option(&row.sisu_person_id)
157                .filter(|person_id| ids_by_person_id.contains_key(person_id))
158                .or_else(|| {
159                    person_id_by_student_number
160                        .get(row.student_number.expose_secret())
161                        .copied()
162                })?;
163            Some(RosterEnrolee {
164                user_id: row.user_id,
165                enrolment_ids: ids_by_person_id.get(person_id).cloned().unwrap_or_default(),
166            })
167        })
168        .collect()
169}
170
171/// Claims a linking mail for everyone on one module's roster we hold no link for, and returns the
172/// counters its configuration row carries.
173async fn claim_linking_mails(
174    conn: &mut PgConnection,
175    module: &ModuleToList,
176    people: &[&RosterPerson],
177    linked: &LinkedAccounts<'_>,
178) -> CreditRegistrationResult<ModuleListingOutcome> {
179    let (mut outcome, discovered) = linking_candidates(module, people, linked);
180    if !discovered.is_empty() {
181        for claimed in claim_linking_mails_batch(conn, &discovered).await? {
182            outcome.mailed_count += claimed.claimed;
183            outcome.suppressed_by_dedup_count += claimed.suppressed_by_dedup;
184            outcome.suppressed_by_rate_cap_count += claimed.suppressed_by_rate_cap;
185        }
186    }
187    Ok(outcome)
188}
189
190/// The people on one module's roster a linking mail may go to, and the counters of those it may
191/// not: already linked, or with no address to mail.
192fn linking_candidates(
193    module: &ModuleToList,
194    people: &[&RosterPerson],
195    linked: &LinkedAccounts<'_>,
196) -> (ModuleListingOutcome, Vec<DiscoveredPerson>) {
197    let mut outcome = ModuleListingOutcome {
198        listed_person_count: i32::try_from(people.len()).unwrap_or(i32::MAX),
199        ..ModuleListingOutcome::default()
200    };
201    let mut discovered = Vec::new();
202    for &person in people {
203        if linked.is_linked(person) {
204            outcome.already_linked_count += 1;
205            continue;
206        }
207        let listed = DiscoveredPerson::listed(person, module.course_id);
208        if listed.addresses.is_empty() {
209            // The only genuinely unreachable population, and the reason it has a counter of its own.
210            outcome.no_address_count += 1;
211            continue;
212        }
213        discovered.push(listed);
214    }
215    (outcome, discovered)
216}
217
218#[cfg(test)]
219mod tests {
220    use chrono::{TimeDelta, Utc};
221    use headless_lms_models::library::credit_registration::study_registry::RosterEnrolment;
222    use headless_lms_models::secret::DbSecret;
223    use headless_lms_models::verified_student_numbers::StudentNumberVerificationMethod;
224    use secrecy::SecretString;
225    use uuid::Uuid;
226
227    use super::*;
228
229    fn person(person_id: &str, student_number: &str, email: Option<&str>) -> RosterPerson {
230        RosterPerson {
231            student_number: SecretString::from(student_number),
232            person_id: SecretString::from(person_id),
233            first_names: None,
234            last_name: None,
235            primary_email: email.map(SecretString::from),
236            secondary_email: None,
237            enrolment: None,
238        }
239    }
240
241    fn enrolled(person: RosterPerson, enrolment_id: &str, days_ago: i64) -> RosterPerson {
242        RosterPerson {
243            enrolment: Some(RosterEnrolment {
244                id: Some(enrolment_id.to_string()),
245                course_unit_realisation_id: None,
246                state: None,
247                enrolment_date_time: Some(Utc::now() - TimeDelta::days(days_ago)),
248            }),
249            ..person
250        }
251    }
252
253    fn link(student_number: &str, person_id: Option<&str>) -> VerifiedStudentNumber {
254        let now = Utc::now();
255        VerifiedStudentNumber {
256            id: Uuid::new_v4(),
257            created_at: now,
258            updated_at: now,
259            deleted_at: None,
260            user_id: Uuid::new_v4(),
261            student_number: DbSecret::new(student_number),
262            sisu_person_id: person_id.map(DbSecret::new),
263            first_names: None,
264            last_name: None,
265            verified_at: now,
266            verified_via: StudentNumberVerificationMethod::EmailedLink,
267            verified_via_email: None,
268            linked_by_user_id: None,
269            link_reason: None,
270            verified_from_course_id: None,
271        }
272    }
273
274    fn module() -> ModuleToList {
275        ModuleToList {
276            course_module_id: Uuid::new_v4(),
277            course_id: Uuid::new_v4(),
278            uh_course_code: "TKT1".to_string(),
279            course_language_code: "fi".to_string(),
280        }
281    }
282
283    #[test]
284    fn a_person_on_several_realisations_is_kept_once_with_the_latest_enrolment() {
285        let people = [
286            enrolled(person("p1", "1", None), "old", 10),
287            person("p2", "2", None),
288            enrolled(person("p1", "1", None), "new", 1),
289            enrolled(person("p1", "1", None), "older", 20),
290        ];
291        let kept: Vec<_> = distinct_people(&people)
292            .iter()
293            .map(|person| {
294                (
295                    person.person_id.expose_secret().to_string(),
296                    person
297                        .enrolment
298                        .as_ref()
299                        .and_then(|enrolment| enrolment.id.clone()),
300                )
301            })
302            .collect();
303        assert_eq!(
304            kept,
305            [
306                ("p1".to_string(), Some("new".to_string())),
307                ("p2".to_string(), None)
308            ]
309        );
310    }
311
312    #[test]
313    fn a_linked_account_wakes_with_every_enrolment_it_is_listed_under() {
314        let people = [
315            enrolled(person("p1", "1", None), "e1", 2),
316            enrolled(person("p1", "1", None), "e2", 1),
317            enrolled(person("p2", "2", None), "e3", 1),
318        ];
319        let by_person_id = link("1", Some("p1"));
320        let by_number_only = link("2", None);
321        let unlisted = link("9", Some("p9"));
322        let links = [by_person_id.clone(), by_number_only.clone(), unlisted];
323        let enrolees = roster_enrolees(&people, &LinkedAccounts::new(&links));
324        let woken: Vec<_> = enrolees
325            .iter()
326            .map(|enrolee| (enrolee.user_id, enrolee.enrolment_ids.clone()))
327            .collect();
328        assert_eq!(
329            woken,
330            [
331                (
332                    by_person_id.user_id,
333                    vec!["e1".to_string(), "e2".to_string()]
334                ),
335                (by_number_only.user_id, vec!["e3".to_string()]),
336            ]
337        );
338    }
339
340    #[test]
341    fn only_unlinked_people_with_an_address_are_mailed() {
342        let people = [
343            person("p1", "1", Some("linked-by-id@example.com")),
344            person("p2", "2", Some("linked-by-number@example.com")),
345            person("p3", "3", None),
346            person("p4", "4", Some("  ")),
347            person("p5", "5", Some("new@example.com")),
348        ];
349        let listed: Vec<&RosterPerson> = people.iter().collect();
350        let module = module();
351        let links = [link("other", Some("p1")), link("2", None)];
352        let (outcome, discovered) =
353            linking_candidates(&module, &listed, &LinkedAccounts::new(&links));
354        assert_eq!(
355            outcome,
356            ModuleListingOutcome {
357                listed_person_count: 5,
358                already_linked_count: 2,
359                no_address_count: 2,
360                ..ModuleListingOutcome::default()
361            }
362        );
363        assert_eq!(discovered.len(), 1);
364        assert_eq!(discovered[0].sisu_person_id.expose_secret(), "p5");
365        assert_eq!(discovered[0].course_id, module.course_id);
366        assert_eq!(discovered[0].addresses.len(), 1);
367    }
368}