Skip to main content

headless_lms_server/controllers/main_frontend/credit_registration_admin/
account_linking.rs

1//! The account-linking funnel, resending and hand-resolving linking mails, and manual links.
2
3use std::future::Future;
4use std::pin::Pin;
5
6use headless_lms_models::course_module_suotar_realisations;
7use headless_lms_models::credit_registration_account_linking_emails::{
8    self, StaleUnclaimedLinkingMails,
9};
10use headless_lms_models::credit_registration_admin_actions::{
11    CreditRegistrationAdminAction, CreditRegistrationAdminActionTarget, GLOBAL_ADMIN_ROLE,
12    NewCreditRegistrationAdminAction,
13};
14use headless_lms_models::credit_registrations;
15use headless_lms_models::email_deliveries::EmailSendStatus;
16use headless_lms_models::library::credit_registration::account_linking::{
17    LINKING_MAIL_QUIET_PERIOD_SECS, MAX_LINKING_MAILS_PER_PERSON_AND_COURSE, retire_capped_mails,
18};
19use headless_lms_models::verified_student_numbers::{
20    self, NewVerifiedStudentNumber, StudentNumberVerificationMethod,
21};
22use utoipa::ToSchema;
23
24use crate::controllers::main_frontend::course_credit_registrations::record_resend_and_fetch_mails;
25use crate::domain::credit_registration_phases::PhaseContext;
26use crate::domain::credit_registration_phases::linking_mail_resend::{
27    ResendOutcome, ResolvedPerson, resend_linking_mail_for_target, resolve_person,
28};
29use crate::prelude::*;
30use headless_lms_base::config::ApplicationConfiguration;
31
32use super::{
33    AdminLinkingEmail, authorize_credit_registration_admin, build_linking_emails, required_reason,
34};
35
36const STALE_UNCLAIMED_LIMIT: i64 = 200;
37
38/// Marks a manual action's study registry call in the call log as something a person set off.
39const RESEND_CALLER: &str = "admin-resend";
40const RESOLVE_CALLER: &str = "admin-resolve-person";
41
42/// A fat-finger guard on top of the per-person caps, which this endpoint can only override by retiring
43/// ledger rows.
44const RESEND_QUIET_PERIOD_SECS: i64 = 60;
45
46fn phase_context<'a>(
47    pool: &'a web::Data<PgPool>,
48    suotar_client: &'a web::Data<headless_lms_utils::services::suotar::SuotarClient>,
49    app_conf: &'a ApplicationConfiguration,
50    caller: &'a str,
51) -> PhaseContext<'a> {
52    PhaseContext::from_app(pool, suotar_client, app_conf, caller)
53}
54
55/// The account-linking funnel. The `_last_run` steps come from counters the discovery phase overwrites
56/// whole, the `_in_window` ones from the window: there is no single denominator.
57#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
58pub struct AccountLinkingFunnel {
59    pub persons_discovered_last_run: i64,
60    pub already_linked_last_run: i64,
61    pub mails_claimed_in_window: i64,
62    pub mails_sent_in_window: i64,
63    pub numbers_claimed_in_window: i64,
64    /// Never folded into the claimed count: an admin's judgement is not a claim.
65    pub manual_links_in_window: i64,
66    pub suppressed_by_dedup_last_run: i64,
67    pub suppressed_by_rate_cap_last_run: i64,
68    pub no_address_in_study_registry_last_run: i64,
69    /// The branch that skips the mail entirely: discovered persons linked straight away because the
70    /// study registry holds a verified account address for them. A terminal branch off `discovered`,
71    /// not a stage every person passes through.
72    pub fast_tracked_in_window: i64,
73    pub fast_tracked_last_run: i64,
74}
75
76#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
77pub struct AccountLinkingSendStatusTotals {
78    pub queued: i64,
79    pub retrying: i64,
80    pub sent: i64,
81    pub send_failed: i64,
82}
83
84/// Hard send failures grouped by recipient domain.
85#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
86pub struct AccountLinkingFailureDomain {
87    pub domain: String,
88    pub count: i64,
89}
90
91#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
92pub struct AccountLinkingRealisationCounters {
93    pub course_id: Uuid,
94    pub course_name: String,
95    pub course_module_id: Uuid,
96    pub course_module_name: Option<String>,
97    pub course_unit_realisation_id: String,
98    pub label: Option<String>,
99    pub uh_course_code: Option<String>,
100    /// When the counters below were collected. Not the last attempt: a failing realisation keeps the
101    /// last roster that arrived.
102    pub last_listed_at: Option<DateTime<Utc>>,
103    pub last_listing_attempted_at: Option<DateTime<Utc>>,
104    /// Set while the listing attempts since `last_listed_at` are failing, so an empty course and an
105    /// unreachable one do not read alike.
106    pub last_listing_error:
107        Option<headless_lms_models::credit_registrations::CreditRegistrationErrorCode>,
108    pub consecutive_listing_failures: i32,
109    pub listed_person_count: Option<i32>,
110    pub already_linked_count: Option<i32>,
111    pub mailed_count: Option<i32>,
112    pub suppressed_by_dedup_count: Option<i32>,
113    pub suppressed_by_rate_cap_count: Option<i32>,
114    /// Persons the registry holds no address for: the one population no remedy here can reach.
115    pub no_address_count: Option<i32>,
116    pub fast_tracked_count: Option<i32>,
117    pub fast_track_skipped_no_account_count: Option<i32>,
118    /// Matched an account that has never proved the address. The population an email-verification
119    /// campaign would convert.
120    pub fast_track_skipped_unverified_count: Option<i32>,
121    pub fast_track_skipped_stale_verification_count: Option<i32>,
122    /// A rise here is the only early warning of a university address reissued to a different person.
123    pub fast_track_skipped_name_mismatch_count: Option<i32>,
124    pub fast_track_skipped_account_has_number_count: Option<i32>,
125    pub fast_track_skipped_unlinked_before_count: Option<i32>,
126}
127
128/// One mail attempt: the address it went to and what we can say about its delivery.
129#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
130pub struct AccountLinkingSendOutcome {
131    pub address: String,
132    pub send_status: EmailSendStatus,
133}
134
135/// A person mailed to the cap for one course whose number was never claimed.
136#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
137pub struct AccountLinkingStaleAddress {
138    pub student_number: String,
139    pub sisu_person_id: String,
140    pub course_id: Uuid,
141    pub course_name: String,
142    pub mail_count: i64,
143    pub first_sent_at: DateTime<Utc>,
144    pub last_sent_at: DateTime<Utc>,
145    /// In full, newest last, one per mail.
146    pub sends: Vec<AccountLinkingSendOutcome>,
147}
148
149#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
150pub struct VerifiedStudentNumberMethodTotal {
151    pub verified_via: StudentNumberVerificationMethod,
152    pub count: i64,
153}
154
155#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
156pub struct AccountLinkingStats {
157    pub window_secs: i64,
158    pub funnel: AccountLinkingFunnel,
159    pub send_status_totals: AccountLinkingSendStatusTotals,
160    pub hard_failure_domains: Vec<AccountLinkingFailureDomain>,
161    pub realisations: Vec<AccountLinkingRealisationCounters>,
162    pub stale_addresses: Vec<AccountLinkingStaleAddress>,
163    pub links_total_by_method: Vec<VerifiedStudentNumberMethodTotal>,
164    pub links_in_window_by_method: Vec<VerifiedStudentNumberMethodTotal>,
165    /// Accounts with an eligible completion still waiting for a student number.
166    pub waiting_for_student_number_count: i64,
167    pub max_mails_per_person_and_course: i64,
168    pub quiet_period_secs: i64,
169}
170
171#[derive(Debug, Deserialize)]
172pub struct AccountLinkingStatsQuery {
173    window_days: Option<u32>,
174}
175
176#[derive(Debug, Deserialize, ToSchema)]
177pub struct AdminResendAccountLinkingEmailPayload {
178    pub student_number: String,
179    pub course_id: Uuid,
180    /// Retires the mails a cap is counting, then runs the ordinary send path. Requires a reason.
181    pub override_rate_caps: bool,
182    pub reason: Option<String>,
183}
184
185#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
186pub struct AdminResendAccountLinkingEmailResult {
187    pub outcome: ResendOutcome,
188    /// Mails retired to get past a cap. Always zero without an override.
189    pub retired_mail_count: i64,
190    pub linking_emails: Vec<AdminLinkingEmail>,
191    pub mails_sent_for_this_course: i64,
192    pub max_mails_per_person_and_course: i64,
193    pub quiet_period_secs: i64,
194}
195
196#[derive(Debug, Deserialize, ToSchema)]
197pub struct AdminResolveStudentNumberPayload {
198    pub student_number: String,
199}
200
201/// The preview a manual link is gated on. No addresses from the registry — `resolve-persons` answers
202/// with a name and an id only — so the addresses here are the ones we mailed.
203#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
204pub struct AdminResolveStudentNumberResult {
205    pub found: bool,
206    pub student_number: String,
207    /// Echoed back to the manual-link endpoint, which refuses without it.
208    pub sisu_person_id: Option<String>,
209    pub first_names: Option<String>,
210    pub last_name: Option<String>,
211    /// The registry's own per-item code, an identifier rather than prose.
212    pub code: Option<String>,
213    pub study_registry_unavailable: bool,
214    pub already_linked_to_user_id: Option<Uuid>,
215    pub already_linked_to_user_email: Option<String>,
216    pub already_linked_via: Option<StudentNumberVerificationMethod>,
217    pub linking_emails: Vec<AdminLinkingEmail>,
218}
219
220#[derive(Debug, Deserialize, ToSchema)]
221pub struct AdminManuallyLinkStudentNumberPayload {
222    pub user_id: Uuid,
223    pub student_number: String,
224    /// From the preview. Re-resolved on arrival, and a mismatch is refused, so a typo cannot mint a
225    /// link to somebody else.
226    pub sisu_person_id: String,
227    pub reason: String,
228}
229
230#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
231#[serde(rename_all = "snake_case")]
232pub enum AdminManualLinkOutcome {
233    Linked,
234    /// The registry does not know the number.
235    StudentNumberNotFound,
236    /// The registry named a different person than the preview did.
237    PreviewMismatch,
238    /// The number is live on another account. Unlink that one first.
239    AlreadyLinkedToAnotherAccount,
240    AlreadyLinkedToThisAccount,
241    StudyRegistryUnavailable,
242}
243
244#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
245pub struct AdminManuallyLinkStudentNumberResult {
246    pub outcome: AdminManualLinkOutcome,
247    pub verified_student_number_id: Option<Uuid>,
248    /// Registrations the link unblocked.
249    pub affected_registration_count: i64,
250}
251
252/**
253GET `/api/v0/main-frontend/credit-registration-admin/account-linking` - The linking funnel, the
254per-realisation counters, the send-status totals and the stale-address list.
255*/
256#[instrument(skip(pool))]
257#[utoipa::path(
258    get,
259    path = "/account-linking",
260    operation_id = "getAccountLinkingStats",
261    tag = "credit-registration-admin",
262    params(("window_days" = Option<u32>, Query, description = "Window for the windowed funnel steps, in days")),
263    responses(
264        (status = 200, description = "Where account linking stands", body = AccountLinkingStats)
265    )
266)]
267pub async fn get_account_linking_stats(
268    user: AuthUser,
269    pool: web::Data<PgPool>,
270    query: web::Query<AccountLinkingStatsQuery>,
271) -> ControllerResult<web::Json<AccountLinkingStats>> {
272    let mut conn = pool.acquire().await?;
273    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
274
275    let window_days = i64::from(query.window_days.unwrap_or(30).clamp(1, 365));
276    let window_secs = window_days * 24 * 60 * 60;
277    let since = Utc::now() - chrono::Duration::days(window_days);
278
279    let realisations = course_module_suotar_realisations::get_active_discovery_reports(&mut conn)
280        .await?
281        .into_iter()
282        .map(|row| AccountLinkingRealisationCounters {
283            course_id: row.course_id,
284            course_name: row.course_name,
285            course_module_id: row.course_module_id,
286            course_module_name: row.course_module_name,
287            course_unit_realisation_id: row.course_unit_realisation_id,
288            label: row.label,
289            uh_course_code: row.uh_course_code,
290            last_listed_at: row.last_listed_at,
291            last_listing_attempted_at: row.last_listing_attempted_at,
292            last_listing_error: row.last_listing_error,
293            consecutive_listing_failures: row.consecutive_listing_failures,
294            listed_person_count: row.last_listed_person_count,
295            already_linked_count: row.last_already_linked_count,
296            mailed_count: row.last_mailed_count,
297            suppressed_by_dedup_count: row.last_suppressed_by_dedup_count,
298            suppressed_by_rate_cap_count: row.last_suppressed_by_rate_cap_count,
299            no_address_count: row.last_no_address_count,
300            fast_tracked_count: row.last_fast_tracked_count,
301            fast_track_skipped_no_account_count: row.last_fast_track_skipped_no_account_count,
302            fast_track_skipped_unverified_count: row.last_fast_track_skipped_unverified_count,
303            fast_track_skipped_stale_verification_count: row
304                .last_fast_track_skipped_stale_verification_count,
305            fast_track_skipped_name_mismatch_count: row.last_fast_track_skipped_name_mismatch_count,
306            fast_track_skipped_account_has_number_count: row
307                .last_fast_track_skipped_account_has_number_count,
308            fast_track_skipped_unlinked_before_count: row
309                .last_fast_track_skipped_unlinked_before_count,
310        })
311        .collect::<Vec<_>>();
312    let sum = |pick: fn(&AccountLinkingRealisationCounters) -> Option<i32>| -> i64 {
313        realisations
314            .iter()
315            .filter_map(pick)
316            .map(i64::from)
317            .sum::<i64>()
318    };
319
320    let now = Utc::now();
321    let totals = credit_registration_account_linking_emails::get_send_status_totals_since(
322        &mut conn, since, now,
323    )
324    .await?;
325    let send_status_totals = AccountLinkingSendStatusTotals {
326        queued: totals.queued,
327        retrying: totals.retrying,
328        sent: totals.sent,
329        send_failed: totals.send_failed,
330    };
331    let hard_failure_domains: Vec<AccountLinkingFailureDomain> =
332        credit_registration_account_linking_emails::get_send_failure_domains_since(
333            &mut conn, since, now,
334        )
335        .await?
336        .into_iter()
337        .map(|row| AccountLinkingFailureDomain {
338            domain: row.domain,
339            count: row.count,
340        })
341        .collect();
342
343    let method_counts = verified_student_numbers::count_by_method_since(&mut conn, since).await?;
344    let links_total_by_method = method_counts
345        .iter()
346        .map(
347            |&(verified_via, total, _)| VerifiedStudentNumberMethodTotal {
348                verified_via,
349                count: total,
350            },
351        )
352        .collect::<Vec<_>>();
353    let links_in_window_by_method = method_counts
354        .iter()
355        .map(
356            |&(verified_via, _, in_window)| VerifiedStudentNumberMethodTotal {
357                verified_via,
358                count: in_window,
359            },
360        )
361        .collect::<Vec<_>>();
362    let in_window = |method: StudentNumberVerificationMethod| -> i64 {
363        links_in_window_by_method
364            .iter()
365            .filter(|row| row.verified_via == method)
366            .map(|row| row.count)
367            .sum()
368    };
369
370    let stale = credit_registration_account_linking_emails::get_stale_unclaimed(
371        &mut conn,
372        MAX_LINKING_MAILS_PER_PERSON_AND_COURSE,
373        STALE_UNCLAIMED_LIMIT,
374    )
375    .await?;
376    let stale_addresses = build_stale_addresses(&mut conn, stale).await?;
377
378    let waiting_for_student_number_count = credit_registrations::count_pending_by_reason(&mut conn)
379        .await?
380        .student_number_count;
381
382    let funnel = AccountLinkingFunnel {
383        persons_discovered_last_run: sum(|row| row.listed_person_count),
384        already_linked_last_run: sum(|row| row.already_linked_count),
385        mails_claimed_in_window: totals.mails_in_window,
386        mails_sent_in_window: send_status_totals.sent,
387        numbers_claimed_in_window: in_window(StudentNumberVerificationMethod::EmailedLink),
388        manual_links_in_window: in_window(StudentNumberVerificationMethod::AdminManual),
389        suppressed_by_dedup_last_run: sum(|row| row.suppressed_by_dedup_count),
390        suppressed_by_rate_cap_last_run: sum(|row| row.suppressed_by_rate_cap_count),
391        no_address_in_study_registry_last_run: sum(|row| row.no_address_count),
392        fast_tracked_in_window: in_window(StudentNumberVerificationMethod::EmailMatchFastTrack),
393        fast_tracked_last_run: sum(|row| row.fast_tracked_count),
394    };
395
396    token.authorized_ok(web::Json(AccountLinkingStats {
397        window_secs,
398        funnel,
399        send_status_totals,
400        hard_failure_domains,
401        realisations,
402        stale_addresses,
403        links_total_by_method,
404        links_in_window_by_method,
405        waiting_for_student_number_count,
406        max_mails_per_person_and_course: MAX_LINKING_MAILS_PER_PERSON_AND_COURSE,
407        quiet_period_secs: LINKING_MAIL_QUIET_PERIOD_SECS,
408    }))
409}
410
411/**
412POST `/api/v0/main-frontend/credit-registration-admin/account-linking/resend` - Sets off another
413account-linking mail for one person on one course.
414
415The mail goes to the addresses the study registry holds, and the recipient still has to open the link
416while signed in, so the ownership proof is intact. An override does not ask a cap for an exemption: it
417retires the ledger rows the cap is counting, as its own audited action, then runs the ordinary path.
418*/
419#[instrument(skip(pool, payload, app_conf, suotar_client))]
420#[utoipa::path(
421    post,
422    path = "/account-linking/resend",
423    operation_id = "adminResendAccountLinkingEmail",
424    tag = "credit-registration-admin",
425    request_body = AdminResendAccountLinkingEmailPayload,
426    responses(
427        (status = 200, description = "What the attempt did", body = AdminResendAccountLinkingEmailResult),
428        (status = 422, description = "An override without a reason, or too soon after the last resend")
429    )
430)]
431pub async fn admin_resend_account_linking_email(
432    user: AuthUser,
433    pool: web::Data<PgPool>,
434    payload: web::Json<AdminResendAccountLinkingEmailPayload>,
435    app_conf: web::Data<ApplicationConfiguration>,
436    suotar_client: web::Data<headless_lms_utils::services::suotar::SuotarClient>,
437) -> ControllerResult<web::Json<AdminResendAccountLinkingEmailResult>> {
438    let mut conn = pool.acquire().await?;
439    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
440
441    let enabled_module_ids =
442        models::course_modules::get_credit_registration_enabled_ids_for_course(
443            &mut conn,
444            payload.course_id,
445        )
446        .await?;
447    if enabled_module_ids.is_empty() {
448        return Err(controller_err!(
449            BadRequest,
450            "This course has no credit registration module configured.".to_string()
451        ));
452    }
453
454    let student_number = payload.student_number.trim();
455    if student_number.is_empty() {
456        return Err(controller_err!(
457            BadRequest,
458            "Name a student number.".to_string()
459        ));
460    }
461    let override_reason = if payload.override_rate_caps {
462        Some(required_reason(payload.reason.as_deref().unwrap_or(""))?.to_string())
463    } else {
464        None
465    };
466
467    let recent = models::credit_registration_admin_actions::count_queued_resends_by_actor_since(
468        &mut conn,
469        user.id,
470        Utc::now() - chrono::Duration::seconds(RESEND_QUIET_PERIOD_SECS),
471    )
472    .await?;
473    if recent > 0 {
474        return Err(controller_err!(
475            BadRequest,
476            "Wait a minute between resends.".to_string()
477        ));
478    }
479
480    let ctx = phase_context(&pool, &suotar_client, &app_conf, RESEND_CALLER);
481    // Boxed so both the no-op and the override branch, which reaches for `conn`, type-check as the
482    // same value; it only runs once the shared helper has confirmed the number is not already linked.
483    let before_send: Pin<Box<dyn Future<Output = anyhow::Result<i64>> + '_>> =
484        match &override_reason {
485            Some(reason) => Box::pin(async {
486                Ok(retire_capped_mails(
487                    &mut conn,
488                    user.id,
489                    GLOBAL_ADMIN_ROLE,
490                    payload.course_id,
491                    student_number,
492                    reason,
493                )
494                .await?)
495            }),
496            None => Box::pin(async { Ok(0) }),
497        };
498    let attempt =
499        resend_linking_mail_for_target(&ctx, payload.course_id, student_number, before_send)
500            .await?;
501    let outcome = ResendOutcome::from(attempt.decision);
502
503    finish_resend(
504        &mut conn,
505        &user,
506        &payload,
507        student_number,
508        outcome,
509        attempt.retired_mail_count,
510        token,
511    )
512    .await
513}
514
515/**
516POST `/api/v0/main-frontend/credit-registration-admin/account-linking/resolve-person` - Looks one
517student number up in the study registry without changing anything.
518
519The preview a manual link is gated on. Writes nothing but the call log row every study registry call
520writes.
521*/
522#[instrument(skip(pool, payload, app_conf, suotar_client))]
523#[utoipa::path(
524    post,
525    path = "/account-linking/resolve-person",
526    operation_id = "adminResolveStudentNumberForLinking",
527    tag = "credit-registration-admin",
528    request_body = AdminResolveStudentNumberPayload,
529    responses(
530        (status = 200, description = "Who the study registry says the number belongs to", body = AdminResolveStudentNumberResult),
531        (status = 422, description = "No student number given")
532    )
533)]
534pub async fn admin_resolve_student_number_for_linking(
535    user: AuthUser,
536    pool: web::Data<PgPool>,
537    payload: web::Json<AdminResolveStudentNumberPayload>,
538    app_conf: web::Data<ApplicationConfiguration>,
539    suotar_client: web::Data<headless_lms_utils::services::suotar::SuotarClient>,
540) -> ControllerResult<web::Json<AdminResolveStudentNumberResult>> {
541    let mut conn = pool.acquire().await?;
542    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
543
544    if !models::course_modules::any_credit_registration_enabled(&mut conn).await? {
545        return Err(controller_err!(
546            BadRequest,
547            "No course has credit registration configured.".to_string()
548        ));
549    }
550
551    let student_number = payload.student_number.trim();
552    if student_number.is_empty() {
553        return Err(controller_err!(
554            BadRequest,
555            "Name a student number.".to_string()
556        ));
557    }
558
559    let ctx = phase_context(&pool, &suotar_client, &app_conf, RESOLVE_CALLER);
560    let resolved = resolve_person(&ctx, student_number).await;
561    let existing = verified_student_numbers::get_by_student_number(&mut conn, student_number)
562        .await?
563        .or(match &resolved {
564            Ok(Some(person)) => verified_student_numbers::get_by_sisu_person_ids(
565                &mut conn,
566                std::slice::from_ref(&person.sisu_person_id),
567            )
568            .await?
569            .into_iter()
570            .next(),
571            _ => None,
572        });
573    let already_linked_to_user_email = match &existing {
574        Some(link) => models::user_details::get_user_details_by_user_id(&mut conn, link.user_id)
575            .await
576            .ok()
577            .map(|details| details.email),
578        None => None,
579    };
580
581    let mails = match &resolved {
582        Ok(Some(person)) => {
583            credit_registration_account_linking_emails::get_by_sisu_person_id(
584                &mut conn,
585                &person.sisu_person_id,
586            )
587            .await?
588        }
589        _ => Vec::new(),
590    };
591    let linking_emails = build_linking_emails(&mut conn, mails).await?;
592
593    let shared = AdminResolveStudentNumberResult {
594        found: false,
595        student_number: student_number.to_string(),
596        sisu_person_id: None,
597        first_names: None,
598        last_name: None,
599        code: None,
600        study_registry_unavailable: false,
601        already_linked_to_user_id: existing.as_ref().map(|link| link.user_id),
602        already_linked_to_user_email,
603        already_linked_via: existing.as_ref().map(|link| link.verified_via),
604        linking_emails,
605    };
606    let result = match resolved {
607        Ok(Some(person)) => AdminResolveStudentNumberResult {
608            found: true,
609            sisu_person_id: Some(person.sisu_person_id),
610            first_names: Some(person.first_names),
611            last_name: Some(person.last_name),
612            code: Some(person.code),
613            ..shared
614        },
615        Ok(None) => AdminResolveStudentNumberResult { ..shared },
616        Err(_) => AdminResolveStudentNumberResult {
617            study_registry_unavailable: true,
618            ..shared
619        },
620    };
621
622    token.authorized_ok(web::Json(result))
623}
624
625/**
626POST `/api/v0/main-frontend/credit-registration-admin/account-linking/manual-link` - Links a student
627number to an account on an admin's judgement.
628
629The last resort, for a student whose mailbox host will not accept our mail at all. An admin's judgement
630stands in for proof of mailbox control, so the link is marked `admin_manual` forever, carries the
631reason and names the admin.
632*/
633#[instrument(skip(pool, payload, app_conf, suotar_client))]
634#[utoipa::path(
635    post,
636    path = "/account-linking/manual-link",
637    operation_id = "adminManuallyLinkStudentNumber",
638    tag = "credit-registration-admin",
639    request_body = AdminManuallyLinkStudentNumberPayload,
640    responses(
641        (status = 200, description = "What the attempt did", body = AdminManuallyLinkStudentNumberResult),
642        (status = 422, description = "No reason, no student number, or no person id from the preview")
643    )
644)]
645pub async fn admin_manually_link_student_number(
646    user: AuthUser,
647    pool: web::Data<PgPool>,
648    payload: web::Json<AdminManuallyLinkStudentNumberPayload>,
649    app_conf: web::Data<ApplicationConfiguration>,
650    suotar_client: web::Data<headless_lms_utils::services::suotar::SuotarClient>,
651) -> ControllerResult<web::Json<AdminManuallyLinkStudentNumberResult>> {
652    let mut conn = pool.acquire().await?;
653    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
654
655    if !models::course_modules::any_credit_registration_enabled(&mut conn).await? {
656        return Err(controller_err!(
657            BadRequest,
658            "No course has credit registration configured.".to_string()
659        ));
660    }
661
662    let ManualLinkRequest {
663        reason,
664        student_number,
665        previewed_person_id,
666    } = manual_link_request(&payload)?;
667    let reason = reason.to_string();
668
669    let refused = |outcome| AdminManuallyLinkStudentNumberResult {
670        outcome,
671        verified_student_number_id: None,
672        affected_registration_count: 0,
673    };
674    let ctx = phase_context(&pool, &suotar_client, &app_conf, RESOLVE_CALLER);
675    let person: ResolvedPerson = match resolve_person(&ctx, student_number).await {
676        Ok(Some(person)) => person,
677        Ok(None) => {
678            return token.authorized_ok(web::Json(refused(
679                AdminManualLinkOutcome::StudentNumberNotFound,
680            )));
681        }
682        Err(_) => {
683            return token.authorized_ok(web::Json(refused(
684                AdminManualLinkOutcome::StudyRegistryUnavailable,
685            )));
686        }
687    };
688    if person.sisu_person_id != previewed_person_id {
689        return token.authorized_ok(web::Json(refused(AdminManualLinkOutcome::PreviewMismatch)));
690    }
691
692    let holder = verified_student_numbers::get_by_student_number(&mut conn, student_number).await?;
693    if let Some(holder) = &holder {
694        let outcome = if holder.user_id == payload.user_id {
695            AdminManualLinkOutcome::AlreadyLinkedToThisAccount
696        } else {
697            AdminManualLinkOutcome::AlreadyLinkedToAnotherAccount
698        };
699        return token.authorized_ok(web::Json(refused(outcome)));
700    }
701    // Both unique keys, not just the number: a student who changed programmes keeps their Sisu
702    // person id and gets a new number, so checking the number alone lets this through and then
703    // trips `uq_verified_student_numbers_person` as a bare 500. See `find_conflicting_account` on
704    // the student's own claim path, which this mirrors.
705    let person_holder =
706        verified_student_numbers::get_by_sisu_person_id(&mut conn, &person.sisu_person_id).await?;
707    if let Some(holder) = &person_holder {
708        let outcome = if holder.user_id == payload.user_id {
709            AdminManualLinkOutcome::AlreadyLinkedToThisAccount
710        } else {
711            AdminManualLinkOutcome::AlreadyLinkedToAnotherAccount
712        };
713        return token.authorized_ok(web::Json(refused(outcome)));
714    }
715
716    let mut tx = conn.begin().await?;
717    // A student who changed programmes has a new number; the old link is retired, not deleted, so the
718    // audit trail survives.
719    let current_link_id = verified_student_numbers::get_by_user_id(&mut tx, payload.user_id)
720        .await?
721        .map(|current| current.id);
722    let (verified_student_number_id, affected_registration_count) =
723        verified_student_numbers::replace_verified_student_number(
724            &mut tx,
725            current_link_id,
726            &NewVerifiedStudentNumber {
727                user_id: payload.user_id,
728                student_number: student_number.to_string(),
729                sisu_person_id: person.sisu_person_id.clone(),
730                first_names: Some(person.first_names.clone()),
731                last_name: Some(person.last_name.clone()),
732                verified_via: StudentNumberVerificationMethod::AdminManual,
733                // No mailbox was proved, so there is no address the proof could rest on.
734                verified_via_email: None,
735                verified_via_email_match_field: None,
736                account_email_verified_at: None,
737                linked_by_user_id: Some(user.id),
738                link_reason: Some(reason.clone()),
739                verified_from_course_id: None,
740            },
741            Some(user.id),
742            models::credit_registration_events::CreditRegistrationEventKind::AdminAction,
743            "An administrator linked this student number by hand.",
744        )
745        .await?;
746    models::credit_registration_admin_actions::record(
747        &mut tx,
748        &NewCreditRegistrationAdminAction {
749            target_id: Some(verified_student_number_id),
750            reason: Some(reason),
751            details: Some(serde_json::json!({
752                "user_id": payload.user_id,
753                "student_number": student_number,
754            })),
755            affected_row_count: Some(
756                i32::try_from(affected_registration_count).unwrap_or(i32::MAX),
757            ),
758            ..NewCreditRegistrationAdminAction::new(
759                CreditRegistrationAdminAction::ManualLinkStudentNumber,
760                CreditRegistrationAdminActionTarget::VerifiedStudentNumber,
761                user.id,
762                GLOBAL_ADMIN_ROLE,
763            )
764        },
765    )
766    .await?;
767    tx.commit().await?;
768
769    token.authorized_ok(web::Json(AdminManuallyLinkStudentNumberResult {
770        outcome: AdminManualLinkOutcome::Linked,
771        verified_student_number_id: Some(verified_student_number_id),
772        affected_registration_count,
773    }))
774}
775
776/// The three values a manual link may not be attempted without.
777struct ManualLinkRequest<'a> {
778    reason: &'a str,
779    student_number: &'a str,
780    previewed_person_id: &'a str,
781}
782
783/// Refuses a manual link that skipped the preview or gave no reason, before anything is asked of the
784/// study registry. The person id can only have come from the preview: it is the registry's own
785/// identifier, not something a caller could produce from the student number in front of them.
786fn manual_link_request(
787    payload: &AdminManuallyLinkStudentNumberPayload,
788) -> Result<ManualLinkRequest<'_>, ControllerError> {
789    let reason = required_reason(&payload.reason)?;
790    let student_number = payload.student_number.trim();
791    if student_number.is_empty() {
792        return Err(controller_err!(
793            BadRequest,
794            "Name a student number.".to_string()
795        ));
796    }
797    let previewed_person_id = payload.sisu_person_id.trim();
798    if previewed_person_id.is_empty() {
799        return Err(controller_err!(
800            BadRequest,
801            "Check the number in the study registry first.".to_string()
802        ));
803    }
804    Ok(ManualLinkRequest {
805        reason,
806        student_number,
807        previewed_person_id,
808    })
809}
810
811/// Audits the resend whatever it did, and reports where this person's mails now stand.
812async fn finish_resend(
813    conn: &mut PgConnection,
814    user: &AuthUser,
815    payload: &AdminResendAccountLinkingEmailPayload,
816    student_number: &str,
817    outcome: ResendOutcome,
818    retired_mail_count: i64,
819    token: crate::domain::authorization::AuthorizationToken,
820) -> ControllerResult<web::Json<AdminResendAccountLinkingEmailResult>> {
821    let (mails, mails_sent_for_this_course) = record_resend_and_fetch_mails(
822        conn,
823        payload.course_id,
824        Some(student_number),
825        user.id,
826        GLOBAL_ADMIN_ROLE,
827        None,
828        payload.reason.clone(),
829        serde_json::json!({
830            "outcome": outcome,
831            "student_number": student_number,
832            "override_rate_caps": payload.override_rate_caps,
833            "retired_mail_count": retired_mail_count,
834        }),
835    )
836    .await?;
837    let linking_emails = build_linking_emails(conn, mails).await?;
838
839    token.authorized_ok(web::Json(AdminResendAccountLinkingEmailResult {
840        outcome,
841        retired_mail_count,
842        linking_emails,
843        mails_sent_for_this_course,
844        max_mails_per_person_and_course: MAX_LINKING_MAILS_PER_PERSON_AND_COURSE,
845        quiet_period_secs: LINKING_MAIL_QUIET_PERIOD_SECS,
846    }))
847}
848
849async fn build_stale_addresses(
850    conn: &mut PgConnection,
851    rows: Vec<StaleUnclaimedLinkingMails>,
852) -> Result<Vec<AccountLinkingStaleAddress>, ControllerError> {
853    let ids: Vec<Uuid> = rows.iter().flat_map(|row| row.mail_ids.clone()).collect();
854    let reports =
855        credit_registration_account_linking_emails::get_send_status_reports(conn, &ids).await?;
856    Ok(rows
857        .into_iter()
858        .map(|row| {
859            let sends = row
860                .mail_ids
861                .iter()
862                .zip(row.addresses)
863                .map(|(id, address)| AccountLinkingSendOutcome {
864                    address,
865                    send_status: reports
866                        .get(id)
867                        .map(|report| report.email_send_status)
868                        .unwrap_or(EmailSendStatus::Queued),
869                })
870                .collect();
871            AccountLinkingStaleAddress {
872                sends,
873                student_number: row.student_number,
874                sisu_person_id: row.sisu_person_id,
875                course_id: row.course_id,
876                course_name: row.course_name,
877                mail_count: row.mail_count,
878                first_sent_at: row.first_sent_at,
879                last_sent_at: row.last_sent_at,
880            }
881        })
882        .collect())
883}
884
885pub fn _add_routes(cfg: &mut ServiceConfig) {
886    cfg.route("/account-linking", web::get().to(get_account_linking_stats))
887        .route(
888            "/account-linking/resend",
889            web::post().to(admin_resend_account_linking_email),
890        )
891        .route(
892            "/account-linking/resolve-person",
893            web::post().to(admin_resolve_student_number_for_linking),
894        )
895        .route(
896            "/account-linking/manual-link",
897            web::post().to(admin_manually_link_student_number),
898        );
899}
900
901#[cfg(test)]
902mod tests {
903    use super::*;
904
905    fn manual_link_payload(
906        reason: &str,
907        student_number: &str,
908        sisu_person_id: &str,
909    ) -> AdminManuallyLinkStudentNumberPayload {
910        AdminManuallyLinkStudentNumberPayload {
911            user_id: Uuid::new_v4(),
912            student_number: student_number.to_string(),
913            sisu_person_id: sisu_person_id.to_string(),
914            reason: reason.to_string(),
915        }
916    }
917
918    #[test]
919    fn a_manual_link_is_refused_without_a_preview_and_without_a_reason() {
920        assert!(
921            manual_link_request(&manual_link_payload(
922                "Host bounces our mail.",
923                "012345678",
924                ""
925            ))
926            .is_err()
927        );
928        assert!(manual_link_request(&manual_link_payload("   ", "012345678", "hy-hlo-1")).is_err());
929        assert!(manual_link_request(&manual_link_payload("", "012345678", "hy-hlo-1")).is_err());
930        assert!(
931            manual_link_request(&manual_link_payload(
932                "Host bounces our mail.",
933                "",
934                "hy-hlo-1"
935            ))
936            .is_err()
937        );
938        let payload =
939            manual_link_payload("  Host bounces our mail.  ", " 012345678 ", " hy-hlo-1 ");
940        let allowed = manual_link_request(&payload)
941            .expect("a reason, a number and a previewed person id are all there");
942        assert_eq!(allowed.reason, "Host bounces our mail.");
943        assert_eq!(allowed.student_number, "012345678");
944        assert_eq!(allowed.previewed_person_id, "hy-hlo-1");
945    }
946}