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 rows = sqlx::query_as!(
372        AdminPageRow,
373        r#"
374SELECT vsn.id,
375  vsn.user_id,
376  ud.email AS "user_email?",
377  ud.first_name AS "first_name?",
378  ud.last_name AS "last_name?",
379  vsn.student_number,
380  vsn.sisu_person_id,
381  vsn.verified_at,
382  vsn.verified_via,
383  vsn.verified_via_email,
384  vsn.linked_by_user_id,
385  vsn.link_reason,
386  vsn.verified_from_course_id,
387  (
388    SELECT COUNT(*)
389    FROM credit_registrations cr
390    WHERE cr.user_id = vsn.user_id
391      AND cr.superseded_by_id IS NULL
392      AND cr.deleted_at IS NULL
393  ) AS "live_registration_count!",
394  COUNT(*) OVER () AS "total_count!"
395FROM verified_student_numbers vsn
396  LEFT JOIN user_details ud ON ud.user_id = vsn.user_id
397WHERE vsn.deleted_at IS NULL
398  AND (
399    $1::student_number_verification_method IS NULL
400    OR vsn.verified_via = $1
401  )
402  AND (
403    $2::text IS NULL
404    OR LOWER(vsn.student_number) LIKE '%' || $2 || '%' ESCAPE '\'
405    OR ud.name_search_helper LIKE '%' || $2 || '%' ESCAPE '\'
406    OR ud.email_search_helper LIKE '%' || $2 || '%' ESCAPE '\'
407  )
408ORDER BY vsn.verified_at DESC,
409  vsn.id
410LIMIT $3 OFFSET $4
411        "#,
412        verified_via as Option<StudentNumberVerificationMethod>,
413        search_pattern.as_deref(),
414        limit,
415        offset,
416    )
417    .fetch_all(conn)
418    .await?;
419    let total_count = rows.first().map_or(0, |row| row.total_count);
420    let data = rows
421        .into_iter()
422        .map(|row| {
423            let AdminPageRow {
424                id,
425                user_id,
426                user_email,
427                first_name,
428                last_name,
429                student_number,
430                sisu_person_id,
431                verified_at,
432                verified_via,
433                verified_via_email,
434                linked_by_user_id,
435                link_reason,
436                verified_from_course_id,
437                live_registration_count,
438                total_count: _,
439            } = row;
440            AdminVerifiedStudentNumber {
441                id,
442                user_id,
443                user_email,
444                first_name,
445                last_name,
446                student_number,
447                sisu_person_id,
448                verified_at,
449                verified_via,
450                verified_via_email,
451                linked_by_user_id,
452                link_reason,
453                verified_from_course_id,
454                live_registration_count,
455            }
456        })
457        .collect();
458    Ok((data, total_count))
459}
460
461/// Live links per method, both all-time and since a cutoff, in one pass over the table, so an
462/// admin-established one is never hidden inside a total.
463pub async fn count_by_method_since(
464    conn: &mut PgConnection,
465    since: DateTime<Utc>,
466) -> ModelResult<Vec<(StudentNumberVerificationMethod, i64, i64)>> {
467    let rows = sqlx::query!(
468        r#"
469SELECT verified_via,
470  COUNT(*) AS "total!",
471  COUNT(*) FILTER (WHERE verified_at >= $1) AS "since_count!"
472FROM verified_student_numbers
473WHERE deleted_at IS NULL
474GROUP BY verified_via
475        "#,
476        since,
477    )
478    .fetch_all(conn)
479    .await?;
480    Ok(rows
481        .into_iter()
482        .map(|row| (row.verified_via, row.total, row.since_count))
483        .collect())
484}
485
486/// Gives a live link that has no Sisu person id yet the one the registry resolved its number to,
487/// along with the registry's names where the link has none.
488///
489/// Returns `false`, writing nothing, when another live link already holds `sisu_person_id` or the
490/// link was retired or holds a different person meanwhile.
491pub async fn fill_sisu_person_id(
492    conn: &mut PgConnection,
493    id: Uuid,
494    sisu_person_id: &DbSecret,
495    first_names: Option<&DbSecret>,
496    last_name: Option<&DbSecret>,
497) -> ModelResult<bool> {
498    let filled = sqlx::query_scalar!(
499        r#"
500UPDATE verified_student_numbers
501SET sisu_person_id = $2,
502  first_names = COALESCE(first_names, $3),
503  last_name = COALESCE(last_name, $4)
504WHERE id = $1
505  AND deleted_at IS NULL
506  AND (
507    sisu_person_id IS NULL
508    OR sisu_person_id = $2
509  )
510  AND NOT EXISTS (
511    SELECT 1
512    FROM verified_student_numbers other
513    WHERE other.sisu_person_id = $2
514      AND other.deleted_at IS NULL
515      AND other.id <> $1
516  )
517RETURNING id
518        "#,
519        id,
520        sisu_person_id.expose_secret(),
521        first_names.map(ExposeSecret::expose_secret),
522        last_name.map(ExposeSecret::expose_secret),
523    )
524    .fetch_optional(conn)
525    .await?;
526    Ok(filled.is_some())
527}
528
529/// Unlinks by soft-delete; relinking inserts a new row, keeping the old number for audit.
530pub async fn soft_delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
531    sqlx::query!(
532        r#"
533UPDATE verified_student_numbers
534SET deleted_at = now()
535WHERE id = $1
536  AND deleted_at IS NULL
537        "#,
538        id
539    )
540    .execute(conn)
541    .await?;
542    Ok(())
543}
544
545/// Retires `current_link_id` (the account's link the caller already resolved, if any), inserts `new`
546/// in its place, clears the mailed links to `new`'s number that are no longer owed, and audits the
547/// change on the account's live registrations.
548///
549/// Returns the new link's id and how many of the account's registrations the change unblocked.
550/// `actor_user_id` is `None` when a worker made the link and no person decided it.
551pub async fn replace_verified_student_number(
552    conn: &mut PgConnection,
553    current_link_id: Option<Uuid>,
554    new: &NewVerifiedStudentNumber,
555    actor_user_id: Option<Uuid>,
556    event_kind: CreditRegistrationEventKind,
557    event_message: &str,
558) -> ModelResult<(Uuid, i64)> {
559    if let Some(id) = current_link_id {
560        soft_delete(conn, id).await?;
561    }
562    let verified_student_number_id = insert(conn, PKeyPolicy::Generate, new).await?;
563    crate::student_number_verification_tokens::soft_delete_unused_for_student_number(
564        conn,
565        new.student_number.expose_secret(),
566    )
567    .await?;
568    let affected_registration_count =
569        record_student_number_change(conn, new.user_id, actor_user_id, event_kind, event_message)
570            .await?;
571    Ok((verified_student_number_id, affected_registration_count))
572}
573
574/// Enrolled students of the course who hold no live student number link, and so cannot have credits
575/// registered for them until they link one.
576pub async fn count_unlinked_enrolled_students_for_course(
577    conn: &mut PgConnection,
578    course_id: Uuid,
579) -> ModelResult<i64> {
580    let count = sqlx::query_scalar!(
581        r#"
582SELECT COUNT(*) AS "count!"
583FROM (
584    SELECT DISTINCT cie.user_id
585    FROM course_instance_enrollments cie
586    WHERE cie.course_id = $1
587      AND cie.deleted_at IS NULL
588  ) enrolled
589  LEFT JOIN verified_student_numbers vsn ON vsn.user_id = enrolled.user_id
590  AND vsn.deleted_at IS NULL
591WHERE vsn.id IS NULL
592        "#,
593        course_id,
594    )
595    .fetch_one(conn)
596    .await?;
597    Ok(count)
598}
599
600/// Links each of `user_ids` to the student number a registrar last reported for them, as a
601/// [`StudentNumberVerificationMethod::StudyRegistry`] link, and records a conflict wherever a live
602/// link still stands in the way. A link someone made by hand (the student or an admin) always wins,
603/// but another account's study-registry link on the reported number moves to the account reported
604/// most recently, unless the holder's own report of it is newer: a student with two accounts gets
605/// the number on the later one. A retired link does
606/// not block: the registrar's report is authoritative, so a number the student or an admin unlinked,
607/// or one resolve-person-ids dropped over a conflict, is linked again. Returns how many links were
608/// made.
609pub async fn link_numbers_reported_by_study_registry(
610    conn: &mut PgConnection,
611    user_ids: &[Uuid],
612) -> ModelResult<u64> {
613    // Chosen before anything is retired, so a holder that is itself in `user_ids` cannot win its
614    // number back.
615    let links = sqlx::query!(
616        r#"
617SELECT DISTINCT ON (reported.student_number) reported.user_id AS "user_id!",
618  reported.student_number AS "student_number!",
619  holder.id AS "holder_link_id?",
620  holder.user_id AS "holder_user_id?"
621FROM study_registry_reported_student_numbers reported
622  JOIN course_module_completion_registered_to_study_registries report ON report.id = reported.registered_completion_id
623  LEFT JOIN verified_student_numbers holder ON holder.student_number = reported.student_number
624  AND holder.deleted_at IS NULL
625  LEFT JOIN study_registry_reported_student_numbers holder_reported ON holder_reported.user_id = holder.user_id
626  AND holder_reported.student_number = reported.student_number
627  LEFT JOIN course_module_completion_registered_to_study_registries holder_report ON holder_report.id = holder_reported.registered_completion_id
628WHERE reported.user_id = ANY($1::uuid [])
629  AND reported.student_number ~ '^[0-9]{6,12}$'
630  AND NOT EXISTS (
631    SELECT 1
632    FROM verified_student_numbers own
633    WHERE own.user_id = reported.user_id
634      AND own.deleted_at IS NULL
635  )
636  AND (
637    holder.id IS NULL
638    OR (
639      holder.verified_via = 'study_registry'
640      AND (
641        holder_report.id IS NULL
642        OR holder_report.created_at < report.created_at
643      )
644    )
645  )
646ORDER BY reported.student_number,
647  report.created_at DESC,
648  reported.user_id
649        "#,
650        user_ids,
651    )
652    .fetch_all(&mut *conn)
653    .await?;
654    for link in &links {
655        if let (Some(holder_link_id), Some(holder_user_id)) =
656            (link.holder_link_id, link.holder_user_id)
657        {
658            unlink_verified_student_number(
659                conn,
660                holder_link_id,
661                holder_user_id,
662                None,
663                CreditRegistrationEventKind::StateChanged,
664                "A registrar reported this student number for another account, so it moved there.",
665            )
666            .await?;
667        }
668    }
669    let (linked_user_ids, linked_numbers): (Vec<Uuid>, Vec<String>) = links
670        .into_iter()
671        .map(|link| (link.user_id, link.student_number))
672        .unzip();
673    let linked = sqlx::query!(
674        r#"
675INSERT INTO verified_student_numbers (user_id, student_number, verified_via)
676SELECT user_id,
677  student_number,
678  'study_registry'
679FROM UNNEST($1::uuid [], $2::text []) AS link(user_id, student_number)
680ON CONFLICT DO NOTHING
681        "#,
682        &linked_user_ids,
683        &linked_numbers,
684    )
685    .execute(&mut *conn)
686    .await?
687    .rows_affected();
688    sqlx::query!(
689        r#"
690INSERT INTO study_registry_student_number_conflicts (
691    user_id,
692    student_number,
693    registered_completion_id,
694    conflicting_verified_student_number_id
695  )
696SELECT reported.user_id,
697  reported.student_number,
698  reported.registered_completion_id,
699  blocker.id
700FROM study_registry_reported_student_numbers reported
701  JOIN LATERAL (
702    SELECT vsn.id
703    FROM verified_student_numbers vsn
704    WHERE vsn.deleted_at IS NULL
705      AND (
706        (
707          vsn.user_id = reported.user_id
708          AND vsn.student_number <> reported.student_number
709        )
710        OR (
711          vsn.user_id <> reported.user_id
712          AND vsn.student_number = reported.student_number
713        )
714      )
715    ORDER BY vsn.user_id = reported.user_id DESC
716    LIMIT 1
717  ) blocker ON TRUE
718WHERE reported.user_id = ANY($1::uuid [])
719  AND reported.student_number ~ '^[0-9]{6,12}$'
720ON CONFLICT DO NOTHING
721        "#,
722        user_ids,
723    )
724    .execute(conn)
725    .await?;
726    Ok(linked)
727}