1use std::collections::HashMap;
10
11use headless_lms_models::{
12 completion_registration_credit_justifications,
13 course_module_completions::CourseModuleCompletion,
14 credit_registration_account_linking_emails::{self, CreditRegistrationAccountLinkingEmail},
15 credit_registration_enrolment_routes::{
16 self, CreditRegistrationEnrolmentRoute, EnrolmentRouteAnswer,
17 },
18 credit_registration_events::CreditRegistrationEventKind,
19 credit_registrations::{
20 CreditRegistrationErrorCode, CreditRegistrationState, StudentCreditRegistration,
21 StudentRegistrationFilter,
22 },
23 email_deliveries::{EmailSendStatus, EmailSendStatusReport},
24 library::credit_registration::StudentFacingCreditRegistrationStatus,
25 library::credit_registration::student_notifications,
26 student_number_verification_tokens::{self, StudentNumberVerificationToken},
27 verified_student_numbers::{
28 self, LinkConflict, NewVerifiedStudentNumber, StudentNumberVerificationMethod,
29 VerifiedStudentNumber,
30 },
31};
32use headless_lms_models::{
33 credit_registration_enrolment_check_signals,
34 library::credit_registration::enrolment_check_schedule::EnrolmentCheckSource,
35 library::credit_registration::enrolment_checks,
36};
37use headless_lms_utils::secret_string::expose_option;
38use models::library::credit_registration::student_number_change;
39use secrecy::ExposeSecret;
40use utoipa::{OpenApi, ToSchema};
41
42use crate::domain::credit_registration::enrolment_recheck::{
43 RecheckTarget, can_student_request_enrolment_recheck, start_student_enrolment_recheck,
44};
45use crate::domain::credit_registration::mail_status::{NotificationEmailStatus, mask_email};
46use crate::domain::rate_limit_middleware_builder::{RateLimit, RateLimitConfig, RateLimitKey};
47use crate::prelude::*;
48use headless_lms_base::config::ApplicationConfiguration;
49use headless_lms_credit_registration::account_linking::book_listing_for_unlinked_student;
50
51#[derive(OpenApi)]
52#[openapi(paths(
53 get_my_credit_registrations,
54 get_my_credit_registration_for_course_module,
55 get_my_credit_registration_enrolment_banners,
56 request_credit_registration_enrolment_recheck,
57 dismiss_credit_registration_enrolment_banner,
58 get_my_verified_student_number,
59 get_credit_registration_settings,
60 get_my_enrolment_route,
61 set_my_enrolment_route,
62 confirm_my_enrolment,
63 withdraw_my_enrolment_confirmation,
64 record_my_enrolment_page_visit,
65 set_my_credit_justification,
66 unlink_my_student_number,
67 preview_student_number_verification_token,
68 claim_student_number_verification_token
69))]
70pub(crate) struct MainFrontendCreditRegistrationsApiDoc;
71
72#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
74pub struct LinkingEmailStatus {
75 pub email_send_status: EmailSendStatus,
76 pub sent_at: Option<DateTime<Utc>>,
77 pub emailed_to_masked: String,
78}
79
80#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
81pub struct MyCreditRegistration {
82 pub id: Uuid,
83 pub course_id: Uuid,
84 pub course_name: String,
85 pub course_slug: String,
86 pub course_module_id: Uuid,
87 pub course_module_name: Option<String>,
88 pub uh_course_code: Option<String>,
89 pub ects_credits: Option<f32>,
90 pub completion_date: DateTime<Utc>,
91 pub student_facing_status: StudentFacingCreditRegistrationStatus,
92 pub registry_already_held_equal_or_better: bool,
97 pub status_is_moving: bool,
99 pub error_code: Option<CreditRegistrationErrorCode>,
100 pub next_attempt_at: DateTime<Utc>,
101 pub registered_at: Option<DateTime<Utc>>,
102 pub sisu_attainment_id: Option<String>,
103 pub student_number: Option<String>,
106 pub grade_id: Option<String>,
107 pub grade_scale_id: Option<String>,
110 pub credits: Option<f32>,
111 pub attempt_number: i32,
112 pub superseded: bool,
113 pub can_request_enrolment_recheck: bool,
114 pub enrolment_found: bool,
117 pub enrolment_checked_at: Option<DateTime<Utc>>,
119 pub enrolment_realisation_name: Option<String>,
120 pub submitted_at: Option<DateTime<Utc>>,
123 pub is_processing_in_sisu: bool,
125 pub enrolment_link: Option<String>,
127 pub linking_email: Option<LinkingEmailStatus>,
130 pub notification_email: Option<NotificationEmailStatus>,
132}
133
134#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
136pub struct MyCreditRegistrationForCourseModule {
137 pub registration: MyCreditRegistration,
138 pub earlier_attempts: Vec<MyCreditRegistration>,
141}
142
143#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
144pub struct RequestCreditRegistrationEnrolmentRecheckResult {
145 pub recheck_started: bool,
147}
148
149#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
152pub struct MyVerifiedStudentNumber {
153 pub student_number: String,
154 pub verified_at: DateTime<Utc>,
155 pub verified_via: StudentNumberVerificationMethod,
156 pub verified_via_email_masked: Option<String>,
158}
159
160#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
161pub struct UnlinkMyStudentNumberResult {
162 pub affected_registration_count: i64,
164}
165
166#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
169pub struct StudentNumberVerificationTokenPreview {
170 pub student_number: String,
171 pub course_name: Option<String>,
172 pub emailed_to_masked: String,
173 pub expires_at: DateTime<Utc>,
174 pub expired: bool,
175 pub already_used: bool,
176 pub already_used_by_this_account: bool,
178 pub conflicts_with_other_account: bool,
181 pub current_student_number: Option<String>,
183 pub target_account_email: String,
185 pub claimable: bool,
186}
187
188#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
189#[serde(rename_all = "snake_case")]
190pub enum ClaimStudentNumberVerificationTokenOutcome {
191 Linked,
192 AlreadyLinkedToThisAccount,
194 Expired,
195 AlreadyUsed,
196 StudentNumberAlreadyLinkedToAnotherAccount,
198}
199
200#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
201pub struct ClaimStudentNumberVerificationTokenResult {
202 pub outcome: ClaimStudentNumberVerificationTokenOutcome,
203 pub student_number: Option<String>,
204 pub linked_course_name: Option<String>,
205 pub newly_unblocked_registration_count: i64,
207}
208
209#[instrument(skip(pool))]
214#[utoipa::path(
215 get,
216 path = "/my",
217 operation_id = "getMyCreditRegistrations",
218 tag = "credit-registrations",
219 responses(
220 (status = 200, description = "The caller's credit registrations", body = Vec<MyCreditRegistration>)
221 )
222)]
223pub async fn get_my_credit_registrations(
224 user: AuthUser,
225 pool: web::Data<PgPool>,
226) -> ControllerResult<web::Json<Vec<MyCreditRegistration>>> {
227 let mut conn = pool.acquire().await?;
228 let token = skip_authorize();
229
230 let res =
231 build_my_credit_registrations(&mut conn, user.id, StudentRegistrationFilter::default())
232 .await?;
233
234 token.authorized_ok(web::Json(res))
235}
236
237#[instrument(skip(pool))]
243#[utoipa::path(
244 get,
245 path = "/my/by-course-module/{course_module_id}",
246 operation_id = "getMyCreditRegistrationForCourseModule",
247 tag = "credit-registrations",
248 params(("course_module_id" = Uuid, Path, description = "Course module id")),
249 responses(
250 (status = 200, description = "The caller's registration for the module", body = Option<MyCreditRegistrationForCourseModule>)
251 )
252)]
253pub async fn get_my_credit_registration_for_course_module(
254 user: AuthUser,
255 pool: web::Data<PgPool>,
256 course_module_id: web::Path<Uuid>,
257) -> ControllerResult<web::Json<Option<MyCreditRegistrationForCourseModule>>> {
258 let mut conn = pool.acquire().await?;
259 let token = skip_authorize();
260
261 let mut all = build_my_credit_registrations(
262 &mut conn,
263 user.id,
264 StudentRegistrationFilter {
265 course_module_id: Some(*course_module_id),
266 ..StudentRegistrationFilter::default()
267 },
268 )
269 .await?;
270 let live_position = all.iter().position(|row| !row.superseded);
271 let res = live_position.map(|position| {
272 let registration = all.remove(position);
273 MyCreditRegistrationForCourseModule {
274 registration,
275 earlier_attempts: all,
276 }
277 });
278
279 token.authorized_ok(web::Json(res))
280}
281
282#[instrument(skip(pool))]
290#[utoipa::path(
291 get,
292 path = "/my/enrolment-banners/by-course/{course_id}",
293 operation_id = "getMyCreditRegistrationEnrolmentBanners",
294 tag = "credit-registrations",
295 params(("course_id" = Uuid, Path, description = "Course id")),
296 responses(
297 (status = 200, description = "The caller's undismissed enrolment banners on the course", body = Vec<MyCreditRegistration>)
298 )
299)]
300pub async fn get_my_credit_registration_enrolment_banners(
301 user: AuthUser,
302 pool: web::Data<PgPool>,
303 course_id: web::Path<Uuid>,
304) -> ControllerResult<web::Json<Vec<MyCreditRegistration>>> {
305 let mut conn = pool.acquire().await?;
306 let token = skip_authorize();
307
308 let mut res = build_my_credit_registrations(
309 &mut conn,
310 user.id,
311 StudentRegistrationFilter {
312 course_id: Some(*course_id),
313 enrolment_banner_due: true,
314 ..StudentRegistrationFilter::default()
315 },
316 )
317 .await?;
318 res.retain(|registration| {
320 registration.student_facing_status == StudentFacingCreditRegistrationStatus::NeedsEnrolment
321 });
322
323 token.authorized_ok(web::Json(res))
324}
325
326#[instrument(skip(pool))]
333#[utoipa::path(
334 post,
335 path = "/my/{id}/dismiss-enrolment-banner",
336 operation_id = "dismissCreditRegistrationEnrolmentBanner",
337 tag = "credit-registrations",
338 params(("id" = Uuid, Path, description = "Credit registration id")),
339 responses(
340 (status = 200, description = "The banner is dismissed"),
341 (status = 403, description = "Not the caller's registration")
342 )
343)]
344pub async fn dismiss_credit_registration_enrolment_banner(
345 user: AuthUser,
346 pool: web::Data<PgPool>,
347 id: web::Path<Uuid>,
348) -> ControllerResult<web::Json<()>> {
349 let mut conn = pool.acquire().await?;
350 let token = skip_authorize();
351
352 let registration = models::credit_registrations::get_by_id(&mut conn, *id).await?;
353 if registration.user_id != user.id {
354 return Err(controller_err!(
355 Forbidden,
356 "Not your registration.".to_string()
357 ));
358 }
359 models::credit_registrations::dismiss_enrolment_banner(&mut conn, registration.id, user.id)
360 .await?;
361
362 token.authorized_ok(web::Json(()))
363}
364
365#[instrument(skip(pool))]
370#[utoipa::path(
371 post,
372 path = "/my/{id}/recheck-enrolment",
373 operation_id = "requestCreditRegistrationEnrolmentRecheck",
374 tag = "credit-registrations",
375 params(("id" = Uuid, Path, description = "Credit registration id")),
376 responses(
377 (status = 200, description = "Whether a recheck was started", body = RequestCreditRegistrationEnrolmentRecheckResult),
378 (status = 403, description = "Not the caller's registration"),
379 (status = 400, description = "The registration is not waiting for an enrolment")
380 )
381)]
382pub async fn request_credit_registration_enrolment_recheck(
383 user: AuthUser,
384 pool: web::Data<PgPool>,
385 id: web::Path<Uuid>,
386) -> ControllerResult<web::Json<RequestCreditRegistrationEnrolmentRecheckResult>> {
387 let mut conn = pool.acquire().await?;
388 let token = skip_authorize();
389
390 let registration = models::credit_registrations::get_by_id(&mut conn, *id).await?;
391 if registration.user_id != user.id {
392 return Err(controller_err!(
393 Forbidden,
394 "Not your registration.".to_string()
395 ));
396 }
397 if registration.state != CreditRegistrationState::NoUsableEnrolment {
398 return Err(controller_err!(
399 BadRequest,
400 "This registration is not waiting for an enrolment.".to_string()
401 ));
402 }
403
404 let outcome = start_student_enrolment_recheck(
405 &mut conn,
406 user.id,
407 RecheckTarget {
408 registration_id: registration.id,
409 course_module_completion_id: registration.course_module_completion_id,
410 },
411 registration.created_at,
412 "The student asked us to check for an enrolment again.",
413 )
414 .await?;
415
416 token.authorized_ok(web::Json(RequestCreditRegistrationEnrolmentRecheckResult {
417 recheck_started: outcome.started_check(),
418 }))
419}
420
421#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
423pub struct CreditRegistrationSettings {
424 pub account_linking_enabled: bool,
427}
428
429#[instrument(skip(app_conf))]
434#[utoipa::path(
435 get,
436 path = "/settings",
437 operation_id = "getCreditRegistrationSettings",
438 tag = "credit-registrations",
439 responses(
440 (status = 200, description = "The switches", body = CreditRegistrationSettings)
441 )
442)]
443pub async fn get_credit_registration_settings(
444 _user: AuthUser,
445 app_conf: web::Data<ApplicationConfiguration>,
446) -> ControllerResult<web::Json<CreditRegistrationSettings>> {
447 let token = skip_authorize();
448 token.authorized_ok(web::Json(CreditRegistrationSettings {
449 account_linking_enabled: app_conf.suotar_configuration.account_linking_enabled,
450 }))
451}
452
453#[instrument(skip(pool))]
458#[utoipa::path(
459 get,
460 path = "/my/student-number",
461 operation_id = "getMyVerifiedStudentNumber",
462 tag = "credit-registrations",
463 responses(
464 (status = 200, description = "The caller's linked student number", body = Option<MyVerifiedStudentNumber>)
465 )
466)]
467pub async fn get_my_verified_student_number(
468 user: AuthUser,
469 pool: web::Data<PgPool>,
470) -> ControllerResult<web::Json<Option<MyVerifiedStudentNumber>>> {
471 let mut conn = pool.acquire().await?;
472 let token = skip_authorize();
473
474 let res = verified_student_numbers::get_by_user_id(&mut conn, user.id)
475 .await?
476 .map(to_my_verified_student_number);
477
478 token.authorized_ok(web::Json(res))
479}
480
481#[instrument(skip(pool))]
488#[utoipa::path(
489 delete,
490 path = "/my/student-number",
491 operation_id = "unlinkMyStudentNumber",
492 tag = "credit-registrations",
493 responses(
494 (status = 200, description = "How many registrations went back to waiting", body = UnlinkMyStudentNumberResult)
495 )
496)]
497pub async fn unlink_my_student_number(
498 user: AuthUser,
499 pool: web::Data<PgPool>,
500) -> ControllerResult<web::Json<UnlinkMyStudentNumberResult>> {
501 let mut conn = pool.acquire().await?;
502 let token = skip_authorize();
503
504 let Some(linked) = verified_student_numbers::get_by_user_id(&mut conn, user.id).await? else {
505 return token.authorized_ok(web::Json(UnlinkMyStudentNumberResult {
506 affected_registration_count: 0,
507 }));
508 };
509
510 let mut tx = conn.begin().await?;
511 let affected_registration_count = student_number_change::unlink_verified_student_number(
512 &mut tx,
513 linked.id,
514 user.id,
515 Some(user.id),
516 CreditRegistrationEventKind::StudentAction,
517 "The student unlinked their student number.",
518 )
519 .await?;
520 tx.commit().await?;
521
522 token.authorized_ok(web::Json(UnlinkMyStudentNumberResult {
523 affected_registration_count,
524 }))
525}
526
527#[instrument(skip(pool, path))]
534#[utoipa::path(
535 get,
536 path = "/student-number-verifications/{token}",
537 operation_id = "previewStudentNumberVerificationToken",
538 tag = "credit-registrations",
539 params(("token" = String, Path, description = "The mailed verification token")),
540 responses(
541 (status = 200, description = "What the token would link", body = StudentNumberVerificationTokenPreview),
542 (status = 404, description = "No such token")
543 )
544)]
545pub async fn preview_student_number_verification_token(
546 user: AuthUser,
547 pool: web::Data<PgPool>,
548 path: web::Path<String>,
549) -> ControllerResult<web::Json<StudentNumberVerificationTokenPreview>> {
550 let mut conn = pool.acquire().await?;
551 let auth_token = skip_authorize();
552
553 let verification_token = get_token_or_404(&mut conn, &path).await?;
554 let current_link = verified_student_numbers::get_by_user_id(&mut conn, user.id).await?;
555 let conflict = find_conflicting_account(&mut conn, &verification_token, user.id).await?;
556 let course_name = course_name_of_token(&mut conn, &verification_token).await?;
557 let details = models::user_details::get_user_details_by_user_id(&mut conn, user.id).await?;
558
559 let expired =
560 verification_token.expires_at <= Utc::now() || verification_token.deleted_at.is_some();
561 let already_used = verification_token.used_at.is_some();
562
563 auth_token.authorized_ok(web::Json(StudentNumberVerificationTokenPreview {
564 student_number: verification_token.student_number.expose_secret().to_owned(),
565 course_name,
566 emailed_to_masked: mask_email(verification_token.emailed_to.expose_secret()),
567 expires_at: verification_token.expires_at,
568 expired,
569 already_used,
570 already_used_by_this_account: verification_token.claimed_by_user_id == Some(user.id),
571 conflicts_with_other_account: conflict,
572 current_student_number: current_link
573 .map(|link| link.student_number.expose_secret().to_owned()),
574 target_account_email: details.email,
575 claimable: !expired && !already_used && !conflict,
576 }))
577}
578
579#[instrument(skip(pool, path))]
587#[utoipa::path(
588 post,
589 path = "/student-number-verifications/{token}/claim",
590 operation_id = "claimStudentNumberVerificationToken",
591 tag = "credit-registrations",
592 params(("token" = String, Path, description = "The mailed verification token")),
593 responses(
594 (status = 200, description = "What the claim did", body = ClaimStudentNumberVerificationTokenResult),
595 (status = 404, description = "No such token")
596 )
597)]
598pub async fn claim_student_number_verification_token(
599 user: AuthUser,
600 pool: web::Data<PgPool>,
601 path: web::Path<String>,
602) -> ControllerResult<web::Json<ClaimStudentNumberVerificationTokenResult>> {
603 let mut conn = pool.acquire().await?;
604 let auth_token = skip_authorize();
605
606 let verification_token = get_token_or_404(&mut conn, &path).await?;
607 let refused = |outcome| ClaimStudentNumberVerificationTokenResult {
608 outcome,
609 student_number: None,
610 linked_course_name: None,
611 newly_unblocked_registration_count: 0,
612 };
613
614 if verification_token.used_at.is_some() {
615 return auth_token.authorized_ok(web::Json(refused(
616 ClaimStudentNumberVerificationTokenOutcome::AlreadyUsed,
617 )));
618 }
619 if verification_token.expires_at <= Utc::now() || verification_token.deleted_at.is_some() {
620 return auth_token.authorized_ok(web::Json(refused(
621 ClaimStudentNumberVerificationTokenOutcome::Expired,
622 )));
623 }
624 if find_conflicting_account(&mut conn, &verification_token, user.id).await? {
625 return auth_token.authorized_ok(web::Json(refused(
626 ClaimStudentNumberVerificationTokenOutcome::StudentNumberAlreadyLinkedToAnotherAccount,
627 )));
628 }
629
630 let course_name = course_name_of_token(&mut conn, &verification_token).await?;
631 let current_link = verified_student_numbers::get_by_user_id(&mut conn, user.id).await?;
632 let already_ours = current_link.as_ref().is_some_and(|link| {
633 link.student_number.expose_secret() == verification_token.student_number.expose_secret()
634 });
635
636 let mut tx = conn.begin().await?;
637 if !student_number_verification_tokens::claim(&mut tx, &verification_token.token, user.id)
639 .await?
640 {
641 tx.rollback().await?;
642 return auth_token.authorized_ok(web::Json(refused(
643 ClaimStudentNumberVerificationTokenOutcome::AlreadyUsed,
644 )));
645 }
646 if already_ours {
647 tx.commit().await?;
648 return auth_token.authorized_ok(web::Json(ClaimStudentNumberVerificationTokenResult {
649 outcome: ClaimStudentNumberVerificationTokenOutcome::AlreadyLinkedToThisAccount,
650 student_number: Some(verification_token.student_number.expose_secret().to_owned()),
651 linked_course_name: course_name,
652 newly_unblocked_registration_count: 0,
653 }));
654 }
655
656 let (_, newly_unblocked_registration_count) =
659 verified_student_numbers::replace_verified_student_number(
660 &mut tx,
661 current_link.map(|link| link.id),
662 &NewVerifiedStudentNumber {
663 user_id: user.id,
664 student_number: verification_token.student_number.clone(),
665 sisu_person_id: verification_token.sisu_person_id.clone(),
666 first_names: verification_token.first_names.clone(),
667 last_name: verification_token.last_name.clone(),
668 verified_via: StudentNumberVerificationMethod::EmailedLink,
669 verified_via_email: Some(verification_token.emailed_to.clone()),
670 linked_by_user_id: None,
671 link_reason: None,
672 verified_from_course_id: verification_token.course_id,
673 },
674 Some(user.id),
675 CreditRegistrationEventKind::StudentAction,
676 "The student linked a student number.",
677 )
678 .await?;
679 if let Some(course_id) = verification_token.course_id {
681 credit_registration_enrolment_check_signals::record_account_link_for_course(
682 &mut tx, user.id, course_id,
683 )
684 .await?;
685 }
686 tx.commit().await?;
687
688 auth_token.authorized_ok(web::Json(ClaimStudentNumberVerificationTokenResult {
689 outcome: ClaimStudentNumberVerificationTokenOutcome::Linked,
690 student_number: Some(verification_token.student_number.expose_secret().to_owned()),
691 linked_course_name: course_name,
692 newly_unblocked_registration_count,
693 }))
694}
695
696async fn build_my_credit_registrations(
699 conn: &mut PgConnection,
700 user_id: Uuid,
701 filter: StudentRegistrationFilter,
702) -> Result<Vec<MyCreditRegistration>, ControllerError> {
703 let rows =
704 models::credit_registrations::get_student_facing_by_user_id(conn, user_id, filter).await?;
705
706 let ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
707 let notification_mails = student_notifications::get_for_registrations(conn, &ids).await?;
708
709 let mut linking_mails: Option<LinkingMailCache> = None;
710 let mut res = Vec::with_capacity(rows.len());
711 for row in rows {
712 let state = row.state;
713 let status = if row.is_requested_check_unanswered() {
714 StudentFacingCreditRegistrationStatus::LookingForEnrolment
715 } else {
716 StudentFacingCreditRegistrationStatus::of(
717 state,
718 row.preconditions(),
719 row.enrolment_resolved,
720 )
721 };
722 let enrolment_link = if status == StudentFacingCreditRegistrationStatus::NeedsEnrolment {
723 row.enrolment_link.clone()
724 } else {
725 None
726 };
727 let linking_email = if status == StudentFacingCreditRegistrationStatus::NeedsStudentNumber {
728 resolve_linking_email(conn, user_id, &row, &mut linking_mails).await?
729 } else {
730 None
731 };
732 let notification_email =
733 NotificationEmailStatus::for_state(state, row.id, ¬ification_mails);
734 res.push(to_my_credit_registration(
735 row,
736 status,
737 enrolment_link,
738 linking_email,
739 notification_email,
740 ));
741 }
742 Ok(res)
743}
744
745fn to_my_credit_registration(
746 row: StudentCreditRegistration,
747 status: StudentFacingCreditRegistrationStatus,
748 enrolment_link: Option<String>,
749 linking_email: Option<LinkingEmailStatus>,
750 notification_email: Option<NotificationEmailStatus>,
751) -> MyCreditRegistration {
752 let enrolment_found = row.has_usable_enrolment();
753 let can_request_enrolment_recheck = can_student_request_enrolment_recheck(
754 row.state,
755 row.created_at,
756 row.enrolment_check_requested_at,
757 row.enrolment_checked_at,
758 );
759 MyCreditRegistration {
760 id: row.id,
761 course_id: row.course_id,
762 course_name: row.course_name,
763 course_slug: row.course_slug,
764 course_module_id: row.course_module_id,
765 course_module_name: row.course_module_name,
766 uh_course_code: row.uh_course_code,
767 ects_credits: row.ects_credits,
768 completion_date: row.completion_date,
769 student_facing_status: status,
770 registry_already_held_equal_or_better: row.state == CreditRegistrationState::NotImproved,
771 status_is_moving: status.is_moving(),
772 error_code: row.error_code,
773 next_attempt_at: row.next_attempt_at,
774 registered_at: row.registered_at,
775 sisu_attainment_id: row.sisu_attainment_id,
776 student_number: expose_option(&row.student_number).map(str::to_owned),
777 grade_id: row.grade_id,
778 grade_scale_id: row.grade_scale_id,
779 credits: row.credits,
780 attempt_number: row.attempt_number,
781 superseded: row.superseded_by_id.is_some(),
782 can_request_enrolment_recheck,
783 enrolment_found,
784 enrolment_checked_at: row.enrolment_checked_at,
785 enrolment_realisation_name: row.enrolment_realisation_name,
786 submitted_at: row.submitted_at,
787 is_processing_in_sisu: status == StudentFacingCreditRegistrationStatus::WaitingForSisu
788 && row.is_partially_registered,
789 enrolment_link,
790 linking_email,
791 notification_email,
792 }
793}
794
795struct LinkingMailCache {
798 mails: Vec<CreditRegistrationAccountLinkingEmail>,
799 reports: HashMap<Uuid, EmailSendStatusReport>,
800}
801
802async fn resolve_linking_email(
805 conn: &mut PgConnection,
806 user_id: Uuid,
807 row: &StudentCreditRegistration,
808 cache: &mut Option<LinkingMailCache>,
809) -> Result<Option<LinkingEmailStatus>, ControllerError> {
810 if cache.is_none() {
811 let mails =
812 match verified_student_numbers::get_latest_including_deleted_by_user_id(conn, user_id)
813 .await?
814 .and_then(|link| link.sisu_person_id)
815 {
816 Some(person_id) => {
817 credit_registration_account_linking_emails::get_by_sisu_person_id(
818 conn,
819 person_id.expose_secret(),
820 )
821 .await?
822 }
823 None => Vec::new(),
824 };
825 let ids: Vec<Uuid> = mails.iter().map(|mail| mail.id).collect();
826 let reports =
827 credit_registration_account_linking_emails::get_send_status_reports(conn, &ids).await?;
828 *cache = Some(LinkingMailCache { mails, reports });
829 }
830 let cache = cache.as_ref().ok_or_else(|| {
831 controller_err!(
832 InternalServerError,
833 "linking mail cache was not populated".to_string()
834 )
835 })?;
836 let Some(mail) = cache
837 .mails
838 .iter()
839 .find(|mail| mail.course_id == row.course_id)
840 else {
841 return Ok(None);
842 };
843 let Some(report) = cache.reports.get(&mail.id) else {
844 return Ok(None);
845 };
846 Ok(Some(LinkingEmailStatus {
847 email_send_status: report.email_send_status,
848 sent_at: report.sent_at,
849 emailed_to_masked: mask_email(mail.emailed_to.expose_secret()),
850 }))
851}
852
853fn to_my_verified_student_number(link: VerifiedStudentNumber) -> MyVerifiedStudentNumber {
854 MyVerifiedStudentNumber {
855 student_number: link.student_number.expose_secret().to_owned(),
856 verified_at: link.verified_at,
857 verified_via: link.verified_via,
858 verified_via_email_masked: expose_option(&link.verified_via_email).map(mask_email),
859 }
860}
861
862async fn get_token_or_404(
863 conn: &mut PgConnection,
864 token: &str,
865) -> Result<StudentNumberVerificationToken, ControllerError> {
866 student_number_verification_tokens::get_by_token_any_state(conn, &DbSecret::new(token))
867 .await?
868 .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))
869}
870
871async fn find_conflicting_account(
873 conn: &mut PgConnection,
874 token: &StudentNumberVerificationToken,
875 user_id: Uuid,
876) -> Result<bool, ControllerError> {
877 let conflict = verified_student_numbers::find_link_conflict(
878 conn,
879 token.student_number.expose_secret(),
880 token.sisu_person_id.expose_secret(),
881 user_id,
882 )
883 .await?;
884 Ok(conflict == Some(LinkConflict::AnotherAccount))
885}
886
887async fn course_name_of_token(
888 conn: &mut PgConnection,
889 token: &StudentNumberVerificationToken,
890) -> Result<Option<String>, ControllerError> {
891 let Some(course_id) = token.course_id else {
892 return Ok(None);
893 };
894 let course = models::courses::get_course(conn, course_id).await?;
895 Ok(Some(course.name))
896}
897
898#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
900pub struct MyEnrolmentRoute {
901 pub course_module_completion_id: Uuid,
902 pub route: Option<CreditRegistrationEnrolmentRoute>,
904 pub enrolment_confirmed_at: Option<DateTime<Utc>>,
905 pub can_change: bool,
909}
910
911#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
912pub struct SetEnrolmentRoutePayload {
913 pub route: CreditRegistrationEnrolmentRoute,
914}
915
916async fn my_registration_completion_for_module(
923 conn: &mut PgConnection,
924 user_id: Uuid,
925 course_module_id: Uuid,
926) -> Result<CourseModuleCompletion, ControllerError> {
927 let completion =
928 models::course_module_completions::get_registration_completion_by_user_and_course_module_id(
929 conn,
930 user_id,
931 course_module_id,
932 )
933 .await?;
934 Ok(completion)
935}
936
937async fn my_completion_for_module(
943 conn: &mut PgConnection,
944 user_id: Uuid,
945 course_module_id: Uuid,
946) -> Result<Uuid, ControllerError> {
947 let completion = my_registration_completion_for_module(conn, user_id, course_module_id).await?;
948 if !completion.register_credits_via_suotar {
949 return Err(controller_err!(NotFound, "Not found.".to_string()));
950 }
951 Ok(completion.id)
952}
953
954async fn my_live_registration_for_module(
956 conn: &mut PgConnection,
957 user_id: Uuid,
958 course_module_id: Uuid,
959) -> Result<Option<StudentCreditRegistration>, ControllerError> {
960 let rows = models::credit_registrations::get_student_facing_by_user_id(
961 conn,
962 user_id,
963 StudentRegistrationFilter {
964 course_module_id: Some(course_module_id),
965 ..StudentRegistrationFilter::default()
966 },
967 )
968 .await?;
969 Ok(rows.into_iter().find(|row| row.superseded_by_id.is_none()))
970}
971
972async fn build_my_enrolment_route(
973 conn: &mut PgConnection,
974 user_id: Uuid,
975 course_module_id: Uuid,
976) -> Result<(MyEnrolmentRoute, Option<StudentCreditRegistration>), ControllerError> {
977 let course_module_completion_id =
978 my_completion_for_module(conn, user_id, course_module_id).await?;
979 let registration = my_live_registration_for_module(conn, user_id, course_module_id).await?;
980 let answer = credit_registration_enrolment_routes::get_by_completion_id(
981 conn,
982 course_module_completion_id,
983 )
984 .await?;
985 let can_change = !registration
986 .as_ref()
987 .is_some_and(|row| row.has_usable_enrolment());
988 Ok((
989 my_enrolment_route(course_module_completion_id, answer, can_change),
990 registration,
991 ))
992}
993
994fn my_enrolment_route(
997 course_module_completion_id: Uuid,
998 answer: Option<EnrolmentRouteAnswer>,
999 can_change: bool,
1000) -> MyEnrolmentRoute {
1001 MyEnrolmentRoute {
1002 course_module_completion_id,
1003 route: answer.as_ref().map(|answer| answer.route),
1004 enrolment_confirmed_at: answer.and_then(|answer| answer.enrolment_confirmed_at),
1005 can_change,
1006 }
1007}
1008
1009#[instrument(skip(pool))]
1014#[utoipa::path(
1015 get,
1016 path = "/my/by-course-module/{course_module_id}/enrolment-route",
1017 operation_id = "getMyEnrolmentRoute",
1018 tag = "credit-registrations",
1019 params(("course_module_id" = Uuid, Path, description = "Course module id")),
1020 responses(
1021 (status = 200, description = "The caller's answer for the module", body = MyEnrolmentRoute)
1022 )
1023)]
1024pub async fn get_my_enrolment_route(
1025 user: AuthUser,
1026 pool: web::Data<PgPool>,
1027 course_module_id: web::Path<Uuid>,
1028) -> ControllerResult<web::Json<MyEnrolmentRoute>> {
1029 let mut conn = pool.acquire().await?;
1030 let token = skip_authorize();
1031
1032 let (res, _) = build_my_enrolment_route(&mut conn, user.id, *course_module_id).await?;
1033
1034 token.authorized_ok(web::Json(res))
1035}
1036
1037#[instrument(skip(pool))]
1042#[utoipa::path(
1043 put,
1044 path = "/my/by-course-module/{course_module_id}/enrolment-route",
1045 operation_id = "setMyEnrolmentRoute",
1046 tag = "credit-registrations",
1047 params(("course_module_id" = Uuid, Path, description = "Course module id")),
1048 request_body = SetEnrolmentRoutePayload,
1049 responses(
1050 (status = 200, description = "The stored answer", body = MyEnrolmentRoute)
1051 )
1052)]
1053pub async fn set_my_enrolment_route(
1054 user: AuthUser,
1055 pool: web::Data<PgPool>,
1056 course_module_id: web::Path<Uuid>,
1057 payload: web::Json<SetEnrolmentRoutePayload>,
1058) -> ControllerResult<web::Json<MyEnrolmentRoute>> {
1059 let mut conn = pool.acquire().await?;
1060 let token = skip_authorize();
1061
1062 let (current, _) = build_my_enrolment_route(&mut conn, user.id, *course_module_id).await?;
1063 if !current.can_change {
1064 return Err(controller_err!(
1065 BadRequest,
1066 "We can already see your enrolment, so this answer no longer changes anything."
1067 .to_string()
1068 ));
1069 }
1070 let answer = credit_registration_enrolment_routes::set_route(
1071 &mut conn,
1072 current.course_module_completion_id,
1073 user.id,
1074 payload.route,
1075 )
1076 .await?;
1077
1078 token.authorized_ok(web::Json(my_enrolment_route(
1079 current.course_module_completion_id,
1080 Some(answer),
1081 current.can_change,
1082 )))
1083}
1084
1085#[instrument(skip(pool, app_conf))]
1095#[utoipa::path(
1096 post,
1097 path = "/my/by-course-module/{course_module_id}/enrolment-route/confirm",
1098 operation_id = "confirmMyEnrolment",
1099 tag = "credit-registrations",
1100 params(("course_module_id" = Uuid, Path, description = "Course module id")),
1101 responses(
1102 (status = 200, description = "The stored answer", body = MyEnrolmentRoute)
1103 )
1104)]
1105pub async fn confirm_my_enrolment(
1106 user: AuthUser,
1107 pool: web::Data<PgPool>,
1108 app_conf: web::Data<ApplicationConfiguration>,
1109 course_module_id: web::Path<Uuid>,
1110) -> ControllerResult<web::Json<MyEnrolmentRoute>> {
1111 let mut conn = pool.acquire().await?;
1112 let token = skip_authorize();
1113
1114 let (current, registration) =
1115 build_my_enrolment_route(&mut conn, user.id, *course_module_id).await?;
1116 if current.route.is_none() {
1117 return Err(controller_err!(
1118 BadRequest,
1119 "Tell us where you enrol first.".to_string()
1120 ));
1121 }
1122 if !current.can_change {
1123 return Err(controller_err!(
1124 BadRequest,
1125 "We can already see your enrolment.".to_string()
1126 ));
1127 }
1128 let answer = credit_registration_enrolment_routes::set_enrolment_confirmed(
1129 &mut conn,
1130 current.course_module_completion_id,
1131 true,
1132 )
1133 .await?;
1134 match registration {
1135 Some(registration) if registration.is_waiting_for_enrolment() => {
1136 start_student_enrolment_recheck(
1137 &mut conn,
1138 user.id,
1139 RecheckTarget {
1140 registration_id: registration.id,
1141 course_module_completion_id: current.course_module_completion_id,
1142 },
1143 registration.created_at,
1144 "The student said they had enrolled.",
1145 )
1146 .await?;
1147 }
1148 _ => {
1149 credit_registration_enrolment_check_signals::record_check_request(
1151 &mut conn,
1152 current.course_module_completion_id,
1153 EnrolmentCheckSource::StudentRequest,
1154 )
1155 .await?;
1156 book_roster_listing_for_unlinked_student(
1157 &mut conn,
1158 &app_conf,
1159 user.id,
1160 *course_module_id,
1161 false,
1162 )
1163 .await?;
1164 }
1165 }
1166
1167 token.authorized_ok(web::Json(my_enrolment_route(
1168 current.course_module_completion_id,
1169 Some(answer),
1170 current.can_change,
1171 )))
1172}
1173
1174#[instrument(skip(pool))]
1179#[utoipa::path(
1180 delete,
1181 path = "/my/by-course-module/{course_module_id}/enrolment-route/confirm",
1182 operation_id = "withdrawMyEnrolmentConfirmation",
1183 tag = "credit-registrations",
1184 params(("course_module_id" = Uuid, Path, description = "Course module id")),
1185 responses(
1186 (status = 200, description = "The stored answer", body = MyEnrolmentRoute)
1187 )
1188)]
1189pub async fn withdraw_my_enrolment_confirmation(
1190 user: AuthUser,
1191 pool: web::Data<PgPool>,
1192 course_module_id: web::Path<Uuid>,
1193) -> ControllerResult<web::Json<MyEnrolmentRoute>> {
1194 let mut conn = pool.acquire().await?;
1195 let token = skip_authorize();
1196
1197 let (current, _) = build_my_enrolment_route(&mut conn, user.id, *course_module_id).await?;
1198 if !current.can_change {
1199 return Err(controller_err!(
1200 BadRequest,
1201 "We can already see your enrolment, so there is nothing to undo.".to_string()
1202 ));
1203 }
1204 let answer = match current.route {
1205 None => None,
1207 Some(_) => Some(
1208 credit_registration_enrolment_routes::set_enrolment_confirmed(
1209 &mut conn,
1210 current.course_module_completion_id,
1211 false,
1212 )
1213 .await?,
1214 ),
1215 };
1216
1217 token.authorized_ok(web::Json(my_enrolment_route(
1218 current.course_module_completion_id,
1219 answer,
1220 current.can_change,
1221 )))
1222}
1223
1224#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
1226pub struct MyCreditJustification {
1227 pub course_module_completion_id: Uuid,
1228 pub justification: String,
1229 pub updated_at: DateTime<Utc>,
1230}
1231
1232#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
1233pub struct SetCreditJustificationPayload {
1234 pub justification: String,
1235}
1236
1237const CREDIT_JUSTIFICATION_MAX_LENGTH: usize = 4000;
1240
1241#[instrument(skip(pool))]
1250#[utoipa::path(
1251 put,
1252 path = "/my/by-course-module/{course_module_id}/credit-justification",
1253 operation_id = "setMyCreditJustification",
1254 tag = "credit-registrations",
1255 params(("course_module_id" = Uuid, Path, description = "Course module id")),
1256 request_body = SetCreditJustificationPayload,
1257 responses(
1258 (status = 200, description = "The stored answer", body = MyCreditJustification)
1259 )
1260)]
1261pub async fn set_my_credit_justification(
1262 user: AuthUser,
1263 pool: web::Data<PgPool>,
1264 course_module_id: web::Path<Uuid>,
1265 payload: web::Json<SetCreditJustificationPayload>,
1266) -> ControllerResult<web::Json<MyCreditJustification>> {
1267 let mut conn = pool.acquire().await?;
1268 let token = skip_authorize();
1269
1270 let justification = payload.justification.trim();
1271 if justification.is_empty() {
1272 return Err(controller_err!(
1273 BadRequest,
1274 "Tell us why you need the credits.".to_string()
1275 ));
1276 }
1277 if justification.chars().count() > CREDIT_JUSTIFICATION_MAX_LENGTH {
1278 return Err(controller_err!(
1279 BadRequest,
1280 format!("Keep your answer under {CREDIT_JUSTIFICATION_MAX_LENGTH} characters.")
1281 ));
1282 }
1283 let completion =
1284 my_registration_completion_for_module(&mut conn, user.id, *course_module_id).await?;
1285 let stored = completion_registration_credit_justifications::upsert(
1286 &mut conn,
1287 completion.id,
1288 user.id,
1289 justification,
1290 )
1291 .await?;
1292
1293 token.authorized_ok(web::Json(MyCreditJustification {
1294 course_module_completion_id: stored.course_module_completion_id,
1295 justification: stored.justification,
1296 updated_at: stored.updated_at,
1297 }))
1298}
1299
1300#[instrument(skip(pool, app_conf))]
1310#[utoipa::path(
1311 post,
1312 path = "/my/by-course-module/{course_module_id}/enrolment-page-visit",
1313 operation_id = "recordMyEnrolmentPageVisit",
1314 tag = "credit-registrations",
1315 params(("course_module_id" = Uuid, Path, description = "Course module id")),
1316 responses(
1317 (status = 200, description = "The visit is recorded"),
1318 (status = 404, description = "No completion on the push path for this module")
1319 )
1320)]
1321pub async fn record_my_enrolment_page_visit(
1322 user: AuthUser,
1323 pool: web::Data<PgPool>,
1324 app_conf: web::Data<ApplicationConfiguration>,
1325 course_module_id: web::Path<Uuid>,
1326) -> ControllerResult<web::Json<()>> {
1327 let mut conn = pool.acquire().await?;
1328 let token = skip_authorize();
1329
1330 let course_module_completion_id =
1331 my_completion_for_module(&mut conn, user.id, *course_module_id).await?;
1332 let registration =
1333 my_live_registration_for_module(&mut conn, user.id, *course_module_id).await?;
1334 if registration
1335 .as_ref()
1336 .is_some_and(StudentCreditRegistration::has_usable_enrolment)
1337 {
1338 return token.authorized_ok(web::Json(()));
1339 }
1340 let previous_visit_at = credit_registration_enrolment_check_signals::record_visit(
1341 &mut conn,
1342 course_module_completion_id,
1343 )
1344 .await?;
1345 match registration {
1346 Some(registration) if registration.is_waiting_for_enrolment() => {
1347 enrolment_checks::record_visit(&mut conn, registration.id, Utc::now()).await?;
1348 }
1349 _ => {
1350 let today = Utc::now().date_naive();
1352 if previous_visit_at.is_none_or(|visited| visited.date_naive() != today) {
1353 book_roster_listing_for_unlinked_student(
1354 &mut conn,
1355 &app_conf,
1356 user.id,
1357 *course_module_id,
1358 true,
1359 )
1360 .await?;
1361 }
1362 }
1363 }
1364
1365 token.authorized_ok(web::Json(()))
1366}
1367
1368async fn book_roster_listing_for_unlinked_student(
1371 conn: &mut PgConnection,
1372 app_conf: &ApplicationConfiguration,
1373 user_id: Uuid,
1374 course_module_id: Uuid,
1375 is_visit: bool,
1376) -> Result<(), ControllerError> {
1377 if !app_conf.suotar_configuration.account_linking_enabled {
1378 return Ok(());
1379 }
1380 book_listing_for_unlinked_student(conn, user_id, course_module_id, is_visit).await?;
1381 Ok(())
1382}
1383
1384pub fn _add_routes(cfg: &mut ServiceConfig) {
1385 cfg.route("/my", web::get().to(get_my_credit_registrations))
1386 .route("/settings", web::get().to(get_credit_registration_settings))
1387 .route(
1388 "/my/student-number",
1389 web::get().to(get_my_verified_student_number),
1390 )
1391 .route(
1392 "/my/student-number",
1393 web::delete().to(unlink_my_student_number),
1394 )
1395 .route(
1396 "/my/by-course-module/{course_module_id}",
1397 web::get().to(get_my_credit_registration_for_course_module),
1398 )
1399 .service(
1400 web::resource("/my/by-course-module/{course_module_id}/enrolment-route")
1401 .route(web::get().to(get_my_enrolment_route))
1402 .route(web::put().to(set_my_enrolment_route)),
1403 )
1404 .service(
1405 web::resource("/my/by-course-module/{course_module_id}/enrolment-route/confirm")
1406 .route(web::post().to(confirm_my_enrolment))
1407 .route(web::delete().to(withdraw_my_enrolment_confirmation)),
1408 )
1409 .service(
1410 web::resource("/my/by-course-module/{course_module_id}/credit-justification")
1411 .route(web::put().to(set_my_credit_justification)),
1412 )
1413 .service(
1414 web::resource("/my/by-course-module/{course_module_id}/enrolment-page-visit")
1415 .wrap(
1416 RateLimit::new(RateLimitConfig {
1417 per_minute: Some(10),
1418 per_hour: Some(60),
1419 ..Default::default()
1420 })
1421 .keyed_by(RateLimitKey::User),
1422 )
1423 .route(web::post().to(record_my_enrolment_page_visit)),
1424 )
1425 .route(
1426 "/my/enrolment-banners/by-course/{course_id}",
1427 web::get().to(get_my_credit_registration_enrolment_banners),
1428 )
1429 .service(
1432 web::resource("/my/{id}/recheck-enrolment")
1433 .wrap(
1434 RateLimit::new(RateLimitConfig {
1435 per_minute: Some(5),
1436 per_hour: Some(30),
1437 ..Default::default()
1438 })
1439 .keyed_by(RateLimitKey::User),
1440 )
1441 .route(web::post().to(request_credit_registration_enrolment_recheck)),
1442 )
1443 .service(
1444 web::resource("/my/{id}/dismiss-enrolment-banner")
1445 .route(web::post().to(dismiss_credit_registration_enrolment_banner)),
1446 )
1447 .route(
1450 "/student-number-verifications/{token}",
1451 web::get().to(preview_student_number_verification_token),
1452 )
1453 .service(
1454 web::resource("/student-number-verifications/{token}/claim")
1455 .wrap(RateLimit::new(RateLimitConfig {
1456 per_minute: Some(10),
1457 per_hour: Some(60),
1458 ..Default::default()
1459 }))
1460 .route(web::post().to(claim_student_number_verification_token)),
1461 );
1462}