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#[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 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 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 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
117pub 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
137pub 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
178pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub enum LinkConflict {
203 SameAccount,
205 AnotherAccount,
207}
208
209pub 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
256pub 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
296pub 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#[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 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
336struct 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
355pub 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
464pub 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
489pub 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
532pub 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
548pub 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
577pub 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
603pub async fn link_numbers_reported_by_study_registry(
613 conn: &mut PgConnection,
614 user_ids: &[Uuid],
615) -> ModelResult<u64> {
616 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#[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
753pub 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}