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    credit_registration_account_linking_emails::{self, CreditRegistrationAccountLinkingEmail},
13    credit_registration_events::{CreditRegistrationEventKind, NewCreditRegistrationEvent},
14    credit_registrations::{
15        CreditRegistrationErrorCode, CreditRegistrationState, RegistrationScope,
16        StudentCreditRegistration, StudentRegistrationFilter,
17    },
18    email_deliveries::{EmailSendStatus, EmailSendStatusReport},
19    library::credit_registration::StudentFacingCreditRegistrationStatus,
20    library::credit_registration::student_notifications::{
21        self, CreditRegistrationNotificationKind, RegistrationNotificationEmail,
22    },
23    open_university_product_access_tokens,
24    student_number_verification_tokens::{self, StudentNumberVerificationToken},
25    verified_student_numbers::{
26        self, NewVerifiedStudentNumber, StudentNumberVerificationMethod, VerifiedStudentNumber,
27    },
28};
29use models::library::credit_registration::preconditions::{
30    PRECONDITIONS_LIMIT, recompute_preconditions,
31};
32use models::library::credit_registration::student_number_change;
33use utoipa::{OpenApi, ToSchema};
34
35use crate::domain::rate_limit_middleware_builder::{RateLimit, RateLimitConfig};
36use crate::prelude::*;
37
38/// How long after the last enrolment check the student may ask us to look again. The pipeline's own
39/// recheck is daily, so this only bounds the button.
40const ENROLMENT_RECHECK_MIN_INTERVAL_SECS: i64 = 60 * 60;
41
42#[derive(OpenApi)]
43#[openapi(paths(
44    get_my_credit_registrations,
45    get_my_credit_registration_for_course_module,
46    get_my_credit_registration_enrolment_banners,
47    request_credit_registration_enrolment_recheck,
48    dismiss_credit_registration_enrolment_banner,
49    get_my_verified_student_number,
50    dismiss_my_auto_link_notice,
51    unlink_my_student_number,
52    preview_student_number_verification_token,
53    claim_student_number_verification_token
54))]
55pub(crate) struct MainFrontendCreditRegistrationsApiDoc;
56
57/// What we can honestly say about the linking mail: our send status, never a delivery.
58#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
59pub struct LinkingEmailStatus {
60    pub email_send_status: EmailSendStatus,
61    pub sent_at: Option<DateTime<Utc>>,
62    pub emailed_to_masked: String,
63}
64
65/// The same, for one of the two terminal-state mails. No address: these go to the account's own,
66/// which the reader either owns or already sees.
67#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
68pub struct NotificationEmailStatus {
69    pub kind: CreditRegistrationNotificationKind,
70    pub email_send_status: EmailSendStatus,
71    pub sent_at: Option<DateTime<Utc>>,
72}
73
74impl NotificationEmailStatus {
75    /// The mail that belongs with the row's current state, or `None` for a state that has none: a
76    /// row shows at most one line, and an old action-needed mail on a since-registered row would
77    /// contradict the badge above it.
78    pub(crate) fn for_state(
79        state: CreditRegistrationState,
80        credit_registration_id: Uuid,
81        mails: &[RegistrationNotificationEmail],
82    ) -> Option<Self> {
83        let wanted = CreditRegistrationNotificationKind::for_state(state)?;
84        let mail = mails.iter().find(|mail| {
85            mail.credit_registration_id == credit_registration_id && mail.kind == wanted
86        })?;
87        Some(Self {
88            kind: mail.kind,
89            email_send_status: mail.send_status.email_send_status,
90            sent_at: mail.send_status.sent_at,
91        })
92    }
93}
94
95#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
96pub struct MyCreditRegistration {
97    pub id: Uuid,
98    pub course_id: Uuid,
99    pub course_name: String,
100    pub course_slug: String,
101    pub course_module_id: Uuid,
102    pub course_module_name: Option<String>,
103    pub uh_course_code: Option<String>,
104    pub ects_credits: Option<f32>,
105    pub completion_date: DateTime<Utc>,
106    pub student_facing_status: StudentFacingCreditRegistrationStatus,
107    /// The registry declined this attempt because it already holds an equal or better grade. Still a
108    /// `registered` stage — the credit exists — but the student raised a grade and nothing changed,
109    /// so it earns a line of its own. Unlike the ledger state, this one is safe to expose: it says
110    /// something about the student's own transcript and nothing about how we treat them.
111    pub registry_already_held_equal_or_better: bool,
112    /// Whether the pipeline is still expected to move this row: drives the status page's polling.
113    pub status_is_moving: bool,
114    pub error_code: Option<CreditRegistrationErrorCode>,
115    pub next_attempt_at: DateTime<Utc>,
116    pub registered_at: Option<DateTime<Utc>>,
117    pub sisu_attainment_id: Option<String>,
118    pub grade_id: Option<String>,
119    /// Names the scale `grade_id` is on, without which "1" reads as a one out of five when it means
120    /// a pass.
121    pub grade_scale_id: Option<String>,
122    pub credits: Option<f32>,
123    pub attempt_number: i32,
124    pub superseded: bool,
125    pub can_request_enrolment_recheck: bool,
126    pub enrolment_realisation_name: Option<String>,
127    /// The open university enrolment page, for a row the study registry has no enrolment for.
128    pub enrolment_link: Option<String>,
129    /// Only on a row waiting for a student number whose account was linked at some point: the mail is
130    /// addressed to a Sisu person, and a never-linked account names none.
131    pub linking_email: Option<LinkingEmailStatus>,
132    /// The terminal-state mail this row's status has, if one has been queued.
133    pub notification_email: Option<NotificationEmailStatus>,
134}
135
136/// The live registration for one course module, with the attempts a newer one replaced.
137#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
138pub struct MyCreditRegistrationForCourseModule {
139    pub registration: MyCreditRegistration,
140    /// The module's other rows, newest completion first. Shown because the study registry may hold an
141    /// earlier attempt's attainment as well as the current one's.
142    pub earlier_attempts: Vec<MyCreditRegistration>,
143}
144
145#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
146pub struct RequestCreditRegistrationEnrolmentRecheckResult {
147    /// False when we looked so recently that asking again would tell the student nothing new.
148    pub recheck_started: bool,
149    pub next_recheck_allowed_at: Option<DateTime<Utc>>,
150}
151
152/// The account's linked student number, unmasked: it is the holder's own.
153#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
154pub struct MyVerifiedStudentNumber {
155    pub student_number: String,
156    pub verified_at: DateTime<Utc>,
157    pub verified_via: StudentNumberVerificationMethod,
158    /// The Sisu-held address the proof rests on, masked; `None` when support linked it by hand.
159    pub verified_via_email_masked: Option<String>,
160    pub first_names: Option<String>,
161    pub last_name: Option<String>,
162    /// Whether the pipeline linked this without asking, because the study registry holds this
163    /// account's verified address for the student number. True until the student puts the notice
164    /// away, and the notice is what makes a wrong automatic link noticeable.
165    pub linked_automatically: bool,
166    pub auto_link_notice_dismissed: bool,
167}
168
169#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
170pub struct UnlinkMyStudentNumberResult {
171    /// Registrations that went back to waiting for a student number.
172    pub affected_registration_count: i64,
173}
174
175/// What a mailed link would do, without doing it. Read-only on purpose: a mail scanner must not be
176/// able to spend the token.
177#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
178pub struct StudentNumberVerificationTokenPreview {
179    pub student_number: String,
180    pub first_names: Option<String>,
181    pub last_name: Option<String>,
182    pub course_name: Option<String>,
183    pub emailed_to_masked: String,
184    pub expires_at: DateTime<Utc>,
185    pub expired: bool,
186    pub already_used: bool,
187    /// So the page can say "you already used this link" rather than accusing someone else.
188    pub already_used_by_this_account: bool,
189    /// A support case, not something the student can resolve: moving a number between accounts on
190    /// mailbox access alone would let anyone detach another account's link.
191    pub conflicts_with_other_account: bool,
192    /// What this account is linked to now. Claiming replaces it.
193    pub current_student_number: Option<String>,
194    /// Shown in the confirmation: being signed in to the wrong account is the common mistake.
195    pub target_account_email: String,
196    pub claimable: bool,
197}
198
199#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
200#[serde(rename_all = "snake_case")]
201pub enum ClaimStudentNumberVerificationTokenOutcome {
202    Linked,
203    /// The token named the number this account already holds. Consumed, and nothing changed.
204    AlreadyLinkedToThisAccount,
205    Expired,
206    AlreadyUsed,
207    /// Refused without consuming the token, so support can still act on it.
208    StudentNumberAlreadyLinkedToAnotherAccount,
209}
210
211#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
212pub struct ClaimStudentNumberVerificationTokenResult {
213    pub outcome: ClaimStudentNumberVerificationTokenOutcome,
214    pub student_number: Option<String>,
215    pub linked_course_name: Option<String>,
216    /// Completions that stopped waiting for a student number because of this claim.
217    pub newly_unblocked_registration_count: i64,
218}
219
220/**
221GET `/api/v0/main-frontend/credit-registrations/my` - Every credit registration of the signed-in
222account, newest completion first.
223*/
224#[instrument(skip(pool))]
225#[utoipa::path(
226    get,
227    path = "/my",
228    operation_id = "getMyCreditRegistrations",
229    tag = "credit-registrations",
230    responses(
231        (status = 200, description = "The caller's credit registrations", body = Vec<MyCreditRegistration>)
232    )
233)]
234pub async fn get_my_credit_registrations(
235    user: AuthUser,
236    pool: web::Data<PgPool>,
237) -> ControllerResult<web::Json<Vec<MyCreditRegistration>>> {
238    let mut conn = pool.acquire().await?;
239    let token = skip_authorize();
240
241    let res =
242        build_my_credit_registrations(&mut conn, user.id, StudentRegistrationFilter::default())
243            .await?;
244
245    token.authorized_ok(web::Json(res))
246}
247
248/**
249GET `/api/v0/main-frontend/credit-registrations/my/by-course-module/{course_module_id}` - The
250signed-in account's registration for one course module, or null when the pipeline has not created one
251yet.
252*/
253#[instrument(skip(pool))]
254#[utoipa::path(
255    get,
256    path = "/my/by-course-module/{course_module_id}",
257    operation_id = "getMyCreditRegistrationForCourseModule",
258    tag = "credit-registrations",
259    params(("course_module_id" = Uuid, Path, description = "Course module id")),
260    responses(
261        (status = 200, description = "The caller's registration for the module", body = Option<MyCreditRegistrationForCourseModule>)
262    )
263)]
264pub async fn get_my_credit_registration_for_course_module(
265    user: AuthUser,
266    pool: web::Data<PgPool>,
267    course_module_id: web::Path<Uuid>,
268) -> ControllerResult<web::Json<Option<MyCreditRegistrationForCourseModule>>> {
269    let mut conn = pool.acquire().await?;
270    let token = skip_authorize();
271
272    let mut all = build_my_credit_registrations(
273        &mut conn,
274        user.id,
275        StudentRegistrationFilter {
276            course_module_id: Some(*course_module_id),
277            ..StudentRegistrationFilter::default()
278        },
279    )
280    .await?;
281    let live_position = all.iter().position(|row| !row.superseded);
282    let res = live_position.map(|position| {
283        let registration = all.remove(position);
284        MyCreditRegistrationForCourseModule {
285            registration,
286            earlier_attempts: all,
287        }
288    });
289
290    token.authorized_ok(web::Json(res))
291}
292
293/**
294GET `/api/v0/main-frontend/credit-registrations/my/enrolment-banners/by-course/{course_id}` - The
295caller's registrations on one course that owe them the in-course re-enrol banner.
296
297Scoped to the course rather than filtered from `/my` on the client, because every course-material page
298view calls this. Empty is the normal answer.
299*/
300#[instrument(skip(pool))]
301#[utoipa::path(
302    get,
303    path = "/my/enrolment-banners/by-course/{course_id}",
304    operation_id = "getMyCreditRegistrationEnrolmentBanners",
305    tag = "credit-registrations",
306    params(("course_id" = Uuid, Path, description = "Course id")),
307    responses(
308        (status = 200, description = "The caller's undismissed enrolment banners on the course", body = Vec<MyCreditRegistration>)
309    )
310)]
311pub async fn get_my_credit_registration_enrolment_banners(
312    user: AuthUser,
313    pool: web::Data<PgPool>,
314    course_id: web::Path<Uuid>,
315) -> ControllerResult<web::Json<Vec<MyCreditRegistration>>> {
316    let mut conn = pool.acquire().await?;
317    let token = skip_authorize();
318
319    let res = build_my_credit_registrations(
320        &mut conn,
321        user.id,
322        StudentRegistrationFilter {
323            course_id: Some(*course_id),
324            enrolment_banner_due: true,
325            ..StudentRegistrationFilter::default()
326        },
327    )
328    .await?;
329
330    token.authorized_ok(web::Json(res))
331}
332
333/**
334POST `/api/v0/main-frontend/credit-registrations/my/{id}/dismiss-enrolment-banner` - Puts away the
335in-course re-enrol banner for one registration.
336
337Idempotent. Not a permanent opt-out: a later entry into the same state clears the dismissal.
338*/
339#[instrument(skip(pool))]
340#[utoipa::path(
341    post,
342    path = "/my/{id}/dismiss-enrolment-banner",
343    operation_id = "dismissCreditRegistrationEnrolmentBanner",
344    tag = "credit-registrations",
345    params(("id" = Uuid, Path, description = "Credit registration id")),
346    responses(
347        (status = 200, description = "The banner is dismissed"),
348        (status = 403, description = "Not the caller's registration")
349    )
350)]
351pub async fn dismiss_credit_registration_enrolment_banner(
352    user: AuthUser,
353    pool: web::Data<PgPool>,
354    id: web::Path<Uuid>,
355) -> ControllerResult<web::Json<()>> {
356    let mut conn = pool.acquire().await?;
357    let token = skip_authorize();
358
359    let registration = models::credit_registrations::get_by_id(&mut conn, *id).await?;
360    if registration.user_id != user.id {
361        return Err(controller_err!(
362            Forbidden,
363            "Not your registration.".to_string()
364        ));
365    }
366    models::credit_registrations::dismiss_enrolment_banner(&mut conn, registration.id, user.id)
367        .await?;
368
369    token.authorized_ok(web::Json(()))
370}
371
372/**
373POST `/api/v0/main-frontend/credit-registrations/my/{id}/recheck-enrolment` - Asks the pipeline to
374look for an enrolment again, for a row parked because the study registry had none.
375*/
376#[instrument(skip(pool))]
377#[utoipa::path(
378    post,
379    path = "/my/{id}/recheck-enrolment",
380    operation_id = "requestCreditRegistrationEnrolmentRecheck",
381    tag = "credit-registrations",
382    params(("id" = Uuid, Path, description = "Credit registration id")),
383    responses(
384        (status = 200, description = "Whether a recheck was started", body = RequestCreditRegistrationEnrolmentRecheckResult),
385        (status = 403, description = "Not the caller's registration"),
386        (status = 400, description = "The registration is not waiting for an enrolment")
387    )
388)]
389pub async fn request_credit_registration_enrolment_recheck(
390    user: AuthUser,
391    pool: web::Data<PgPool>,
392    id: web::Path<Uuid>,
393) -> ControllerResult<web::Json<RequestCreditRegistrationEnrolmentRecheckResult>> {
394    let mut conn = pool.acquire().await?;
395    let token = skip_authorize();
396
397    let registration = models::credit_registrations::get_by_id(&mut conn, *id).await?;
398    if registration.user_id != user.id {
399        return Err(controller_err!(
400            Forbidden,
401            "Not your registration.".to_string()
402        ));
403    }
404    if registration.state != CreditRegistrationState::NoUsableEnrolment {
405        return Err(controller_err!(
406            BadRequest,
407            "This registration is not waiting for an enrolment.".to_string()
408        ));
409    }
410
411    let next_allowed = registration
412        .enrolment_checked_at
413        .map(|checked| checked + chrono::Duration::seconds(ENROLMENT_RECHECK_MIN_INTERVAL_SECS));
414    if next_allowed.is_some_and(|allowed| allowed > Utc::now()) {
415        return token.authorized_ok(web::Json(RequestCreditRegistrationEnrolmentRecheckResult {
416            recheck_started: false,
417            next_recheck_allowed_at: next_allowed,
418        }));
419    }
420
421    let mut tx = conn.begin().await?;
422    models::credit_registration_events::insert(
423        &mut tx,
424        &NewCreditRegistrationEvent {
425            actor_user_id: Some(user.id),
426            message: Some("The student asked us to look for an enrolment again.".to_string()),
427            ..NewCreditRegistrationEvent::new(
428                registration.id,
429                CreditRegistrationEventKind::StudentAction,
430            )
431        },
432    )
433    .await?;
434    models::credit_registrations::make_due_now_batch(&mut tx, &[registration.id]).await?;
435    recompute_preconditions(
436        &mut tx,
437        &RegistrationScope {
438            credit_registration_ids: vec![registration.id],
439            ..RegistrationScope::default()
440        },
441        PRECONDITIONS_LIMIT,
442    )
443    .await?;
444    tx.commit().await?;
445
446    token.authorized_ok(web::Json(RequestCreditRegistrationEnrolmentRecheckResult {
447        recheck_started: true,
448        next_recheck_allowed_at: None,
449    }))
450}
451
452/**
453GET `/api/v0/main-frontend/credit-registrations/my/student-number` - The student number linked to the
454signed-in account, or null.
455*/
456#[instrument(skip(pool))]
457#[utoipa::path(
458    get,
459    path = "/my/student-number",
460    operation_id = "getMyVerifiedStudentNumber",
461    tag = "credit-registrations",
462    responses(
463        (status = 200, description = "The caller's linked student number", body = Option<MyVerifiedStudentNumber>)
464    )
465)]
466pub async fn get_my_verified_student_number(
467    user: AuthUser,
468    pool: web::Data<PgPool>,
469) -> ControllerResult<web::Json<Option<MyVerifiedStudentNumber>>> {
470    let mut conn = pool.acquire().await?;
471    let token = skip_authorize();
472
473    let res = verified_student_numbers::get_by_user_id(&mut conn, user.id)
474        .await?
475        .map(to_my_verified_student_number);
476
477    token.authorized_ok(web::Json(res))
478}
479
480/**
481POST `/api/v0/main-frontend/credit-registrations/my/student-number/dismiss-auto-link-notice` - Puts
482away the notice saying the pipeline linked this student number without asking.
483
484Dismissing only hides the notice; the number stays linked and the unlink endpoint stays available.
485*/
486#[instrument(skip(pool))]
487#[utoipa::path(
488    post,
489    path = "/my/student-number/dismiss-auto-link-notice",
490    operation_id = "dismissMyAutoLinkNotice",
491    tag = "credit-registrations",
492    responses(
493        (status = 200, description = "The notice is dismissed")
494    )
495)]
496pub async fn dismiss_my_auto_link_notice(
497    user: AuthUser,
498    pool: web::Data<PgPool>,
499) -> ControllerResult<web::Json<()>> {
500    let mut conn = pool.acquire().await?;
501    let token = skip_authorize();
502
503    verified_student_numbers::dismiss_auto_link_notice(&mut conn, user.id).await?;
504
505    token.authorized_ok(web::Json(()))
506}
507
508/**
509DELETE `/api/v0/main-frontend/credit-registrations/my/student-number` - Unlinks the student number
510from the signed-in account.
511
512Registrations that have not been sent go back to waiting; credits already in Sisu are untouched.
513*/
514#[instrument(skip(pool))]
515#[utoipa::path(
516    delete,
517    path = "/my/student-number",
518    operation_id = "unlinkMyStudentNumber",
519    tag = "credit-registrations",
520    responses(
521        (status = 200, description = "How many registrations went back to waiting", body = UnlinkMyStudentNumberResult)
522    )
523)]
524pub async fn unlink_my_student_number(
525    user: AuthUser,
526    pool: web::Data<PgPool>,
527) -> ControllerResult<web::Json<UnlinkMyStudentNumberResult>> {
528    let mut conn = pool.acquire().await?;
529    let token = skip_authorize();
530
531    let Some(linked) = verified_student_numbers::get_by_user_id(&mut conn, user.id).await? else {
532        return token.authorized_ok(web::Json(UnlinkMyStudentNumberResult {
533            affected_registration_count: 0,
534        }));
535    };
536
537    let mut tx = conn.begin().await?;
538    let affected_registration_count = student_number_change::unlink_verified_student_number(
539        &mut tx,
540        linked.id,
541        user.id,
542        Some(user.id),
543        CreditRegistrationEventKind::StudentAction,
544        "The student unlinked their student number.",
545    )
546    .await?;
547    tx.commit().await?;
548
549    token.authorized_ok(web::Json(UnlinkMyStudentNumberResult {
550        affected_registration_count,
551    }))
552}
553
554/**
555GET `/api/v0/main-frontend/credit-registrations/student-number-verifications/{token}` - What the
556mailed link would link, without linking it.
557
558Writes nothing: the link has to survive a mail scanner fetching it.
559*/
560#[instrument(skip(pool, path))]
561#[utoipa::path(
562    get,
563    path = "/student-number-verifications/{token}",
564    operation_id = "previewStudentNumberVerificationToken",
565    tag = "credit-registrations",
566    params(("token" = String, Path, description = "The mailed verification token")),
567    responses(
568        (status = 200, description = "What the token would link", body = StudentNumberVerificationTokenPreview),
569        (status = 404, description = "No such token")
570    )
571)]
572pub async fn preview_student_number_verification_token(
573    user: AuthUser,
574    pool: web::Data<PgPool>,
575    path: web::Path<String>,
576) -> ControllerResult<web::Json<StudentNumberVerificationTokenPreview>> {
577    let mut conn = pool.acquire().await?;
578    let auth_token = skip_authorize();
579
580    let verification_token = get_token_or_404(&mut conn, &path).await?;
581    let current_link = verified_student_numbers::get_by_user_id(&mut conn, user.id).await?;
582    let conflict = find_conflicting_account(&mut conn, &verification_token, user.id).await?;
583    let course_name = course_name_of_token(&mut conn, &verification_token).await?;
584    let details = models::user_details::get_user_details_by_user_id(&mut conn, user.id).await?;
585
586    let expired =
587        verification_token.expires_at <= Utc::now() || verification_token.deleted_at.is_some();
588    let already_used = verification_token.used_at.is_some();
589
590    auth_token.authorized_ok(web::Json(StudentNumberVerificationTokenPreview {
591        student_number: verification_token.student_number.clone(),
592        first_names: verification_token.first_names.clone(),
593        last_name: verification_token.last_name.clone(),
594        course_name,
595        emailed_to_masked: mask_email(&verification_token.emailed_to),
596        expires_at: verification_token.expires_at,
597        expired,
598        already_used,
599        already_used_by_this_account: verification_token.claimed_by_user_id == Some(user.id),
600        conflicts_with_other_account: conflict,
601        current_student_number: current_link.map(|link| link.student_number),
602        target_account_email: details.email,
603        claimable: !expired && !already_used && !conflict,
604    }))
605}
606
607/**
608POST `/api/v0/main-frontend/credit-registrations/student-number-verifications/{token}/claim` - Spends
609a mailed link and links the student number to the signed-in account.
610
611Any signed-in account may claim any valid token: holding it proves control of the Sisu-held mailbox,
612and the session says which of our accounts the person wants to use.
613*/
614#[instrument(skip(pool, path))]
615#[utoipa::path(
616    post,
617    path = "/student-number-verifications/{token}/claim",
618    operation_id = "claimStudentNumberVerificationToken",
619    tag = "credit-registrations",
620    params(("token" = String, Path, description = "The mailed verification token")),
621    responses(
622        (status = 200, description = "What the claim did", body = ClaimStudentNumberVerificationTokenResult),
623        (status = 404, description = "No such token")
624    )
625)]
626pub async fn claim_student_number_verification_token(
627    user: AuthUser,
628    pool: web::Data<PgPool>,
629    path: web::Path<String>,
630) -> ControllerResult<web::Json<ClaimStudentNumberVerificationTokenResult>> {
631    let mut conn = pool.acquire().await?;
632    let auth_token = skip_authorize();
633
634    let verification_token = get_token_or_404(&mut conn, &path).await?;
635    let refused = |outcome| ClaimStudentNumberVerificationTokenResult {
636        outcome,
637        student_number: None,
638        linked_course_name: None,
639        newly_unblocked_registration_count: 0,
640    };
641
642    if verification_token.used_at.is_some() {
643        return auth_token.authorized_ok(web::Json(refused(
644            ClaimStudentNumberVerificationTokenOutcome::AlreadyUsed,
645        )));
646    }
647    if verification_token.expires_at <= Utc::now() || verification_token.deleted_at.is_some() {
648        return auth_token.authorized_ok(web::Json(refused(
649            ClaimStudentNumberVerificationTokenOutcome::Expired,
650        )));
651    }
652    if find_conflicting_account(&mut conn, &verification_token, user.id).await? {
653        return auth_token.authorized_ok(web::Json(refused(
654            ClaimStudentNumberVerificationTokenOutcome::StudentNumberAlreadyLinkedToAnotherAccount,
655        )));
656    }
657
658    let course_name = course_name_of_token(&mut conn, &verification_token).await?;
659    let current_link = verified_student_numbers::get_by_user_id(&mut conn, user.id).await?;
660    let already_ours = current_link
661        .as_ref()
662        .is_some_and(|link| link.student_number == verification_token.student_number);
663
664    let mut tx = conn.begin().await?;
665    // The atomic single-use guard: two concurrent claims cannot both win here.
666    if !student_number_verification_tokens::claim(&mut tx, &verification_token.token, user.id)
667        .await?
668    {
669        tx.rollback().await?;
670        return auth_token.authorized_ok(web::Json(refused(
671            ClaimStudentNumberVerificationTokenOutcome::AlreadyUsed,
672        )));
673    }
674    if already_ours {
675        tx.commit().await?;
676        return auth_token.authorized_ok(web::Json(ClaimStudentNumberVerificationTokenResult {
677            outcome: ClaimStudentNumberVerificationTokenOutcome::AlreadyLinkedToThisAccount,
678            student_number: Some(verification_token.student_number),
679            linked_course_name: course_name,
680            newly_unblocked_registration_count: 0,
681        }));
682    }
683
684    // A student who changed programmes has a new number; the old link is retired, not deleted, so the
685    // audit trail survives.
686    let (_, newly_unblocked_registration_count) =
687        verified_student_numbers::replace_verified_student_number(
688            &mut tx,
689            current_link.map(|link| link.id),
690            &NewVerifiedStudentNumber {
691                user_id: user.id,
692                student_number: verification_token.student_number.clone(),
693                sisu_person_id: verification_token.sisu_person_id.clone(),
694                first_names: verification_token.first_names.clone(),
695                last_name: verification_token.last_name.clone(),
696                verified_via: StudentNumberVerificationMethod::EmailedLink,
697                verified_via_email: Some(verification_token.emailed_to.clone()),
698                verified_via_email_match_field: None,
699                account_email_verified_at: None,
700                linked_by_user_id: None,
701                link_reason: None,
702                verified_from_course_id: verification_token.course_id,
703            },
704            Some(user.id),
705            CreditRegistrationEventKind::StudentAction,
706            "The student linked a student number.",
707        )
708        .await?;
709    tx.commit().await?;
710
711    auth_token.authorized_ok(web::Json(ClaimStudentNumberVerificationTokenResult {
712        outcome: ClaimStudentNumberVerificationTokenOutcome::Linked,
713        student_number: Some(verification_token.student_number),
714        linked_course_name: course_name,
715        newly_unblocked_registration_count,
716    }))
717}
718
719/// Assembles the wire rows for one account, adding the enrolment link and the linking-mail status the
720/// ledger does not carry.
721async fn build_my_credit_registrations(
722    conn: &mut PgConnection,
723    user_id: Uuid,
724    filter: StudentRegistrationFilter,
725) -> Result<Vec<MyCreditRegistration>, ControllerError> {
726    let rows =
727        models::credit_registrations::get_student_facing_by_user_id(conn, user_id, filter).await?;
728
729    let ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
730    let notification_mails = student_notifications::get_for_registrations(conn, &ids).await?;
731
732    let mut enrolment_links: HashMap<String, Option<String>> = HashMap::new();
733    let mut linking_mails: Option<LinkingMailCache> = None;
734    let mut res = Vec::with_capacity(rows.len());
735    for row in rows {
736        let state = row.state;
737        let status = StudentFacingCreditRegistrationStatus::of(state, row.preconditions());
738        let enrolment_link = if status == StudentFacingCreditRegistrationStatus::NeedsEnrolment {
739            resolve_enrolment_link(conn, &row, &mut enrolment_links).await?
740        } else {
741            None
742        };
743        let linking_email = if status == StudentFacingCreditRegistrationStatus::NeedsStudentNumber {
744            resolve_linking_email(conn, user_id, &row, &mut linking_mails).await?
745        } else {
746            None
747        };
748        let notification_email =
749            NotificationEmailStatus::for_state(state, row.id, &notification_mails);
750        res.push(to_my_credit_registration(
751            row,
752            status,
753            enrolment_link,
754            linking_email,
755            notification_email,
756        ));
757    }
758    Ok(res)
759}
760
761fn to_my_credit_registration(
762    row: StudentCreditRegistration,
763    status: StudentFacingCreditRegistrationStatus,
764    enrolment_link: Option<String>,
765    linking_email: Option<LinkingEmailStatus>,
766    notification_email: Option<NotificationEmailStatus>,
767) -> MyCreditRegistration {
768    let can_request_enrolment_recheck = row.state == CreditRegistrationState::NoUsableEnrolment
769        && row.enrolment_checked_at.is_none_or(|checked| {
770            checked + chrono::Duration::seconds(ENROLMENT_RECHECK_MIN_INTERVAL_SECS) <= Utc::now()
771        });
772    MyCreditRegistration {
773        id: row.id,
774        course_id: row.course_id,
775        course_name: row.course_name,
776        course_slug: row.course_slug,
777        course_module_id: row.course_module_id,
778        course_module_name: row.course_module_name,
779        uh_course_code: row.uh_course_code,
780        ects_credits: row.ects_credits,
781        completion_date: row.completion_date,
782        student_facing_status: status,
783        registry_already_held_equal_or_better: row.state == CreditRegistrationState::NotImproved,
784        status_is_moving: status.is_moving(),
785        error_code: row.error_code,
786        next_attempt_at: row.next_attempt_at,
787        registered_at: row.registered_at,
788        sisu_attainment_id: row.sisu_attainment_id,
789        grade_id: row.grade_id,
790        grade_scale_id: row.grade_scale_id,
791        credits: row.credits,
792        attempt_number: row.attempt_number,
793        superseded: row.superseded_by_id.is_some(),
794        can_request_enrolment_recheck,
795        enrolment_realisation_name: row.enrolment_realisation_name,
796        enrolment_link,
797        linking_email,
798        notification_email,
799    }
800}
801
802/// The enrolment page for the module's open university product, cached per product because several of
803/// a student's rows can share one.
804async fn resolve_enrolment_link(
805    conn: &mut PgConnection,
806    row: &StudentCreditRegistration,
807    cache: &mut HashMap<String, Option<String>>,
808) -> Result<Option<String>, ControllerError> {
809    let Some(product_id) = row.open_university_product_id.as_ref() else {
810        return Ok(None);
811    };
812    if let Some(cached) = cache.get(product_id) {
813        return Ok(cached.clone());
814    }
815    let link =
816        open_university_product_access_tokens::enrolment_url_for_product(conn, Some(product_id))
817            .await?;
818    cache.insert(product_id.clone(), link.clone());
819    Ok(link)
820}
821
822/// An account's linking mails and their send status, fetched once per request rather than once per
823/// row: they are the same for every one of a student's rows.
824struct LinkingMailCache {
825    mails: Vec<CreditRegistrationAccountLinkingEmail>,
826    reports: HashMap<Uuid, EmailSendStatusReport>,
827}
828
829/// The latest linking mail for this account's Sisu person on this course. `None` for an account that
830/// was never linked: the mail is addressed to a Sisu person, not to an email address.
831async fn resolve_linking_email(
832    conn: &mut PgConnection,
833    user_id: Uuid,
834    row: &StudentCreditRegistration,
835    cache: &mut Option<LinkingMailCache>,
836) -> Result<Option<LinkingEmailStatus>, ControllerError> {
837    if cache.is_none() {
838        let mails =
839            match verified_student_numbers::get_latest_including_deleted_by_user_id(conn, user_id)
840                .await?
841            {
842                Some(link) => {
843                    credit_registration_account_linking_emails::get_by_sisu_person_id(
844                        conn,
845                        &link.sisu_person_id,
846                    )
847                    .await?
848                }
849                None => Vec::new(),
850            };
851        let ids: Vec<Uuid> = mails.iter().map(|mail| mail.id).collect();
852        let reports =
853            credit_registration_account_linking_emails::get_send_status_reports(conn, &ids).await?;
854        *cache = Some(LinkingMailCache { mails, reports });
855    }
856    let cache = cache.as_ref().ok_or_else(|| {
857        controller_err!(
858            InternalServerError,
859            "linking mail cache was not populated".to_string()
860        )
861    })?;
862    let Some(mail) = cache
863        .mails
864        .iter()
865        .find(|mail| mail.course_id == row.course_id)
866    else {
867        return Ok(None);
868    };
869    let Some(report) = cache.reports.get(&mail.id) else {
870        return Ok(None);
871    };
872    Ok(Some(LinkingEmailStatus {
873        email_send_status: report.email_send_status,
874        sent_at: report.sent_at,
875        emailed_to_masked: mask_email(&mail.emailed_to),
876    }))
877}
878
879fn to_my_verified_student_number(link: VerifiedStudentNumber) -> MyVerifiedStudentNumber {
880    MyVerifiedStudentNumber {
881        student_number: link.student_number,
882        verified_at: link.verified_at,
883        verified_via: link.verified_via,
884        verified_via_email_masked: link.verified_via_email.as_deref().map(mask_email),
885        first_names: link.first_names,
886        last_name: link.last_name,
887        linked_automatically: link.verified_via
888            == StudentNumberVerificationMethod::EmailMatchFastTrack,
889        auto_link_notice_dismissed: link.auto_link_notice_dismissed_at.is_some(),
890    }
891}
892
893async fn get_token_or_404(
894    conn: &mut PgConnection,
895    token: &str,
896) -> Result<StudentNumberVerificationToken, ControllerError> {
897    student_number_verification_tokens::get_by_token_any_state(conn, &DbSecret::new(token))
898        .await?
899        .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))
900}
901
902/// Whether the token's holder is already live on some other account of ours.
903///
904/// Both keys, because both are unique: a student who changed programme keeps their Sisu person id
905/// and gets a new number, so checking the number alone lets the claim through and then trips
906/// `uq_verified_student_numbers_person` — after the token has been spent, and as a bare 500.
907async fn find_conflicting_account(
908    conn: &mut PgConnection,
909    token: &StudentNumberVerificationToken,
910    user_id: Uuid,
911) -> Result<bool, ControllerError> {
912    let by_number =
913        verified_student_numbers::get_by_student_number(conn, &token.student_number).await?;
914    if by_number.is_some_and(|link| link.user_id != user_id) {
915        return Ok(true);
916    }
917    let by_person =
918        verified_student_numbers::get_by_sisu_person_id(conn, &token.sisu_person_id).await?;
919    Ok(by_person.is_some_and(|link| link.user_id != user_id))
920}
921
922async fn course_name_of_token(
923    conn: &mut PgConnection,
924    token: &StudentNumberVerificationToken,
925) -> Result<Option<String>, ControllerError> {
926    let Some(course_id) = token.course_id else {
927        return Ok(None);
928    };
929    let course = models::courses::get_course(conn, course_id).await?;
930    Ok(Some(course.name))
931}
932
933/// Keeps the domain and drops the local part: enough to recognise which mailbox to open, not a new
934/// disclosure of an address. Teachers get the same masking; only admins see an address in full.
935pub(crate) fn mask_email(email: &str) -> String {
936    match email.split_once('@') {
937        Some((_, domain)) => format!("...@{domain}"),
938        None => "...".to_string(),
939    }
940}
941
942pub fn _add_routes(cfg: &mut ServiceConfig) {
943    cfg.route("/my", web::get().to(get_my_credit_registrations))
944        .route(
945            "/my/student-number",
946            web::get().to(get_my_verified_student_number),
947        )
948        .route(
949            "/my/student-number",
950            web::delete().to(unlink_my_student_number),
951        )
952        .route(
953            "/my/student-number/dismiss-auto-link-notice",
954            web::post().to(dismiss_my_auto_link_notice),
955        )
956        .route(
957            "/my/by-course-module/{course_module_id}",
958            web::get().to(get_my_credit_registration_for_course_module),
959        )
960        .route(
961            "/my/enrolment-banners/by-course/{course_id}",
962            web::get().to(get_my_credit_registration_enrolment_banners),
963        )
964        // `.route(web::post())`, never `.to()`: a resource's default route answers every method, so
965        // these mutations would run on a GET a link can trigger with the visitor's session cookie.
966        .service(
967            web::resource("/my/{id}/recheck-enrolment")
968                .wrap(RateLimit::new(RateLimitConfig {
969                    per_minute: Some(5),
970                    per_hour: Some(30),
971                    ..Default::default()
972                }))
973                .route(web::post().to(request_credit_registration_enrolment_recheck)),
974        )
975        .service(
976            web::resource("/my/{id}/dismiss-enrolment-banner")
977                .route(web::post().to(dismiss_credit_registration_enrolment_banner)),
978        )
979        .route(
980            "/student-number-verifications/{token}",
981            web::get().to(preview_student_number_verification_token),
982        )
983        .service(
984            web::resource("/student-number-verifications/{token}/claim")
985                .wrap(RateLimit::new(RateLimitConfig {
986                    per_minute: Some(10),
987                    per_hour: Some(60),
988                    ..Default::default()
989                }))
990                .route(web::post().to(claim_student_number_verification_token)),
991        );
992}