1use std::collections::HashMap;
10
11use headless_lms_models::{
12 credit_registration_account_linking_emails::{self, CreditRegistrationAccountLinkingEmail},
13 credit_registration_events::{CreditRegistrationEventKind, NewCreditRegistrationEvent},
14 credit_registrations::{
15 CreditRegistrationErrorCode, CreditRegistrationState, RegistrationScope,
16 StudentCreditRegistration, StudentRegistrationFilter,
17 },
18 email_deliveries::{EmailSendStatus, EmailSendStatusReport},
19 library::credit_registration::StudentFacingCreditRegistrationStatus,
20 library::credit_registration::student_notifications::{
21 self, CreditRegistrationNotificationKind, RegistrationNotificationEmail,
22 },
23 open_university_product_access_tokens,
24 student_number_verification_tokens::{self, StudentNumberVerificationToken},
25 verified_student_numbers::{
26 self, NewVerifiedStudentNumber, StudentNumberVerificationMethod, VerifiedStudentNumber,
27 },
28};
29use models::library::credit_registration::preconditions::{
30 PRECONDITIONS_LIMIT, recompute_preconditions,
31};
32use models::library::credit_registration::student_number_change;
33use utoipa::{OpenApi, ToSchema};
34
35use crate::domain::rate_limit_middleware_builder::{RateLimit, RateLimitConfig};
36use crate::prelude::*;
37
38const ENROLMENT_RECHECK_MIN_INTERVAL_SECS: i64 = 60 * 60;
41
42#[derive(OpenApi)]
43#[openapi(paths(
44 get_my_credit_registrations,
45 get_my_credit_registration_for_course_module,
46 get_my_credit_registration_enrolment_banners,
47 request_credit_registration_enrolment_recheck,
48 dismiss_credit_registration_enrolment_banner,
49 get_my_verified_student_number,
50 dismiss_my_auto_link_notice,
51 unlink_my_student_number,
52 preview_student_number_verification_token,
53 claim_student_number_verification_token
54))]
55pub(crate) struct MainFrontendCreditRegistrationsApiDoc;
56
57#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
59pub struct LinkingEmailStatus {
60 pub email_send_status: EmailSendStatus,
61 pub sent_at: Option<DateTime<Utc>>,
62 pub emailed_to_masked: String,
63}
64
65#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
68pub struct NotificationEmailStatus {
69 pub kind: CreditRegistrationNotificationKind,
70 pub email_send_status: EmailSendStatus,
71 pub sent_at: Option<DateTime<Utc>>,
72}
73
74impl NotificationEmailStatus {
75 pub(crate) fn for_state(
79 state: CreditRegistrationState,
80 credit_registration_id: Uuid,
81 mails: &[RegistrationNotificationEmail],
82 ) -> Option<Self> {
83 let wanted = CreditRegistrationNotificationKind::for_state(state)?;
84 let mail = mails.iter().find(|mail| {
85 mail.credit_registration_id == credit_registration_id && mail.kind == wanted
86 })?;
87 Some(Self {
88 kind: mail.kind,
89 email_send_status: mail.send_status.email_send_status,
90 sent_at: mail.send_status.sent_at,
91 })
92 }
93}
94
95#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
96pub struct MyCreditRegistration {
97 pub id: Uuid,
98 pub course_id: Uuid,
99 pub course_name: String,
100 pub course_slug: String,
101 pub course_module_id: Uuid,
102 pub course_module_name: Option<String>,
103 pub uh_course_code: Option<String>,
104 pub ects_credits: Option<f32>,
105 pub completion_date: DateTime<Utc>,
106 pub student_facing_status: StudentFacingCreditRegistrationStatus,
107 pub registry_already_held_equal_or_better: bool,
112 pub status_is_moving: bool,
114 pub error_code: Option<CreditRegistrationErrorCode>,
115 pub next_attempt_at: DateTime<Utc>,
116 pub registered_at: Option<DateTime<Utc>>,
117 pub sisu_attainment_id: Option<String>,
118 pub grade_id: Option<String>,
119 pub grade_scale_id: Option<String>,
122 pub credits: Option<f32>,
123 pub attempt_number: i32,
124 pub superseded: bool,
125 pub can_request_enrolment_recheck: bool,
126 pub enrolment_realisation_name: Option<String>,
127 pub enrolment_link: Option<String>,
129 pub linking_email: Option<LinkingEmailStatus>,
132 pub notification_email: Option<NotificationEmailStatus>,
134}
135
136#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
138pub struct MyCreditRegistrationForCourseModule {
139 pub registration: MyCreditRegistration,
140 pub earlier_attempts: Vec<MyCreditRegistration>,
143}
144
145#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
146pub struct RequestCreditRegistrationEnrolmentRecheckResult {
147 pub recheck_started: bool,
149 pub next_recheck_allowed_at: Option<DateTime<Utc>>,
150}
151
152#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
154pub struct MyVerifiedStudentNumber {
155 pub student_number: String,
156 pub verified_at: DateTime<Utc>,
157 pub verified_via: StudentNumberVerificationMethod,
158 pub verified_via_email_masked: Option<String>,
160 pub first_names: Option<String>,
161 pub last_name: Option<String>,
162 pub linked_automatically: bool,
166 pub auto_link_notice_dismissed: bool,
167}
168
169#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
170pub struct UnlinkMyStudentNumberResult {
171 pub affected_registration_count: i64,
173}
174
175#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
178pub struct StudentNumberVerificationTokenPreview {
179 pub student_number: String,
180 pub first_names: Option<String>,
181 pub last_name: Option<String>,
182 pub course_name: Option<String>,
183 pub emailed_to_masked: String,
184 pub expires_at: DateTime<Utc>,
185 pub expired: bool,
186 pub already_used: bool,
187 pub already_used_by_this_account: bool,
189 pub conflicts_with_other_account: bool,
192 pub current_student_number: Option<String>,
194 pub target_account_email: String,
196 pub claimable: bool,
197}
198
199#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
200#[serde(rename_all = "snake_case")]
201pub enum ClaimStudentNumberVerificationTokenOutcome {
202 Linked,
203 AlreadyLinkedToThisAccount,
205 Expired,
206 AlreadyUsed,
207 StudentNumberAlreadyLinkedToAnotherAccount,
209}
210
211#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
212pub struct ClaimStudentNumberVerificationTokenResult {
213 pub outcome: ClaimStudentNumberVerificationTokenOutcome,
214 pub student_number: Option<String>,
215 pub linked_course_name: Option<String>,
216 pub newly_unblocked_registration_count: i64,
218}
219
220#[instrument(skip(pool))]
225#[utoipa::path(
226 get,
227 path = "/my",
228 operation_id = "getMyCreditRegistrations",
229 tag = "credit-registrations",
230 responses(
231 (status = 200, description = "The caller's credit registrations", body = Vec<MyCreditRegistration>)
232 )
233)]
234pub async fn get_my_credit_registrations(
235 user: AuthUser,
236 pool: web::Data<PgPool>,
237) -> ControllerResult<web::Json<Vec<MyCreditRegistration>>> {
238 let mut conn = pool.acquire().await?;
239 let token = skip_authorize();
240
241 let res =
242 build_my_credit_registrations(&mut conn, user.id, StudentRegistrationFilter::default())
243 .await?;
244
245 token.authorized_ok(web::Json(res))
246}
247
248#[instrument(skip(pool))]
254#[utoipa::path(
255 get,
256 path = "/my/by-course-module/{course_module_id}",
257 operation_id = "getMyCreditRegistrationForCourseModule",
258 tag = "credit-registrations",
259 params(("course_module_id" = Uuid, Path, description = "Course module id")),
260 responses(
261 (status = 200, description = "The caller's registration for the module", body = Option<MyCreditRegistrationForCourseModule>)
262 )
263)]
264pub async fn get_my_credit_registration_for_course_module(
265 user: AuthUser,
266 pool: web::Data<PgPool>,
267 course_module_id: web::Path<Uuid>,
268) -> ControllerResult<web::Json<Option<MyCreditRegistrationForCourseModule>>> {
269 let mut conn = pool.acquire().await?;
270 let token = skip_authorize();
271
272 let mut all = build_my_credit_registrations(
273 &mut conn,
274 user.id,
275 StudentRegistrationFilter {
276 course_module_id: Some(*course_module_id),
277 ..StudentRegistrationFilter::default()
278 },
279 )
280 .await?;
281 let live_position = all.iter().position(|row| !row.superseded);
282 let res = live_position.map(|position| {
283 let registration = all.remove(position);
284 MyCreditRegistrationForCourseModule {
285 registration,
286 earlier_attempts: all,
287 }
288 });
289
290 token.authorized_ok(web::Json(res))
291}
292
293#[instrument(skip(pool))]
301#[utoipa::path(
302 get,
303 path = "/my/enrolment-banners/by-course/{course_id}",
304 operation_id = "getMyCreditRegistrationEnrolmentBanners",
305 tag = "credit-registrations",
306 params(("course_id" = Uuid, Path, description = "Course id")),
307 responses(
308 (status = 200, description = "The caller's undismissed enrolment banners on the course", body = Vec<MyCreditRegistration>)
309 )
310)]
311pub async fn get_my_credit_registration_enrolment_banners(
312 user: AuthUser,
313 pool: web::Data<PgPool>,
314 course_id: web::Path<Uuid>,
315) -> ControllerResult<web::Json<Vec<MyCreditRegistration>>> {
316 let mut conn = pool.acquire().await?;
317 let token = skip_authorize();
318
319 let res = build_my_credit_registrations(
320 &mut conn,
321 user.id,
322 StudentRegistrationFilter {
323 course_id: Some(*course_id),
324 enrolment_banner_due: true,
325 ..StudentRegistrationFilter::default()
326 },
327 )
328 .await?;
329
330 token.authorized_ok(web::Json(res))
331}
332
333#[instrument(skip(pool))]
340#[utoipa::path(
341 post,
342 path = "/my/{id}/dismiss-enrolment-banner",
343 operation_id = "dismissCreditRegistrationEnrolmentBanner",
344 tag = "credit-registrations",
345 params(("id" = Uuid, Path, description = "Credit registration id")),
346 responses(
347 (status = 200, description = "The banner is dismissed"),
348 (status = 403, description = "Not the caller's registration")
349 )
350)]
351pub async fn dismiss_credit_registration_enrolment_banner(
352 user: AuthUser,
353 pool: web::Data<PgPool>,
354 id: web::Path<Uuid>,
355) -> ControllerResult<web::Json<()>> {
356 let mut conn = pool.acquire().await?;
357 let token = skip_authorize();
358
359 let registration = models::credit_registrations::get_by_id(&mut conn, *id).await?;
360 if registration.user_id != user.id {
361 return Err(controller_err!(
362 Forbidden,
363 "Not your registration.".to_string()
364 ));
365 }
366 models::credit_registrations::dismiss_enrolment_banner(&mut conn, registration.id, user.id)
367 .await?;
368
369 token.authorized_ok(web::Json(()))
370}
371
372#[instrument(skip(pool))]
377#[utoipa::path(
378 post,
379 path = "/my/{id}/recheck-enrolment",
380 operation_id = "requestCreditRegistrationEnrolmentRecheck",
381 tag = "credit-registrations",
382 params(("id" = Uuid, Path, description = "Credit registration id")),
383 responses(
384 (status = 200, description = "Whether a recheck was started", body = RequestCreditRegistrationEnrolmentRecheckResult),
385 (status = 403, description = "Not the caller's registration"),
386 (status = 400, description = "The registration is not waiting for an enrolment")
387 )
388)]
389pub async fn request_credit_registration_enrolment_recheck(
390 user: AuthUser,
391 pool: web::Data<PgPool>,
392 id: web::Path<Uuid>,
393) -> ControllerResult<web::Json<RequestCreditRegistrationEnrolmentRecheckResult>> {
394 let mut conn = pool.acquire().await?;
395 let token = skip_authorize();
396
397 let registration = models::credit_registrations::get_by_id(&mut conn, *id).await?;
398 if registration.user_id != user.id {
399 return Err(controller_err!(
400 Forbidden,
401 "Not your registration.".to_string()
402 ));
403 }
404 if registration.state != CreditRegistrationState::NoUsableEnrolment {
405 return Err(controller_err!(
406 BadRequest,
407 "This registration is not waiting for an enrolment.".to_string()
408 ));
409 }
410
411 let next_allowed = registration
412 .enrolment_checked_at
413 .map(|checked| checked + chrono::Duration::seconds(ENROLMENT_RECHECK_MIN_INTERVAL_SECS));
414 if next_allowed.is_some_and(|allowed| allowed > Utc::now()) {
415 return token.authorized_ok(web::Json(RequestCreditRegistrationEnrolmentRecheckResult {
416 recheck_started: false,
417 next_recheck_allowed_at: next_allowed,
418 }));
419 }
420
421 let mut tx = conn.begin().await?;
422 models::credit_registration_events::insert(
423 &mut tx,
424 &NewCreditRegistrationEvent {
425 actor_user_id: Some(user.id),
426 message: Some("The student asked us to look for an enrolment again.".to_string()),
427 ..NewCreditRegistrationEvent::new(
428 registration.id,
429 CreditRegistrationEventKind::StudentAction,
430 )
431 },
432 )
433 .await?;
434 models::credit_registrations::make_due_now_batch(&mut tx, &[registration.id]).await?;
435 recompute_preconditions(
436 &mut tx,
437 &RegistrationScope {
438 credit_registration_ids: vec![registration.id],
439 ..RegistrationScope::default()
440 },
441 PRECONDITIONS_LIMIT,
442 )
443 .await?;
444 tx.commit().await?;
445
446 token.authorized_ok(web::Json(RequestCreditRegistrationEnrolmentRecheckResult {
447 recheck_started: true,
448 next_recheck_allowed_at: None,
449 }))
450}
451
452#[instrument(skip(pool))]
457#[utoipa::path(
458 get,
459 path = "/my/student-number",
460 operation_id = "getMyVerifiedStudentNumber",
461 tag = "credit-registrations",
462 responses(
463 (status = 200, description = "The caller's linked student number", body = Option<MyVerifiedStudentNumber>)
464 )
465)]
466pub async fn get_my_verified_student_number(
467 user: AuthUser,
468 pool: web::Data<PgPool>,
469) -> ControllerResult<web::Json<Option<MyVerifiedStudentNumber>>> {
470 let mut conn = pool.acquire().await?;
471 let token = skip_authorize();
472
473 let res = verified_student_numbers::get_by_user_id(&mut conn, user.id)
474 .await?
475 .map(to_my_verified_student_number);
476
477 token.authorized_ok(web::Json(res))
478}
479
480#[instrument(skip(pool))]
487#[utoipa::path(
488 post,
489 path = "/my/student-number/dismiss-auto-link-notice",
490 operation_id = "dismissMyAutoLinkNotice",
491 tag = "credit-registrations",
492 responses(
493 (status = 200, description = "The notice is dismissed")
494 )
495)]
496pub async fn dismiss_my_auto_link_notice(
497 user: AuthUser,
498 pool: web::Data<PgPool>,
499) -> ControllerResult<web::Json<()>> {
500 let mut conn = pool.acquire().await?;
501 let token = skip_authorize();
502
503 verified_student_numbers::dismiss_auto_link_notice(&mut conn, user.id).await?;
504
505 token.authorized_ok(web::Json(()))
506}
507
508#[instrument(skip(pool))]
515#[utoipa::path(
516 delete,
517 path = "/my/student-number",
518 operation_id = "unlinkMyStudentNumber",
519 tag = "credit-registrations",
520 responses(
521 (status = 200, description = "How many registrations went back to waiting", body = UnlinkMyStudentNumberResult)
522 )
523)]
524pub async fn unlink_my_student_number(
525 user: AuthUser,
526 pool: web::Data<PgPool>,
527) -> ControllerResult<web::Json<UnlinkMyStudentNumberResult>> {
528 let mut conn = pool.acquire().await?;
529 let token = skip_authorize();
530
531 let Some(linked) = verified_student_numbers::get_by_user_id(&mut conn, user.id).await? else {
532 return token.authorized_ok(web::Json(UnlinkMyStudentNumberResult {
533 affected_registration_count: 0,
534 }));
535 };
536
537 let mut tx = conn.begin().await?;
538 let affected_registration_count = student_number_change::unlink_verified_student_number(
539 &mut tx,
540 linked.id,
541 user.id,
542 Some(user.id),
543 CreditRegistrationEventKind::StudentAction,
544 "The student unlinked their student number.",
545 )
546 .await?;
547 tx.commit().await?;
548
549 token.authorized_ok(web::Json(UnlinkMyStudentNumberResult {
550 affected_registration_count,
551 }))
552}
553
554#[instrument(skip(pool, path))]
561#[utoipa::path(
562 get,
563 path = "/student-number-verifications/{token}",
564 operation_id = "previewStudentNumberVerificationToken",
565 tag = "credit-registrations",
566 params(("token" = String, Path, description = "The mailed verification token")),
567 responses(
568 (status = 200, description = "What the token would link", body = StudentNumberVerificationTokenPreview),
569 (status = 404, description = "No such token")
570 )
571)]
572pub async fn preview_student_number_verification_token(
573 user: AuthUser,
574 pool: web::Data<PgPool>,
575 path: web::Path<String>,
576) -> ControllerResult<web::Json<StudentNumberVerificationTokenPreview>> {
577 let mut conn = pool.acquire().await?;
578 let auth_token = skip_authorize();
579
580 let verification_token = get_token_or_404(&mut conn, &path).await?;
581 let current_link = verified_student_numbers::get_by_user_id(&mut conn, user.id).await?;
582 let conflict = find_conflicting_account(&mut conn, &verification_token, user.id).await?;
583 let course_name = course_name_of_token(&mut conn, &verification_token).await?;
584 let details = models::user_details::get_user_details_by_user_id(&mut conn, user.id).await?;
585
586 let expired =
587 verification_token.expires_at <= Utc::now() || verification_token.deleted_at.is_some();
588 let already_used = verification_token.used_at.is_some();
589
590 auth_token.authorized_ok(web::Json(StudentNumberVerificationTokenPreview {
591 student_number: verification_token.student_number.clone(),
592 first_names: verification_token.first_names.clone(),
593 last_name: verification_token.last_name.clone(),
594 course_name,
595 emailed_to_masked: mask_email(&verification_token.emailed_to),
596 expires_at: verification_token.expires_at,
597 expired,
598 already_used,
599 already_used_by_this_account: verification_token.claimed_by_user_id == Some(user.id),
600 conflicts_with_other_account: conflict,
601 current_student_number: current_link.map(|link| link.student_number),
602 target_account_email: details.email,
603 claimable: !expired && !already_used && !conflict,
604 }))
605}
606
607#[instrument(skip(pool, path))]
615#[utoipa::path(
616 post,
617 path = "/student-number-verifications/{token}/claim",
618 operation_id = "claimStudentNumberVerificationToken",
619 tag = "credit-registrations",
620 params(("token" = String, Path, description = "The mailed verification token")),
621 responses(
622 (status = 200, description = "What the claim did", body = ClaimStudentNumberVerificationTokenResult),
623 (status = 404, description = "No such token")
624 )
625)]
626pub async fn claim_student_number_verification_token(
627 user: AuthUser,
628 pool: web::Data<PgPool>,
629 path: web::Path<String>,
630) -> ControllerResult<web::Json<ClaimStudentNumberVerificationTokenResult>> {
631 let mut conn = pool.acquire().await?;
632 let auth_token = skip_authorize();
633
634 let verification_token = get_token_or_404(&mut conn, &path).await?;
635 let refused = |outcome| ClaimStudentNumberVerificationTokenResult {
636 outcome,
637 student_number: None,
638 linked_course_name: None,
639 newly_unblocked_registration_count: 0,
640 };
641
642 if verification_token.used_at.is_some() {
643 return auth_token.authorized_ok(web::Json(refused(
644 ClaimStudentNumberVerificationTokenOutcome::AlreadyUsed,
645 )));
646 }
647 if verification_token.expires_at <= Utc::now() || verification_token.deleted_at.is_some() {
648 return auth_token.authorized_ok(web::Json(refused(
649 ClaimStudentNumberVerificationTokenOutcome::Expired,
650 )));
651 }
652 if find_conflicting_account(&mut conn, &verification_token, user.id).await? {
653 return auth_token.authorized_ok(web::Json(refused(
654 ClaimStudentNumberVerificationTokenOutcome::StudentNumberAlreadyLinkedToAnotherAccount,
655 )));
656 }
657
658 let course_name = course_name_of_token(&mut conn, &verification_token).await?;
659 let current_link = verified_student_numbers::get_by_user_id(&mut conn, user.id).await?;
660 let already_ours = current_link
661 .as_ref()
662 .is_some_and(|link| link.student_number == verification_token.student_number);
663
664 let mut tx = conn.begin().await?;
665 if !student_number_verification_tokens::claim(&mut tx, &verification_token.token, user.id)
667 .await?
668 {
669 tx.rollback().await?;
670 return auth_token.authorized_ok(web::Json(refused(
671 ClaimStudentNumberVerificationTokenOutcome::AlreadyUsed,
672 )));
673 }
674 if already_ours {
675 tx.commit().await?;
676 return auth_token.authorized_ok(web::Json(ClaimStudentNumberVerificationTokenResult {
677 outcome: ClaimStudentNumberVerificationTokenOutcome::AlreadyLinkedToThisAccount,
678 student_number: Some(verification_token.student_number),
679 linked_course_name: course_name,
680 newly_unblocked_registration_count: 0,
681 }));
682 }
683
684 let (_, newly_unblocked_registration_count) =
687 verified_student_numbers::replace_verified_student_number(
688 &mut tx,
689 current_link.map(|link| link.id),
690 &NewVerifiedStudentNumber {
691 user_id: user.id,
692 student_number: verification_token.student_number.clone(),
693 sisu_person_id: verification_token.sisu_person_id.clone(),
694 first_names: verification_token.first_names.clone(),
695 last_name: verification_token.last_name.clone(),
696 verified_via: StudentNumberVerificationMethod::EmailedLink,
697 verified_via_email: Some(verification_token.emailed_to.clone()),
698 verified_via_email_match_field: None,
699 account_email_verified_at: None,
700 linked_by_user_id: None,
701 link_reason: None,
702 verified_from_course_id: verification_token.course_id,
703 },
704 Some(user.id),
705 CreditRegistrationEventKind::StudentAction,
706 "The student linked a student number.",
707 )
708 .await?;
709 tx.commit().await?;
710
711 auth_token.authorized_ok(web::Json(ClaimStudentNumberVerificationTokenResult {
712 outcome: ClaimStudentNumberVerificationTokenOutcome::Linked,
713 student_number: Some(verification_token.student_number),
714 linked_course_name: course_name,
715 newly_unblocked_registration_count,
716 }))
717}
718
719async fn build_my_credit_registrations(
722 conn: &mut PgConnection,
723 user_id: Uuid,
724 filter: StudentRegistrationFilter,
725) -> Result<Vec<MyCreditRegistration>, ControllerError> {
726 let rows =
727 models::credit_registrations::get_student_facing_by_user_id(conn, user_id, filter).await?;
728
729 let ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
730 let notification_mails = student_notifications::get_for_registrations(conn, &ids).await?;
731
732 let mut enrolment_links: HashMap<String, Option<String>> = HashMap::new();
733 let mut linking_mails: Option<LinkingMailCache> = None;
734 let mut res = Vec::with_capacity(rows.len());
735 for row in rows {
736 let state = row.state;
737 let status = StudentFacingCreditRegistrationStatus::of(state, row.preconditions());
738 let enrolment_link = if status == StudentFacingCreditRegistrationStatus::NeedsEnrolment {
739 resolve_enrolment_link(conn, &row, &mut enrolment_links).await?
740 } else {
741 None
742 };
743 let linking_email = if status == StudentFacingCreditRegistrationStatus::NeedsStudentNumber {
744 resolve_linking_email(conn, user_id, &row, &mut linking_mails).await?
745 } else {
746 None
747 };
748 let notification_email =
749 NotificationEmailStatus::for_state(state, row.id, ¬ification_mails);
750 res.push(to_my_credit_registration(
751 row,
752 status,
753 enrolment_link,
754 linking_email,
755 notification_email,
756 ));
757 }
758 Ok(res)
759}
760
761fn to_my_credit_registration(
762 row: StudentCreditRegistration,
763 status: StudentFacingCreditRegistrationStatus,
764 enrolment_link: Option<String>,
765 linking_email: Option<LinkingEmailStatus>,
766 notification_email: Option<NotificationEmailStatus>,
767) -> MyCreditRegistration {
768 let can_request_enrolment_recheck = row.state == CreditRegistrationState::NoUsableEnrolment
769 && row.enrolment_checked_at.is_none_or(|checked| {
770 checked + chrono::Duration::seconds(ENROLMENT_RECHECK_MIN_INTERVAL_SECS) <= Utc::now()
771 });
772 MyCreditRegistration {
773 id: row.id,
774 course_id: row.course_id,
775 course_name: row.course_name,
776 course_slug: row.course_slug,
777 course_module_id: row.course_module_id,
778 course_module_name: row.course_module_name,
779 uh_course_code: row.uh_course_code,
780 ects_credits: row.ects_credits,
781 completion_date: row.completion_date,
782 student_facing_status: status,
783 registry_already_held_equal_or_better: row.state == CreditRegistrationState::NotImproved,
784 status_is_moving: status.is_moving(),
785 error_code: row.error_code,
786 next_attempt_at: row.next_attempt_at,
787 registered_at: row.registered_at,
788 sisu_attainment_id: row.sisu_attainment_id,
789 grade_id: row.grade_id,
790 grade_scale_id: row.grade_scale_id,
791 credits: row.credits,
792 attempt_number: row.attempt_number,
793 superseded: row.superseded_by_id.is_some(),
794 can_request_enrolment_recheck,
795 enrolment_realisation_name: row.enrolment_realisation_name,
796 enrolment_link,
797 linking_email,
798 notification_email,
799 }
800}
801
802async fn resolve_enrolment_link(
805 conn: &mut PgConnection,
806 row: &StudentCreditRegistration,
807 cache: &mut HashMap<String, Option<String>>,
808) -> Result<Option<String>, ControllerError> {
809 let Some(product_id) = row.open_university_product_id.as_ref() else {
810 return Ok(None);
811 };
812 if let Some(cached) = cache.get(product_id) {
813 return Ok(cached.clone());
814 }
815 let link =
816 open_university_product_access_tokens::enrolment_url_for_product(conn, Some(product_id))
817 .await?;
818 cache.insert(product_id.clone(), link.clone());
819 Ok(link)
820}
821
822struct LinkingMailCache {
825 mails: Vec<CreditRegistrationAccountLinkingEmail>,
826 reports: HashMap<Uuid, EmailSendStatusReport>,
827}
828
829async fn resolve_linking_email(
832 conn: &mut PgConnection,
833 user_id: Uuid,
834 row: &StudentCreditRegistration,
835 cache: &mut Option<LinkingMailCache>,
836) -> Result<Option<LinkingEmailStatus>, ControllerError> {
837 if cache.is_none() {
838 let mails =
839 match verified_student_numbers::get_latest_including_deleted_by_user_id(conn, user_id)
840 .await?
841 {
842 Some(link) => {
843 credit_registration_account_linking_emails::get_by_sisu_person_id(
844 conn,
845 &link.sisu_person_id,
846 )
847 .await?
848 }
849 None => Vec::new(),
850 };
851 let ids: Vec<Uuid> = mails.iter().map(|mail| mail.id).collect();
852 let reports =
853 credit_registration_account_linking_emails::get_send_status_reports(conn, &ids).await?;
854 *cache = Some(LinkingMailCache { mails, reports });
855 }
856 let cache = cache.as_ref().ok_or_else(|| {
857 controller_err!(
858 InternalServerError,
859 "linking mail cache was not populated".to_string()
860 )
861 })?;
862 let Some(mail) = cache
863 .mails
864 .iter()
865 .find(|mail| mail.course_id == row.course_id)
866 else {
867 return Ok(None);
868 };
869 let Some(report) = cache.reports.get(&mail.id) else {
870 return Ok(None);
871 };
872 Ok(Some(LinkingEmailStatus {
873 email_send_status: report.email_send_status,
874 sent_at: report.sent_at,
875 emailed_to_masked: mask_email(&mail.emailed_to),
876 }))
877}
878
879fn to_my_verified_student_number(link: VerifiedStudentNumber) -> MyVerifiedStudentNumber {
880 MyVerifiedStudentNumber {
881 student_number: link.student_number,
882 verified_at: link.verified_at,
883 verified_via: link.verified_via,
884 verified_via_email_masked: link.verified_via_email.as_deref().map(mask_email),
885 first_names: link.first_names,
886 last_name: link.last_name,
887 linked_automatically: link.verified_via
888 == StudentNumberVerificationMethod::EmailMatchFastTrack,
889 auto_link_notice_dismissed: link.auto_link_notice_dismissed_at.is_some(),
890 }
891}
892
893async fn get_token_or_404(
894 conn: &mut PgConnection,
895 token: &str,
896) -> Result<StudentNumberVerificationToken, ControllerError> {
897 student_number_verification_tokens::get_by_token_any_state(conn, &DbSecret::new(token))
898 .await?
899 .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))
900}
901
902async fn find_conflicting_account(
908 conn: &mut PgConnection,
909 token: &StudentNumberVerificationToken,
910 user_id: Uuid,
911) -> Result<bool, ControllerError> {
912 let by_number =
913 verified_student_numbers::get_by_student_number(conn, &token.student_number).await?;
914 if by_number.is_some_and(|link| link.user_id != user_id) {
915 return Ok(true);
916 }
917 let by_person =
918 verified_student_numbers::get_by_sisu_person_id(conn, &token.sisu_person_id).await?;
919 Ok(by_person.is_some_and(|link| link.user_id != user_id))
920}
921
922async fn course_name_of_token(
923 conn: &mut PgConnection,
924 token: &StudentNumberVerificationToken,
925) -> Result<Option<String>, ControllerError> {
926 let Some(course_id) = token.course_id else {
927 return Ok(None);
928 };
929 let course = models::courses::get_course(conn, course_id).await?;
930 Ok(Some(course.name))
931}
932
933pub(crate) fn mask_email(email: &str) -> String {
936 match email.split_once('@') {
937 Some((_, domain)) => format!("...@{domain}"),
938 None => "...".to_string(),
939 }
940}
941
942pub fn _add_routes(cfg: &mut ServiceConfig) {
943 cfg.route("/my", web::get().to(get_my_credit_registrations))
944 .route(
945 "/my/student-number",
946 web::get().to(get_my_verified_student_number),
947 )
948 .route(
949 "/my/student-number",
950 web::delete().to(unlink_my_student_number),
951 )
952 .route(
953 "/my/student-number/dismiss-auto-link-notice",
954 web::post().to(dismiss_my_auto_link_notice),
955 )
956 .route(
957 "/my/by-course-module/{course_module_id}",
958 web::get().to(get_my_credit_registration_for_course_module),
959 )
960 .route(
961 "/my/enrolment-banners/by-course/{course_id}",
962 web::get().to(get_my_credit_registration_enrolment_banners),
963 )
964 .service(
967 web::resource("/my/{id}/recheck-enrolment")
968 .wrap(RateLimit::new(RateLimitConfig {
969 per_minute: Some(5),
970 per_hour: Some(30),
971 ..Default::default()
972 }))
973 .route(web::post().to(request_credit_registration_enrolment_recheck)),
974 )
975 .service(
976 web::resource("/my/{id}/dismiss-enrolment-banner")
977 .route(web::post().to(dismiss_credit_registration_enrolment_banner)),
978 )
979 .route(
980 "/student-number-verifications/{token}",
981 web::get().to(preview_student_number_verification_token),
982 )
983 .service(
984 web::resource("/student-number-verifications/{token}/claim")
985 .wrap(RateLimit::new(RateLimitConfig {
986 per_minute: Some(10),
987 per_hour: Some(60),
988 ..Default::default()
989 }))
990 .route(web::post().to(claim_student_number_verification_token)),
991 );
992}