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::{BTreeSet, 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, replace_lapsed_slots,
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 long after a linking mail for a course the same person may get another for it.
44pub const LINKING_MAIL_RESEND_INTERVAL: TimeDelta = TimeDelta::days(7);
45
46/// How many linking mails one person may ever get for one course, tokens that expired unused
47/// included.
48pub const MAX_LINKING_MAILS_PER_PERSON_AND_COURSE: i64 = 3;
49
50/// No override retires a mail claimed this recently, so a second click, tab or colleague cannot
51/// replace one that has only just gone out.
52pub const JUST_CLAIMED_PERIOD: TimeDelta = TimeDelta::minutes(1);
53
54/// What [`retire_capped_mails`] did.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct RetiredMails {
57    pub retired_count: i64,
58    /// A mail within [`JUST_CLAIMED_PERIOD`] was left in place, where the caps still count it.
59    pub kept_just_claimed: bool,
60}
61
62/// One person Sisu's `list-by-course` returned, and the address we mail them at.
63#[derive(Debug, Clone)]
64pub struct DiscoveredPerson {
65    pub sisu_person_id: DbSecret,
66    pub student_number: DbSecret,
67    pub first_names: Option<DbSecret>,
68    pub last_name: Option<DbSecret>,
69    pub course_id: Uuid,
70    /// The one address in Sisu we mail, trimmed: each address mailed would spend one of the
71    /// person's mails for the course. See [`mail_address`].
72    pub address: Option<DbSecret>,
73}
74
75impl DiscoveredPerson {
76    /// `person` as listed on a roster of `course_id`'s; the caller decides what a missing address
77    /// means.
78    pub fn listed(person: &RosterPerson, course_id: Uuid) -> Self {
79        Self {
80            sisu_person_id: person.person_id.clone().into(),
81            student_number: person.student_number.clone().into(),
82            first_names: person.first_names.clone().map(Into::into),
83            last_name: person.last_name.clone().map(Into::into),
84            course_id,
85            address: mail_address(person),
86        }
87    }
88}
89
90/// The address a linking mail to `person` goes to, if Sisu holds one: the primary, else the
91/// secondary.
92///
93/// Sisu verifies neither, so the confirmation page shows the Sisu name to make a planted address
94/// harder to exploit. Open University students without a University account often have only a
95/// secondary address, and Sisu clears old primaries.
96pub fn mail_address(person: &RosterPerson) -> Option<DbSecret> {
97    listed_addresses(person).next().map(DbSecret::new)
98}
99
100/// The person's non-empty Sisu addresses, trimmed, primary first.
101pub fn listed_addresses(person: &RosterPerson) -> impl Iterator<Item = &str> {
102    [&person.primary_email, &person.secondary_email]
103        .into_iter()
104        .flatten()
105        .map(|address| address.expose_secret().trim())
106        .filter(|address| !address.is_empty())
107}
108
109/// For each of the lowercased `addresses` that is the account email of someone with a completion
110/// the push path will register (`credit_registration_eligible_completions`, prerequisites met and
111/// not held for review) on one of `course_ids` created at or after `since`, the courses it is on.
112pub async fn registerable_completion_courses_by_account_email(
113    conn: &mut PgConnection,
114    course_ids: &[Uuid],
115    since: DateTime<Utc>,
116    addresses: &[String],
117) -> ModelResult<HashMap<String, BTreeSet<Uuid>>> {
118    let rows = sqlx::query!(
119        r#"
120SELECT DISTINCT lower(user_details.email) AS "email!",
121  ec.course_id AS "course_id!"
122FROM credit_registration_eligible_completions ec
123  JOIN user_details ON user_details.user_id = ec.user_id
124WHERE ec.course_id = ANY($1::uuid [])
125  AND ec.created_at >= $2
126  AND ec.fully_eligible
127  AND lower(user_details.email) = ANY($3::text [])
128        "#,
129        course_ids,
130        since,
131        addresses,
132    )
133    .fetch_all(conn)
134    .await?;
135    let mut courses_by_email: HashMap<String, BTreeSet<Uuid>> = HashMap::new();
136    for row in rows {
137        courses_by_email
138            .entry(row.email)
139            .or_default()
140            .insert(row.course_id);
141    }
142    Ok(courses_by_email)
143}
144
145/// What claiming a linking mail did for one person. Ordered from weakest to strongest, so the
146/// strongest of several claims for one person is their maximum.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
148pub enum LinkingMailClaim {
149    NoAddress,
150    SuppressedByRateCap,
151    /// An earlier mail to the address still has a usable link, or another writer claimed it first.
152    SuppressedByDedup,
153    /// A mail the `link-emails` phase still owes.
154    Claimed,
155}
156
157/// One person's [`claim_linking_mails_batch`].
158pub async fn claim_linking_mail(
159    conn: &mut PgConnection,
160    person: &DiscoveredPerson,
161) -> ModelResult<LinkingMailClaim> {
162    claim_linking_mails_batch(conn, std::slice::from_ref(person))
163        .await?
164        .into_iter()
165        .next()
166        .ok_or_else(missing_model_error(
167            ModelErrorType::Generic,
168            "Claiming linking mails answered nothing for the one person asked about.",
169        ))
170}
171
172/// Claims one slot and one unbound token per person whose address has no usable link from us yet,
173/// dedup before the rate caps. Returns one claim per input, in order.
174pub async fn claim_linking_mails_batch(
175    conn: &mut PgConnection,
176    people: &[DiscoveredPerson],
177) -> ModelResult<Vec<LinkingMailClaim>> {
178    let mut claims = vec![LinkingMailClaim::NoAddress; people.len()];
179    let sisu_person_ids: Vec<String> = people
180        .iter()
181        .filter(|person| person.address.is_some())
182        .map(|person| person.sisu_person_id.expose_secret().to_owned())
183        .collect();
184    if sisu_person_ids.is_empty() {
185        return Ok(claims);
186    }
187    let facts = get_existing_facts_for_persons(conn, &sisu_person_ids).await?;
188    let mut by_person: HashMap<&str, Vec<&ExistingLinkingMailFact>> = HashMap::new();
189    for fact in &facts {
190        by_person
191            .entry(fact.sisu_person_id.expose_secret())
192            .or_default()
193            .push(fact);
194    }
195    let now = Utc::now();
196
197    // Tokens are minted before any slot is claimed: the random value cannot come from SQL.
198    let mut new_tokens = Vec::new();
199    let mut new_slots = Vec::new();
200    let mut token_ids = Vec::new();
201    let mut token_owner: HashMap<Uuid, usize> = HashMap::new();
202    for (i, person) in people.iter().enumerate() {
203        let Some(address) = &person.address else {
204            continue;
205        };
206        let person_facts = by_person
207            .get(person.sisu_person_id.expose_secret())
208            .map(Vec::as_slice)
209            .unwrap_or(&[]);
210        if holds_address(person_facts, person.course_id, address.expose_secret()) {
211            claims[i] = LinkingMailClaim::SuppressedByDedup;
212            continue;
213        }
214        if !is_within_caps(person, person_facts, now) {
215            claims[i] = LinkingMailClaim::SuppressedByRateCap;
216            continue;
217        }
218        let token_id = Uuid::new_v4();
219        new_tokens.push(NewStudentNumberVerificationToken {
220            student_number: person.student_number.clone(),
221            sisu_person_id: person.sisu_person_id.clone(),
222            first_names: person.first_names.clone(),
223            last_name: person.last_name.clone(),
224            emailed_to: address.clone(),
225            course_id: Some(person.course_id),
226        });
227        token_owner.insert(token_id, i);
228        token_ids.push(token_id);
229        new_slots.push(NewAccountLinkingEmail {
230            student_number: person.student_number.clone(),
231            sisu_person_id: person.sisu_person_id.clone(),
232            course_id: person.course_id,
233            emailed_to: address.clone(),
234            student_number_verification_token_id: Some(token_id),
235            email_delivery_id: None,
236        });
237    }
238    if new_slots.is_empty() {
239        return Ok(claims);
240    }
241    insert_tokens_batch(conn, &token_ids, &new_tokens).await?;
242
243    replace_lapsed_slots(conn, &new_slots).await?;
244    let claimed_token_ids = claim_send_slots(conn, &new_slots, &token_ids).await?;
245    let lost_token_ids: Vec<Uuid> = token_owner
246        .keys()
247        .filter(|id| !claimed_token_ids.contains(id))
248        .copied()
249        .collect();
250    // A refused claim must leave no usable token behind: nobody would ever send that link.
251    void_tokens(conn, &lost_token_ids).await?;
252    for (token_id, person_index) in &token_owner {
253        claims[*person_index] = if claimed_token_ids.contains(token_id) {
254            LinkingMailClaim::Claimed
255        } else {
256            // Lost the race to another writer; same outcome for the recipient as dedup.
257            LinkingMailClaim::SuppressedByDedup
258        };
259    }
260    Ok(claims)
261}
262
263/// Retires the linking-mail rows the caps are counting for this person, so the ordinary claim path can
264/// take a slot again. No parameter relaxes a cap: the single writer of the ledger evaluates them from
265/// the rows that exist, so getting past one means soft-deleting rows, audited as its own action.
266pub async fn retire_capped_mails(
267    conn: &mut PgConnection,
268    actor_user_id: Uuid,
269    actor_role: &str,
270    course_id: Uuid,
271    student_number: &str,
272    reason: &str,
273) -> ModelResult<RetiredMails> {
274    let mut outcome = RetiredMails {
275        retired_count: 0,
276        kept_just_claimed: false,
277    };
278    let Some(person_id) = person_id_of_mails(conn, course_id, student_number).await? else {
279        return Ok(outcome);
280    };
281    let now = Utc::now();
282    let quiet_since = now - LINKING_MAIL_QUIET_PERIOD;
283    let mails = credit_registration_account_linking_emails::get_by_sisu_person_id(
284        conn,
285        person_id.expose_secret(),
286    )
287    .await?;
288    // This course's rows carry the dedup guard and the lifetime cap; a recent row on any course
289    // carries the quiet period, which is about the person's inbox rather than one course.
290    let (just_claimed, retirable): (Vec<_>, Vec<_>) = mails
291        .iter()
292        .filter(|mail| mail.course_id == course_id || mail.sent_at >= quiet_since)
293        .partition(|mail| mail.sent_at >= now - JUST_CLAIMED_PERIOD);
294    outcome.kept_just_claimed = !just_claimed.is_empty();
295    let retired: Vec<Uuid> = retirable.iter().map(|mail| mail.id).collect();
296    if retired.is_empty() {
297        return Ok(outcome);
298    }
299
300    let mut tx = conn.begin().await?;
301    credit_registration_account_linking_emails::soft_delete_batch(&mut tx, &retired).await?;
302    crate::credit_registration_admin_actions::record(
303        &mut tx,
304        &NewCreditRegistrationAdminAction {
305            target_id: Some(course_id),
306            reason: Some(reason.to_string()),
307            details: Some(serde_json::json!({
308                "student_number": student_number,
309                "retired_linking_email_ids": retired,
310            })),
311            affected_row_count: Some(i32::try_from(retired.len()).unwrap_or(i32::MAX)),
312            ..NewCreditRegistrationAdminAction::new(
313                CreditRegistrationAdminAction::OverrideRateCap,
314                CreditRegistrationAdminActionTarget::Course,
315                actor_user_id,
316                actor_role,
317            )
318        },
319    )
320    .await?;
321    tx.commit().await?;
322    outcome.retired_count = retired.len() as i64;
323    Ok(outcome)
324}
325
326async fn person_id_of_mails(
327    conn: &mut PgConnection,
328    course_id: Uuid,
329    student_number: &str,
330) -> ModelResult<Option<DbSecret>> {
331    let mails = credit_registration_account_linking_emails::get_by_course_id_and_student_number(
332        conn,
333        course_id,
334        student_number,
335    )
336    .await?;
337    Ok(mails.into_iter().next().map(|mail| mail.sisu_person_id))
338}
339
340/// Whether the caps still allow this person another mail for this course.
341fn is_within_caps(
342    person: &DiscoveredPerson,
343    facts: &[&ExistingLinkingMailFact],
344    now: DateTime<Utc>,
345) -> bool {
346    // The quiet period is about the person's inbox, so it ignores the course.
347    if facts
348        .iter()
349        .any(|fact| fact.sent_at >= now - LINKING_MAIL_QUIET_PERIOD)
350    {
351        return false;
352    }
353    let course_facts: Vec<_> = facts
354        .iter()
355        .filter(|fact| fact.course_id == person.course_id)
356        .collect();
357    !course_facts
358        .iter()
359        .any(|fact| fact.sent_at >= now - LINKING_MAIL_RESEND_INTERVAL)
360        && (course_facts.len() as i64) < MAX_LINKING_MAILS_PER_PERSON_AND_COURSE
361}
362
363/// Whether a mail to this (person, course, address) still holds the address. The unique index
364/// behind [`claim_send_slots`] is what actually prevents a second one.
365fn holds_address(facts: &[&ExistingLinkingMailFact], course_id: Uuid, address: &str) -> bool {
366    facts.iter().any(|fact| {
367        fact.holds_address
368            && fact.course_id == course_id
369            && fact
370                .emailed_to
371                .expose_secret()
372                .eq_ignore_ascii_case(address)
373    })
374}
375
376/// Soft-deletes tokens whose slot lost the race, so no unusable link is left behind.
377async fn void_tokens(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
378    if ids.is_empty() {
379        return Ok(());
380    }
381    sqlx::query!(
382        r#"
383UPDATE student_number_verification_tokens
384SET deleted_at = now()
385WHERE id = ANY($1::uuid [])
386  AND deleted_at IS NULL
387        "#,
388        ids,
389    )
390    .execute(conn)
391    .await?;
392    Ok(())
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use crate::credit_registration_account_linking_emails::{
399        count_sent_for_person_and_course, get_by_sisu_person_id,
400    };
401    use crate::credit_registration_admin_actions::{self, GLOBAL_ADMIN_ROLE};
402    use crate::student_number_verification_tokens::{claim, get_by_ids};
403    use crate::test_helper::*;
404
405    fn person(course_id: Uuid, address: &str) -> DiscoveredPerson {
406        DiscoveredPerson {
407            sisu_person_id: "hy-hlo-1".to_string().into(),
408            student_number: "012345678".to_string().into(),
409            first_names: Some("Aada Maria".to_string().into()),
410            last_name: Some("Virtanen".to_string().into()),
411            course_id,
412            address: Some(DbSecret::new(address)),
413        }
414    }
415
416    #[tokio::test]
417    async fn mailing_the_same_address_twice_is_refused_as_a_duplicate() {
418        insert_data!(:tx, :user, :org, :course);
419        let discovered = person(course, "aada.uni@example.com");
420        assert_eq!(
421            claim_linking_mail(tx.as_mut(), &discovered).await.unwrap(),
422            LinkingMailClaim::Claimed
423        );
424        assert_eq!(
425            claim_linking_mail(tx.as_mut(), &discovered).await.unwrap(),
426            LinkingMailClaim::SuppressedByDedup
427        );
428        assert_eq!(
429            get_by_sisu_person_id(tx.as_mut(), "hy-hlo-1")
430                .await
431                .unwrap()
432                .len(),
433            1
434        );
435    }
436
437    #[tokio::test]
438    async fn a_person_mailed_today_is_left_alone_even_at_another_address() {
439        insert_data!(:tx, :user, :org, :course);
440        claim_linking_mail(tx.as_mut(), &person(course, "aada.uni@example.com"))
441            .await
442            .unwrap();
443        let claimed = claim_linking_mail(tx.as_mut(), &person(course, "aada@example.com"))
444            .await
445            .unwrap();
446        assert_eq!(claimed, LinkingMailClaim::SuppressedByRateCap);
447    }
448
449    #[tokio::test]
450    async fn the_token_is_created_unbound_and_can_be_claimed_only_once() {
451        insert_data!(:tx, :user, :org, :course);
452        claim_linking_mail(tx.as_mut(), &person(course, "aada.uni@example.com"))
453            .await
454            .unwrap();
455        let slot = get_by_sisu_person_id(tx.as_mut(), "hy-hlo-1")
456            .await
457            .unwrap()
458            .pop()
459            .expect("the claim wrote a slot");
460        let token_id = slot.student_number_verification_token_id.unwrap();
461        let token = get_by_ids(tx.as_mut(), &[token_id])
462            .await
463            .unwrap()
464            .remove(&token_id)
465            .expect("the claim minted a token");
466        assert_eq!(token.claimed_by_user_id, None);
467        assert_eq!(token.used_at, None);
468        assert!(token.expires_at > Utc::now());
469
470        assert!(claim(tx.as_mut(), &token.token, user).await.unwrap());
471        assert!(!claim(tx.as_mut(), &token.token, user).await.unwrap());
472    }
473
474    #[tokio::test]
475    async fn the_rate_cap_override_retires_the_ledger_rows_and_audits_itself() {
476        insert_data!(:tx, :user, :org, :course);
477
478        let claimed = claim_linking_mail(tx.as_mut(), &person(course, "aada.uni@example.com"))
479            .await
480            .unwrap();
481        assert_eq!(claimed, LinkingMailClaim::Claimed);
482        assert_eq!(
483            count_sent_for_person_and_course(tx.as_mut(), "hy-hlo-1", course)
484                .await
485                .unwrap(),
486            1
487        );
488        let mails = credit_registration_account_linking_emails::get_by_sisu_person_id(
489            tx.as_mut(),
490            "hy-hlo-1",
491        )
492        .await
493        .unwrap();
494        for mail in mails {
495            credit_registration_account_linking_emails::backdate(
496                tx.as_mut(),
497                mail.id,
498                chrono::Duration::hours(1),
499            )
500            .await
501            .unwrap();
502        }
503
504        let retired = retire_capped_mails(
505            tx.as_mut(),
506            user,
507            GLOBAL_ADMIN_ROLE,
508            course,
509            "012345678",
510            "The recipient's mail host rejects everything we send.",
511        )
512        .await
513        .unwrap();
514        assert_eq!(retired.retired_count, 1);
515        assert_eq!(
516            count_sent_for_person_and_course(tx.as_mut(), "hy-hlo-1", course)
517                .await
518                .unwrap(),
519            0
520        );
521
522        let actions = credit_registration_admin_actions::get_by_actor(tx.as_mut(), user, 10)
523            .await
524            .unwrap();
525        assert_eq!(actions.len(), 1);
526        let action = &actions[0];
527        assert_eq!(
528            action.action,
529            CreditRegistrationAdminAction::OverrideRateCap
530        );
531        assert_eq!(action.actor_role, GLOBAL_ADMIN_ROLE);
532        assert_eq!(
533            action.reason.as_deref(),
534            Some("The recipient's mail host rejects everything we send.")
535        );
536        assert_eq!(action.affected_row_count, Some(1));
537    }
538
539    #[tokio::test]
540    async fn an_override_with_nothing_to_retire_writes_nothing() {
541        insert_data!(:tx, :user, :org, :course);
542
543        let retired = retire_capped_mails(
544            tx.as_mut(),
545            user,
546            GLOBAL_ADMIN_ROLE,
547            course,
548            "012345678",
549            "No mails yet.",
550        )
551        .await
552        .unwrap();
553        assert_eq!(retired.retired_count, 0);
554        assert!(
555            credit_registration_admin_actions::get_by_actor(tx.as_mut(), user, 10)
556                .await
557                .unwrap()
558                .is_empty()
559        );
560    }
561}