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