Skip to main content

headless_lms_models/
verified_student_numbers.rs

1use headless_lms_utils::secret_string::expose_option;
2use secrecy::ExposeSecret;
3use utoipa::ToSchema;
4
5use crate::credit_registration_events::CreditRegistrationEventKind;
6use crate::library::credit_registration::student_number_change::{
7    record_student_number_change, unlink_verified_student_number,
8};
9use crate::prelude::*;
10
11/// How a student number was proven to belong to an account.
12#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type, ToSchema)]
13#[sqlx(
14    type_name = "student_number_verification_method",
15    rename_all = "snake_case"
16)]
17#[serde(rename_all = "snake_case")]
18pub enum StudentNumberVerificationMethod {
19    EmailedLink,
20    AdminManual,
21    /// A registrar reported registering one of the account's completions under the number. Such a
22    /// link carries no Sisu person id.
23    StudyRegistry,
24}
25
26#[derive(Debug, Clone)]
27pub struct VerifiedStudentNumber {
28    pub id: Uuid,
29    pub created_at: DateTime<Utc>,
30    pub updated_at: DateTime<Utc>,
31    pub deleted_at: Option<DateTime<Utc>>,
32    pub user_id: Uuid,
33    pub student_number: DbSecret,
34    /// `None` only for [`StudentNumberVerificationMethod::StudyRegistry`] links.
35    pub sisu_person_id: Option<DbSecret>,
36    pub first_names: Option<DbSecret>,
37    pub last_name: Option<DbSecret>,
38    pub verified_at: DateTime<Utc>,
39    pub verified_via: StudentNumberVerificationMethod,
40    pub verified_via_email: Option<DbSecret>,
41    pub linked_by_user_id: Option<Uuid>,
42    pub link_reason: Option<String>,
43    pub verified_from_course_id: Option<Uuid>,
44}
45
46#[derive(Debug, Clone)]
47pub struct NewVerifiedStudentNumber {
48    pub user_id: Uuid,
49    pub student_number: DbSecret,
50    pub sisu_person_id: DbSecret,
51    pub first_names: Option<DbSecret>,
52    pub last_name: Option<DbSecret>,
53    pub verified_via: StudentNumberVerificationMethod,
54    /// The Sisu-held address the proof rests on. Must be `None` exactly for `AdminManual`.
55    pub verified_via_email: Option<DbSecret>,
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    linked_by_user_id,
78    link_reason,
79    verified_from_course_id
80  )
81VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
82RETURNING id
83        "#,
84        pkey_policy.into_uuid(),
85        new.user_id,
86        new.student_number.expose_secret(),
87        new.sisu_person_id.expose_secret(),
88        expose_option(&new.first_names),
89        expose_option(&new.last_name),
90        new.verified_via as StudentNumberVerificationMethod,
91        expose_option(&new.verified_via_email),
92        new.linked_by_user_id,
93        new.link_reason,
94        new.verified_from_course_id,
95    )
96    .fetch_one(conn)
97    .await?;
98    Ok(res.id)
99}
100
101pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<VerifiedStudentNumber> {
102    let res = sqlx::query_as!(
103        VerifiedStudentNumber,
104        r#"
105SELECT *
106FROM verified_student_numbers
107WHERE id = $1
108  AND deleted_at IS NULL
109        "#,
110        id
111    )
112    .fetch_one(conn)
113    .await?;
114    Ok(res)
115}
116
117/// The account's live link, if it has one. At most one exists by partial unique index.
118pub async fn get_by_user_id(
119    conn: &mut PgConnection,
120    user_id: Uuid,
121) -> ModelResult<Option<VerifiedStudentNumber>> {
122    let res = sqlx::query_as!(
123        VerifiedStudentNumber,
124        r#"
125SELECT *
126FROM verified_student_numbers
127WHERE user_id = $1
128  AND deleted_at IS NULL
129        "#,
130        user_id
131    )
132    .fetch_optional(conn)
133    .await?;
134    Ok(res)
135}
136
137/// The account's most recent link, retired ones included: a retired link is the only record an
138/// unlinked account has of the Sisu person its linking mail was addressed to.
139pub async fn get_latest_including_deleted_by_user_id(
140    conn: &mut PgConnection,
141    user_id: Uuid,
142) -> ModelResult<Option<VerifiedStudentNumber>> {
143    let res = sqlx::query_as!(
144        VerifiedStudentNumber,
145        r#"
146SELECT *
147FROM verified_student_numbers
148WHERE user_id = $1
149ORDER BY verified_at DESC
150LIMIT 1
151        "#,
152        user_id
153    )
154    .fetch_optional(conn)
155    .await?;
156    Ok(res)
157}
158
159pub async fn get_by_student_number(
160    conn: &mut PgConnection,
161    student_number: &str,
162) -> ModelResult<Option<VerifiedStudentNumber>> {
163    let res = sqlx::query_as!(
164        VerifiedStudentNumber,
165        r#"
166SELECT *
167FROM verified_student_numbers
168WHERE student_number = $1
169  AND deleted_at IS NULL
170        "#,
171        student_number
172    )
173    .fetch_optional(conn)
174    .await?;
175    Ok(res)
176}
177
178/// The live link for one Sisu person. Unique alongside the student number, so a programme change
179/// that issues a new number still collides here.
180pub async fn get_by_sisu_person_id(
181    conn: &mut PgConnection,
182    sisu_person_id: &str,
183) -> ModelResult<Option<VerifiedStudentNumber>> {
184    let res = sqlx::query_as!(
185        VerifiedStudentNumber,
186        r#"
187SELECT *
188FROM verified_student_numbers
189WHERE sisu_person_id = $1
190  AND deleted_at IS NULL
191        "#,
192        sisu_person_id
193    )
194    .fetch_optional(conn)
195    .await?;
196    Ok(res)
197}
198
199/// Whose live link already holds a student number or its Sisu person, from the point of view of
200/// `user_id` about to link them.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub enum LinkConflict {
203    /// Linked to `user_id` already.
204    SameAccount,
205    /// Linked to another account; linking `user_id` would break a unique key.
206    AnotherAccount,
207}
208
209/// Checks both unique keys before linking: a student who changed programme keeps their Sisu person
210/// id and gets a new number, so checking the number alone lets the link through and then trips
211/// `uq_verified_student_numbers_person` as a bare 500. Another account's hold wins over our own.
212pub async fn find_link_conflict(
213    conn: &mut PgConnection,
214    student_number: &str,
215    sisu_person_id: &str,
216    user_id: Uuid,
217) -> ModelResult<Option<LinkConflict>> {
218    let by_number = get_by_student_number(conn, student_number).await?;
219    if by_number
220        .as_ref()
221        .is_some_and(|link| link.user_id != user_id)
222    {
223        return Ok(Some(LinkConflict::AnotherAccount));
224    }
225    let by_person = get_by_sisu_person_id(conn, sisu_person_id).await?;
226    let holders = [by_number, by_person];
227    let conflict = if holders.iter().flatten().any(|link| link.user_id != user_id) {
228        Some(LinkConflict::AnotherAccount)
229    } else if holders.iter().any(Option::is_some) {
230        Some(LinkConflict::SameAccount)
231    } else {
232        None
233    };
234    Ok(conflict)
235}
236
237pub async fn get_by_user_ids(
238    conn: &mut PgConnection,
239    user_ids: &[Uuid],
240) -> ModelResult<Vec<VerifiedStudentNumber>> {
241    let res = sqlx::query_as!(
242        VerifiedStudentNumber,
243        r#"
244SELECT *
245FROM verified_student_numbers
246WHERE user_id = ANY($1::uuid [])
247  AND deleted_at IS NULL
248        "#,
249        user_ids
250    )
251    .fetch_all(conn)
252    .await?;
253    Ok(res)
254}
255
256/// The person id rather than the number, because the number changes when a student moves between
257/// programmes while the person id does not.
258pub async fn get_by_sisu_person_ids(
259    conn: &mut PgConnection,
260    sisu_person_ids: &[String],
261) -> ModelResult<Vec<VerifiedStudentNumber>> {
262    let res = sqlx::query_as!(
263        VerifiedStudentNumber,
264        r#"
265SELECT *
266FROM verified_student_numbers
267WHERE sisu_person_id = ANY($1::varchar [])
268  AND deleted_at IS NULL
269        "#,
270        sisu_person_ids
271    )
272    .fetch_all(conn)
273    .await?;
274    Ok(res)
275}
276
277pub async fn get_by_student_numbers(
278    conn: &mut PgConnection,
279    student_numbers: &[String],
280) -> ModelResult<Vec<VerifiedStudentNumber>> {
281    let res = sqlx::query_as!(
282        VerifiedStudentNumber,
283        r#"
284SELECT *
285FROM verified_student_numbers
286WHERE student_number = ANY($1::varchar [])
287  AND deleted_at IS NULL
288        "#,
289        student_numbers
290    )
291    .fetch_all(conn)
292    .await?;
293    Ok(res)
294}
295
296/// Batched form of [`get_latest_including_deleted_by_user_id`], one row per account.
297pub async fn get_latest_including_deleted_by_user_ids(
298    conn: &mut PgConnection,
299    user_ids: &[Uuid],
300) -> ModelResult<Vec<VerifiedStudentNumber>> {
301    let res = sqlx::query_as!(
302        VerifiedStudentNumber,
303        r#"
304SELECT DISTINCT ON (user_id) *
305FROM verified_student_numbers
306WHERE user_id = ANY($1::uuid [])
307ORDER BY user_id, verified_at DESC
308        "#,
309        user_ids
310    )
311    .fetch_all(conn)
312    .await?;
313    Ok(res)
314}
315
316/// One link as an admin support view shows it, with the account it belongs to.
317#[derive(Debug, Clone)]
318pub struct AdminVerifiedStudentNumber {
319    pub id: Uuid,
320    pub user_id: Uuid,
321    pub user_email: Option<String>,
322    pub first_name: Option<String>,
323    pub last_name: Option<String>,
324    pub student_number: DbSecret,
325    pub sisu_person_id: Option<DbSecret>,
326    pub verified_at: DateTime<Utc>,
327    pub verified_via: StudentNumberVerificationMethod,
328    /// The Sisu-held address the proof rests on, in full. `None` for an admin-established link.
329    pub verified_via_email: Option<DbSecret>,
330    pub linked_by_user_id: Option<Uuid>,
331    pub link_reason: Option<String>,
332    pub verified_from_course_id: Option<Uuid>,
333    pub live_registration_count: i64,
334}
335
336/// A row with the page's total attached, so a page and its count can only come from one query.
337struct AdminPageRow {
338    id: Uuid,
339    user_id: Uuid,
340    user_email: Option<String>,
341    first_name: Option<String>,
342    last_name: Option<String>,
343    student_number: DbSecret,
344    sisu_person_id: Option<DbSecret>,
345    verified_at: DateTime<Utc>,
346    verified_via: StudentNumberVerificationMethod,
347    verified_via_email: Option<DbSecret>,
348    linked_by_user_id: Option<Uuid>,
349    link_reason: Option<String>,
350    verified_from_course_id: Option<Uuid>,
351    live_registration_count: i64,
352    total_count: i64,
353}
354
355/// Live links only, newest first: a retired link is not a number we hold. Returns the page together
356/// with how many rows match the filters in total, from one query via `COUNT(*) OVER()`.
357///
358/// `search` is escaped here, not by the caller: `escape_like_pattern` is easy to forget to call, and
359/// forgetting it would let `%`/`_` in a student number match more than intended.
360pub async fn get_admin_page(
361    conn: &mut PgConnection,
362    verified_via: Option<StudentNumberVerificationMethod>,
363    search: Option<&str>,
364    limit: i64,
365    offset: i64,
366) -> ModelResult<(Vec<AdminVerifiedStudentNumber>, i64)> {
367    let search_pattern = search
368        .map(str::trim)
369        .filter(|s| !s.is_empty())
370        .map(|s| crate::library::students_view::escape_like_pattern(&s.to_lowercase()));
371    let user_search = crate::user_details::UserListSearch::new(search.unwrap_or_default());
372    let rows = sqlx::query_as!(
373        AdminPageRow,
374        r#"
375SELECT vsn.id,
376  vsn.user_id,
377  ud.email AS "user_email?",
378  ud.first_name AS "first_name?",
379  ud.last_name AS "last_name?",
380  vsn.student_number,
381  vsn.sisu_person_id,
382  vsn.verified_at,
383  vsn.verified_via,
384  vsn.verified_via_email,
385  vsn.linked_by_user_id,
386  vsn.link_reason,
387  vsn.verified_from_course_id,
388  (
389    SELECT COUNT(*)
390    FROM credit_registrations cr
391    WHERE cr.user_id = vsn.user_id
392      AND cr.superseded_by_id IS NULL
393      AND cr.deleted_at IS NULL
394  ) AS "live_registration_count!",
395  COUNT(*) OVER () AS "total_count!"
396FROM verified_student_numbers vsn
397  LEFT JOIN user_details ud ON ud.user_id = vsn.user_id
398WHERE vsn.deleted_at IS NULL
399  AND (
400    $1::student_number_verification_method IS NULL
401    OR vsn.verified_via = $1
402  )
403  AND (
404    $2::text IS NULL
405    OR LOWER(vsn.student_number) LIKE '%' || $2 || '%' ESCAPE '\'
406    OR (ud.name_search_helper || ' ' || ud.email_search_helper) LIKE ALL ($5::text [])
407    OR word_similarity($6::text, ud.name_search_helper || ' ' || ud.email_search_helper) >= 0.5
408  )
409ORDER BY vsn.verified_at DESC,
410  vsn.id
411LIMIT $3 OFFSET $4
412        "#,
413        verified_via as Option<StudentNumberVerificationMethod>,
414        search_pattern.as_deref(),
415        limit,
416        offset,
417        &user_search.word_patterns,
418        user_search.misspelling_term.as_deref(),
419    )
420    .fetch_all(conn)
421    .await?;
422    let total_count = rows.first().map_or(0, |row| row.total_count);
423    let data = rows
424        .into_iter()
425        .map(|row| {
426            let AdminPageRow {
427                id,
428                user_id,
429                user_email,
430                first_name,
431                last_name,
432                student_number,
433                sisu_person_id,
434                verified_at,
435                verified_via,
436                verified_via_email,
437                linked_by_user_id,
438                link_reason,
439                verified_from_course_id,
440                live_registration_count,
441                total_count: _,
442            } = row;
443            AdminVerifiedStudentNumber {
444                id,
445                user_id,
446                user_email,
447                first_name,
448                last_name,
449                student_number,
450                sisu_person_id,
451                verified_at,
452                verified_via,
453                verified_via_email,
454                linked_by_user_id,
455                link_reason,
456                verified_from_course_id,
457                live_registration_count,
458            }
459        })
460        .collect();
461    Ok((data, total_count))
462}
463
464/// Live links per method, both all-time and since a cutoff, in one pass over the table, so an
465/// admin-established one is never hidden inside a total.
466pub async fn count_by_method_since(
467    conn: &mut PgConnection,
468    since: DateTime<Utc>,
469) -> ModelResult<Vec<(StudentNumberVerificationMethod, i64, i64)>> {
470    let rows = sqlx::query!(
471        r#"
472SELECT verified_via,
473  COUNT(*) AS "total!",
474  COUNT(*) FILTER (WHERE verified_at >= $1) AS "since_count!"
475FROM verified_student_numbers
476WHERE deleted_at IS NULL
477GROUP BY verified_via
478        "#,
479        since,
480    )
481    .fetch_all(conn)
482    .await?;
483    Ok(rows
484        .into_iter()
485        .map(|row| (row.verified_via, row.total, row.since_count))
486        .collect())
487}
488
489/// Gives a live link that has no Sisu person id yet the one the registry resolved its number to,
490/// along with the registry's names where the link has none.
491///
492/// Returns `false`, writing nothing, when another live link already holds `sisu_person_id` or the
493/// link was retired or holds a different person meanwhile.
494pub async fn fill_sisu_person_id(
495    conn: &mut PgConnection,
496    id: Uuid,
497    sisu_person_id: &DbSecret,
498    first_names: Option<&DbSecret>,
499    last_name: Option<&DbSecret>,
500) -> ModelResult<bool> {
501    let filled = sqlx::query_scalar!(
502        r#"
503UPDATE verified_student_numbers
504SET sisu_person_id = $2,
505  first_names = COALESCE(first_names, $3),
506  last_name = COALESCE(last_name, $4)
507WHERE id = $1
508  AND deleted_at IS NULL
509  AND (
510    sisu_person_id IS NULL
511    OR sisu_person_id = $2
512  )
513  AND NOT EXISTS (
514    SELECT 1
515    FROM verified_student_numbers other
516    WHERE other.sisu_person_id = $2
517      AND other.deleted_at IS NULL
518      AND other.id <> $1
519  )
520RETURNING id
521        "#,
522        id,
523        sisu_person_id.expose_secret(),
524        first_names.map(ExposeSecret::expose_secret),
525        last_name.map(ExposeSecret::expose_secret),
526    )
527    .fetch_optional(conn)
528    .await?;
529    Ok(filled.is_some())
530}
531
532/// Unlinks by soft-delete; relinking inserts a new row, keeping the old number for audit.
533pub async fn soft_delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
534    sqlx::query!(
535        r#"
536UPDATE verified_student_numbers
537SET deleted_at = now()
538WHERE id = $1
539  AND deleted_at IS NULL
540        "#,
541        id
542    )
543    .execute(conn)
544    .await?;
545    Ok(())
546}
547
548/// Retires `current_link_id` (the account's link the caller already resolved, if any), inserts `new`
549/// in its place, clears the mailed links to `new`'s number that are no longer owed, and audits the
550/// change on the account's live registrations.
551///
552/// Returns the new link's id and how many of the account's registrations the change unblocked.
553/// `actor_user_id` is `None` when a worker made the link and no person decided it.
554pub async fn replace_verified_student_number(
555    conn: &mut PgConnection,
556    current_link_id: Option<Uuid>,
557    new: &NewVerifiedStudentNumber,
558    actor_user_id: Option<Uuid>,
559    event_kind: CreditRegistrationEventKind,
560    event_message: &str,
561) -> ModelResult<(Uuid, i64)> {
562    if let Some(id) = current_link_id {
563        soft_delete(conn, id).await?;
564    }
565    let verified_student_number_id = insert(conn, PKeyPolicy::Generate, new).await?;
566    crate::student_number_verification_tokens::soft_delete_unused_for_student_number(
567        conn,
568        new.student_number.expose_secret(),
569    )
570    .await?;
571    let affected_registration_count =
572        record_student_number_change(conn, new.user_id, actor_user_id, event_kind, event_message)
573            .await?;
574    Ok((verified_student_number_id, affected_registration_count))
575}
576
577/// Enrolled students of the course who hold no live student number link, and so cannot have credits
578/// registered for them until they link one.
579pub async fn count_unlinked_enrolled_students_for_course(
580    conn: &mut PgConnection,
581    course_id: Uuid,
582) -> ModelResult<i64> {
583    let count = sqlx::query_scalar!(
584        r#"
585SELECT COUNT(*) AS "count!"
586FROM (
587    SELECT DISTINCT cie.user_id
588    FROM course_instance_enrollments cie
589    WHERE cie.course_id = $1
590      AND cie.deleted_at IS NULL
591  ) enrolled
592  LEFT JOIN verified_student_numbers vsn ON vsn.user_id = enrolled.user_id
593  AND vsn.deleted_at IS NULL
594WHERE vsn.id IS NULL
595        "#,
596        course_id,
597    )
598    .fetch_one(conn)
599    .await?;
600    Ok(count)
601}
602
603/// Links each of `user_ids` to the student number a registrar last reported for them, as a
604/// [`StudentNumberVerificationMethod::StudyRegistry`] link, and records a conflict wherever a live
605/// link still stands in the way. A link someone made by hand (the student or an admin) always wins,
606/// but another account's study-registry link on the reported number moves to the account reported
607/// most recently, unless the holder's own report of it is newer: a student with two accounts gets
608/// the number on the later one. A retired link does not block: the registrar's report is
609/// authoritative, so a number the student or an admin unlinked, or one resolve-person-ids dropped
610/// over a conflict, is linked again. A conflict an admin dismissed is not recorded again. Returns
611/// how many links were made.
612pub async fn link_numbers_reported_by_study_registry(
613    conn: &mut PgConnection,
614    user_ids: &[Uuid],
615) -> ModelResult<u64> {
616    // Chosen before anything is retired, so a holder that is itself in `user_ids` cannot win its
617    // number back.
618    let links = sqlx::query!(
619        r#"
620SELECT DISTINCT ON (reported.student_number) reported.user_id AS "user_id!",
621  reported.student_number AS "student_number!",
622  holder.id AS "holder_link_id?",
623  holder.user_id AS "holder_user_id?"
624FROM study_registry_reported_student_numbers reported
625  JOIN course_module_completion_registered_to_study_registries report ON report.id = reported.registered_completion_id
626  LEFT JOIN verified_student_numbers holder ON holder.student_number = reported.student_number
627  AND holder.deleted_at IS NULL
628  LEFT JOIN study_registry_reported_student_numbers holder_reported ON holder_reported.user_id = holder.user_id
629  AND holder_reported.student_number = reported.student_number
630  LEFT JOIN course_module_completion_registered_to_study_registries holder_report ON holder_report.id = holder_reported.registered_completion_id
631WHERE reported.user_id = ANY($1::uuid [])
632  AND reported.student_number ~ '^[0-9]{6,12}$'
633  AND NOT EXISTS (
634    SELECT 1
635    FROM verified_student_numbers own
636    WHERE own.user_id = reported.user_id
637      AND own.deleted_at IS NULL
638  )
639  AND (
640    holder.id IS NULL
641    OR (
642      holder.verified_via = 'study_registry'
643      AND (
644        holder_report.id IS NULL
645        OR holder_report.created_at < report.created_at
646      )
647    )
648  )
649ORDER BY reported.student_number,
650  report.created_at DESC,
651  reported.user_id
652        "#,
653        user_ids,
654    )
655    .fetch_all(&mut *conn)
656    .await?;
657    for link in &links {
658        if let (Some(holder_link_id), Some(holder_user_id)) =
659            (link.holder_link_id, link.holder_user_id)
660        {
661            unlink_verified_student_number(
662                conn,
663                holder_link_id,
664                holder_user_id,
665                None,
666                CreditRegistrationEventKind::StateChanged,
667                "A registrar reported this student number for another account, so it moved there.",
668            )
669            .await?;
670        }
671    }
672    let (linked_user_ids, linked_numbers): (Vec<Uuid>, Vec<String>) = links
673        .into_iter()
674        .map(|link| (link.user_id, link.student_number))
675        .unzip();
676    let linked = sqlx::query!(
677        r#"
678INSERT INTO verified_student_numbers (user_id, student_number, verified_via)
679SELECT user_id,
680  student_number,
681  'study_registry'
682FROM UNNEST($1::uuid [], $2::text []) AS link(user_id, student_number)
683ON CONFLICT DO NOTHING
684        "#,
685        &linked_user_ids,
686        &linked_numbers,
687    )
688    .execute(&mut *conn)
689    .await?
690    .rows_affected();
691    sqlx::query!(
692        r#"
693INSERT INTO study_registry_student_number_conflicts (
694    user_id,
695    student_number,
696    registered_completion_id,
697    conflicting_verified_student_number_id
698  )
699SELECT reported.user_id,
700  reported.student_number,
701  reported.registered_completion_id,
702  blocker.id
703FROM study_registry_reported_student_numbers reported
704  JOIN LATERAL (
705    SELECT vsn.id
706    FROM verified_student_numbers vsn
707    WHERE vsn.deleted_at IS NULL
708      AND (
709        (
710          vsn.user_id = reported.user_id
711          AND vsn.student_number <> reported.student_number
712        )
713        OR (
714          vsn.user_id <> reported.user_id
715          AND vsn.student_number = reported.student_number
716        )
717      )
718    ORDER BY vsn.user_id = reported.user_id DESC
719    LIMIT 1
720  ) blocker ON TRUE
721WHERE reported.user_id = ANY($1::uuid [])
722  AND reported.student_number ~ '^[0-9]{6,12}$'
723  AND NOT EXISTS (
724    SELECT 1
725    FROM study_registry_student_number_conflicts dismissed
726    WHERE dismissed.user_id = reported.user_id
727      AND dismissed.student_number = reported.student_number
728      AND dismissed.dismissed_at IS NOT NULL
729      AND dismissed.deleted_at IS NULL
730  )
731ON CONFLICT DO NOTHING
732        "#,
733        user_ids,
734    )
735    .execute(conn)
736    .await?;
737    Ok(linked)
738}
739
740/// A link made by a linking email or by hand, for the linking page's recent list.
741#[derive(Debug, Clone, PartialEq)]
742pub struct RecentStudentNumberLink {
743    pub user_id: Uuid,
744    pub first_name: Option<String>,
745    pub last_name: Option<String>,
746    pub email: Option<String>,
747    pub verified_at: DateTime<Utc>,
748    pub verified_via: StudentNumberVerificationMethod,
749    pub verified_from_course_id: Option<Uuid>,
750    pub verified_from_course_name: Option<String>,
751}
752
753/// Live links made by a linking email or by hand, newest first. Study registry links are left out:
754/// they arrive by the thousand from imports.
755pub async fn get_recent_linked_by_email_or_hand(
756    conn: &mut PgConnection,
757    limit: i64,
758) -> ModelResult<Vec<RecentStudentNumberLink>> {
759    let res = sqlx::query_as!(
760        RecentStudentNumberLink,
761        r#"
762SELECT vsn.user_id,
763  ud.first_name AS "first_name?",
764  ud.last_name AS "last_name?",
765  ud.email AS "email?",
766  vsn.verified_at,
767  vsn.verified_via AS "verified_via: StudentNumberVerificationMethod",
768  vsn.verified_from_course_id,
769  c.name AS "verified_from_course_name?"
770FROM verified_student_numbers vsn
771  LEFT JOIN user_details ud ON ud.user_id = vsn.user_id
772  LEFT JOIN courses c ON c.id = vsn.verified_from_course_id
773WHERE vsn.deleted_at IS NULL
774  AND vsn.verified_via IN ('emailed_link', 'admin_manual')
775ORDER BY vsn.verified_at DESC
776LIMIT $1
777        "#,
778        limit,
779    )
780    .fetch_all(conn)
781    .await?;
782    Ok(res)
783}