1use 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
25pub(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
53fn 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
80async 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 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
105struct 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 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
136fn 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
171async 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
190fn 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 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}