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::{
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/// What we can honestly say about the linking mail: our send status, never a delivery.
84#[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/// Whether the link in a linking mail can still be opened, and if not, whether a new mail can
93/// replace it.
94#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
95#[serde(rename_all = "snake_case")]
96pub enum LinkingEmailLinkState {
97    Usable,
98    /// The caps still allow another mail for this course.
99    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    /// The registry declined this attempt because it already holds an equal or better grade. Still a
117    /// `registered` stage — the credit exists — but the student raised a grade and nothing changed,
118    /// so it earns a line of its own. Unlike the ledger state, this one is safe to expose: it says
119    /// something about the student's own transcript and nothing about how we treat them.
120    pub registry_already_held_equal_or_better: bool,
121    /// Whether the pipeline is still expected to move this row: drives the status page's polling.
122    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    /// The student number we submitted this registration under, so a `registered` row can be
128    /// checked against the student's own card; `None` before the row was ready to send.
129    pub student_number: Option<String>,
130    pub grade_id: Option<String>,
131    /// Names the scale `grade_id` is on, without which "1" reads as a one out of five when it means
132    /// a pass.
133    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    /// Whether a usable enrolment has been settled on, which is what ticks the step rather than the
139    /// name below it: a realisation with no teacher label yet leaves that name empty.
140    pub enrolment_found: bool,
141    /// When we last looked for an enrolment, so the page can say how fresh its answer is.
142    pub enrolment_checked_at: Option<DateTime<Utc>>,
143    pub enrolment_realisation_name: Option<String>,
144    /// When the attainment went to the study registry. Ticks the sending step; `registered_at` is
145    /// when the registry confirmed it.
146    pub submitted_at: Option<DateTime<Utc>>,
147    /// A `waiting_for_sisu` row Sisu has received but not finished processing into credits.
148    pub is_processing_in_sisu: bool,
149    /// The module's open university enrolment page, in every status: an open university student
150    /// needs it before they have a student number to be looked up by.
151    pub enrolment_link: Option<String>,
152    /// The step list for a registration on its way to Sisu; `None` until a usable enrolment is
153    /// found, and for a row that is not on its way.
154    pub progress: Option<StudentRegistrationProgress>,
155    /// Only on a row waiting for a student number whose account was linked at some point: the mail is
156    /// addressed to a Sisu person, and a never-linked account names none.
157    pub linking_email: Option<LinkingEmailStatus>,
158    /// The terminal-state mail this row's status has, if one has been queued.
159    pub notification_email: Option<NotificationEmailStatus>,
160}
161
162/// The live registration for one course module, with the attempts a newer one replaced.
163#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
164pub struct MyCreditRegistrationForCourseModule {
165    pub registration: MyCreditRegistration,
166    /// The module's other rows, newest completion first. Shown because the study registry may hold an
167    /// earlier attempt's attainment as well as the current one's.
168    pub earlier_attempts: Vec<MyCreditRegistration>,
169}
170
171#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
172pub struct RequestCreditRegistrationEnrolmentRecheckResult {
173    /// False when we looked so recently that asking again would tell the student nothing new.
174    pub recheck_started: bool,
175}
176
177/// The account's linked student number, unmasked: it is the holder's own. Deliberately carries no
178/// Sisu-held names.
179#[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    /// The Sisu-held address the proof rests on, masked; `None` when support linked it by hand.
185    pub verified_via_email_masked: Option<String>,
186}
187
188#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
189pub struct UnlinkMyStudentNumberResult {
190    /// Registrations that went back to waiting for a student number.
191    pub affected_registration_count: i64,
192}
193
194/// What a mailed link would do, without doing it. Read-only on purpose: a mail scanner must not be
195/// able to spend the token.
196#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
197pub struct StudentNumberVerificationTokenPreview {
198    pub student_number: String,
199    /// The name Sisu holds for the student number. Sisu does not verify the address the link was
200    /// mailed to, so the name is what tells someone they are about to link someone else's number.
201    /// `None` unless `claimable`.
202    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    /// So the page can say "you already used this link" rather than accusing someone else.
209    pub already_used_by_this_account: bool,
210    /// A support case, not something the student can resolve: moving a number between accounts on
211    /// mailbox access alone would let anyone detach another account's link.
212    pub conflicts_with_other_account: bool,
213    /// What this account is linked to now. Claiming replaces it.
214    pub current_student_number: Option<String>,
215    /// Shown in the confirmation: being signed in to the wrong account is the common mistake.
216    pub target_account_email: String,
217    /// The signed-in account's name, `None` when it has none.
218    pub target_account_name: Option<String>,
219    /// The name the account must take on claiming; `None` when it already uses Sisu's or the link is
220    /// not `claimable`.
221    pub name_change: Option<NameReplacement>,
222    pub claimable: bool,
223}
224
225/// The answer to the confirmation page's name question.
226#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, ToSchema)]
227pub struct ClaimStudentNumberVerificationTokenRequest {
228    /// One of the preview's `name_change.first_name_options`; required when it has a `name_change`.
229    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    /// The token named the number this account already holds. Consumed, and nothing changed.
237    AlreadyLinkedToThisAccount,
238    Expired,
239    AlreadyUsed,
240    /// Refused without consuming the token, so support can still act on it.
241    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    /// Completions that stopped waiting for a student number because of this claim.
250    pub newly_unblocked_registration_count: i64,
251}
252
253/**
254GET `/api/v0/main-frontend/credit-registrations/my` - Every credit registration of the signed-in
255account, newest completion first.
256*/
257#[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/**
282GET `/api/v0/main-frontend/credit-registrations/my/by-course-module/{course_module_id}` - The
283signed-in account's registration for one course module, or null when the pipeline has not created one
284yet.
285*/
286#[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/**
327GET `/api/v0/main-frontend/credit-registrations/my/enrolment-banners/by-course/{course_id}` - The
328caller's registrations on one course that owe them the in-course re-enrol banner.
329
330Scoped to the course rather than filtered from `/my` on the client, because every course-material page
331view calls this. Empty is the normal answer.
332*/
333#[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    // Left out while a check the student asked for is out: its answer decides whether they must act.
363    res.retain(|registration| {
364        registration.student_facing_status == StudentFacingCreditRegistrationStatus::NeedsEnrolment
365    });
366
367    token.authorized_ok(web::Json(res))
368}
369
370/**
371POST `/api/v0/main-frontend/credit-registrations/my/{id}/dismiss-enrolment-banner` - Puts away the
372in-course re-enrol banner for one registration.
373
374Idempotent. Not a permanent opt-out: a later entry into the same state clears the dismissal.
375*/
376#[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/**
410POST `/api/v0/main-frontend/credit-registrations/my/{id}/recheck-enrolment` - Asks the pipeline to
411look for an enrolment again, for a row parked because the study registry had none.
412*/
413#[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/// Deployment-wide switches the credit registration views adapt to.
466#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
467pub struct CreditRegistrationSettings {
468    /// Whether linking mails are sent and can be resent. When off, students get a number only from
469    /// the study registry or an admin.
470    pub account_linking_enabled: bool,
471}
472
473/**
474GET `/api/v0/main-frontend/credit-registrations/settings` - Deployment-wide credit registration
475switches.
476*/
477#[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/**
498GET `/api/v0/main-frontend/credit-registrations/my/student-number` - The student number linked to the
499signed-in account, or null.
500*/
501#[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/**
526DELETE `/api/v0/main-frontend/credit-registrations/my/student-number` - Unlinks the student number
527from the signed-in account.
528
529Registrations that have not been sent go back to waiting; credits already in Sisu are untouched.
530*/
531#[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/**
572GET `/api/v0/main-frontend/credit-registrations/student-number-verifications/{token}` - What the
573mailed link would link, without linking it.
574
575Writes nothing: the link has to survive a mail scanner fetching it.
576*/
577#[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    // A dead link may have been forwarded, so it no longer shows whose number it is.
607    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/**
632POST `/api/v0/main-frontend/credit-registrations/student-number-verifications/{token}/claim` - Spends
633a mailed link and links the student number to the signed-in account.
634
635Any signed-in account may claim any valid token: holding it proves control of the Sisu-held mailbox,
636and the session says which of our accounts the person wants to use. The account's name is set to
637Sisu's in the same transaction, with the first name the student picked, and pushed to TMC.
638*/
639#[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    // The atomic single-use guard: two concurrent claims cannot both win here.
714    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        // Last before the commit: TMC cannot be rolled back, so nothing that may fail comes after it.
730        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
754/// Links the token's student number to `user_id`, replacing `current_link`, and returns how many
755/// registrations that unblocked.
756async 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    // A student who changed programmes has a new number; the old link is retired, not deleted, so the
763    // audit trail survives.
764    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    // The mail went out because the course roster lists them, which proves an enrolment.
786    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
795/// Assembles the wire rows for one account, adding the linking-mail status the ledger does not carry.
796async 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, &notification_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
911/// An account's linking mails and their send status, fetched once per request rather than once per
912/// row: they are the same for every one of a student's rows.
913struct LinkingMailCache {
914    mails: Vec<CreditRegistrationAccountLinkingEmail>,
915    reports: HashMap<Uuid, EmailSendStatusReport>,
916    link_states: HashMap<Uuid, LinkingMailLinkState>,
917}
918
919/// The latest linking mail for this account's Sisu person on this course. `None` for an account that
920/// was never linked: the mail is addressed to a Sisu person, not to an email address.
921async 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        // Only a number proven through its Sisu mailbox is known to be this student's; mails found
929        // through any other unlinked number may be someone else's.
930        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
1007/// Whether the token's holder is already live on some other account of ours.
1008async 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
1034/// The first names and last name Sisu held when the token was minted, or `None` when it held neither.
1035fn full_name(token: &StudentNumberVerificationToken) -> Option<String> {
1036    joined_name(
1037        expose_option(&token.first_names),
1038        expose_option(&token.last_name),
1039    )
1040}
1041
1042/// `first` and `last` as one display name, or `None` when both are blank.
1043fn 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
1053/// The name claiming `token` gives the account described by `details`; `None` keeps its name.
1054fn 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/// The caller's answer about where they enrol one module, and whether it can still be changed.
1067#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
1068pub struct MyEnrolmentRoute {
1069    pub course_module_completion_id: Uuid,
1070    /// `None` until the student answers the question, as are the answers below.
1071    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    /// False once a usable enrolment has been found: the answer only picks which enrolment
1079    /// instructions to show, so once we have the enrolment there is nothing left for it to change.
1080    /// A row parked on `no_usable_enrolment` is still asking the student to enrol, so it stays true.
1081    pub can_change: bool,
1082}
1083
1084/// Every answer at once, replacing the stored ones: an answer left out is cleared.
1085#[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
1094/// The caller's completion for a module, whichever registration path it is on, which is also the
1095/// ownership check: the lookup is scoped to their own user, so a module someone else completed is a
1096/// not-found rather than a forbidden.
1097///
1098/// The one [`models::course_module_completions::select_registration_completion`] picks, the same
1099/// one the registration page itself is drawn from.
1100async 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
1115/// The caller's completion for a module, as [`my_registration_completion_for_module`], but only on
1116/// the push path.
1117///
1118/// A completion the old path owns is a not-found: the enrolment answers only exist for the push
1119/// path, and nothing should be stored against a completion that will never ask the question.
1120async 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
1132/// The caller's live registration for a module, or `None` before the pipeline has created one.
1133async 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
1172/// Kept apart from [`build_my_enrolment_route`] so a write can answer from the row it already
1173/// returned, instead of reading the completion and the registration back a second time.
1174fn 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/**
1206GET `/api/v0/main-frontend/credit-registrations/my/by-course-module/{course_module_id}/enrolment-route`
1207- What the caller said about where they enrol this module.
1208*/
1209#[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/**
1234PUT `/api/v0/main-frontend/credit-registrations/my/by-course-module/{course_module_id}/enrolment-route`
1235- Records which university relationship the caller has, which decides where they are told to enrol,
1236and their answers to the questions that follow it. Replaces every stored answer.
1237*/
1238#[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/**
1290POST `/api/v0/main-frontend/credit-registrations/my/by-course-module/{course_module_id}/enrolment-route/confirm`
1291- The caller says they have enrolled.
1292
1293Counts as a check request: a waiting registration restarts its checks on the check-requested
1294schedule, under the limit every check request shares. Recorded against the completion too, so a
1295registration that starts waiting later starts on that schedule. For a caller with no linked
1296student number, it also makes the course code's enrolment list due sooner.
1297*/
1298#[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            // No row waits yet: a row that starts waiting takes its group from the signal, and an
1353            // unlinked student's signal makes their course code's enrolment list due.
1354            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/**
1371DELETE `/api/v0/main-frontend/credit-registrations/my/by-course-module/{course_module_id}/enrolment-route/confirm`
1372- The caller takes back saying they had enrolled.
1373*/
1374#[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        // No answer is already what this asks for, so it is an answer rather than a 404.
1402        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/**
1421POST `/api/v0/main-frontend/credit-registrations/my/by-course-module/{course_module_id}/enrolment-route/certificate-choice`
1422- The caller takes the certificate instead of registering credits.
1423
1424Advisory like the other answers: the pipeline still registers the completion.
1425*/
1426#[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/**
1473DELETE `/api/v0/main-frontend/credit-registrations/my/by-course-module/{course_module_id}/enrolment-route/certificate-choice`
1474- The caller takes back choosing the certificate.
1475*/
1476#[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        // No answer is already what this asks for, so it is an answer rather than a 404.
1504        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/// What the caller wrote about needing the credits rather than a certificate.
1523#[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
1535/// Long enough for the few sentences the question asks for; a bound at all so a scripted client
1536/// cannot park a book in the column.
1537const CREDIT_JUSTIFICATION_MAX_LENGTH: usize = 4000;
1538
1539/**
1540PUT `/api/v0/main-frontend/credit-registrations/my/by-course-module/{course_module_id}/credit-justification`
1541- Records why the caller needs the credits in the study registry rather than a certificate.
1542
1543Advisory: nothing reads it, and it neither gates nor speeds up the registration the student goes on
1544to make. Asked on the old registration page, so unlike the enrolment answers it is stored for
1545completions on either path.
1546*/
1547#[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/**
1599POST `/api/v0/main-frontend/credit-registrations/my/by-course-module/{course_module_id}/enrolment-page-visit`
1600- The caller opened the registration page after completing, and it is showing them how to enrol.
1601
1602Moves a waiting registration onto the schedule for students who have looked, or restarts that
1603schedule at most once a day. Recorded against the completion too, so a visit before there is a
1604registration, or before a student number is linked, still counts once there is; an unlinked
1605caller's visit also makes the course code's enrolment list due sooner. Idempotent enough to call on
1606every page load; the page sends it once per load.
1607*/
1608#[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        // `.route(web::post())`, never `.to()`: a resource's default route answers every method, so
1721        // these mutations would run on a GET a link can trigger with the visitor's session cookie.
1722        .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        // `redacted_request_line` in `start_server.rs` masks the token by this segment's name: rename
1739        // both, or tokens reach the access log.
1740        .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}