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 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
461pub 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
486pub 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
529pub 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
545pub 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
574pub 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
600pub async fn link_numbers_reported_by_study_registry(
610 conn: &mut PgConnection,
611 user_ids: &[Uuid],
612) -> ModelResult<u64> {
613 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}