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