Skip to main content

headless_lms_server/controllers/main_frontend/
credit_registrations.rs

1/*!
2Handlers for HTTP requests to `/api/v0/main-frontend/credit-registrations`.
3
4Every handler filters by `user.id` in SQL and re-checks ownership before it writes. The stage a
5student is shown is [`StudentFacingCreditRegistrationStatus`], computed in the models crate and never
6re-derived here.
7*/
8
9use 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/// What we can honestly say about the linking mail: our send status, never a delivery.
73#[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    /// The registry declined this attempt because it already holds an equal or better grade. Still a
93    /// `registered` stage — the credit exists — but the student raised a grade and nothing changed,
94    /// so it earns a line of its own. Unlike the ledger state, this one is safe to expose: it says
95    /// something about the student's own transcript and nothing about how we treat them.
96    pub registry_already_held_equal_or_better: bool,
97    /// Whether the pipeline is still expected to move this row: drives the status page's polling.
98    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    /// The student number we submitted this registration under, so a `registered` row can be
104    /// checked against the student's own card; `None` before the row was ready to send.
105    pub student_number: Option<String>,
106    pub grade_id: Option<String>,
107    /// Names the scale `grade_id` is on, without which "1" reads as a one out of five when it means
108    /// a pass.
109    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    /// Whether a usable enrolment has been settled on, which is what ticks the step rather than the
115    /// name below it: a realisation with no teacher label yet leaves that name empty.
116    pub enrolment_found: bool,
117    /// When we last looked for an enrolment, so the page can say how fresh its answer is.
118    pub enrolment_checked_at: Option<DateTime<Utc>>,
119    pub enrolment_realisation_name: Option<String>,
120    /// When the attainment went to the study registry. Ticks the sending step; `registered_at` is
121    /// when the registry confirmed it.
122    pub submitted_at: Option<DateTime<Utc>>,
123    /// A `waiting_for_sisu` row Sisu has received but not finished processing into credits.
124    pub is_processing_in_sisu: bool,
125    /// The open university enrolment page, for a row the study registry has no enrolment for.
126    pub enrolment_link: Option<String>,
127    /// Only on a row waiting for a student number whose account was linked at some point: the mail is
128    /// addressed to a Sisu person, and a never-linked account names none.
129    pub linking_email: Option<LinkingEmailStatus>,
130    /// The terminal-state mail this row's status has, if one has been queued.
131    pub notification_email: Option<NotificationEmailStatus>,
132}
133
134/// The live registration for one course module, with the attempts a newer one replaced.
135#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
136pub struct MyCreditRegistrationForCourseModule {
137    pub registration: MyCreditRegistration,
138    /// The module's other rows, newest completion first. Shown because the study registry may hold an
139    /// earlier attempt's attainment as well as the current one's.
140    pub earlier_attempts: Vec<MyCreditRegistration>,
141}
142
143#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
144pub struct RequestCreditRegistrationEnrolmentRecheckResult {
145    /// False when we looked so recently that asking again would tell the student nothing new.
146    pub recheck_started: bool,
147}
148
149/// The account's linked student number, unmasked: it is the holder's own. Deliberately carries no
150/// Sisu-held names.
151#[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    /// The Sisu-held address the proof rests on, masked; `None` when support linked it by hand.
157    pub verified_via_email_masked: Option<String>,
158}
159
160#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
161pub struct UnlinkMyStudentNumberResult {
162    /// Registrations that went back to waiting for a student number.
163    pub affected_registration_count: i64,
164}
165
166/// What a mailed link would do, without doing it. Read-only on purpose: a mail scanner must not be
167/// able to spend the token. Deliberately carries no Sisu-held names.
168#[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    /// So the page can say "you already used this link" rather than accusing someone else.
177    pub already_used_by_this_account: bool,
178    /// A support case, not something the student can resolve: moving a number between accounts on
179    /// mailbox access alone would let anyone detach another account's link.
180    pub conflicts_with_other_account: bool,
181    /// What this account is linked to now. Claiming replaces it.
182    pub current_student_number: Option<String>,
183    /// Shown in the confirmation: being signed in to the wrong account is the common mistake.
184    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    /// The token named the number this account already holds. Consumed, and nothing changed.
193    AlreadyLinkedToThisAccount,
194    Expired,
195    AlreadyUsed,
196    /// Refused without consuming the token, so support can still act on it.
197    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    /// Completions that stopped waiting for a student number because of this claim.
206    pub newly_unblocked_registration_count: i64,
207}
208
209/**
210GET `/api/v0/main-frontend/credit-registrations/my` - Every credit registration of the signed-in
211account, newest completion first.
212*/
213#[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/**
238GET `/api/v0/main-frontend/credit-registrations/my/by-course-module/{course_module_id}` - The
239signed-in account's registration for one course module, or null when the pipeline has not created one
240yet.
241*/
242#[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/**
283GET `/api/v0/main-frontend/credit-registrations/my/enrolment-banners/by-course/{course_id}` - The
284caller's registrations on one course that owe them the in-course re-enrol banner.
285
286Scoped to the course rather than filtered from `/my` on the client, because every course-material page
287view calls this. Empty is the normal answer.
288*/
289#[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    // Left out while a check the student asked for is out: its answer decides whether they must act.
319    res.retain(|registration| {
320        registration.student_facing_status == StudentFacingCreditRegistrationStatus::NeedsEnrolment
321    });
322
323    token.authorized_ok(web::Json(res))
324}
325
326/**
327POST `/api/v0/main-frontend/credit-registrations/my/{id}/dismiss-enrolment-banner` - Puts away the
328in-course re-enrol banner for one registration.
329
330Idempotent. Not a permanent opt-out: a later entry into the same state clears the dismissal.
331*/
332#[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/**
366POST `/api/v0/main-frontend/credit-registrations/my/{id}/recheck-enrolment` - Asks the pipeline to
367look for an enrolment again, for a row parked because the study registry had none.
368*/
369#[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/// Deployment-wide switches the credit registration views adapt to.
422#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
423pub struct CreditRegistrationSettings {
424    /// Whether linking mails are sent and can be resent. When off, students get a number only from
425    /// the study registry or an admin.
426    pub account_linking_enabled: bool,
427}
428
429/**
430GET `/api/v0/main-frontend/credit-registrations/settings` - Deployment-wide credit registration
431switches.
432*/
433#[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/**
454GET `/api/v0/main-frontend/credit-registrations/my/student-number` - The student number linked to the
455signed-in account, or null.
456*/
457#[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/**
482DELETE `/api/v0/main-frontend/credit-registrations/my/student-number` - Unlinks the student number
483from the signed-in account.
484
485Registrations that have not been sent go back to waiting; credits already in Sisu are untouched.
486*/
487#[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/**
528GET `/api/v0/main-frontend/credit-registrations/student-number-verifications/{token}` - What the
529mailed link would link, without linking it.
530
531Writes nothing: the link has to survive a mail scanner fetching it.
532*/
533#[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/**
580POST `/api/v0/main-frontend/credit-registrations/student-number-verifications/{token}/claim` - Spends
581a mailed link and links the student number to the signed-in account.
582
583Any signed-in account may claim any valid token: holding it proves control of the Sisu-held mailbox,
584and the session says which of our accounts the person wants to use.
585*/
586#[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    // The atomic single-use guard: two concurrent claims cannot both win here.
638    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    // A student who changed programmes has a new number; the old link is retired, not deleted, so the
657    // audit trail survives.
658    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    // The mail went out because the course roster lists them, which proves an enrolment.
680    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
696/// Assembles the wire rows for one account, adding the enrolment link and the linking-mail status the
697/// ledger does not carry.
698async 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, &notification_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
795/// An account's linking mails and their send status, fetched once per request rather than once per
796/// row: they are the same for every one of a student's rows.
797struct LinkingMailCache {
798    mails: Vec<CreditRegistrationAccountLinkingEmail>,
799    reports: HashMap<Uuid, EmailSendStatusReport>,
800}
801
802/// The latest linking mail for this account's Sisu person on this course. `None` for an account that
803/// was never linked: the mail is addressed to a Sisu person, not to an email address.
804async 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
871/// Whether the token's holder is already live on some other account of ours.
872async 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/// The caller's answer about where they enrol one module, and whether it can still be changed.
899#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
900pub struct MyEnrolmentRoute {
901    pub course_module_completion_id: Uuid,
902    /// `None` until the student answers the question.
903    pub route: Option<CreditRegistrationEnrolmentRoute>,
904    pub enrolment_confirmed_at: Option<DateTime<Utc>>,
905    /// False once a usable enrolment has been found: the answer only picks which enrolment
906    /// instructions to show, so once we have the enrolment there is nothing left for it to change.
907    /// A row parked on `no_usable_enrolment` is still asking the student to enrol, so it stays true.
908    pub can_change: bool,
909}
910
911#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
912pub struct SetEnrolmentRoutePayload {
913    pub route: CreditRegistrationEnrolmentRoute,
914}
915
916/// The caller's completion for a module, whichever registration path it is on, which is also the
917/// ownership check: the lookup is scoped to their own user, so a module someone else completed is a
918/// not-found rather than a forbidden.
919///
920/// The one [`models::course_module_completions::select_registration_completion`] picks, the same
921/// one the registration page itself is drawn from.
922async 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
937/// The caller's completion for a module, as [`my_registration_completion_for_module`], but only on
938/// the push path.
939///
940/// A completion the old path owns is a not-found: the enrolment answers only exist for the push
941/// path, and nothing should be stored against a completion that will never ask the question.
942async 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
954/// The caller's live registration for a module, or `None` before the pipeline has created one.
955async 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
994/// Kept apart from [`build_my_enrolment_route`] so a write can answer from the row it already
995/// returned, instead of reading the completion and the registration back a second time.
996fn 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/**
1010GET `/api/v0/main-frontend/credit-registrations/my/by-course-module/{course_module_id}/enrolment-route`
1011- What the caller said about where they enrol this module.
1012*/
1013#[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/**
1038PUT `/api/v0/main-frontend/credit-registrations/my/by-course-module/{course_module_id}/enrolment-route`
1039- Records which university relationship the caller has, which decides where they are told to enrol.
1040*/
1041#[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/**
1086POST `/api/v0/main-frontend/credit-registrations/my/by-course-module/{course_module_id}/enrolment-route/confirm`
1087- The caller says they have enrolled.
1088
1089Counts as a check request: a waiting registration restarts its checks on the check-requested
1090schedule, under the limit every check request shares. Recorded against the completion too, so a
1091registration that starts waiting later starts on that schedule. With account linking on, a caller
1092with no linked student number books a roster listing of the course code instead.
1093*/
1094#[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            // No row waits yet: a row that starts waiting takes its group from the signal.
1150            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/**
1175DELETE `/api/v0/main-frontend/credit-registrations/my/by-course-module/{course_module_id}/enrolment-route/confirm`
1176- The caller takes back saying they had enrolled.
1177*/
1178#[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        // No answer is already what this asks for, so it is an answer rather than a 404.
1206        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/// What the caller wrote about needing the credits rather than a certificate.
1225#[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
1237/// Long enough for the few sentences the question asks for; a bound at all so a scripted client
1238/// cannot park a book in the column.
1239const CREDIT_JUSTIFICATION_MAX_LENGTH: usize = 4000;
1240
1241/**
1242PUT `/api/v0/main-frontend/credit-registrations/my/by-course-module/{course_module_id}/credit-justification`
1243- Records why the caller needs the credits in the study registry rather than a certificate.
1244
1245Advisory: nothing reads it, and it neither gates nor speeds up the registration the student goes on
1246to make. Asked on the old registration page, so unlike the enrolment answers it is stored for
1247completions on either path.
1248*/
1249#[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/**
1301POST `/api/v0/main-frontend/credit-registrations/my/by-course-module/{course_module_id}/enrolment-page-visit`
1302- The caller opened the registration page after completing, and it is showing them how to enrol.
1303
1304Moves a waiting registration onto the schedule for students who have looked, or restarts that
1305schedule at most once a day. Recorded against the completion too, so a visit before there is a
1306registration, or before a student number is linked, still counts once there is. Idempotent enough
1307to call on every page load; the page sends it once per load.
1308*/
1309#[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            // Each unlinked visitor asks for a listing at most once a day.
1351            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
1368/// With account linking on, books a roster listing for a student we hold no number for; see
1369/// [`book_listing_for_unlinked_student`].
1370async 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        // `.route(web::post())`, never `.to()`: a resource's default route answers every method, so
1430        // these mutations would run on a GET a link can trigger with the visitor's session cookie.
1431        .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        // `redacted_request_line` in `start_server.rs` masks the token by this segment's name: rename
1448        // both, or tokens reach the access log.
1449        .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}