Skip to main content

headless_lms_models/
student_number_verification_tokens.rs

1use std::collections::HashMap;
2
3use rand::distr::{Alphanumeric, SampleString};
4use secrecy::ExposeSecret;
5
6use crate::prelude::*;
7
8/// Length of a linking token, which is the only proof of ownership and is bound to no account.
9const TOKEN_LENGTH: usize = 128;
10
11#[derive(Debug, Clone)]
12pub struct StudentNumberVerificationToken {
13    pub id: Uuid,
14    pub created_at: DateTime<Utc>,
15    pub updated_at: DateTime<Utc>,
16    pub deleted_at: Option<DateTime<Utc>>,
17    pub token: DbSecret,
18    pub claimed_by_user_id: Option<Uuid>,
19    pub student_number: String,
20    pub sisu_person_id: String,
21    pub first_names: Option<String>,
22    pub last_name: Option<String>,
23    pub emailed_to: String,
24    pub course_id: Option<Uuid>,
25    pub expires_at: DateTime<Utc>,
26    pub used_at: Option<DateTime<Utc>>,
27}
28
29#[derive(Debug, Clone, PartialEq)]
30pub struct NewStudentNumberVerificationToken {
31    pub student_number: String,
32    pub sisu_person_id: String,
33    pub first_names: Option<String>,
34    pub last_name: Option<String>,
35    pub emailed_to: String,
36    pub course_id: Option<Uuid>,
37}
38
39/// Mints a token for a Sisu person, bound to no account: the click while logged in creates the
40/// binding. Returns the row id and the plaintext token for the mailed link.
41pub async fn insert(
42    conn: &mut PgConnection,
43    pkey_policy: PKeyPolicy<Uuid>,
44    new: &NewStudentNumberVerificationToken,
45) -> ModelResult<(Uuid, DbSecret)> {
46    let token = DbSecret::new(Alphanumeric.sample_string(&mut rand::rng(), TOKEN_LENGTH));
47    let res = sqlx::query!(
48        r#"
49INSERT INTO student_number_verification_tokens (
50    id,
51    token,
52    student_number,
53    sisu_person_id,
54    first_names,
55    last_name,
56    emailed_to,
57    course_id
58  )
59VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
60RETURNING id
61        "#,
62        pkey_policy.into_uuid(),
63        token.expose_secret(),
64        new.student_number,
65        new.sisu_person_id,
66        new.first_names,
67        new.last_name,
68        new.emailed_to,
69        new.course_id,
70    )
71    .fetch_one(conn)
72    .await?;
73    Ok((res.id, token))
74}
75
76/// Batch form of [`insert`]: mints one token per row in a single `INSERT`, at the caller-chosen
77/// ids, in order. Each row still gets its own random plaintext token.
78pub async fn insert_batch(
79    conn: &mut PgConnection,
80    ids: &[Uuid],
81    news: &[NewStudentNumberVerificationToken],
82) -> ModelResult<()> {
83    if ids.is_empty() {
84        return Ok(());
85    }
86    let tokens: Vec<String> = (0..news.len())
87        .map(|_| Alphanumeric.sample_string(&mut rand::rng(), TOKEN_LENGTH))
88        .collect();
89    let student_numbers: Vec<String> = news.iter().map(|n| n.student_number.clone()).collect();
90    let sisu_person_ids: Vec<String> = news.iter().map(|n| n.sisu_person_id.clone()).collect();
91    let first_names: Vec<Option<String>> = news.iter().map(|n| n.first_names.clone()).collect();
92    let last_names: Vec<Option<String>> = news.iter().map(|n| n.last_name.clone()).collect();
93    let emailed_tos: Vec<String> = news.iter().map(|n| n.emailed_to.clone()).collect();
94    let course_ids: Vec<Option<Uuid>> = news.iter().map(|n| n.course_id).collect();
95
96    sqlx::query!(
97        r#"
98INSERT INTO student_number_verification_tokens (
99    id,
100    token,
101    student_number,
102    sisu_person_id,
103    first_names,
104    last_name,
105    emailed_to,
106    course_id
107  )
108SELECT * FROM UNNEST($1::uuid [], $2::text [], $3::text [], $4::text [], $5::text [], $6::text [], $7::text [], $8::uuid [])
109        "#,
110        ids,
111        &tokens,
112        &student_numbers,
113        &sisu_person_ids,
114        &first_names as &[Option<String>],
115        &last_names as &[Option<String>],
116        &emailed_tos,
117        &course_ids as &[Option<Uuid>],
118    )
119    .execute(conn)
120    .await?;
121    Ok(())
122}
123
124/// A token row with everything pinned, for the seed only.
125#[derive(Debug, Clone, PartialEq)]
126pub struct SeedStudentNumberVerificationToken {
127    /// Fixed plaintext so a spec can navigate straight to the link. At least 128 characters, or the
128    /// `student_number_verification_token_length` check rejects the row.
129    pub token: String,
130    pub student_number: String,
131    pub sisu_person_id: String,
132    pub first_names: Option<String>,
133    pub last_name: Option<String>,
134    pub emailed_to: String,
135    pub course_id: Option<Uuid>,
136    pub expires_at: DateTime<Utc>,
137    pub used_at: Option<DateTime<Utc>>,
138    pub claimed_by_user_id: Option<Uuid>,
139}
140
141/// Seeds a token with a fixed plaintext value and a chosen expiry/claim state, which [`insert`]
142/// cannot do: system tests need the valid, expired and used links to be constants. Seed use only.
143pub async fn insert_seed_row(
144    conn: &mut PgConnection,
145    pkey_policy: PKeyPolicy<Uuid>,
146    seed: &SeedStudentNumberVerificationToken,
147) -> ModelResult<Uuid> {
148    let res = sqlx::query!(
149        r#"
150INSERT INTO student_number_verification_tokens (
151    id,
152    token,
153    student_number,
154    sisu_person_id,
155    first_names,
156    last_name,
157    emailed_to,
158    course_id,
159    expires_at,
160    used_at,
161    claimed_by_user_id
162  )
163VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
164RETURNING id
165        "#,
166        pkey_policy.into_uuid(),
167        seed.token,
168        seed.student_number,
169        seed.sisu_person_id,
170        seed.first_names,
171        seed.last_name,
172        seed.emailed_to,
173        seed.course_id,
174        seed.expires_at,
175        seed.used_at,
176        seed.claimed_by_user_id,
177    )
178    .fetch_one(conn)
179    .await?;
180    Ok(res.id)
181}
182
183/// Looks up a token in any state, so the landing page can tell an expired link from a spent one.
184pub async fn get_by_token_any_state(
185    conn: &mut PgConnection,
186    token: &DbSecret,
187) -> ModelResult<Option<StudentNumberVerificationToken>> {
188    let res = sqlx::query_as!(
189        StudentNumberVerificationToken,
190        r#"
191SELECT *
192FROM student_number_verification_tokens
193WHERE token = $1
194        "#,
195        token.expose_secret()
196    )
197    .fetch_optional(conn)
198    .await?;
199    Ok(res)
200}
201
202/// The live tokens of these ids, keyed by id.
203pub async fn get_by_ids(
204    conn: &mut PgConnection,
205    ids: &[Uuid],
206) -> ModelResult<HashMap<Uuid, StudentNumberVerificationToken>> {
207    let res = sqlx::query_as!(
208        StudentNumberVerificationToken,
209        r#"
210SELECT *
211FROM student_number_verification_tokens
212WHERE id = ANY($1::uuid [])
213  AND deleted_at IS NULL
214        "#,
215        ids
216    )
217    .fetch_all(conn)
218    .await?;
219    Ok(res.into_iter().map(|row| (row.id, row)).collect())
220}
221
222/// Marks the token claimed by the account. Returns false if another claim already won the race.
223pub async fn claim(
224    conn: &mut PgConnection,
225    token: &DbSecret,
226    claimed_by_user_id: Uuid,
227) -> ModelResult<bool> {
228    let claimed = sqlx::query!(
229        r#"
230UPDATE student_number_verification_tokens
231SET used_at = now(),
232  claimed_by_user_id = $2
233WHERE token = $1
234  AND used_at IS NULL
235  AND deleted_at IS NULL
236  AND expires_at > now()
237RETURNING id
238        "#,
239        token.expose_secret(),
240        claimed_by_user_id,
241    )
242    .fetch_optional(conn)
243    .await?;
244    Ok(claimed.is_some())
245}
246
247/// Retires tokens whose link has expired unused, oldest first.
248///
249/// Soft delete, not a delete: `credit_registration_account_linking_emails` references these rows,
250/// and the dedup ledger has to keep saying which token a mail carried.
251pub async fn soft_delete_expired(conn: &mut PgConnection, limit: i64) -> ModelResult<u64> {
252    let res = sqlx::query!(
253        r#"
254UPDATE student_number_verification_tokens
255SET deleted_at = now()
256WHERE id IN (
257    SELECT id
258    FROM student_number_verification_tokens
259    WHERE used_at IS NULL
260      AND deleted_at IS NULL
261      AND expires_at < now()
262    ORDER BY expires_at
263    LIMIT $1
264  )
265        "#,
266        limit
267    )
268    .execute(conn)
269    .await?;
270    Ok(res.rows_affected())
271}
272
273/// Retires outstanding tokens for a student number, once the link was established some other way.
274pub async fn soft_delete_unused_for_student_number(
275    conn: &mut PgConnection,
276    student_number: &str,
277) -> ModelResult<u64> {
278    let res = sqlx::query!(
279        r#"
280UPDATE student_number_verification_tokens
281SET deleted_at = now()
282WHERE student_number = $1
283  AND used_at IS NULL
284  AND deleted_at IS NULL
285        "#,
286        student_number
287    )
288    .execute(conn)
289    .await?;
290    Ok(res.rows_affected())
291}