headless_lms_models/library/credit_registration/
fast_track.rs1use std::collections::HashMap;
10
11use unicode_normalization::UnicodeNormalization;
12
13use crate::credit_registration_events::CreditRegistrationEventKind;
14use crate::prelude::*;
15use crate::user_details::EmailVerificationMethod;
16use crate::verified_student_numbers::{
17 NewVerifiedStudentNumber, StudentNumberVerificationMethod, replace_verified_student_number,
18};
19
20pub const MATCHED_FIELD_PRIMARY: &str = "primary";
23
24#[derive(Debug, Clone, PartialEq)]
26pub struct FastTrackCandidate {
27 pub user_id: Uuid,
28 pub email: String,
29 pub email_verified_at: Option<DateTime<Utc>>,
30 pub email_verified_method: Option<EmailVerificationMethod>,
31 pub first_name: Option<String>,
32 pub last_name: Option<String>,
33 pub has_live_student_number: bool,
36 pub unlinked_a_fast_track_link_before: bool,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum FastTrackDecision {
43 Link,
44 NoAccountMatch,
46 UnverifiedAccount,
49 StaleVerification,
50 NameMismatch,
51 AccountHasStudentNumber,
52 UnlinkedBefore,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct RegistryName<'a> {
58 pub first_names: Option<&'a str>,
59 pub last_name: Option<&'a str>,
60}
61
62pub fn decide_fast_track(
69 candidate: Option<&FastTrackCandidate>,
70 registry_name: RegistryName<'_>,
71 now: DateTime<Utc>,
72 max_verification_age: chrono::Duration,
73) -> FastTrackDecision {
74 let Some(candidate) = candidate else {
75 return FastTrackDecision::NoAccountMatch;
76 };
77 let (Some(verified_at), Some(method)) =
78 (candidate.email_verified_at, candidate.email_verified_method)
79 else {
80 return FastTrackDecision::UnverifiedAccount;
81 };
82 if method == EmailVerificationMethod::AdminAsserted {
83 return FastTrackDecision::UnverifiedAccount;
84 }
85 if verified_at < now - max_verification_age {
86 return FastTrackDecision::StaleVerification;
87 }
88 if candidate.unlinked_a_fast_track_link_before {
89 return FastTrackDecision::UnlinkedBefore;
90 }
91 if candidate.has_live_student_number {
92 return FastTrackDecision::AccountHasStudentNumber;
93 }
94 if !names_loosely_match(registry_name, candidate) {
95 return FastTrackDecision::NameMismatch;
96 }
97 FastTrackDecision::Link
98}
99
100fn names_loosely_match(registry_name: RegistryName<'_>, candidate: &FastTrackCandidate) -> bool {
107 let account_first = fold(candidate.first_name.as_deref().unwrap_or_default());
108 let account_last = fold(candidate.last_name.as_deref().unwrap_or_default());
109 let registry_last = fold(registry_name.last_name.unwrap_or_default());
110
111 if !account_last.is_empty() && account_last == registry_last {
112 return true;
113 }
114 if account_first.is_empty() {
115 return false;
116 }
117 registry_name
118 .first_names
119 .unwrap_or_default()
120 .split_whitespace()
121 .any(|given| fold(given) == account_first)
122}
123
124fn fold(name: &str) -> String {
126 name.trim()
127 .nfkd()
128 .filter(|c| !unicode_normalization::char::is_combining_mark(*c))
129 .flat_map(char::to_lowercase)
130 .collect()
131}
132
133pub async fn find_fast_track_candidate(
145 conn: &mut PgConnection,
146 primary_email: &str,
147 sisu_person_id: &str,
148) -> ModelResult<Option<FastTrackCandidate>> {
149 let address = primary_email.trim();
150 if address.is_empty() {
151 return Ok(None);
152 }
153 let mut candidates = sqlx::query_as!(
154 FastTrackCandidate,
155 r#"
156SELECT ud.user_id,
157 ud.email,
158 ud.email_verified_at,
159 ud.email_verified_method,
160 ud.first_name,
161 ud.last_name,
162 EXISTS(
163 SELECT 1
164 FROM verified_student_numbers vsn
165 WHERE vsn.user_id = ud.user_id
166 AND vsn.deleted_at IS NULL
167 ) AS "has_live_student_number!",
168 EXISTS(
169 SELECT 1
170 FROM verified_student_numbers vsn
171 WHERE vsn.user_id = ud.user_id
172 AND vsn.sisu_person_id = $2
173 AND vsn.verified_via = 'email_match_fast_track'::student_number_verification_method
174 AND vsn.deleted_at IS NOT NULL
175 ) AS "unlinked_a_fast_track_link_before!"
176FROM user_details ud
177 JOIN users u ON u.id = ud.user_id
178WHERE LOWER(ud.email) = LOWER($1)
179 AND u.deleted_at IS NULL
180LIMIT 2 FOR SHARE OF ud
181 "#,
182 address,
183 sisu_person_id,
184 )
185 .fetch_all(conn)
186 .await?;
187 Ok((candidates.len() == 1).then(|| candidates.remove(0)))
188}
189
190pub async fn find_fast_track_candidates(
197 conn: &mut PgConnection,
198 people: &[FastTrackLookup],
199) -> ModelResult<HashMap<String, FastTrackCandidate>> {
200 let (emails, person_ids): (Vec<String>, Vec<String>) = people
201 .iter()
202 .filter(|person| !person.primary_email.trim().is_empty())
203 .map(|person| {
204 (
205 person.primary_email.trim().to_string(),
206 person.sisu_person_id.clone(),
207 )
208 })
209 .unzip();
210 if emails.is_empty() {
211 return Ok(HashMap::new());
212 }
213 let rows = sqlx::query!(
214 r#"
215SELECT wanted.sisu_person_id AS "sisu_person_id!",
216 ud.user_id,
217 ud.email,
218 ud.email_verified_at,
219 ud.email_verified_method AS "email_verified_method?: EmailVerificationMethod",
220 ud.first_name,
221 ud.last_name,
222 EXISTS(
223 SELECT 1
224 FROM verified_student_numbers vsn
225 WHERE vsn.user_id = ud.user_id
226 AND vsn.deleted_at IS NULL
227 ) AS "has_live_student_number!",
228 EXISTS(
229 SELECT 1
230 FROM verified_student_numbers vsn
231 WHERE vsn.user_id = ud.user_id
232 AND vsn.sisu_person_id = wanted.sisu_person_id
233 AND vsn.verified_via = 'email_match_fast_track'::student_number_verification_method
234 AND vsn.deleted_at IS NOT NULL
235 ) AS "unlinked_a_fast_track_link_before!"
236FROM UNNEST($1::text [], $2::text []) AS wanted(email, sisu_person_id)
237 JOIN user_details ud ON LOWER(ud.email) = LOWER(wanted.email)
238 JOIN users u ON u.id = ud.user_id
239WHERE u.deleted_at IS NULL
240 AND (
241 SELECT COUNT(*)
242 FROM user_details other
243 JOIN users other_user ON other_user.id = other.user_id
244 WHERE LOWER(other.email) = LOWER(wanted.email)
245 AND other_user.deleted_at IS NULL
246 ) = 1
247 "#,
248 &emails,
249 &person_ids,
250 )
251 .fetch_all(conn)
252 .await?;
253 Ok(rows
254 .into_iter()
255 .map(|row| {
256 (
257 row.sisu_person_id,
258 FastTrackCandidate {
259 user_id: row.user_id,
260 email: row.email,
261 email_verified_at: row.email_verified_at,
262 email_verified_method: row.email_verified_method,
263 first_name: row.first_name,
264 last_name: row.last_name,
265 has_live_student_number: row.has_live_student_number,
266 unlinked_a_fast_track_link_before: row.unlinked_a_fast_track_link_before,
267 },
268 )
269 })
270 .collect())
271}
272
273#[derive(Debug, Clone, PartialEq)]
275pub struct FastTrackLookup {
276 pub primary_email: String,
277 pub sisu_person_id: String,
278}
279
280#[derive(Debug, Clone, PartialEq)]
282pub struct FastTrackLink<'a> {
283 pub student_number: &'a str,
284 pub sisu_person_id: &'a str,
285 pub first_names: Option<&'a str>,
286 pub last_name: Option<&'a str>,
287 pub course_id: Uuid,
288}
289
290pub async fn link_by_email_match(
297 conn: &mut PgConnection,
298 person: &FastTrackLink<'_>,
299 candidate: &FastTrackCandidate,
300) -> ModelResult<Uuid> {
301 let (id, _) = replace_verified_student_number(
302 conn,
303 None,
304 &NewVerifiedStudentNumber {
305 user_id: candidate.user_id,
306 student_number: person.student_number.to_string(),
307 sisu_person_id: person.sisu_person_id.to_string(),
308 first_names: person.first_names.map(str::to_string),
309 last_name: person.last_name.map(str::to_string),
310 verified_via: StudentNumberVerificationMethod::EmailMatchFastTrack,
311 verified_via_email: Some(candidate.email.clone()),
312 verified_via_email_match_field: Some(MATCHED_FIELD_PRIMARY.to_string()),
313 account_email_verified_at: candidate.email_verified_at,
317 linked_by_user_id: None,
318 link_reason: None,
319 verified_from_course_id: Some(person.course_id),
320 },
321 None,
322 CreditRegistrationEventKind::Created,
323 "Linked automatically: the study registry holds this account's verified email address.",
324 )
325 .await?;
326 Ok(id)
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 const YEAR: i64 = 365;
334
335 fn candidate() -> FastTrackCandidate {
336 FastTrackCandidate {
337 user_id: Uuid::new_v4(),
338 email: "aada.virtanen@helsinki.fi".to_string(),
339 email_verified_at: Some(Utc::now() - chrono::Duration::days(30)),
340 email_verified_method: Some(EmailVerificationMethod::EmailedCode),
341 first_name: Some("Aada".to_string()),
342 last_name: Some("Virtanen".to_string()),
343 has_live_student_number: false,
344 unlinked_a_fast_track_link_before: false,
345 }
346 }
347
348 fn decide(candidate: Option<&FastTrackCandidate>) -> FastTrackDecision {
349 decide_fast_track(
350 candidate,
351 RegistryName {
352 first_names: Some("Aada Maria"),
353 last_name: Some("Virtanen"),
354 },
355 Utc::now(),
356 chrono::Duration::days(YEAR),
357 )
358 }
359
360 #[test]
361 fn a_fresh_proof_on_a_matching_name_links() {
362 assert_eq!(decide(Some(&candidate())), FastTrackDecision::Link);
363 }
364
365 #[test]
366 fn an_address_no_account_holds_falls_through() {
367 assert_eq!(decide(None), FastTrackDecision::NoAccountMatch);
368 }
369
370 #[test]
371 fn an_unproven_address_never_links() {
372 let unverified = FastTrackCandidate {
373 email_verified_at: None,
374 email_verified_method: None,
375 ..candidate()
376 };
377 assert_eq!(
378 decide(Some(&unverified)),
379 FastTrackDecision::UnverifiedAccount
380 );
381 }
382
383 #[test]
384 fn support_asserting_an_address_is_not_a_proof_of_mailbox_control() {
385 let asserted = FastTrackCandidate {
386 email_verified_method: Some(EmailVerificationMethod::AdminAsserted),
387 ..candidate()
388 };
389 assert_eq!(
390 decide(Some(&asserted)),
391 FastTrackDecision::UnverifiedAccount
392 );
393 }
394
395 #[test]
396 fn a_proof_older_than_the_window_never_links() {
397 let stale = FastTrackCandidate {
398 email_verified_at: Some(Utc::now() - chrono::Duration::days(YEAR + 1)),
399 ..candidate()
400 };
401 assert_eq!(decide(Some(&stale)), FastTrackDecision::StaleVerification);
402 }
403
404 #[test]
405 fn an_account_that_already_holds_a_number_is_left_to_the_mailed_link() {
406 let linked = FastTrackCandidate {
407 has_live_student_number: true,
408 ..candidate()
409 };
410 assert_eq!(
411 decide(Some(&linked)),
412 FastTrackDecision::AccountHasStudentNumber
413 );
414 }
415
416 #[test]
418 fn an_account_that_unlinked_this_person_before_is_not_relinked() {
419 let rejected = FastTrackCandidate {
420 unlinked_a_fast_track_link_before: true,
421 ..candidate()
422 };
423 assert_eq!(decide(Some(&rejected)), FastTrackDecision::UnlinkedBefore);
424 }
425
426 #[test]
427 fn a_wholly_different_name_falls_through() {
428 let stranger = FastTrackCandidate {
429 first_name: Some("Bertta".to_string()),
430 last_name: Some("Korhonen".to_string()),
431 ..candidate()
432 };
433 assert_eq!(decide(Some(&stranger)), FastTrackDecision::NameMismatch);
434 }
435
436 #[test]
437 fn one_matching_given_name_is_enough() {
438 let nickname = FastTrackCandidate {
439 first_name: Some("Maria".to_string()),
440 last_name: Some("Married-Name".to_string()),
441 ..candidate()
442 };
443 assert_eq!(decide(Some(&nickname)), FastTrackDecision::Link);
444 }
445
446 #[test]
447 fn diacritics_and_case_do_not_make_two_names_differ() {
448 let folded = FastTrackCandidate {
449 first_name: Some("aada".to_string()),
450 last_name: Some("VIRTÄNEN".to_string()),
451 ..candidate()
452 };
453 assert_eq!(decide(Some(&folded)), FastTrackDecision::Link);
454 }
455}