Skip to main content

headless_lms_models/library/credit_registration/
account_linking.rs

1//! Claiming the right to mail a Sisu person an account-linking link, and minting the token it
2//! carries. Both caps and the dedup guard are evaluated here and nowhere else, and no argument
3//! switches them off; an override has to soft-delete a ledger row.
4//!
5//! The token is minted bound to no account: the recipient's Sisu address is routinely not the
6//! address on their account here, so the click while logged in is what creates the binding.
7
8use std::collections::HashMap;
9
10use chrono::TimeDelta;
11use secrecy::ExposeSecret;
12
13use crate::credit_registration_account_linking_emails::{
14    self, ExistingLinkingMailFact, NewAccountLinkingEmail, claim_send_slots,
15    get_existing_facts_for_persons,
16};
17use crate::credit_registration_admin_actions::{
18    CreditRegistrationAdminAction, CreditRegistrationAdminActionTarget,
19    NewCreditRegistrationAdminAction,
20};
21use crate::error::missing_model_error;
22use crate::prelude::*;
23use crate::student_number_verification_tokens::{
24    NewStudentNumberVerificationToken, insert_batch as insert_tokens_batch,
25};
26
27use super::study_registry::RosterPerson;
28
29/// Both the mailed URL and the frontend route that serves it are built from this one value.
30pub const LINK_STUDENT_NUMBER_PATH: &str = "/link-student-number";
31
32/// The link the mail carries: a bearer credential, whoever holds it can claim the student number.
33pub fn link_student_number_url(base_url: &str, token: &str) -> String {
34    format!(
35        "{}{LINK_STUDENT_NUMBER_PATH}/{token}",
36        base_url.trim_end_matches('/')
37    )
38}
39
40/// How long after a linking mail the person is left alone, across every course and address.
41pub const LINKING_MAIL_QUIET_PERIOD: TimeDelta = TimeDelta::days(1);
42
43/// How many linking mails one person may ever get for one course, tokens that expired unused
44/// included.
45pub const MAX_LINKING_MAILS_PER_PERSON_AND_COURSE: i64 = 3;
46
47/// One person Sisu's `list-by-course` returned, and every address we could reach them at.
48#[derive(Debug, Clone)]
49pub struct DiscoveredPerson {
50    pub sisu_person_id: DbSecret,
51    pub student_number: DbSecret,
52    pub first_names: Option<DbSecret>,
53    pub last_name: Option<DbSecret>,
54    pub course_id: Uuid,
55    /// Each address gets its own mail and its own token: we cannot tell which one they read.
56    pub addresses: Vec<DbSecret>,
57}
58
59impl DiscoveredPerson {
60    /// `person` as listed on a roster of `course_id`'s, with every address the registry holds for
61    /// them; the caller decides what an empty address list means.
62    pub fn listed(person: &RosterPerson, course_id: Uuid) -> Self {
63        Self {
64            sisu_person_id: person.person_id.clone().into(),
65            student_number: person.student_number.clone().into(),
66            first_names: person.first_names.clone().map(Into::into),
67            last_name: person.last_name.clone().map(Into::into),
68            course_id,
69            addresses: listed_person_addresses(person),
70        }
71    }
72}
73
74/// Every address the study registry holds for a listed person, in the order it lists them; which
75/// one they read is not something we can know.
76fn listed_person_addresses(person: &RosterPerson) -> Vec<DbSecret> {
77    [&person.primary_email, &person.secondary_email]
78        .into_iter()
79        .flatten()
80        .filter(|address| !address.expose_secret().trim().is_empty())
81        .map(|address| DbSecret::from(address.clone()))
82        .collect()
83}
84
85/// The three buckets are disjoint and sum to the addresses tried.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
87pub struct ClaimedLinkingMails {
88    pub claimed: i32,
89    pub suppressed_by_dedup: i32,
90    pub suppressed_by_rate_cap: i32,
91}
92
93/// One person's [`claim_linking_mails_batch`].
94pub async fn claim_linking_mails(
95    conn: &mut PgConnection,
96    person: &DiscoveredPerson,
97) -> ModelResult<ClaimedLinkingMails> {
98    claim_linking_mails_batch(conn, std::slice::from_ref(person))
99        .await?
100        .into_iter()
101        .next()
102        .ok_or_else(missing_model_error(
103            ModelErrorType::Generic,
104            "Claiming linking mails answered nothing for the one person asked about.",
105        ))
106}
107
108/// Claims one slot and one unbound token per person and address, dedup before rate cap and the
109/// allowance spent left to right. Returns one outcome per input, in order; a claimed slot means a
110/// mail the `link-emails` phase still owes.
111pub async fn claim_linking_mails_batch(
112    conn: &mut PgConnection,
113    people: &[DiscoveredPerson],
114) -> ModelResult<Vec<ClaimedLinkingMails>> {
115    let mut outcomes = vec![ClaimedLinkingMails::default(); people.len()];
116    let per_person_addresses: Vec<Vec<DbSecret>> = people
117        .iter()
118        .map(|person| distinct_addresses(&person.addresses))
119        .collect();
120
121    let sisu_person_ids: Vec<String> = people
122        .iter()
123        .zip(&per_person_addresses)
124        .filter(|(_, addresses)| !addresses.is_empty())
125        .map(|(person, _)| person.sisu_person_id.expose_secret().to_owned())
126        .collect();
127    if sisu_person_ids.is_empty() {
128        return Ok(outcomes);
129    }
130    let facts = get_existing_facts_for_persons(conn, &sisu_person_ids).await?;
131    let mut by_person: HashMap<&str, Vec<&ExistingLinkingMailFact>> = HashMap::new();
132    for fact in &facts {
133        by_person
134            .entry(fact.sisu_person_id.expose_secret())
135            .or_default()
136            .push(fact);
137    }
138    let quiet_since = Utc::now() - LINKING_MAIL_QUIET_PERIOD;
139
140    let mut to_claim: Vec<(usize, &DbSecret)> = Vec::new();
141    for (i, (person, addresses)) in people.iter().zip(&per_person_addresses).enumerate() {
142        if addresses.is_empty() {
143            continue;
144        }
145        let person_facts = by_person
146            .get(person.sisu_person_id.expose_secret())
147            .map(Vec::as_slice)
148            .unwrap_or(&[]);
149        let mut allowance = remaining_allowance(person, person_facts, quiet_since);
150        for address in addresses {
151            if already_mailed(person_facts, person.course_id, address.expose_secret()) {
152                outcomes[i].suppressed_by_dedup += 1;
153                continue;
154            }
155            if allowance == 0 {
156                outcomes[i].suppressed_by_rate_cap += 1;
157                continue;
158            }
159            allowance -= 1;
160            to_claim.push((i, address));
161        }
162    }
163    if to_claim.is_empty() {
164        return Ok(outcomes);
165    }
166
167    // Tokens are minted before any slot is claimed: the random value cannot come from SQL.
168    let mut new_tokens = Vec::with_capacity(to_claim.len());
169    let mut new_slots = Vec::with_capacity(to_claim.len());
170    let mut token_ids = Vec::with_capacity(to_claim.len());
171    let mut token_owner: HashMap<Uuid, usize> = HashMap::new();
172    for (person_index, address) in &to_claim {
173        let person = &people[*person_index];
174        let token_id = Uuid::new_v4();
175        new_tokens.push(NewStudentNumberVerificationToken {
176            student_number: person.student_number.clone(),
177            sisu_person_id: person.sisu_person_id.clone(),
178            first_names: person.first_names.clone(),
179            last_name: person.last_name.clone(),
180            emailed_to: (*address).clone(),
181            course_id: Some(person.course_id),
182        });
183        token_owner.insert(token_id, *person_index);
184        token_ids.push(token_id);
185        new_slots.push(NewAccountLinkingEmail {
186            student_number: person.student_number.clone(),
187            sisu_person_id: person.sisu_person_id.clone(),
188            course_id: person.course_id,
189            emailed_to: (*address).clone(),
190            student_number_verification_token_id: Some(token_id),
191            email_delivery_id: None,
192        });
193    }
194    insert_tokens_batch(conn, &token_ids, &new_tokens).await?;
195
196    let claimed_token_ids = claim_send_slots(conn, &new_slots, &token_ids).await?;
197    let lost_token_ids: Vec<Uuid> = token_owner
198        .keys()
199        .filter(|id| !claimed_token_ids.contains(id))
200        .copied()
201        .collect();
202    // A refused claim must leave no usable token behind: nobody would ever send that link.
203    void_tokens(conn, &lost_token_ids).await?;
204    for (token_id, person_index) in &token_owner {
205        if claimed_token_ids.contains(token_id) {
206            outcomes[*person_index].claimed += 1;
207        } else {
208            // Lost the race to another writer; same outcome for the recipient as dedup.
209            outcomes[*person_index].suppressed_by_dedup += 1;
210        }
211    }
212    Ok(outcomes)
213}
214
215/// Retires the linking-mail rows the caps are counting for this person, so the ordinary claim path can
216/// take a slot again. No parameter relaxes a cap: the single writer of the ledger evaluates them from
217/// the rows that exist, so getting past one means soft-deleting rows, audited as its own action.
218pub async fn retire_capped_mails(
219    conn: &mut PgConnection,
220    actor_user_id: Uuid,
221    actor_role: &str,
222    course_id: Uuid,
223    student_number: &str,
224    reason: &str,
225) -> ModelResult<i64> {
226    let Some(person_id) = person_id_of_mails(conn, course_id, student_number).await? else {
227        return Ok(0);
228    };
229    let quiet_since = Utc::now() - LINKING_MAIL_QUIET_PERIOD;
230    let mails = credit_registration_account_linking_emails::get_by_sisu_person_id(
231        conn,
232        person_id.expose_secret(),
233    )
234    .await?;
235    // This course's rows carry the dedup guard and the lifetime cap; a recent row on any course
236    // carries the quiet period, which is about the person's inbox rather than one course.
237    let retired: Vec<Uuid> = mails
238        .iter()
239        .filter(|mail| mail.course_id == course_id || mail.sent_at >= quiet_since)
240        .map(|mail| mail.id)
241        .collect();
242    if retired.is_empty() {
243        return Ok(0);
244    }
245
246    let mut tx = conn.begin().await?;
247    credit_registration_account_linking_emails::soft_delete_batch(&mut tx, &retired).await?;
248    crate::credit_registration_admin_actions::record(
249        &mut tx,
250        &NewCreditRegistrationAdminAction {
251            target_id: Some(course_id),
252            reason: Some(reason.to_string()),
253            details: Some(serde_json::json!({
254                "student_number": student_number,
255                "retired_linking_email_ids": retired,
256            })),
257            affected_row_count: Some(i32::try_from(retired.len()).unwrap_or(i32::MAX)),
258            ..NewCreditRegistrationAdminAction::new(
259                CreditRegistrationAdminAction::OverrideRateCap,
260                CreditRegistrationAdminActionTarget::Course,
261                actor_user_id,
262                actor_role,
263            )
264        },
265    )
266    .await?;
267    tx.commit().await?;
268    Ok(retired.len() as i64)
269}
270
271async fn person_id_of_mails(
272    conn: &mut PgConnection,
273    course_id: Uuid,
274    student_number: &str,
275) -> ModelResult<Option<DbSecret>> {
276    let mails = credit_registration_account_linking_emails::get_by_course_id_and_student_number(
277        conn,
278        course_id,
279        student_number,
280    )
281    .await?;
282    Ok(mails.into_iter().next().map(|mail| mail.sisu_person_id))
283}
284
285/// How many mails the caps still allow this person for this course.
286fn remaining_allowance(
287    person: &DiscoveredPerson,
288    facts: &[&ExistingLinkingMailFact],
289    quiet_since: DateTime<Utc>,
290) -> i64 {
291    // The quiet period is about the person's inbox, so it ignores the course.
292    if facts.iter().any(|fact| fact.sent_at >= quiet_since) {
293        return 0;
294    }
295    let already_sent = facts
296        .iter()
297        .filter(|fact| fact.course_id == person.course_id)
298        .count() as i64;
299    (MAX_LINKING_MAILS_PER_PERSON_AND_COURSE - already_sent).max(0)
300}
301
302/// Whether this (person, course, address) already had its mail. The unique index behind
303/// [`claim_send_slots`] is what actually prevents a second one.
304fn already_mailed(facts: &[&ExistingLinkingMailFact], course_id: Uuid, address: &str) -> bool {
305    facts.iter().any(|fact| {
306        fact.course_id == course_id
307            && fact
308                .emailed_to
309                .expose_secret()
310                .eq_ignore_ascii_case(address)
311    })
312}
313
314/// Soft-deletes tokens whose slot lost the race, so no unusable link is left behind.
315async fn void_tokens(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
316    if ids.is_empty() {
317        return Ok(());
318    }
319    sqlx::query!(
320        r#"
321UPDATE student_number_verification_tokens
322SET deleted_at = now()
323WHERE id = ANY($1::uuid [])
324  AND deleted_at IS NULL
325        "#,
326        ids,
327    )
328    .execute(conn)
329    .await?;
330    Ok(())
331}
332
333/// Sisu can list one address twice and the dedup key is case-insensitive, so the pair is collapsed
334/// before either is charged against a cap.
335fn distinct_addresses(addresses: &[DbSecret]) -> Vec<DbSecret> {
336    let mut kept: Vec<DbSecret> = Vec::new();
337    for address in addresses {
338        let trimmed = address.expose_secret().trim();
339        if trimmed.is_empty() {
340            continue;
341        }
342        if kept
343            .iter()
344            .any(|seen| seen.expose_secret().eq_ignore_ascii_case(trimmed))
345        {
346            continue;
347        }
348        kept.push(DbSecret::new(trimmed));
349    }
350    kept
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::credit_registration_account_linking_emails::{
357        count_sent_for_person_and_course, get_by_sisu_person_id,
358    };
359    use crate::credit_registration_admin_actions::{self, GLOBAL_ADMIN_ROLE};
360    use crate::student_number_verification_tokens::{claim, get_by_ids};
361    use crate::test_helper::*;
362
363    fn person(course_id: Uuid, addresses: &[&str]) -> DiscoveredPerson {
364        DiscoveredPerson {
365            sisu_person_id: "hy-hlo-1".to_string().into(),
366            student_number: "012345678".to_string().into(),
367            first_names: Some("Aada Maria".to_string().into()),
368            last_name: Some("Virtanen".to_string().into()),
369            course_id,
370            addresses: addresses.iter().map(|a| DbSecret::new(*a)).collect(),
371        }
372    }
373
374    #[tokio::test]
375    async fn each_address_of_a_person_gets_its_own_mail_and_token() {
376        insert_data!(:tx, :user, :org, :course);
377        let claimed = claim_linking_mails(
378            tx.as_mut(),
379            &person(course, &["aada.uni@example.com", "aada@example.com"]),
380        )
381        .await
382        .unwrap();
383        assert_eq!(claimed.claimed, 2);
384        assert_eq!(claimed.suppressed_by_dedup, 0);
385        let rows = get_by_sisu_person_id(tx.as_mut(), "hy-hlo-1")
386            .await
387            .unwrap();
388        assert_eq!(rows.len(), 2);
389        for row in rows {
390            assert!(row.student_number_verification_token_id.is_some());
391            assert!(row.email_delivery_id.is_none());
392        }
393    }
394
395    #[tokio::test]
396    async fn one_address_repeated_is_claimed_once() {
397        insert_data!(:tx, :user, :org, :course);
398        let claimed = claim_linking_mails(
399            tx.as_mut(),
400            &person(
401                course,
402                &["Aada.Uni@Example.com", "aada.uni@example.com", "  "],
403            ),
404        )
405        .await
406        .unwrap();
407        assert_eq!(claimed.claimed, 1);
408        assert_eq!(claimed.suppressed_by_rate_cap, 0);
409    }
410
411    #[tokio::test]
412    async fn mailing_the_same_address_twice_is_refused_as_a_duplicate() {
413        insert_data!(:tx, :user, :org, :course);
414        let discovered = person(course, &["aada.uni@example.com"]);
415        assert_eq!(
416            claim_linking_mails(tx.as_mut(), &discovered).await.unwrap(),
417            ClaimedLinkingMails {
418                claimed: 1,
419                ..ClaimedLinkingMails::default()
420            }
421        );
422        assert_eq!(
423            claim_linking_mails(tx.as_mut(), &discovered).await.unwrap(),
424            ClaimedLinkingMails {
425                suppressed_by_dedup: 1,
426                ..ClaimedLinkingMails::default()
427            }
428        );
429        assert_eq!(
430            get_by_sisu_person_id(tx.as_mut(), "hy-hlo-1")
431                .await
432                .unwrap()
433                .len(),
434            1
435        );
436    }
437
438    #[tokio::test]
439    async fn a_person_mailed_today_is_left_alone_even_at_another_address() {
440        insert_data!(:tx, :user, :org, :course);
441        claim_linking_mails(tx.as_mut(), &person(course, &["aada.uni@example.com"]))
442            .await
443            .unwrap();
444        let claimed = claim_linking_mails(tx.as_mut(), &person(course, &["aada@example.com"]))
445            .await
446            .unwrap();
447        assert_eq!(
448            claimed,
449            ClaimedLinkingMails {
450                suppressed_by_rate_cap: 1,
451                ..ClaimedLinkingMails::default()
452            }
453        );
454    }
455
456    #[tokio::test]
457    async fn a_person_and_course_are_never_mailed_more_than_the_cap() {
458        insert_data!(:tx, :user, :org, :course);
459        let cap = usize::try_from(MAX_LINKING_MAILS_PER_PERSON_AND_COURSE).unwrap();
460        let addresses: Vec<DbSecret> = (0..cap + 2)
461            .map(|i| DbSecret::new(format!("aada{i}@example.com")))
462            .collect();
463        let claimed = claim_linking_mails(
464            tx.as_mut(),
465            &DiscoveredPerson {
466                addresses,
467                ..person(course, &[])
468            },
469        )
470        .await
471        .unwrap();
472        assert_eq!(
473            i64::from(claimed.claimed),
474            MAX_LINKING_MAILS_PER_PERSON_AND_COURSE
475        );
476        assert_eq!(claimed.suppressed_by_rate_cap, 2);
477    }
478
479    #[tokio::test]
480    async fn the_token_is_created_unbound_and_can_be_claimed_only_once() {
481        insert_data!(:tx, :user, :org, :course);
482        claim_linking_mails(tx.as_mut(), &person(course, &["aada.uni@example.com"]))
483            .await
484            .unwrap();
485        let slot = get_by_sisu_person_id(tx.as_mut(), "hy-hlo-1")
486            .await
487            .unwrap()
488            .pop()
489            .expect("the claim wrote a slot");
490        let token_id = slot.student_number_verification_token_id.unwrap();
491        let token = get_by_ids(tx.as_mut(), &[token_id])
492            .await
493            .unwrap()
494            .remove(&token_id)
495            .expect("the claim minted a token");
496        assert_eq!(token.claimed_by_user_id, None);
497        assert_eq!(token.used_at, None);
498        assert!(token.expires_at > Utc::now());
499
500        assert!(claim(tx.as_mut(), &token.token, user).await.unwrap());
501        assert!(!claim(tx.as_mut(), &token.token, user).await.unwrap());
502    }
503
504    #[tokio::test]
505    async fn the_rate_cap_override_retires_the_ledger_rows_and_audits_itself() {
506        insert_data!(:tx, :user, :org, :course);
507
508        let claimed = claim_linking_mails(
509            tx.as_mut(),
510            &DiscoveredPerson {
511                sisu_person_id: "hy-hlo-1".to_string().into(),
512                student_number: "012345678".to_string().into(),
513                first_names: Some("Aada Maria".to_string().into()),
514                last_name: Some("Virtanen".to_string().into()),
515                course_id: course,
516                addresses: vec![DbSecret::new("aada.uni@example.com")],
517            },
518        )
519        .await
520        .unwrap();
521        assert_eq!(claimed.claimed, 1);
522        assert_eq!(
523            count_sent_for_person_and_course(tx.as_mut(), "hy-hlo-1", course)
524                .await
525                .unwrap(),
526            1
527        );
528
529        let retired = retire_capped_mails(
530            tx.as_mut(),
531            user,
532            GLOBAL_ADMIN_ROLE,
533            course,
534            "012345678",
535            "The recipient's mail host rejects everything we send.",
536        )
537        .await
538        .unwrap();
539        assert_eq!(retired, 1);
540        assert_eq!(
541            count_sent_for_person_and_course(tx.as_mut(), "hy-hlo-1", course)
542                .await
543                .unwrap(),
544            0
545        );
546
547        let actions = credit_registration_admin_actions::get_by_actor(tx.as_mut(), user, 10)
548            .await
549            .unwrap();
550        assert_eq!(actions.len(), 1);
551        let action = &actions[0];
552        assert_eq!(
553            action.action,
554            CreditRegistrationAdminAction::OverrideRateCap
555        );
556        assert_eq!(action.actor_role, GLOBAL_ADMIN_ROLE);
557        assert_eq!(
558            action.reason.as_deref(),
559            Some("The recipient's mail host rejects everything we send.")
560        );
561        assert_eq!(action.affected_row_count, Some(1));
562    }
563
564    #[tokio::test]
565    async fn an_override_with_nothing_to_retire_writes_nothing() {
566        insert_data!(:tx, :user, :org, :course);
567
568        let retired = retire_capped_mails(
569            tx.as_mut(),
570            user,
571            GLOBAL_ADMIN_ROLE,
572            course,
573            "012345678",
574            "No mails yet.",
575        )
576        .await
577        .unwrap();
578        assert_eq!(retired, 0);
579        assert!(
580            credit_registration_admin_actions::get_by_actor(tx.as_mut(), user, 10)
581                .await
582                .unwrap()
583                .is_empty()
584        );
585    }
586}