Skip to main content

headless_lms_models/
verified_student_numbers.rs

1use utoipa::ToSchema;
2
3use crate::credit_registration_events::CreditRegistrationEventKind;
4use crate::library::credit_registration::student_number_change::record_student_number_change;
5use crate::prelude::*;
6
7/// How a student number was proven to belong to an account.
8#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type, ToSchema)]
9#[sqlx(
10    type_name = "student_number_verification_method",
11    rename_all = "snake_case"
12)]
13#[serde(rename_all = "snake_case")]
14pub enum StudentNumberVerificationMethod {
15    EmailedLink,
16    EmailMatchFastTrack,
17    AdminManual,
18}
19
20#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
21pub struct VerifiedStudentNumber {
22    pub id: Uuid,
23    pub created_at: DateTime<Utc>,
24    pub updated_at: DateTime<Utc>,
25    pub deleted_at: Option<DateTime<Utc>>,
26    pub user_id: Uuid,
27    pub student_number: String,
28    pub sisu_person_id: String,
29    pub first_names: Option<String>,
30    pub last_name: Option<String>,
31    pub verified_at: DateTime<Utc>,
32    pub verified_via: StudentNumberVerificationMethod,
33    pub verified_via_email: Option<String>,
34    pub verified_via_email_match_field: Option<String>,
35    pub account_email_verified_at: Option<DateTime<Utc>>,
36    pub linked_by_user_id: Option<Uuid>,
37    pub link_reason: Option<String>,
38    pub verified_from_course_id: Option<Uuid>,
39    /// Only ever set for [`StudentNumberVerificationMethod::EmailMatchFastTrack`]: the other methods
40    /// have no notice to dismiss, because the student did the linking themselves.
41    pub auto_link_notice_dismissed_at: Option<DateTime<Utc>>,
42}
43
44#[derive(Debug, Clone, PartialEq)]
45pub struct NewVerifiedStudentNumber {
46    pub user_id: Uuid,
47    pub student_number: String,
48    pub sisu_person_id: String,
49    pub first_names: Option<String>,
50    pub last_name: Option<String>,
51    pub verified_via: StudentNumberVerificationMethod,
52    /// The Sisu-held address the proof rests on. Must be `None` exactly for `AdminManual`.
53    pub verified_via_email: Option<String>,
54    pub verified_via_email_match_field: Option<String>,
55    pub account_email_verified_at: Option<DateTime<Utc>>,
56    pub linked_by_user_id: Option<Uuid>,
57    pub link_reason: Option<String>,
58    pub verified_from_course_id: Option<Uuid>,
59}
60
61pub async fn insert(
62    conn: &mut PgConnection,
63    pkey_policy: PKeyPolicy<Uuid>,
64    new: &NewVerifiedStudentNumber,
65) -> ModelResult<Uuid> {
66    let res = sqlx::query!(
67        r#"
68INSERT INTO verified_student_numbers (
69    id,
70    user_id,
71    student_number,
72    sisu_person_id,
73    first_names,
74    last_name,
75    verified_via,
76    verified_via_email,
77    verified_via_email_match_field,
78    account_email_verified_at,
79    linked_by_user_id,
80    link_reason,
81    verified_from_course_id
82  )
83VALUES (
84    $1,
85    $2,
86    $3,
87    $4,
88    $5,
89    $6,
90    $7,
91    $8,
92    $9,
93    $10,
94    $11,
95    $12,
96    $13
97  )
98RETURNING id
99        "#,
100        pkey_policy.into_uuid(),
101        new.user_id,
102        new.student_number,
103        new.sisu_person_id,
104        new.first_names,
105        new.last_name,
106        new.verified_via as StudentNumberVerificationMethod,
107        new.verified_via_email,
108        new.verified_via_email_match_field,
109        new.account_email_verified_at,
110        new.linked_by_user_id,
111        new.link_reason,
112        new.verified_from_course_id,
113    )
114    .fetch_one(conn)
115    .await?;
116    Ok(res.id)
117}
118
119pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<VerifiedStudentNumber> {
120    let res = sqlx::query_as!(
121        VerifiedStudentNumber,
122        r#"
123SELECT *
124FROM verified_student_numbers
125WHERE id = $1
126  AND deleted_at IS NULL
127        "#,
128        id
129    )
130    .fetch_one(conn)
131    .await?;
132    Ok(res)
133}
134
135/// The account's live link, if it has one. At most one exists by partial unique index.
136pub async fn get_by_user_id(
137    conn: &mut PgConnection,
138    user_id: Uuid,
139) -> ModelResult<Option<VerifiedStudentNumber>> {
140    let res = sqlx::query_as!(
141        VerifiedStudentNumber,
142        r#"
143SELECT *
144FROM verified_student_numbers
145WHERE user_id = $1
146  AND deleted_at IS NULL
147        "#,
148        user_id
149    )
150    .fetch_optional(conn)
151    .await?;
152    Ok(res)
153}
154
155/// The account's most recent link, retired ones included: a retired link is the only record an
156/// unlinked account has of the Sisu person its linking mail was addressed to.
157pub async fn get_latest_including_deleted_by_user_id(
158    conn: &mut PgConnection,
159    user_id: Uuid,
160) -> ModelResult<Option<VerifiedStudentNumber>> {
161    let res = sqlx::query_as!(
162        VerifiedStudentNumber,
163        r#"
164SELECT *
165FROM verified_student_numbers
166WHERE user_id = $1
167ORDER BY verified_at DESC
168LIMIT 1
169        "#,
170        user_id
171    )
172    .fetch_optional(conn)
173    .await?;
174    Ok(res)
175}
176
177pub async fn get_by_student_number(
178    conn: &mut PgConnection,
179    student_number: &str,
180) -> ModelResult<Option<VerifiedStudentNumber>> {
181    let res = sqlx::query_as!(
182        VerifiedStudentNumber,
183        r#"
184SELECT *
185FROM verified_student_numbers
186WHERE student_number = $1
187  AND deleted_at IS NULL
188        "#,
189        student_number
190    )
191    .fetch_optional(conn)
192    .await?;
193    Ok(res)
194}
195
196/// The live link for one Sisu person. Unique alongside the student number, so a programme change
197/// that issues a new number still collides here.
198pub async fn get_by_sisu_person_id(
199    conn: &mut PgConnection,
200    sisu_person_id: &str,
201) -> ModelResult<Option<VerifiedStudentNumber>> {
202    let res = sqlx::query_as!(
203        VerifiedStudentNumber,
204        r#"
205SELECT *
206FROM verified_student_numbers
207WHERE sisu_person_id = $1
208  AND deleted_at IS NULL
209        "#,
210        sisu_person_id
211    )
212    .fetch_optional(conn)
213    .await?;
214    Ok(res)
215}
216
217pub async fn get_by_user_ids(
218    conn: &mut PgConnection,
219    user_ids: &[Uuid],
220) -> ModelResult<Vec<VerifiedStudentNumber>> {
221    let res = sqlx::query_as!(
222        VerifiedStudentNumber,
223        r#"
224SELECT *
225FROM verified_student_numbers
226WHERE user_id = ANY($1::uuid [])
227  AND deleted_at IS NULL
228        "#,
229        user_ids
230    )
231    .fetch_all(conn)
232    .await?;
233    Ok(res)
234}
235
236/// The person id rather than the number, because the number changes when a student moves between
237/// programmes while the person id does not.
238pub async fn get_by_sisu_person_ids(
239    conn: &mut PgConnection,
240    sisu_person_ids: &[String],
241) -> ModelResult<Vec<VerifiedStudentNumber>> {
242    let res = sqlx::query_as!(
243        VerifiedStudentNumber,
244        r#"
245SELECT *
246FROM verified_student_numbers
247WHERE sisu_person_id = ANY($1::varchar [])
248  AND deleted_at IS NULL
249        "#,
250        sisu_person_ids
251    )
252    .fetch_all(conn)
253    .await?;
254    Ok(res)
255}
256
257pub async fn get_by_student_numbers(
258    conn: &mut PgConnection,
259    student_numbers: &[String],
260) -> ModelResult<Vec<VerifiedStudentNumber>> {
261    let res = sqlx::query_as!(
262        VerifiedStudentNumber,
263        r#"
264SELECT *
265FROM verified_student_numbers
266WHERE student_number = ANY($1::varchar [])
267  AND deleted_at IS NULL
268        "#,
269        student_numbers
270    )
271    .fetch_all(conn)
272    .await?;
273    Ok(res)
274}
275
276/// Batched form of [`get_latest_including_deleted_by_user_id`], one row per account.
277pub async fn get_latest_including_deleted_by_user_ids(
278    conn: &mut PgConnection,
279    user_ids: &[Uuid],
280) -> ModelResult<Vec<VerifiedStudentNumber>> {
281    let res = sqlx::query_as!(
282        VerifiedStudentNumber,
283        r#"
284SELECT DISTINCT ON (user_id) *
285FROM verified_student_numbers
286WHERE user_id = ANY($1::uuid [])
287ORDER BY user_id, verified_at DESC
288        "#,
289        user_ids
290    )
291    .fetch_all(conn)
292    .await?;
293    Ok(res)
294}
295
296/// One link as an admin support view shows it, with the account it belongs to.
297#[derive(Debug, Clone, PartialEq)]
298pub struct AdminVerifiedStudentNumber {
299    pub id: Uuid,
300    pub user_id: Uuid,
301    pub user_email: Option<String>,
302    pub first_name: Option<String>,
303    pub last_name: Option<String>,
304    pub student_number: String,
305    pub sisu_person_id: String,
306    pub verified_at: DateTime<Utc>,
307    pub verified_via: StudentNumberVerificationMethod,
308    /// The Sisu-held address the proof rests on, in full. `None` for an admin-established link.
309    pub verified_via_email: Option<String>,
310    pub linked_by_user_id: Option<Uuid>,
311    pub link_reason: Option<String>,
312    pub verified_from_course_id: Option<Uuid>,
313    pub live_registration_count: i64,
314}
315
316/// A row with the page's total attached, so a page and its count can only come from one query.
317struct AdminPageRow {
318    id: Uuid,
319    user_id: Uuid,
320    user_email: Option<String>,
321    first_name: Option<String>,
322    last_name: Option<String>,
323    student_number: String,
324    sisu_person_id: String,
325    verified_at: DateTime<Utc>,
326    verified_via: StudentNumberVerificationMethod,
327    verified_via_email: Option<String>,
328    linked_by_user_id: Option<Uuid>,
329    link_reason: Option<String>,
330    verified_from_course_id: Option<Uuid>,
331    live_registration_count: i64,
332    total_count: i64,
333}
334
335/// Live links only, newest first: a retired link is not a number we hold. Returns the page together
336/// with how many rows match the filters in total, from one query via `COUNT(*) OVER()`.
337///
338/// `search` is escaped here, not by the caller: `escape_like_pattern` is easy to forget to call, and
339/// forgetting it would let `%`/`_` in a student number match more than intended.
340pub async fn get_admin_page(
341    conn: &mut PgConnection,
342    verified_via: Option<StudentNumberVerificationMethod>,
343    search: Option<&str>,
344    limit: i64,
345    offset: i64,
346) -> ModelResult<(Vec<AdminVerifiedStudentNumber>, i64)> {
347    let search_pattern = search
348        .map(str::trim)
349        .filter(|s| !s.is_empty())
350        .map(|s| crate::library::students_view::escape_like_pattern(&s.to_lowercase()));
351    let rows = sqlx::query_as!(
352        AdminPageRow,
353        r#"
354SELECT vsn.id,
355  vsn.user_id,
356  ud.email AS "user_email?",
357  ud.first_name AS "first_name?",
358  ud.last_name AS "last_name?",
359  vsn.student_number,
360  vsn.sisu_person_id,
361  vsn.verified_at,
362  vsn.verified_via,
363  vsn.verified_via_email,
364  vsn.linked_by_user_id,
365  vsn.link_reason,
366  vsn.verified_from_course_id,
367  (
368    SELECT COUNT(*)
369    FROM credit_registrations cr
370    WHERE cr.user_id = vsn.user_id
371      AND cr.superseded_by_id IS NULL
372      AND cr.deleted_at IS NULL
373  ) AS "live_registration_count!",
374  COUNT(*) OVER () AS "total_count!"
375FROM verified_student_numbers vsn
376  LEFT JOIN user_details ud ON ud.user_id = vsn.user_id
377WHERE vsn.deleted_at IS NULL
378  AND (
379    $1::student_number_verification_method IS NULL
380    OR vsn.verified_via = $1
381  )
382  AND (
383    $2::text IS NULL
384    OR LOWER(vsn.student_number) LIKE '%' || $2 || '%' ESCAPE '\'
385    OR ud.name_search_helper LIKE '%' || $2 || '%' ESCAPE '\'
386    OR ud.email_search_helper LIKE '%' || $2 || '%' ESCAPE '\'
387  )
388ORDER BY vsn.verified_at DESC,
389  vsn.id
390LIMIT $3 OFFSET $4
391        "#,
392        verified_via as Option<StudentNumberVerificationMethod>,
393        search_pattern.as_deref(),
394        limit,
395        offset,
396    )
397    .fetch_all(conn)
398    .await?;
399    let total_count = rows.first().map_or(0, |row| row.total_count);
400    let data = rows
401        .into_iter()
402        .map(|row| {
403            let AdminPageRow {
404                id,
405                user_id,
406                user_email,
407                first_name,
408                last_name,
409                student_number,
410                sisu_person_id,
411                verified_at,
412                verified_via,
413                verified_via_email,
414                linked_by_user_id,
415                link_reason,
416                verified_from_course_id,
417                live_registration_count,
418                total_count: _,
419            } = row;
420            AdminVerifiedStudentNumber {
421                id,
422                user_id,
423                user_email,
424                first_name,
425                last_name,
426                student_number,
427                sisu_person_id,
428                verified_at,
429                verified_via,
430                verified_via_email,
431                linked_by_user_id,
432                link_reason,
433                verified_from_course_id,
434                live_registration_count,
435            }
436        })
437        .collect();
438    Ok((data, total_count))
439}
440
441/// Live links per method, both all-time and since a cutoff, in one pass over the table, so an
442/// admin-established one is never hidden inside a total.
443pub async fn count_by_method_since(
444    conn: &mut PgConnection,
445    since: DateTime<Utc>,
446) -> ModelResult<Vec<(StudentNumberVerificationMethod, i64, i64)>> {
447    let rows = sqlx::query!(
448        r#"
449SELECT verified_via,
450  COUNT(*) AS "total!",
451  COUNT(*) FILTER (WHERE verified_at >= $1) AS "since_count!"
452FROM verified_student_numbers
453WHERE deleted_at IS NULL
454GROUP BY verified_via
455        "#,
456        since,
457    )
458    .fetch_all(conn)
459    .await?;
460    Ok(rows
461        .into_iter()
462        .map(|row| (row.verified_via, row.total, row.since_count))
463        .collect())
464}
465
466/// Puts away the "we linked this for you" notice for one account's live link. Idempotent; a link the
467/// account does not own is left alone, so the caller's ownership check is the only one needed.
468///
469/// Restricted to `email_match_fast_track`, the only method whose links show the notice at all, so the
470/// timestamp cannot end up on a link the student made themselves.
471pub async fn dismiss_auto_link_notice(conn: &mut PgConnection, user_id: Uuid) -> ModelResult<()> {
472    sqlx::query!(
473        r#"
474UPDATE verified_student_numbers
475SET auto_link_notice_dismissed_at = now()
476WHERE user_id = $1
477  AND deleted_at IS NULL
478  AND auto_link_notice_dismissed_at IS NULL
479  AND verified_via = 'email_match_fast_track'
480        "#,
481        user_id
482    )
483    .execute(conn)
484    .await?;
485    Ok(())
486}
487
488/// Unlinks by soft-delete; relinking inserts a new row, keeping the old number for audit.
489pub async fn soft_delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
490    sqlx::query!(
491        r#"
492UPDATE verified_student_numbers
493SET deleted_at = now()
494WHERE id = $1
495  AND deleted_at IS NULL
496        "#,
497        id
498    )
499    .execute(conn)
500    .await?;
501    Ok(())
502}
503
504/// Retires `current_link_id` (the account's link the caller already resolved, if any), inserts `new`
505/// in its place, clears the mailed links to `new`'s number that are no longer owed, and audits the
506/// change on the account's live registrations.
507///
508/// Returns the new link's id and how many of the account's registrations the change unblocked.
509/// `actor_user_id` is `None` when a worker made the link and no person decided it.
510pub async fn replace_verified_student_number(
511    conn: &mut PgConnection,
512    current_link_id: Option<Uuid>,
513    new: &NewVerifiedStudentNumber,
514    actor_user_id: Option<Uuid>,
515    event_kind: CreditRegistrationEventKind,
516    event_message: &str,
517) -> ModelResult<(Uuid, i64)> {
518    if let Some(id) = current_link_id {
519        soft_delete(conn, id).await?;
520    }
521    let verified_student_number_id = insert(conn, PKeyPolicy::Generate, new).await?;
522    crate::student_number_verification_tokens::soft_delete_unused_for_student_number(
523        conn,
524        &new.student_number,
525    )
526    .await?;
527    let affected_registration_count =
528        record_student_number_change(conn, new.user_id, actor_user_id, event_kind, event_message)
529            .await?;
530    Ok((verified_student_number_id, affected_registration_count))
531}
532
533/// Enrolled students of the course who hold no live student number link, and so cannot have credits
534/// registered for them until they link one.
535pub async fn count_unlinked_enrolled_students_for_course(
536    conn: &mut PgConnection,
537    course_id: Uuid,
538) -> ModelResult<i64> {
539    let count = sqlx::query_scalar!(
540        r#"
541SELECT COUNT(*) AS "count!"
542FROM (
543    SELECT DISTINCT cie.user_id
544    FROM course_instance_enrollments cie
545    WHERE cie.course_id = $1
546      AND cie.deleted_at IS NULL
547  ) enrolled
548  LEFT JOIN verified_student_numbers vsn ON vsn.user_id = enrolled.user_id
549  AND vsn.deleted_at IS NULL
550WHERE vsn.id IS NULL
551        "#,
552        course_id,
553    )
554    .fetch_one(conn)
555    .await?;
556    Ok(count)
557}