Skip to main content

headless_lms_models/
student_number_verification_tokens.rs

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