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#[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 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 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
135pub 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
155pub 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
196pub 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
236pub 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
276pub 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#[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 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
316struct 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
335pub 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
441pub 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
466pub 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
488pub 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
504pub 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
533pub 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}