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::{
15 self, CreditRegistrationAccountLinkingEmail, LinkingMailLinkState,
16 },
17 credit_registration_enrolment_routes::{
18 self, CreditRegistrationEnrolmentRoute, CreditRegistrationIdentification,
19 CreditRegistrationNeed, EnrolmentAnswers, EnrolmentRouteAnswer,
20 },
21 credit_registration_events::{self, CreditRegistrationEventKind},
22 credit_registrations::{
23 CreditRegistrationErrorCode, CreditRegistrationState, StudentCreditRegistration,
24 StudentRegistrationFilter,
25 },
26 email_deliveries::{EmailSendStatus, EmailSendStatusReport},
27 library::credit_registration::StudentFacingCreditRegistrationStatus,
28 library::credit_registration::student_notifications,
29 library::credit_registration::student_progress::{
30 self, ProgressFacts, StudentRegistrationProgress,
31 },
32 student_number_verification_tokens::{self, StudentNumberVerificationToken},
33 user_details::UserDetail,
34 verified_student_numbers::{
35 self, LinkConflict, NewVerifiedStudentNumber, StudentNumberVerificationMethod,
36 VerifiedStudentNumber,
37 },
38};
39use headless_lms_models::{
40 credit_registration_enrolment_check_signals,
41 library::credit_registration::enrolment_check_schedule::EnrolmentCheckSource,
42 library::credit_registration::enrolment_checks,
43};
44use headless_lms_utils::secret_string::expose_option;
45use models::library::credit_registration::account_linking::MAX_LINKING_MAILS_PER_PERSON_AND_COURSE;
46use models::library::credit_registration::sisu_name::{self, NameReplacement};
47use models::library::credit_registration::student_number_change;
48use secrecy::ExposeSecret;
49use utoipa::{OpenApi, ToSchema};
50
51use crate::domain::credit_registration::enrolment_recheck::{
52 RecheckTarget, can_student_request_enrolment_recheck, start_student_enrolment_recheck,
53};
54use crate::domain::credit_registration::mail_status::{NotificationEmailStatus, mask_email};
55use crate::domain::rate_limit_middleware_builder::{RateLimit, RateLimitConfig, RateLimitKey};
56use crate::prelude::*;
57use headless_lms_base::config::ApplicationConfiguration;
58use headless_lms_utils::services::tmc::TmcClient;
59
60#[derive(OpenApi)]
61#[openapi(paths(
62 get_my_credit_registrations,
63 get_my_credit_registration_for_course_module,
64 get_my_credit_registration_enrolment_banners,
65 request_credit_registration_enrolment_recheck,
66 dismiss_credit_registration_enrolment_banner,
67 get_my_verified_student_number,
68 get_credit_registration_settings,
69 get_my_enrolment_route,
70 set_my_enrolment_route,
71 confirm_my_enrolment,
72 withdraw_my_enrolment_confirmation,
73 choose_my_certificate,
74 withdraw_my_certificate_choice,
75 record_my_enrolment_page_visit,
76 set_my_credit_justification,
77 unlink_my_student_number,
78 preview_student_number_verification_token,
79 claim_student_number_verification_token
80))]
81pub(crate) struct MainFrontendCreditRegistrationsApiDoc;
82
83#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
85pub struct LinkingEmailStatus {
86 pub email_send_status: EmailSendStatus,
87 pub sent_at: Option<DateTime<Utc>>,
88 pub emailed_to_masked: String,
89 pub link_state: LinkingEmailLinkState,
90}
91
92#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
95#[serde(rename_all = "snake_case")]
96pub enum LinkingEmailLinkState {
97 Usable,
98 ExpiredCanResend,
100 ExpiredNoResend,
101 Used,
102}
103
104#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
105pub struct MyCreditRegistration {
106 pub id: Uuid,
107 pub course_id: Uuid,
108 pub course_name: String,
109 pub course_slug: String,
110 pub course_module_id: Uuid,
111 pub course_module_name: Option<String>,
112 pub uh_course_code: Option<String>,
113 pub ects_credits: Option<f32>,
114 pub completion_date: DateTime<Utc>,
115 pub student_facing_status: StudentFacingCreditRegistrationStatus,
116 pub registry_already_held_equal_or_better: bool,
121 pub status_is_moving: bool,
123 pub error_code: Option<CreditRegistrationErrorCode>,
124 pub next_attempt_at: DateTime<Utc>,
125 pub registered_at: Option<DateTime<Utc>>,
126 pub sisu_attainment_id: Option<String>,
127 pub student_number: Option<String>,
130 pub grade_id: Option<String>,
131 pub grade_scale_id: Option<String>,
134 pub credits: Option<f32>,
135 pub attempt_number: i32,
136 pub superseded: bool,
137 pub can_request_enrolment_recheck: bool,
138 pub enrolment_found: bool,
141 pub enrolment_checked_at: Option<DateTime<Utc>>,
143 pub enrolment_realisation_name: Option<String>,
144 pub submitted_at: Option<DateTime<Utc>>,
147 pub is_processing_in_sisu: bool,
149 pub enrolment_link: Option<String>,
152 pub progress: Option<StudentRegistrationProgress>,
155 pub linking_email: Option<LinkingEmailStatus>,
158 pub notification_email: Option<NotificationEmailStatus>,
160}
161
162#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
164pub struct MyCreditRegistrationForCourseModule {
165 pub registration: MyCreditRegistration,
166 pub earlier_attempts: Vec<MyCreditRegistration>,
169}
170
171#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
172pub struct RequestCreditRegistrationEnrolmentRecheckResult {
173 pub recheck_started: bool,
175}
176
177#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
180pub struct MyVerifiedStudentNumber {
181 pub student_number: String,
182 pub verified_at: DateTime<Utc>,
183 pub verified_via: StudentNumberVerificationMethod,
184 pub verified_via_email_masked: Option<String>,
186}
187
188#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
189pub struct UnlinkMyStudentNumberResult {
190 pub affected_registration_count: i64,
192}
193
194#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
197pub struct StudentNumberVerificationTokenPreview {
198 pub student_number: String,
199 pub sisu_full_name: Option<String>,
203 pub course_name: Option<String>,
204 pub emailed_to_masked: String,
205 pub expires_at: DateTime<Utc>,
206 pub expired: bool,
207 pub already_used: bool,
208 pub already_used_by_this_account: bool,
210 pub conflicts_with_other_account: bool,
213 pub current_student_number: Option<String>,
215 pub target_account_email: String,
217 pub target_account_name: Option<String>,
219 pub name_change: Option<NameReplacement>,
222 pub claimable: bool,
223}
224
225#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, ToSchema)]
227pub struct ClaimStudentNumberVerificationTokenRequest {
228 pub first_name: Option<String>,
230}
231
232#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
233#[serde(rename_all = "snake_case")]
234pub enum ClaimStudentNumberVerificationTokenOutcome {
235 Linked,
236 AlreadyLinkedToThisAccount,
238 Expired,
239 AlreadyUsed,
240 StudentNumberAlreadyLinkedToAnotherAccount,
242}
243
244#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
245pub struct ClaimStudentNumberVerificationTokenResult {
246 pub outcome: ClaimStudentNumberVerificationTokenOutcome,
247 pub student_number: Option<String>,
248 pub linked_course_name: Option<String>,
249 pub newly_unblocked_registration_count: i64,
251}
252
253#[instrument(skip(pool))]
258#[utoipa::path(
259 get,
260 path = "/my",
261 operation_id = "getMyCreditRegistrations",
262 tag = "credit-registrations",
263 responses(
264 (status = 200, description = "The caller's credit registrations", body = Vec<MyCreditRegistration>)
265 )
266)]
267pub async fn get_my_credit_registrations(
268 user: AuthUser,
269 pool: web::Data<PgPool>,
270) -> ControllerResult<web::Json<Vec<MyCreditRegistration>>> {
271 let mut conn = pool.acquire().await?;
272 let token = skip_authorize();
273
274 let res =
275 build_my_credit_registrations(&mut conn, user.id, StudentRegistrationFilter::default())
276 .await?;
277
278 token.authorized_ok(web::Json(res))
279}
280
281#[instrument(skip(pool))]
287#[utoipa::path(
288 get,
289 path = "/my/by-course-module/{course_module_id}",
290 operation_id = "getMyCreditRegistrationForCourseModule",
291 tag = "credit-registrations",
292 params(("course_module_id" = Uuid, Path, description = "Course module id")),
293 responses(
294 (status = 200, description = "The caller's registration for the module", body = Option<MyCreditRegistrationForCourseModule>)
295 )
296)]
297pub async fn get_my_credit_registration_for_course_module(
298 user: AuthUser,
299 pool: web::Data<PgPool>,
300 course_module_id: web::Path<Uuid>,
301) -> ControllerResult<web::Json<Option<MyCreditRegistrationForCourseModule>>> {
302 let mut conn = pool.acquire().await?;
303 let token = skip_authorize();
304
305 let mut all = build_my_credit_registrations(
306 &mut conn,
307 user.id,
308 StudentRegistrationFilter {
309 course_module_id: Some(*course_module_id),
310 ..StudentRegistrationFilter::default()
311 },
312 )
313 .await?;
314 let live_position = all.iter().position(|row| !row.superseded);
315 let res = live_position.map(|position| {
316 let registration = all.remove(position);
317 MyCreditRegistrationForCourseModule {
318 registration,
319 earlier_attempts: all,
320 }
321 });
322
323 token.authorized_ok(web::Json(res))
324}
325
326#[instrument(skip(pool))]
334#[utoipa::path(
335 get,
336 path = "/my/enrolment-banners/by-course/{course_id}",
337 operation_id = "getMyCreditRegistrationEnrolmentBanners",
338 tag = "credit-registrations",
339 params(("course_id" = Uuid, Path, description = "Course id")),
340 responses(
341 (status = 200, description = "The caller's undismissed enrolment banners on the course", body = Vec<MyCreditRegistration>)
342 )
343)]
344pub async fn get_my_credit_registration_enrolment_banners(
345 user: AuthUser,
346 pool: web::Data<PgPool>,
347 course_id: web::Path<Uuid>,
348) -> ControllerResult<web::Json<Vec<MyCreditRegistration>>> {
349 let mut conn = pool.acquire().await?;
350 let token = skip_authorize();
351
352 let mut res = build_my_credit_registrations(
353 &mut conn,
354 user.id,
355 StudentRegistrationFilter {
356 course_id: Some(*course_id),
357 enrolment_banner_due: true,
358 ..StudentRegistrationFilter::default()
359 },
360 )
361 .await?;
362 res.retain(|registration| {
364 registration.student_facing_status == StudentFacingCreditRegistrationStatus::NeedsEnrolment
365 });
366
367 token.authorized_ok(web::Json(res))
368}
369
370#[instrument(skip(pool))]
377#[utoipa::path(
378 post,
379 path = "/my/{id}/dismiss-enrolment-banner",
380 operation_id = "dismissCreditRegistrationEnrolmentBanner",
381 tag = "credit-registrations",
382 params(("id" = Uuid, Path, description = "Credit registration id")),
383 responses(
384 (status = 200, description = "The banner is dismissed"),
385 (status = 403, description = "Not the caller's registration")
386 )
387)]
388pub async fn dismiss_credit_registration_enrolment_banner(
389 user: AuthUser,
390 pool: web::Data<PgPool>,
391 id: web::Path<Uuid>,
392) -> ControllerResult<web::Json<()>> {
393 let mut conn = pool.acquire().await?;
394 let token = skip_authorize();
395
396 let registration = models::credit_registrations::get_by_id(&mut conn, *id).await?;
397 if registration.user_id != user.id {
398 return Err(controller_err!(
399 Forbidden,
400 "Not your registration.".to_string()
401 ));
402 }
403 models::credit_registrations::dismiss_enrolment_banner(&mut conn, registration.id, user.id)
404 .await?;
405
406 token.authorized_ok(web::Json(()))
407}
408
409#[instrument(skip(pool))]
414#[utoipa::path(
415 post,
416 path = "/my/{id}/recheck-enrolment",
417 operation_id = "requestCreditRegistrationEnrolmentRecheck",
418 tag = "credit-registrations",
419 params(("id" = Uuid, Path, description = "Credit registration id")),
420 responses(
421 (status = 200, description = "Whether a recheck was started", body = RequestCreditRegistrationEnrolmentRecheckResult),
422 (status = 403, description = "Not the caller's registration"),
423 (status = 400, description = "The registration is not waiting for an enrolment")
424 )
425)]
426pub async fn request_credit_registration_enrolment_recheck(
427 user: AuthUser,
428 pool: web::Data<PgPool>,
429 id: web::Path<Uuid>,
430) -> ControllerResult<web::Json<RequestCreditRegistrationEnrolmentRecheckResult>> {
431 let mut conn = pool.acquire().await?;
432 let token = skip_authorize();
433
434 let registration = models::credit_registrations::get_by_id(&mut conn, *id).await?;
435 if registration.user_id != user.id {
436 return Err(controller_err!(
437 Forbidden,
438 "Not your registration.".to_string()
439 ));
440 }
441 if registration.state != CreditRegistrationState::NoUsableEnrolment {
442 return Err(controller_err!(
443 BadRequest,
444 "This registration is not waiting for an enrolment.".to_string()
445 ));
446 }
447
448 let outcome = start_student_enrolment_recheck(
449 &mut conn,
450 user.id,
451 RecheckTarget {
452 registration_id: registration.id,
453 course_module_completion_id: registration.course_module_completion_id,
454 },
455 registration.created_at,
456 "The student asked us to check for an enrolment again.",
457 )
458 .await?;
459
460 token.authorized_ok(web::Json(RequestCreditRegistrationEnrolmentRecheckResult {
461 recheck_started: outcome.started_check(),
462 }))
463}
464
465#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
467pub struct CreditRegistrationSettings {
468 pub account_linking_enabled: bool,
471}
472
473#[instrument(skip(app_conf))]
478#[utoipa::path(
479 get,
480 path = "/settings",
481 operation_id = "getCreditRegistrationSettings",
482 tag = "credit-registrations",
483 responses(
484 (status = 200, description = "The switches", body = CreditRegistrationSettings)
485 )
486)]
487pub async fn get_credit_registration_settings(
488 _user: AuthUser,
489 app_conf: web::Data<ApplicationConfiguration>,
490) -> ControllerResult<web::Json<CreditRegistrationSettings>> {
491 let token = skip_authorize();
492 token.authorized_ok(web::Json(CreditRegistrationSettings {
493 account_linking_enabled: app_conf.suotar_configuration.is_account_linking_enabled(),
494 }))
495}
496
497#[instrument(skip(pool))]
502#[utoipa::path(
503 get,
504 path = "/my/student-number",
505 operation_id = "getMyVerifiedStudentNumber",
506 tag = "credit-registrations",
507 responses(
508 (status = 200, description = "The caller's linked student number", body = Option<MyVerifiedStudentNumber>)
509 )
510)]
511pub async fn get_my_verified_student_number(
512 user: AuthUser,
513 pool: web::Data<PgPool>,
514) -> ControllerResult<web::Json<Option<MyVerifiedStudentNumber>>> {
515 let mut conn = pool.acquire().await?;
516 let token = skip_authorize();
517
518 let res = verified_student_numbers::get_by_user_id(&mut conn, user.id)
519 .await?
520 .map(to_my_verified_student_number);
521
522 token.authorized_ok(web::Json(res))
523}
524
525#[instrument(skip(pool))]
532#[utoipa::path(
533 delete,
534 path = "/my/student-number",
535 operation_id = "unlinkMyStudentNumber",
536 tag = "credit-registrations",
537 responses(
538 (status = 200, description = "How many registrations went back to waiting", body = UnlinkMyStudentNumberResult)
539 )
540)]
541pub async fn unlink_my_student_number(
542 user: AuthUser,
543 pool: web::Data<PgPool>,
544) -> ControllerResult<web::Json<UnlinkMyStudentNumberResult>> {
545 let mut conn = pool.acquire().await?;
546 let token = skip_authorize();
547
548 let Some(linked) = verified_student_numbers::get_by_user_id(&mut conn, user.id).await? else {
549 return token.authorized_ok(web::Json(UnlinkMyStudentNumberResult {
550 affected_registration_count: 0,
551 }));
552 };
553
554 let mut tx = conn.begin().await?;
555 let affected_registration_count = student_number_change::unlink_verified_student_number(
556 &mut tx,
557 linked.id,
558 user.id,
559 Some(user.id),
560 CreditRegistrationEventKind::StudentAction,
561 "The student unlinked their student number.",
562 )
563 .await?;
564 tx.commit().await?;
565
566 token.authorized_ok(web::Json(UnlinkMyStudentNumberResult {
567 affected_registration_count,
568 }))
569}
570
571#[instrument(skip(pool, path))]
578#[utoipa::path(
579 get,
580 path = "/student-number-verifications/{token}",
581 operation_id = "previewStudentNumberVerificationToken",
582 tag = "credit-registrations",
583 params(("token" = String, Path, description = "The mailed verification token")),
584 responses(
585 (status = 200, description = "What the token would link", body = StudentNumberVerificationTokenPreview),
586 (status = 404, description = "No such token")
587 )
588)]
589pub async fn preview_student_number_verification_token(
590 user: AuthUser,
591 pool: web::Data<PgPool>,
592 path: web::Path<String>,
593) -> ControllerResult<web::Json<StudentNumberVerificationTokenPreview>> {
594 let mut conn = pool.acquire().await?;
595 let auth_token = skip_authorize();
596
597 let verification_token = get_token_or_404(&mut conn, &path).await?;
598 let current_link = verified_student_numbers::get_by_user_id(&mut conn, user.id).await?;
599 let conflict = find_conflicting_account(&mut conn, &verification_token, user.id).await?;
600 let course_name = course_name_of_token(&mut conn, &verification_token).await?;
601 let details = models::user_details::get_user_details_by_user_id(&mut conn, user.id).await?;
602 let expired =
603 verification_token.expires_at <= Utc::now() || verification_token.deleted_at.is_some();
604 let already_used = verification_token.used_at.is_some();
605 let claimable = !expired && !already_used && !conflict;
606 let name_change = name_replacement(&verification_token, &details).filter(|_| claimable);
608
609 auth_token.authorized_ok(web::Json(StudentNumberVerificationTokenPreview {
610 student_number: verification_token.student_number.expose_secret().to_owned(),
611 sisu_full_name: full_name(&verification_token).filter(|_| claimable),
612 course_name,
613 emailed_to_masked: mask_email(verification_token.emailed_to.expose_secret()),
614 expires_at: verification_token.expires_at,
615 expired,
616 already_used,
617 already_used_by_this_account: verification_token.claimed_by_user_id == Some(user.id),
618 conflicts_with_other_account: conflict,
619 current_student_number: current_link
620 .map(|link| link.student_number.expose_secret().to_owned()),
621 target_account_name: joined_name(
622 details.first_name.as_deref(),
623 details.last_name.as_deref(),
624 ),
625 target_account_email: details.email,
626 name_change,
627 claimable,
628 }))
629}
630
631#[instrument(skip(pool, path, payload, tmc_client, app_conf))]
640#[utoipa::path(
641 post,
642 path = "/student-number-verifications/{token}/claim",
643 operation_id = "claimStudentNumberVerificationToken",
644 tag = "credit-registrations",
645 params(("token" = String, Path, description = "The mailed verification token")),
646 request_body = ClaimStudentNumberVerificationTokenRequest,
647 responses(
648 (status = 200, description = "What the claim did", body = ClaimStudentNumberVerificationTokenResult),
649 (status = 400, description = "The first name is not one the preview offered"),
650 (status = 404, description = "No such token")
651 )
652)]
653pub async fn claim_student_number_verification_token(
654 user: AuthUser,
655 pool: web::Data<PgPool>,
656 path: web::Path<String>,
657 payload: web::Json<ClaimStudentNumberVerificationTokenRequest>,
658 tmc_client: web::Data<TmcClient>,
659 app_conf: web::Data<ApplicationConfiguration>,
660) -> ControllerResult<web::Json<ClaimStudentNumberVerificationTokenResult>> {
661 let mut conn = pool.acquire().await?;
662 let auth_token = skip_authorize();
663
664 let verification_token = get_token_or_404(&mut conn, &path).await?;
665 let refused = |outcome| ClaimStudentNumberVerificationTokenResult {
666 outcome,
667 student_number: None,
668 linked_course_name: None,
669 newly_unblocked_registration_count: 0,
670 };
671
672 if verification_token.used_at.is_some() {
673 return auth_token.authorized_ok(web::Json(refused(
674 ClaimStudentNumberVerificationTokenOutcome::AlreadyUsed,
675 )));
676 }
677 if verification_token.expires_at <= Utc::now() || verification_token.deleted_at.is_some() {
678 return auth_token.authorized_ok(web::Json(refused(
679 ClaimStudentNumberVerificationTokenOutcome::Expired,
680 )));
681 }
682 if find_conflicting_account(&mut conn, &verification_token, user.id).await? {
683 return auth_token.authorized_ok(web::Json(refused(
684 ClaimStudentNumberVerificationTokenOutcome::StudentNumberAlreadyLinkedToAnotherAccount,
685 )));
686 }
687
688 let details = models::user_details::get_user_details_by_user_id(&mut conn, user.id).await?;
689 let new_name = name_replacement(&verification_token, &details)
690 .map(|replacement| {
691 let first_name = payload
692 .first_name
693 .clone()
694 .filter(|first_name| replacement.allows(first_name))
695 .ok_or_else(|| {
696 ControllerError::new(
697 ControllerErrorType::BadRequest,
698 "The first name must be one of those the preview offered.",
699 None,
700 )
701 })?;
702 Ok::<_, ControllerError>((first_name, replacement.last_name))
703 })
704 .transpose()?;
705
706 let course_name = course_name_of_token(&mut conn, &verification_token).await?;
707 let current_link = verified_student_numbers::get_by_user_id(&mut conn, user.id).await?;
708 let already_ours = current_link.as_ref().is_some_and(|link| {
709 link.student_number.expose_secret() == verification_token.student_number.expose_secret()
710 });
711
712 let mut tx = conn.begin().await?;
713 if !student_number_verification_tokens::claim(&mut tx, &verification_token.token, user.id)
715 .await?
716 {
717 tx.rollback().await?;
718 return auth_token.authorized_ok(web::Json(refused(
719 ClaimStudentNumberVerificationTokenOutcome::AlreadyUsed,
720 )));
721 }
722 let newly_unblocked_registration_count = if already_ours {
723 0
724 } else {
725 link_token_student_number(&mut tx, &verification_token, current_link, user.id).await?
726 };
727 if let Some((first_name, last_name)) = &new_name {
728 models::user_details::update_user_name(&mut tx, user.id, first_name, last_name).await?;
729 crate::controllers::auth::update_user_information_to_tmc(
731 user.upstream_id(),
732 first_name.clone(),
733 last_name.clone(),
734 None,
735 &tmc_client,
736 &app_conf,
737 )
738 .await?;
739 }
740 tx.commit().await?;
741
742 auth_token.authorized_ok(web::Json(ClaimStudentNumberVerificationTokenResult {
743 outcome: if already_ours {
744 ClaimStudentNumberVerificationTokenOutcome::AlreadyLinkedToThisAccount
745 } else {
746 ClaimStudentNumberVerificationTokenOutcome::Linked
747 },
748 student_number: Some(verification_token.student_number.expose_secret().to_owned()),
749 linked_course_name: course_name,
750 newly_unblocked_registration_count,
751 }))
752}
753
754async fn link_token_student_number(
757 conn: &mut PgConnection,
758 verification_token: &StudentNumberVerificationToken,
759 current_link: Option<VerifiedStudentNumber>,
760 user_id: Uuid,
761) -> Result<i64, ControllerError> {
762 let (_, newly_unblocked_registration_count) =
765 verified_student_numbers::replace_verified_student_number(
766 conn,
767 current_link.map(|link| link.id),
768 &NewVerifiedStudentNumber {
769 user_id,
770 student_number: verification_token.student_number.clone(),
771 sisu_person_id: verification_token.sisu_person_id.clone(),
772 first_names: verification_token.first_names.clone(),
773 last_name: verification_token.last_name.clone(),
774 verified_via: StudentNumberVerificationMethod::EmailedLink,
775 verified_via_email: Some(verification_token.emailed_to.clone()),
776 linked_by_user_id: None,
777 link_reason: None,
778 verified_from_course_id: verification_token.course_id,
779 },
780 Some(user_id),
781 CreditRegistrationEventKind::StudentAction,
782 "The student linked a student number.",
783 )
784 .await?;
785 if let Some(course_id) = verification_token.course_id {
787 credit_registration_enrolment_check_signals::record_account_link_for_course(
788 conn, user_id, course_id,
789 )
790 .await?;
791 }
792 Ok(newly_unblocked_registration_count)
793}
794
795async fn build_my_credit_registrations(
797 conn: &mut PgConnection,
798 user_id: Uuid,
799 filter: StudentRegistrationFilter,
800) -> Result<Vec<MyCreditRegistration>, ControllerError> {
801 let rows =
802 models::credit_registrations::get_student_facing_by_user_id(conn, user_id, filter).await?;
803
804 let ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
805 let notification_mails = student_notifications::get_for_registrations(conn, &ids).await?;
806 let ids_with_enrolment: Vec<Uuid> = rows
807 .iter()
808 .filter(|row| row.has_usable_enrolment())
809 .map(|row| row.id)
810 .collect();
811 let enrolment_found_entries = credit_registration_events::get_latest_state_entries(
812 conn,
813 &ids_with_enrolment,
814 &[student_progress::ENROLMENT_FOUND_STATE],
815 )
816 .await?;
817
818 let mut linking_mails: Option<LinkingMailCache> = None;
819 let mut res = Vec::with_capacity(rows.len());
820 for row in rows {
821 let state = row.state;
822 let status = if row.is_prompted_check_unanswered() {
823 StudentFacingCreditRegistrationStatus::LookingForEnrolment
824 } else {
825 StudentFacingCreditRegistrationStatus::of(
826 state,
827 row.preconditions(),
828 row.enrolment_resolved,
829 )
830 };
831 let linking_email = if status == StudentFacingCreditRegistrationStatus::NeedsStudentNumber {
832 resolve_linking_email(conn, user_id, &row, &mut linking_mails).await?
833 } else {
834 None
835 };
836 let notification_email =
837 NotificationEmailStatus::for_state(state, row.id, ¬ification_mails);
838 let progress = StudentRegistrationProgress::of(ProgressFacts {
839 state,
840 has_usable_enrolment: row.has_usable_enrolment(),
841 enrolment_found_at: enrolment_found_entries
842 .get(&(row.id, student_progress::ENROLMENT_FOUND_STATE))
843 .copied(),
844 has_submitted_attainment: row.has_submitted_attainment,
845 submitted_at: row.submitted_at,
846 partially_registered_at: row.partially_registered_at,
847 registered_at: row.registered_at,
848 });
849 res.push(to_my_credit_registration(
850 row,
851 status,
852 linking_email,
853 notification_email,
854 progress,
855 ));
856 }
857 Ok(res)
858}
859
860fn to_my_credit_registration(
861 row: StudentCreditRegistration,
862 status: StudentFacingCreditRegistrationStatus,
863 linking_email: Option<LinkingEmailStatus>,
864 notification_email: Option<NotificationEmailStatus>,
865 progress: Option<StudentRegistrationProgress>,
866) -> MyCreditRegistration {
867 let enrolment_found = row.has_usable_enrolment();
868 let can_request_enrolment_recheck = can_student_request_enrolment_recheck(
869 row.state,
870 row.created_at,
871 row.enrolment_check_requested_at,
872 row.enrolment_checked_at,
873 );
874 MyCreditRegistration {
875 id: row.id,
876 course_id: row.course_id,
877 course_name: row.course_name,
878 course_slug: row.course_slug,
879 course_module_id: row.course_module_id,
880 course_module_name: row.course_module_name,
881 uh_course_code: row.uh_course_code,
882 ects_credits: row.ects_credits,
883 completion_date: row.completion_date,
884 student_facing_status: status,
885 registry_already_held_equal_or_better: row.state == CreditRegistrationState::NotImproved,
886 status_is_moving: status.is_moving(),
887 error_code: row.error_code,
888 next_attempt_at: row.next_attempt_at,
889 registered_at: row.registered_at,
890 sisu_attainment_id: row.sisu_attainment_id,
891 student_number: expose_option(&row.student_number).map(str::to_owned),
892 grade_id: row.grade_id,
893 grade_scale_id: row.grade_scale_id,
894 credits: row.credits,
895 attempt_number: row.attempt_number,
896 superseded: row.superseded_by_id.is_some(),
897 can_request_enrolment_recheck,
898 enrolment_found,
899 enrolment_checked_at: row.enrolment_checked_at,
900 enrolment_realisation_name: row.enrolment_realisation_name,
901 submitted_at: row.submitted_at,
902 is_processing_in_sisu: status == StudentFacingCreditRegistrationStatus::WaitingForSisu
903 && row.partially_registered_at.is_some(),
904 enrolment_link: row.enrolment_link,
905 progress,
906 linking_email,
907 notification_email,
908 }
909}
910
911struct LinkingMailCache {
914 mails: Vec<CreditRegistrationAccountLinkingEmail>,
915 reports: HashMap<Uuid, EmailSendStatusReport>,
916 link_states: HashMap<Uuid, LinkingMailLinkState>,
917}
918
919async fn resolve_linking_email(
922 conn: &mut PgConnection,
923 user_id: Uuid,
924 row: &StudentCreditRegistration,
925 cache: &mut Option<LinkingMailCache>,
926) -> Result<Option<LinkingEmailStatus>, ControllerError> {
927 if cache.is_none() {
928 let mails =
931 match verified_student_numbers::get_latest_including_deleted_by_user_id(conn, user_id)
932 .await?
933 .filter(|link| link.verified_via == StudentNumberVerificationMethod::EmailedLink)
934 .and_then(|link| link.sisu_person_id)
935 {
936 Some(person_id) => {
937 credit_registration_account_linking_emails::get_by_sisu_person_id(
938 conn,
939 person_id.expose_secret(),
940 )
941 .await?
942 }
943 None => Vec::new(),
944 };
945 let ids: Vec<Uuid> = mails.iter().map(|mail| mail.id).collect();
946 let reports =
947 credit_registration_account_linking_emails::get_send_status_reports(conn, &ids).await?;
948 let link_states =
949 credit_registration_account_linking_emails::get_link_states(conn, &ids).await?;
950 *cache = Some(LinkingMailCache {
951 mails,
952 reports,
953 link_states,
954 });
955 }
956 let cache = cache.as_ref().ok_or_else(|| {
957 controller_err!(
958 InternalServerError,
959 "linking mail cache was not populated".to_string()
960 )
961 })?;
962 let mut course_mails = cache
963 .mails
964 .iter()
965 .filter(|mail| mail.course_id == row.course_id);
966 let Some(mail) = course_mails.next() else {
967 return Ok(None);
968 };
969 let Some(report) = cache.reports.get(&mail.id) else {
970 return Ok(None);
971 };
972 let mail_count = 1 + course_mails.count() as i64;
973 let link_state = match cache.link_states.get(&mail.id) {
974 Some(LinkingMailLinkState::Usable) => LinkingEmailLinkState::Usable,
975 Some(LinkingMailLinkState::Used) => LinkingEmailLinkState::Used,
976 _ if mail_count < MAX_LINKING_MAILS_PER_PERSON_AND_COURSE => {
977 LinkingEmailLinkState::ExpiredCanResend
978 }
979 _ => LinkingEmailLinkState::ExpiredNoResend,
980 };
981 Ok(Some(LinkingEmailStatus {
982 email_send_status: report.email_send_status,
983 sent_at: report.sent_at,
984 emailed_to_masked: mask_email(mail.emailed_to.expose_secret()),
985 link_state,
986 }))
987}
988
989fn to_my_verified_student_number(link: VerifiedStudentNumber) -> MyVerifiedStudentNumber {
990 MyVerifiedStudentNumber {
991 student_number: link.student_number.expose_secret().to_owned(),
992 verified_at: link.verified_at,
993 verified_via: link.verified_via,
994 verified_via_email_masked: expose_option(&link.verified_via_email).map(mask_email),
995 }
996}
997
998async fn get_token_or_404(
999 conn: &mut PgConnection,
1000 token: &str,
1001) -> Result<StudentNumberVerificationToken, ControllerError> {
1002 student_number_verification_tokens::get_by_token_any_state(conn, &DbSecret::new(token))
1003 .await?
1004 .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))
1005}
1006
1007async fn find_conflicting_account(
1009 conn: &mut PgConnection,
1010 token: &StudentNumberVerificationToken,
1011 user_id: Uuid,
1012) -> Result<bool, ControllerError> {
1013 let conflict = verified_student_numbers::find_link_conflict(
1014 conn,
1015 token.student_number.expose_secret(),
1016 token.sisu_person_id.expose_secret(),
1017 user_id,
1018 )
1019 .await?;
1020 Ok(conflict == Some(LinkConflict::AnotherAccount))
1021}
1022
1023async fn course_name_of_token(
1024 conn: &mut PgConnection,
1025 token: &StudentNumberVerificationToken,
1026) -> Result<Option<String>, ControllerError> {
1027 let Some(course_id) = token.course_id else {
1028 return Ok(None);
1029 };
1030 let course = models::courses::get_course(conn, course_id).await?;
1031 Ok(Some(course.name))
1032}
1033
1034fn full_name(token: &StudentNumberVerificationToken) -> Option<String> {
1036 joined_name(
1037 expose_option(&token.first_names),
1038 expose_option(&token.last_name),
1039 )
1040}
1041
1042fn joined_name(first: Option<&str>, last: Option<&str>) -> Option<String> {
1044 let parts: Vec<&str> = [first, last]
1045 .into_iter()
1046 .flatten()
1047 .map(str::trim)
1048 .filter(|part| !part.is_empty())
1049 .collect();
1050 (!parts.is_empty()).then(|| parts.join(" "))
1051}
1052
1053fn name_replacement(
1055 token: &StudentNumberVerificationToken,
1056 details: &UserDetail,
1057) -> Option<NameReplacement> {
1058 sisu_name::align(
1059 expose_option(&token.first_names),
1060 expose_option(&token.last_name),
1061 details.first_name.as_deref(),
1062 details.last_name.as_deref(),
1063 )
1064}
1065
1066#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
1068pub struct MyEnrolmentRoute {
1069 pub course_module_completion_id: Uuid,
1070 pub route: Option<CreditRegistrationEnrolmentRoute>,
1072 pub has_finnish_personal_identity_code: Option<bool>,
1073 pub need: Option<CreditRegistrationNeed>,
1074 pub identification: Option<CreditRegistrationIdentification>,
1075 pub need_after_reconsider: Option<CreditRegistrationNeed>,
1076 pub enrolment_confirmed_at: Option<DateTime<Utc>>,
1077 pub certificate_chosen_at: Option<DateTime<Utc>>,
1078 pub can_change: bool,
1082}
1083
1084#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
1086pub struct SetEnrolmentRoutePayload {
1087 pub route: CreditRegistrationEnrolmentRoute,
1088 pub has_finnish_personal_identity_code: Option<bool>,
1089 pub need: Option<CreditRegistrationNeed>,
1090 pub identification: Option<CreditRegistrationIdentification>,
1091 pub need_after_reconsider: Option<CreditRegistrationNeed>,
1092}
1093
1094async fn my_registration_completion_for_module(
1101 conn: &mut PgConnection,
1102 user_id: Uuid,
1103 course_module_id: Uuid,
1104) -> Result<CourseModuleCompletion, ControllerError> {
1105 let completion =
1106 models::course_module_completions::get_registration_completion_by_user_and_course_module_id(
1107 conn,
1108 user_id,
1109 course_module_id,
1110 )
1111 .await?;
1112 Ok(completion)
1113}
1114
1115async fn my_completion_for_module(
1121 conn: &mut PgConnection,
1122 user_id: Uuid,
1123 course_module_id: Uuid,
1124) -> Result<Uuid, ControllerError> {
1125 let completion = my_registration_completion_for_module(conn, user_id, course_module_id).await?;
1126 if !completion.register_credits_via_suotar {
1127 return Err(controller_err!(NotFound, "Not found.".to_string()));
1128 }
1129 Ok(completion.id)
1130}
1131
1132async fn my_live_registration_for_module(
1134 conn: &mut PgConnection,
1135 user_id: Uuid,
1136 course_module_id: Uuid,
1137) -> Result<Option<StudentCreditRegistration>, ControllerError> {
1138 let rows = models::credit_registrations::get_student_facing_by_user_id(
1139 conn,
1140 user_id,
1141 StudentRegistrationFilter {
1142 course_module_id: Some(course_module_id),
1143 ..StudentRegistrationFilter::default()
1144 },
1145 )
1146 .await?;
1147 Ok(rows.into_iter().find(|row| row.superseded_by_id.is_none()))
1148}
1149
1150async fn build_my_enrolment_route(
1151 conn: &mut PgConnection,
1152 user_id: Uuid,
1153 course_module_id: Uuid,
1154) -> Result<(MyEnrolmentRoute, Option<StudentCreditRegistration>), ControllerError> {
1155 let course_module_completion_id =
1156 my_completion_for_module(conn, user_id, course_module_id).await?;
1157 let registration = my_live_registration_for_module(conn, user_id, course_module_id).await?;
1158 let answer = credit_registration_enrolment_routes::get_by_completion_id(
1159 conn,
1160 course_module_completion_id,
1161 )
1162 .await?;
1163 let can_change = !registration
1164 .as_ref()
1165 .is_some_and(|row| row.has_usable_enrolment());
1166 Ok((
1167 my_enrolment_route(course_module_completion_id, answer, can_change),
1168 registration,
1169 ))
1170}
1171
1172fn my_enrolment_route(
1175 course_module_completion_id: Uuid,
1176 answer: Option<EnrolmentRouteAnswer>,
1177 can_change: bool,
1178) -> MyEnrolmentRoute {
1179 match answer {
1180 Some(answer) => MyEnrolmentRoute {
1181 course_module_completion_id,
1182 route: Some(answer.route),
1183 has_finnish_personal_identity_code: answer.has_finnish_personal_identity_code,
1184 need: answer.need,
1185 identification: answer.identification,
1186 need_after_reconsider: answer.need_after_reconsider,
1187 enrolment_confirmed_at: answer.enrolment_confirmed_at,
1188 certificate_chosen_at: answer.certificate_chosen_at,
1189 can_change,
1190 },
1191 None => MyEnrolmentRoute {
1192 course_module_completion_id,
1193 route: None,
1194 has_finnish_personal_identity_code: None,
1195 need: None,
1196 identification: None,
1197 need_after_reconsider: None,
1198 enrolment_confirmed_at: None,
1199 certificate_chosen_at: None,
1200 can_change,
1201 },
1202 }
1203}
1204
1205#[instrument(skip(pool))]
1210#[utoipa::path(
1211 get,
1212 path = "/my/by-course-module/{course_module_id}/enrolment-route",
1213 operation_id = "getMyEnrolmentRoute",
1214 tag = "credit-registrations",
1215 params(("course_module_id" = Uuid, Path, description = "Course module id")),
1216 responses(
1217 (status = 200, description = "The caller's answer for the module", body = MyEnrolmentRoute)
1218 )
1219)]
1220pub async fn get_my_enrolment_route(
1221 user: AuthUser,
1222 pool: web::Data<PgPool>,
1223 course_module_id: web::Path<Uuid>,
1224) -> ControllerResult<web::Json<MyEnrolmentRoute>> {
1225 let mut conn = pool.acquire().await?;
1226 let token = skip_authorize();
1227
1228 let (res, _) = build_my_enrolment_route(&mut conn, user.id, *course_module_id).await?;
1229
1230 token.authorized_ok(web::Json(res))
1231}
1232
1233#[instrument(skip(pool))]
1239#[utoipa::path(
1240 put,
1241 path = "/my/by-course-module/{course_module_id}/enrolment-route",
1242 operation_id = "setMyEnrolmentRoute",
1243 tag = "credit-registrations",
1244 params(("course_module_id" = Uuid, Path, description = "Course module id")),
1245 request_body = SetEnrolmentRoutePayload,
1246 responses(
1247 (status = 200, description = "The stored answer", body = MyEnrolmentRoute)
1248 )
1249)]
1250pub async fn set_my_enrolment_route(
1251 user: AuthUser,
1252 pool: web::Data<PgPool>,
1253 course_module_id: web::Path<Uuid>,
1254 payload: web::Json<SetEnrolmentRoutePayload>,
1255) -> ControllerResult<web::Json<MyEnrolmentRoute>> {
1256 let mut conn = pool.acquire().await?;
1257 let token = skip_authorize();
1258
1259 let (current, _) = build_my_enrolment_route(&mut conn, user.id, *course_module_id).await?;
1260 if !current.can_change {
1261 return Err(controller_err!(
1262 BadRequest,
1263 "We can already see your enrolment, so this answer no longer changes anything."
1264 .to_string()
1265 ));
1266 }
1267 let payload = payload.into_inner();
1268 let answer = credit_registration_enrolment_routes::set_answers(
1269 &mut conn,
1270 current.course_module_completion_id,
1271 user.id,
1272 &EnrolmentAnswers {
1273 route: payload.route,
1274 has_finnish_personal_identity_code: payload.has_finnish_personal_identity_code,
1275 need: payload.need,
1276 identification: payload.identification,
1277 need_after_reconsider: payload.need_after_reconsider,
1278 },
1279 )
1280 .await?;
1281
1282 token.authorized_ok(web::Json(my_enrolment_route(
1283 current.course_module_completion_id,
1284 Some(answer),
1285 current.can_change,
1286 )))
1287}
1288
1289#[instrument(skip(pool))]
1299#[utoipa::path(
1300 post,
1301 path = "/my/by-course-module/{course_module_id}/enrolment-route/confirm",
1302 operation_id = "confirmMyEnrolment",
1303 tag = "credit-registrations",
1304 params(("course_module_id" = Uuid, Path, description = "Course module id")),
1305 responses(
1306 (status = 200, description = "The stored answer", body = MyEnrolmentRoute)
1307 )
1308)]
1309pub async fn confirm_my_enrolment(
1310 user: AuthUser,
1311 pool: web::Data<PgPool>,
1312 course_module_id: web::Path<Uuid>,
1313) -> ControllerResult<web::Json<MyEnrolmentRoute>> {
1314 let mut conn = pool.acquire().await?;
1315 let token = skip_authorize();
1316
1317 let (current, registration) =
1318 build_my_enrolment_route(&mut conn, user.id, *course_module_id).await?;
1319 if current.route.is_none() {
1320 return Err(controller_err!(
1321 BadRequest,
1322 "Tell us where you enrol first.".to_string()
1323 ));
1324 }
1325 if !current.can_change {
1326 return Err(controller_err!(
1327 BadRequest,
1328 "We can already see your enrolment.".to_string()
1329 ));
1330 }
1331 let answer = credit_registration_enrolment_routes::set_enrolment_confirmed(
1332 &mut conn,
1333 current.course_module_completion_id,
1334 true,
1335 )
1336 .await?;
1337 match registration {
1338 Some(registration) if registration.is_waiting_for_enrolment() => {
1339 start_student_enrolment_recheck(
1340 &mut conn,
1341 user.id,
1342 RecheckTarget {
1343 registration_id: registration.id,
1344 course_module_completion_id: current.course_module_completion_id,
1345 },
1346 registration.created_at,
1347 "The student said they had enrolled.",
1348 )
1349 .await?;
1350 }
1351 _ => {
1352 credit_registration_enrolment_check_signals::record_check_request(
1355 &mut conn,
1356 current.course_module_completion_id,
1357 EnrolmentCheckSource::StudentRequest,
1358 )
1359 .await?;
1360 }
1361 }
1362
1363 token.authorized_ok(web::Json(my_enrolment_route(
1364 current.course_module_completion_id,
1365 Some(answer),
1366 current.can_change,
1367 )))
1368}
1369
1370#[instrument(skip(pool))]
1375#[utoipa::path(
1376 delete,
1377 path = "/my/by-course-module/{course_module_id}/enrolment-route/confirm",
1378 operation_id = "withdrawMyEnrolmentConfirmation",
1379 tag = "credit-registrations",
1380 params(("course_module_id" = Uuid, Path, description = "Course module id")),
1381 responses(
1382 (status = 200, description = "The stored answer", body = MyEnrolmentRoute)
1383 )
1384)]
1385pub async fn withdraw_my_enrolment_confirmation(
1386 user: AuthUser,
1387 pool: web::Data<PgPool>,
1388 course_module_id: web::Path<Uuid>,
1389) -> ControllerResult<web::Json<MyEnrolmentRoute>> {
1390 let mut conn = pool.acquire().await?;
1391 let token = skip_authorize();
1392
1393 let (current, _) = build_my_enrolment_route(&mut conn, user.id, *course_module_id).await?;
1394 if !current.can_change {
1395 return Err(controller_err!(
1396 BadRequest,
1397 "We can already see your enrolment, so there is nothing to undo.".to_string()
1398 ));
1399 }
1400 let answer = match current.route {
1401 None => None,
1403 Some(_) => Some(
1404 credit_registration_enrolment_routes::set_enrolment_confirmed(
1405 &mut conn,
1406 current.course_module_completion_id,
1407 false,
1408 )
1409 .await?,
1410 ),
1411 };
1412
1413 token.authorized_ok(web::Json(my_enrolment_route(
1414 current.course_module_completion_id,
1415 answer,
1416 current.can_change,
1417 )))
1418}
1419
1420#[instrument(skip(pool))]
1427#[utoipa::path(
1428 post,
1429 path = "/my/by-course-module/{course_module_id}/enrolment-route/certificate-choice",
1430 operation_id = "chooseMyCertificate",
1431 tag = "credit-registrations",
1432 params(("course_module_id" = Uuid, Path, description = "Course module id")),
1433 responses(
1434 (status = 200, description = "The stored answer", body = MyEnrolmentRoute)
1435 )
1436)]
1437pub async fn choose_my_certificate(
1438 user: AuthUser,
1439 pool: web::Data<PgPool>,
1440 course_module_id: web::Path<Uuid>,
1441) -> ControllerResult<web::Json<MyEnrolmentRoute>> {
1442 let mut conn = pool.acquire().await?;
1443 let token = skip_authorize();
1444
1445 let (current, _) = build_my_enrolment_route(&mut conn, user.id, *course_module_id).await?;
1446 if current.route.is_none() {
1447 return Err(controller_err!(
1448 BadRequest,
1449 "Tell us where you enrol first.".to_string()
1450 ));
1451 }
1452 if !current.can_change {
1453 return Err(controller_err!(
1454 BadRequest,
1455 "We can already see your enrolment.".to_string()
1456 ));
1457 }
1458 let answer = credit_registration_enrolment_routes::set_certificate_chosen(
1459 &mut conn,
1460 current.course_module_completion_id,
1461 true,
1462 )
1463 .await?;
1464
1465 token.authorized_ok(web::Json(my_enrolment_route(
1466 current.course_module_completion_id,
1467 Some(answer),
1468 current.can_change,
1469 )))
1470}
1471
1472#[instrument(skip(pool))]
1477#[utoipa::path(
1478 delete,
1479 path = "/my/by-course-module/{course_module_id}/enrolment-route/certificate-choice",
1480 operation_id = "withdrawMyCertificateChoice",
1481 tag = "credit-registrations",
1482 params(("course_module_id" = Uuid, Path, description = "Course module id")),
1483 responses(
1484 (status = 200, description = "The stored answer", body = MyEnrolmentRoute)
1485 )
1486)]
1487pub async fn withdraw_my_certificate_choice(
1488 user: AuthUser,
1489 pool: web::Data<PgPool>,
1490 course_module_id: web::Path<Uuid>,
1491) -> ControllerResult<web::Json<MyEnrolmentRoute>> {
1492 let mut conn = pool.acquire().await?;
1493 let token = skip_authorize();
1494
1495 let (current, _) = build_my_enrolment_route(&mut conn, user.id, *course_module_id).await?;
1496 if !current.can_change {
1497 return Err(controller_err!(
1498 BadRequest,
1499 "We can already see your enrolment, so there is nothing to undo.".to_string()
1500 ));
1501 }
1502 let answer = match current.route {
1503 None => None,
1505 Some(_) => Some(
1506 credit_registration_enrolment_routes::set_certificate_chosen(
1507 &mut conn,
1508 current.course_module_completion_id,
1509 false,
1510 )
1511 .await?,
1512 ),
1513 };
1514
1515 token.authorized_ok(web::Json(my_enrolment_route(
1516 current.course_module_completion_id,
1517 answer,
1518 current.can_change,
1519 )))
1520}
1521
1522#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
1524pub struct MyCreditJustification {
1525 pub course_module_completion_id: Uuid,
1526 pub justification: String,
1527 pub updated_at: DateTime<Utc>,
1528}
1529
1530#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
1531pub struct SetCreditJustificationPayload {
1532 pub justification: String,
1533}
1534
1535const CREDIT_JUSTIFICATION_MAX_LENGTH: usize = 4000;
1538
1539#[instrument(skip(pool))]
1548#[utoipa::path(
1549 put,
1550 path = "/my/by-course-module/{course_module_id}/credit-justification",
1551 operation_id = "setMyCreditJustification",
1552 tag = "credit-registrations",
1553 params(("course_module_id" = Uuid, Path, description = "Course module id")),
1554 request_body = SetCreditJustificationPayload,
1555 responses(
1556 (status = 200, description = "The stored answer", body = MyCreditJustification)
1557 )
1558)]
1559pub async fn set_my_credit_justification(
1560 user: AuthUser,
1561 pool: web::Data<PgPool>,
1562 course_module_id: web::Path<Uuid>,
1563 payload: web::Json<SetCreditJustificationPayload>,
1564) -> ControllerResult<web::Json<MyCreditJustification>> {
1565 let mut conn = pool.acquire().await?;
1566 let token = skip_authorize();
1567
1568 let justification = payload.justification.trim();
1569 if justification.is_empty() {
1570 return Err(controller_err!(
1571 BadRequest,
1572 "Tell us why you need the credits.".to_string()
1573 ));
1574 }
1575 if justification.chars().count() > CREDIT_JUSTIFICATION_MAX_LENGTH {
1576 return Err(controller_err!(
1577 BadRequest,
1578 format!("Keep your answer under {CREDIT_JUSTIFICATION_MAX_LENGTH} characters.")
1579 ));
1580 }
1581 let completion =
1582 my_registration_completion_for_module(&mut conn, user.id, *course_module_id).await?;
1583 let stored = completion_registration_credit_justifications::upsert(
1584 &mut conn,
1585 completion.id,
1586 user.id,
1587 justification,
1588 )
1589 .await?;
1590
1591 token.authorized_ok(web::Json(MyCreditJustification {
1592 course_module_completion_id: stored.course_module_completion_id,
1593 justification: stored.justification,
1594 updated_at: stored.updated_at,
1595 }))
1596}
1597
1598#[instrument(skip(pool))]
1609#[utoipa::path(
1610 post,
1611 path = "/my/by-course-module/{course_module_id}/enrolment-page-visit",
1612 operation_id = "recordMyEnrolmentPageVisit",
1613 tag = "credit-registrations",
1614 params(("course_module_id" = Uuid, Path, description = "Course module id")),
1615 responses(
1616 (status = 200, description = "The visit is recorded"),
1617 (status = 404, description = "No completion on the push path for this module")
1618 )
1619)]
1620pub async fn record_my_enrolment_page_visit(
1621 user: AuthUser,
1622 pool: web::Data<PgPool>,
1623 course_module_id: web::Path<Uuid>,
1624) -> ControllerResult<web::Json<()>> {
1625 let mut conn = pool.acquire().await?;
1626 let token = skip_authorize();
1627
1628 let course_module_completion_id =
1629 my_completion_for_module(&mut conn, user.id, *course_module_id).await?;
1630 let registration =
1631 my_live_registration_for_module(&mut conn, user.id, *course_module_id).await?;
1632 if registration
1633 .as_ref()
1634 .is_some_and(StudentCreditRegistration::has_usable_enrolment)
1635 {
1636 return token.authorized_ok(web::Json(()));
1637 }
1638 credit_registration_enrolment_check_signals::record_visit(
1639 &mut conn,
1640 course_module_completion_id,
1641 )
1642 .await?;
1643 if let Some(registration) = registration
1644 && registration.is_waiting_for_enrolment()
1645 {
1646 enrolment_checks::record_visit(&mut conn, registration.id, Utc::now()).await?;
1647 }
1648
1649 token.authorized_ok(web::Json(()))
1650}
1651
1652pub fn _add_routes(cfg: &mut ServiceConfig) {
1653 cfg.route("/my", web::get().to(get_my_credit_registrations))
1654 .route("/settings", web::get().to(get_credit_registration_settings))
1655 .route(
1656 "/my/student-number",
1657 web::get().to(get_my_verified_student_number),
1658 )
1659 .route(
1660 "/my/student-number",
1661 web::delete().to(unlink_my_student_number),
1662 )
1663 .route(
1664 "/my/by-course-module/{course_module_id}",
1665 web::get().to(get_my_credit_registration_for_course_module),
1666 )
1667 .service(
1668 web::resource("/my/by-course-module/{course_module_id}/enrolment-route")
1669 .route(web::get().to(get_my_enrolment_route))
1670 .route(web::put().to(set_my_enrolment_route)),
1671 )
1672 .service(
1673 web::resource("/my/by-course-module/{course_module_id}/enrolment-route/confirm")
1674 .wrap(
1675 RateLimit::new(RateLimitConfig {
1676 per_minute: Some(5),
1677 per_hour: Some(30),
1678 ..Default::default()
1679 })
1680 .keyed_by(RateLimitKey::User),
1681 )
1682 .route(web::post().to(confirm_my_enrolment))
1683 .route(web::delete().to(withdraw_my_enrolment_confirmation)),
1684 )
1685 .service(
1686 web::resource(
1687 "/my/by-course-module/{course_module_id}/enrolment-route/certificate-choice",
1688 )
1689 .wrap(
1690 RateLimit::new(RateLimitConfig {
1691 per_minute: Some(5),
1692 per_hour: Some(30),
1693 ..Default::default()
1694 })
1695 .keyed_by(RateLimitKey::User),
1696 )
1697 .route(web::post().to(choose_my_certificate))
1698 .route(web::delete().to(withdraw_my_certificate_choice)),
1699 )
1700 .service(
1701 web::resource("/my/by-course-module/{course_module_id}/credit-justification")
1702 .route(web::put().to(set_my_credit_justification)),
1703 )
1704 .service(
1705 web::resource("/my/by-course-module/{course_module_id}/enrolment-page-visit")
1706 .wrap(
1707 RateLimit::new(RateLimitConfig {
1708 per_minute: Some(10),
1709 per_hour: Some(60),
1710 ..Default::default()
1711 })
1712 .keyed_by(RateLimitKey::User),
1713 )
1714 .route(web::post().to(record_my_enrolment_page_visit)),
1715 )
1716 .route(
1717 "/my/enrolment-banners/by-course/{course_id}",
1718 web::get().to(get_my_credit_registration_enrolment_banners),
1719 )
1720 .service(
1723 web::resource("/my/{id}/recheck-enrolment")
1724 .wrap(
1725 RateLimit::new(RateLimitConfig {
1726 per_minute: Some(5),
1727 per_hour: Some(30),
1728 ..Default::default()
1729 })
1730 .keyed_by(RateLimitKey::User),
1731 )
1732 .route(web::post().to(request_credit_registration_enrolment_recheck)),
1733 )
1734 .service(
1735 web::resource("/my/{id}/dismiss-enrolment-banner")
1736 .route(web::post().to(dismiss_credit_registration_enrolment_banner)),
1737 )
1738 .route(
1741 "/student-number-verifications/{token}",
1742 web::get().to(preview_student_number_verification_token),
1743 )
1744 .service(
1745 web::resource("/student-number-verifications/{token}/claim")
1746 .wrap(RateLimit::new(RateLimitConfig {
1747 per_minute: Some(10),
1748 per_hour: Some(60),
1749 ..Default::default()
1750 }))
1751 .route(web::post().to(claim_student_number_verification_token)),
1752 );
1753}