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::collections::{HashMap, HashSet};
4
5use headless_lms_models::course_module_suotar_configurations;
6use headless_lms_models::credit_registration_account_linking_emails::{
7    self, LinkingEmailListRow, LinkingEmailListStatus, LinkingMailLinkState,
8    StaleUnclaimedLinkingMails,
9};
10use headless_lms_models::credit_registration_admin_actions::{
11    self, CreditRegistrationAdminAction, CreditRegistrationAdminActionTarget, GLOBAL_ADMIN_ROLE,
12    NewCreditRegistrationAdminAction,
13};
14use headless_lms_models::credit_registration_enrolment_routes::CreditRegistrationEnrolmentRoute;
15use headless_lms_models::credit_registration_phase_state;
16use headless_lms_models::credit_registration_roster_schedules::{
17    self, AccountLinkingCodeCounters, RosterSchedule, ScheduleSelection,
18};
19use headless_lms_models::credit_registrations::{
20    self, AttentionReason, AttentionStanding, CreditRegistrationErrorCode, WaitingPresser,
21};
22use headless_lms_models::email_deliveries::{EmailSendStatus, EmailSendStatusReport};
23use headless_lms_models::library::credit_registration::account_linking::{
24    LINKING_MAIL_QUIET_PERIOD, MAX_LINKING_MAILS_PER_PERSON_AND_COURSE, mail_address,
25};
26use headless_lms_models::library::credit_registration::student_number::parse_student_number;
27use headless_lms_models::library::credit_registration::study_registry::RosterPerson;
28use headless_lms_models::library::credit_registration::timeline::{Engagement, TimelineStep};
29use headless_lms_models::study_registry_student_number_conflicts;
30use headless_lms_models::verified_student_numbers::{
31    self, LinkConflict, NewVerifiedStudentNumber, StudentNumberVerificationMethod,
32};
33use headless_lms_utils::secret_string::expose_option;
34use indexmap::IndexMap;
35use secrecy::{ExposeSecret, SecretString};
36use utoipa::ToSchema;
37
38use crate::controllers::main_frontend::course_credit_registrations::record_resend_and_fetch_mails;
39use crate::domain::credit_registration::linking_candidates::{
40    AccountIdentity, LinkingCandidateSimilarity, similarities, similarity_score,
41};
42use crate::domain::credit_registration::linking_mail_resend::{
43    ResendOutcome, ensure_resend_possible,
44};
45use crate::prelude::*;
46use headless_lms_base::config::ApplicationConfiguration;
47use headless_lms_credit_registration::CreditRegistrationPhase;
48use headless_lms_credit_registration::account_linking::{
49    ManualActionContext, PersonLookupError, RateCapOverride, RegistryPerson, ResendAttempt,
50    list_unlinked_enrolled_before, list_unlinked_on_course_rosters, look_up_person,
51    resend_linking_mail_for_target,
52};
53
54use super::dashboard::{CreditRegistrationPhaseStatus, to_phase_status};
55use super::{
56    AdminLinkingEmail, AdminManualLinkingEmailSend, attention_rules,
57    authorize_credit_registration_admin, build_linking_emails, required_reason,
58};
59
60const STALE_UNCLAIMED_LIMIT: i64 = 200;
61const STUDY_REGISTRY_CONFLICT_LIMIT: i64 = 200;
62const RECENT_LINKING_EMAIL_LIMIT: i64 = 50;
63const WAITING_STUDENT_LIMIT: i64 = 100;
64const UNUSED_LINK_LIMIT: i64 = 200;
65const RECENT_LINK_LIMIT: i64 = 50;
66
67/// Marks a manual action's study registry call in the call log as something a person set off.
68const RESEND_CALLER: &str = "admin-resend";
69const RESOLVE_CALLER: &str = "admin-resolve-person";
70const MANUAL_LINK_CALLER: &str = "admin-manual-link";
71const CANDIDATES_CALLER: &str = "admin-linking-candidates";
72const UNLINKED_ENROLEES_CALLER: &str = "admin-unlinked-enrolees";
73
74/// The account-linking funnel. The `_last_run` steps sum each code's last enrolment list, a person
75/// counted once per code, and the `_in_window` ones come from the window: there is no single
76/// denominator.
77#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
78pub struct AccountLinkingFunnel {
79    pub persons_discovered_last_run: i64,
80    pub already_linked_last_run: i64,
81    pub mails_claimed_in_window: i64,
82    pub mails_sent_in_window: i64,
83    pub numbers_claimed_in_window: i64,
84    /// Never folded into the claimed count: an admin's judgement is not a claim.
85    pub manual_links_in_window: i64,
86    pub suppressed_by_dedup_last_run: i64,
87    pub suppressed_by_rate_cap_last_run: i64,
88    pub no_address_in_study_registry_last_run: i64,
89}
90
91#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
92pub struct AccountLinkingSendStatusTotals {
93    /// Claimed, but not yet queued by the `link-emails` phase.
94    pub waiting_for_link_emails: i64,
95    /// Queued, but not yet attempted by the email worker.
96    pub waiting_for_email_worker: i64,
97    pub retrying: i64,
98    pub sent: i64,
99    pub send_failed: i64,
100    /// Claimed in the window and used since.
101    pub used: i64,
102}
103
104/// Hard send failures grouped by recipient domain.
105#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
106pub struct AccountLinkingFailureDomain {
107    pub domain: String,
108    pub count: i64,
109}
110
111/// A module on a course code, which shares the code's enrolment list.
112#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
113pub struct AccountLinkingCodeModule {
114    pub course_id: Uuid,
115    pub course_name: String,
116    pub course_module_id: Uuid,
117    pub course_module_name: Option<String>,
118    /// When an enrolment list last fed account linking for the module.
119    pub last_listed_at: Option<DateTime<Utc>>,
120}
121
122/// One course code's enrolment list: when it is fetched, and what the last one did for linking.
123#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
124pub struct AccountLinkingCourseCode {
125    pub course_code: String,
126    pub modules: Vec<AccountLinkingCodeModule>,
127    pub last_fetched_at: Option<DateTime<Utc>>,
128    /// The latest fetch that could send linking emails.
129    pub last_mailing_fetch_started_at: Option<DateTime<Utc>>,
130    /// When it is next due, ignoring the failure backoff.
131    pub next_fetch_at: DateTime<Utc>,
132    /// An admin's "Fetch now" no fetch has served yet.
133    pub fetch_requested_at: Option<DateTime<Utc>>,
134    /// People on the code's modules waiting for a student number.
135    pub waiting_count: i64,
136    /// Everyone on the last list, however long ago they enrolled.
137    pub last_listed_person_count: Option<i32>,
138    pub consecutive_failures: i32,
139    pub retry_not_before: Option<DateTime<Utc>>,
140    pub last_error: Option<CreditRegistrationErrorCode>,
141    /// `None` until a list has fed account linking.
142    pub linking: Option<AccountLinkingCodeCounters>,
143    /// People on the same list who enrolled before account linking began and are linked to no
144    /// account, so no linking email went to them.
145    pub unlinked_enrolled_before_count: Option<i32>,
146    /// Pressers on the code's modules still waiting for a student number, since the cutoff.
147    pub pressed_waiting_count: i64,
148    /// Linking emails on the code whose link can still be used.
149    pub unused_link_count: i64,
150    pub is_enrolment_list_empty: bool,
151    pub is_fetch_failing: bool,
152}
153
154/// A student waiting for a student number who pressed "I have enrolled".
155#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
156pub struct AccountLinkingPresser {
157    pub credit_registration_id: Uuid,
158    pub user_id: Uuid,
159    pub email: Option<String>,
160    pub first_name: Option<String>,
161    pub last_name: Option<String>,
162    pub course_id: Uuid,
163    pub course_name: String,
164    pub course_module_name: Option<String>,
165    pub uh_course_code: Option<String>,
166    pub completion_date: DateTime<Utc>,
167    pub pressed_at: DateTime<Utc>,
168    /// How they enrolled.
169    pub enrolment_route: CreditRegistrationEnrolmentRoute,
170    pub last_fetched_at: Option<DateTime<Utc>>,
171    /// The latest fetch that could send linking emails; the stuck rule counts from here.
172    pub last_mailing_fetch_started_at: Option<DateTime<Utc>>,
173    pub next_fetch_at: Option<DateTime<Utc>>,
174    /// On any course sharing the code since the press; not attributable to this student.
175    pub linking_emails_on_code_since_press: i64,
176    pub last_linking_email_on_code_at: Option<DateTime<Utc>>,
177    pub is_enrolment_list_empty: bool,
178    pub is_fetch_failing: bool,
179    /// Carries `student_number_stuck`; its actions live on Needs attention.
180    pub is_stuck: bool,
181    /// Counted in Needs attention: stuck, and neither dismissed nor explained by a blocking problem.
182    pub needs_attention: bool,
183}
184
185/// Students waiting for a student number since the cutoff, by what they have done. Mutually
186/// exclusive, uncapped, and equal to the overview's counts for the same step.
187#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
188pub struct AccountLinkingEngagementCounts {
189    pub pressed: i64,
190    /// Of `pressed`, those stuck.
191    pub pressed_stuck: i64,
192    pub visited: i64,
193    pub not_started: i64,
194}
195
196/// A link that can still be used, and how old it is.
197#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
198pub struct AccountLinkingUnusedLink {
199    pub id: Uuid,
200    pub course_id: Uuid,
201    pub course_name: String,
202    pub uh_course_code: Option<String>,
203    /// In full: support tells the recipients apart by it.
204    pub emailed_to: String,
205    pub claimed_at: DateTime<Utc>,
206    pub expires_at: DateTime<Utc>,
207}
208
209/// A student number linked by a linking email or by hand.
210#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
211pub struct AccountLinkingRecentLink {
212    pub user_id: Uuid,
213    pub first_name: Option<String>,
214    pub last_name: Option<String>,
215    pub email: Option<String>,
216    pub verified_at: DateTime<Utc>,
217    pub verified_via: StudentNumberVerificationMethod,
218    pub verified_from_course_id: Option<Uuid>,
219    pub verified_from_course_name: Option<String>,
220}
221
222/// One linking email, in the full list or the dashboard's recent ones.
223#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
224pub struct AccountLinkingEmailRow {
225    pub id: Uuid,
226    pub course_id: Uuid,
227    pub course_name: String,
228    /// In full: support finds a student's mail by it.
229    pub emailed_to: String,
230    pub student_number: String,
231    /// The name Sisu held for the student number when the mail went out.
232    pub sisu_full_name: Option<String>,
233    pub claimed_at: DateTime<Utc>,
234    /// When the `link-emails` phase handed it to the email worker.
235    pub queued_at: Option<DateTime<Utc>>,
236    /// `None` until queued.
237    pub send_status: Option<EmailSendStatusReport>,
238    pub last_error_message: Option<String>,
239    pub link_state: LinkingMailLinkState,
240    pub link_expires_at: Option<DateTime<Utc>>,
241    pub link_used_at: Option<DateTime<Utc>>,
242    pub claimed_by_user_id: Option<Uuid>,
243    pub claimed_by_email: Option<String>,
244}
245
246/// Filters and paging for the list of every linking email; see
247/// [`credit_registration_account_linking_emails::get_page`].
248#[derive(Debug, Deserialize)]
249pub struct ListAccountLinkingEmailsQuery {
250    page: Option<u32>,
251    limit: Option<u32>,
252    status: Option<LinkingEmailListStatus>,
253    address: Option<String>,
254}
255
256/// A student whose registration waits for a student number.
257#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
258pub struct AccountLinkingWaitingStudent {
259    pub credit_registration_id: Uuid,
260    pub user_id: Uuid,
261    pub email: Option<String>,
262    pub first_name: Option<String>,
263    pub last_name: Option<String>,
264    pub course_id: Uuid,
265    pub course_name: String,
266    pub course_module_name: Option<String>,
267    pub uh_course_code: Option<String>,
268    pub completion_date: DateTime<Utc>,
269    pub last_visited_at: Option<DateTime<Utc>>,
270    /// The latest check request the student made themselves.
271    pub last_check_requested_at: Option<DateTime<Utc>>,
272    /// Pressed means their "I have enrolled" press stands.
273    pub engagement: Engagement,
274}
275
276/// One mail attempt: the address it went to and what we can say about its delivery.
277#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
278pub struct AccountLinkingSendOutcome {
279    pub address: String,
280    pub send_status: EmailSendStatus,
281}
282
283/// A person mailed to the cap for one course whose number was never claimed.
284#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
285pub struct AccountLinkingStaleAddress {
286    pub student_number: String,
287    pub sisu_person_id: String,
288    pub course_id: Uuid,
289    pub course_name: String,
290    pub mail_count: i64,
291    pub first_sent_at: DateTime<Utc>,
292    pub last_sent_at: DateTime<Utc>,
293    /// In full, newest last, one per mail.
294    pub sends: Vec<AccountLinkingSendOutcome>,
295}
296
297/// A student number the study registry reported for an account that another live link kept us from
298/// linking. The existing link stays until someone acts.
299#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
300pub struct StudyRegistryStudentNumberConflict {
301    pub id: Uuid,
302    pub created_at: DateTime<Utc>,
303    pub user_id: Uuid,
304    pub user_email: Option<String>,
305    pub first_name: Option<String>,
306    pub last_name: Option<String>,
307    pub reported_student_number: String,
308    /// The course whose registration reported the number.
309    pub course_id: Uuid,
310    pub course_name: String,
311    /// The link in the way: the same account's link to another number, or another account's link to
312    /// the reported one.
313    pub conflicting_link_user_id: Uuid,
314    pub conflicting_link_user_email: Option<String>,
315    pub conflicting_link_student_number: String,
316    pub conflicting_link_verified_via: StudentNumberVerificationMethod,
317}
318
319#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
320pub struct VerifiedStudentNumberMethodTotal {
321    pub verified_via: StudentNumberVerificationMethod,
322    pub count: i64,
323}
324
325#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
326pub struct AccountLinkingStats {
327    /// When false, no linking mails are sent and resends are refused.
328    pub account_linking_enabled: bool,
329    /// Discovery mails only people who enrolled at or after this.
330    pub account_linking_since: Option<DateTime<Utc>>,
331    pub window_secs: i64,
332    pub funnel: AccountLinkingFunnel,
333    pub send_status_totals: AccountLinkingSendStatusTotals,
334    pub hard_failure_domains: Vec<AccountLinkingFailureDomain>,
335    pub course_codes: Vec<AccountLinkingCourseCode>,
336    /// Newest first, capped.
337    pub recent_linking_emails: Vec<AccountLinkingEmailRow>,
338    pub stale_addresses: Vec<AccountLinkingStaleAddress>,
339    pub links_total_by_method: Vec<VerifiedStudentNumberMethodTotal>,
340    pub links_in_window_by_method: Vec<VerifiedStudentNumberMethodTotal>,
341    /// Accounts with an eligible completion still waiting for a student number.
342    pub waiting_for_student_number_count: i64,
343    /// Of those, the ones completed since `account_linking_since`, longest waiting first, capped.
344    pub waiting_students: Vec<AccountLinkingWaitingStudent>,
345    /// `waiting_students` before the cap.
346    pub waiting_students_total: i64,
347    pub max_mails_per_person_and_course: i64,
348    pub quiet_period_secs: i64,
349    /// Newest first, capped.
350    pub study_registry_conflicts: Vec<StudyRegistryStudentNumberConflict>,
351    /// Enrolment discovery and link-emails, the processing phases a linking email goes out
352    /// through, for the health banner.
353    pub processing_phases: Vec<CreditRegistrationPhaseStatus>,
354    /// Every presser waiting for a student number since the cutoff, stuck first, then longest
355    /// waiting.
356    pub pressers: Vec<AccountLinkingPresser>,
357    pub engagement_counts: AccountLinkingEngagementCounts,
358    /// Oldest first, capped.
359    pub unused_links: Vec<AccountLinkingUnusedLink>,
360    /// Newest first, capped; study registry links only as `links_total_by_method`.
361    pub recent_links: Vec<AccountLinkingRecentLink>,
362}
363
364#[derive(Debug, Deserialize)]
365pub struct AccountLinkingStatsQuery {
366    window_days: Option<u32>,
367}
368
369#[derive(Debug, Deserialize, ToSchema)]
370pub struct AdminRequestEnrolmentListFetchPayload {
371    pub course_code: String,
372}
373
374#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
375pub struct AdminRequestEnrolmentListFetchResult {
376    /// Ignoring the failure backoff, as on the Linking tab.
377    pub next_fetch_at: DateTime<Utc>,
378}
379
380#[derive(Debug, Deserialize, ToSchema)]
381pub struct AdminDismissStudyRegistryConflictPayload {
382    pub reason: String,
383}
384
385#[derive(Debug, Deserialize, ToSchema)]
386pub struct AdminResendAccountLinkingEmailPayload {
387    #[schema(value_type = String)]
388    pub student_number: SecretString,
389    pub course_id: Uuid,
390    /// Retires the mails a cap is counting, then runs the ordinary send path. Requires a reason.
391    pub override_rate_caps: bool,
392    pub reason: Option<String>,
393    /// The registration whose student the mail is a guess for, from the linking candidates. Must be
394    /// on `course_id`; recorded on the audit row.
395    pub credit_registration_id: Option<Uuid>,
396}
397
398#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
399pub struct AdminResendAccountLinkingEmailResult {
400    pub outcome: ResendOutcome,
401    /// Mails retired to get past a cap. Always zero without an override.
402    pub retired_mail_count: i64,
403    pub linking_emails: Vec<AdminLinkingEmail>,
404    pub mails_sent_for_this_course: i64,
405    pub max_mails_per_person_and_course: i64,
406    pub quiet_period_secs: i64,
407    /// The send this attempt made, as the dialogs list it. `None` unless `outcome` is `queued`.
408    pub manual_send: Option<AdminManualLinkingEmailSend>,
409}
410
411/// A person on a course's enrolment list whom no account is linked to, whom support can send a
412/// linking email.
413#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
414pub struct AdminLinkingCandidate {
415    /// Echoed back to the resend endpoint to mail them.
416    pub student_number: String,
417    pub first_names: Option<String>,
418    pub last_name: Option<String>,
419    /// In full: support has to tell the candidates apart before mailing one of them.
420    pub email: Option<String>,
421    /// `None` when the study registry gave no enrolment time.
422    pub enrolled_at: Option<DateTime<Utc>>,
423    /// Linking emails for the course that count toward its cap: replaced ones included, those a
424    /// rate cap override retired not.
425    pub linking_emails_for_course: i64,
426    /// Every linking email someone sent by hand for the course, newest first. Unlike
427    /// `linking_emails_for_course`, keeps those a rate cap override has since retired.
428    pub manual_sends: Vec<AdminManualLinkingEmailSend>,
429    /// What they share with the waiting student's account, by which the linking candidates are
430    /// ordered. Always empty in [`AdminUnlinkedEnrolees`].
431    pub similarities: Vec<LinkingCandidateSimilarity>,
432}
433
434#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
435pub struct AdminLinkingCandidates {
436    pub course_id: Uuid,
437    pub course_name: String,
438    pub course_code: String,
439    pub account_linking_since: DateTime<Utc>,
440    /// The study registry gave no usable answer, so an empty `candidates` says nothing.
441    pub study_registry_unavailable: bool,
442    /// Most like the student's account first.
443    pub candidates: Vec<AdminLinkingCandidate>,
444}
445
446/// A course support can send a linking email for by hand: one taking part in credit registration
447/// with a University of Helsinki course code.
448#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
449pub struct AdminLinkingCourse {
450    pub course_id: Uuid,
451    pub course_name: String,
452    /// Usually one; a course whose modules have different codes has each of them.
453    pub course_codes: Vec<String>,
454}
455
456/// Everyone on a course's enrolment lists whom no account is linked to, whenever they enrolled.
457#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
458pub struct AdminUnlinkedEnrolees {
459    /// The study registry gave no usable answer for some code, so `people` says nothing.
460    pub study_registry_unavailable: bool,
461    /// By last name, then first names, ignoring case; people with no last name last.
462    pub people: Vec<AdminLinkingCandidate>,
463}
464
465#[derive(Debug, Deserialize, ToSchema)]
466pub struct AdminResolveStudentNumberPayload {
467    #[schema(value_type = String)]
468    pub student_number: SecretString,
469}
470
471/// The preview a manual link is gated on. No addresses from the registry — `resolve-persons` answers
472/// with a name and an id only — so the addresses here are the ones we mailed.
473#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
474pub struct AdminResolveStudentNumberResult {
475    pub found: bool,
476    pub student_number: String,
477    /// Echoed back to the manual-link endpoint, which refuses without it.
478    pub sisu_person_id: Option<String>,
479    pub first_names: Option<String>,
480    pub last_name: Option<String>,
481    /// The registry's own per-item code, an identifier rather than prose.
482    pub code: Option<String>,
483    pub study_registry_unavailable: bool,
484    /// The registry's per-item code when it answered with an error other than `personNotFound`,
485    /// which leaves it unknown whether the number exists.
486    pub lookup_error_code: Option<String>,
487    pub already_linked_to_user_id: Option<Uuid>,
488    pub already_linked_to_user_email: Option<String>,
489    pub already_linked_via: Option<StudentNumberVerificationMethod>,
490    pub linking_emails: Vec<AdminLinkingEmail>,
491}
492
493#[derive(Debug, Deserialize, ToSchema)]
494pub struct AdminManuallyLinkStudentNumberPayload {
495    pub user_id: Uuid,
496    #[schema(value_type = String)]
497    pub student_number: SecretString,
498    /// From the preview. Re-resolved on arrival, and a mismatch is refused, so a typo cannot mint a
499    /// link to somebody else.
500    #[schema(value_type = String)]
501    pub sisu_person_id: SecretString,
502    pub reason: String,
503}
504
505#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
506#[serde(rename_all = "snake_case")]
507pub enum AdminManualLinkOutcome {
508    Linked,
509    /// The registry does not know the number.
510    StudentNumberNotFound,
511    /// The registry named a different person than the preview did.
512    PreviewMismatch,
513    /// The number is live on another account. Unlink that one first.
514    AlreadyLinkedToAnotherAccount,
515    AlreadyLinkedToThisAccount,
516    StudyRegistryUnavailable,
517    /// The registry answered with a code we do not know; the preview shows it.
518    UnexpectedStudyRegistryAnswer,
519}
520
521#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
522pub struct AdminManuallyLinkStudentNumberResult {
523    pub outcome: AdminManualLinkOutcome,
524    pub verified_student_number_id: Option<Uuid>,
525    /// Registrations the link unblocked.
526    pub affected_registration_count: i64,
527}
528
529/**
530GET `/api/v0/main-frontend/credit-registration-admin/account-linking` - The linking funnel, each
531course code's enrolment list, the send-status totals, the recent linking emails, who is waiting for
532a student number and the stale-address list.
533*/
534#[instrument(skip(pool, app_conf))]
535#[utoipa::path(
536    get,
537    path = "/account-linking",
538    operation_id = "getAccountLinkingStats",
539    tag = "credit-registration-admin",
540    params(("window_days" = Option<u32>, Query, description = "Window for the windowed funnel steps, in days")),
541    responses(
542        (status = 200, description = "Where account linking stands", body = AccountLinkingStats)
543    )
544)]
545pub async fn get_account_linking_stats(
546    user: AuthUser,
547    pool: web::Data<PgPool>,
548    query: web::Query<AccountLinkingStatsQuery>,
549    app_conf: web::Data<ApplicationConfiguration>,
550) -> ControllerResult<web::Json<AccountLinkingStats>> {
551    let mut conn = pool.acquire().await?;
552    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
553
554    let window_days = i64::from(query.window_days.unwrap_or(30).clamp(1, 365));
555    let window_secs = window_days * 24 * 60 * 60;
556    let since = Utc::now() - chrono::Duration::days(window_days);
557
558    let account_linking_since = app_conf.suotar_configuration.account_linking_since;
559    let presser_rows =
560        credit_registrations::get_waiting_pressers(&mut conn, account_linking_since, None).await?;
561    let unused_by_code =
562        credit_registration_account_linking_emails::count_unused_links_by_course_code(&mut conn)
563            .await?;
564    let course_codes = build_course_codes(
565        &mut conn,
566        account_linking_since,
567        &presser_rows,
568        &unused_by_code,
569    )
570    .await?;
571    let sum = |pick: fn(&AccountLinkingCodeCounters) -> i32| -> i64 {
572        course_codes
573            .iter()
574            .filter_map(|code| code.linking.as_ref())
575            .map(|counters| i64::from(pick(counters)))
576            .sum::<i64>()
577    };
578
579    let now = Utc::now();
580    let totals = credit_registration_account_linking_emails::get_send_status_totals_since(
581        &mut conn, since, now,
582    )
583    .await?;
584    let send_status_totals = AccountLinkingSendStatusTotals {
585        waiting_for_link_emails: totals.waiting_for_link_emails,
586        waiting_for_email_worker: totals.waiting_for_email_worker,
587        retrying: totals.retrying,
588        sent: totals.sent,
589        send_failed: totals.send_failed,
590        used: totals.used,
591    };
592    let hard_failure_domains: Vec<AccountLinkingFailureDomain> =
593        credit_registration_account_linking_emails::get_send_failure_domains_since(
594            &mut conn, since, now,
595        )
596        .await?
597        .into_iter()
598        .map(|row| AccountLinkingFailureDomain {
599            domain: row.domain,
600            count: row.count,
601        })
602        .collect();
603
604    let method_counts = verified_student_numbers::count_by_method_since(&mut conn, since).await?;
605    let links_total_by_method = method_counts
606        .iter()
607        .map(
608            |&(verified_via, total, _)| VerifiedStudentNumberMethodTotal {
609                verified_via,
610                count: total,
611            },
612        )
613        .collect::<Vec<_>>();
614    let links_in_window_by_method = method_counts
615        .iter()
616        .map(
617            |&(verified_via, _, in_window)| VerifiedStudentNumberMethodTotal {
618                verified_via,
619                count: in_window,
620            },
621        )
622        .collect::<Vec<_>>();
623    let in_window = |method: StudentNumberVerificationMethod| -> i64 {
624        links_in_window_by_method
625            .iter()
626            .filter(|row| row.verified_via == method)
627            .map(|row| row.count)
628            .sum()
629    };
630
631    let stale = credit_registration_account_linking_emails::get_stale_unclaimed(
632        &mut conn,
633        MAX_LINKING_MAILS_PER_PERSON_AND_COURSE,
634        STALE_UNCLAIMED_LIMIT,
635    )
636    .await?;
637    let stale_addresses = build_stale_addresses(&mut conn, stale).await?;
638
639    let waiting_for_student_number_count = credit_registrations::count_pending_by_reason(&mut conn)
640        .await?
641        .student_number_count;
642    let (waiting, waiting_students_total) = credit_registrations::get_waiting_for_student_number(
643        &mut conn,
644        account_linking_since,
645        WAITING_STUDENT_LIMIT,
646    )
647    .await?;
648    let waiting_students = waiting
649        .into_iter()
650        .map(|row| AccountLinkingWaitingStudent {
651            credit_registration_id: row.credit_registration_id,
652            user_id: row.user_id,
653            email: row.email,
654            first_name: row.first_name,
655            last_name: row.last_name,
656            course_id: row.course_id,
657            course_name: row.course_name,
658            course_module_name: row.course_module_name,
659            uh_course_code: row.uh_course_code,
660            completion_date: row.completion_date,
661            engagement: Engagement::of(row.pressed_at, row.last_visited_at),
662            last_visited_at: row.last_visited_at,
663            last_check_requested_at: row.last_check_requested_at,
664        })
665        .collect();
666    let recent_linking_emails = credit_registration_account_linking_emails::get_page(
667        &mut conn,
668        None,
669        None,
670        RECENT_LINKING_EMAIL_LIMIT,
671        0,
672    )
673    .await?
674    .into_iter()
675    .map(to_linking_email_row)
676    .collect();
677
678    let funnel = AccountLinkingFunnel {
679        persons_discovered_last_run: sum(|row| row.listed_person_count),
680        already_linked_last_run: sum(|row| row.already_linked_count),
681        mails_claimed_in_window: totals.mails_in_window,
682        mails_sent_in_window: send_status_totals.sent,
683        numbers_claimed_in_window: in_window(StudentNumberVerificationMethod::EmailedLink),
684        manual_links_in_window: in_window(StudentNumberVerificationMethod::AdminManual),
685        suppressed_by_dedup_last_run: sum(|row| row.suppressed_by_dedup_count),
686        suppressed_by_rate_cap_last_run: sum(|row| row.suppressed_by_rate_cap_count),
687        no_address_in_study_registry_last_run: sum(|row| row.no_address_count),
688    };
689
690    let study_registry_conflicts = study_registry_student_number_conflicts::get_unresolved(
691        &mut conn,
692        STUDY_REGISTRY_CONFLICT_LIMIT,
693    )
694    .await?
695    .into_iter()
696    .map(|row| StudyRegistryStudentNumberConflict {
697        id: row.id,
698        created_at: row.created_at,
699        user_id: row.user_id,
700        user_email: row.user_email,
701        first_name: row.first_name,
702        last_name: row.last_name,
703        reported_student_number: row.reported_student_number.expose_secret().to_owned(),
704        course_id: row.course_id,
705        course_name: row.course_name,
706        conflicting_link_user_id: row.conflicting_link_user_id,
707        conflicting_link_user_email: row.conflicting_link_user_email,
708        conflicting_link_student_number: row
709            .conflicting_link_student_number
710            .expose_secret()
711            .to_owned(),
712        conflicting_link_verified_via: row.conflicting_link_verified_via,
713    })
714    .collect();
715
716    let rules = attention_rules(&mut conn, &app_conf).await?;
717    let presser_ids: Vec<Uuid> = presser_rows
718        .iter()
719        .map(|row| row.credit_registration_id)
720        .collect();
721    let attention =
722        credit_registrations::get_attention_items(&mut conn, &rules, Some(&presser_ids)).await?;
723    let needs_attention_by_stuck_id: HashMap<Uuid, bool> = attention
724        .iter()
725        .filter(|row| row.reasons.contains(&AttentionReason::StudentNumberStuck))
726        .map(|row| {
727            (
728                row.id,
729                row.standing(&rules.blocking) == AttentionStanding::NeedsAttention,
730            )
731        })
732        .collect();
733    let mut pressers: Vec<AccountLinkingPresser> = presser_rows
734        .into_iter()
735        .map(|row| {
736            let code = row
737                .uh_course_code
738                .as_deref()
739                .and_then(|code| course_codes.iter().find(|known| known.course_code == code));
740            let stuck_needs_attention = needs_attention_by_stuck_id
741                .get(&row.credit_registration_id)
742                .copied();
743            AccountLinkingPresser {
744                credit_registration_id: row.credit_registration_id,
745                user_id: row.user_id,
746                email: row.email,
747                first_name: row.first_name,
748                last_name: row.last_name,
749                course_id: row.course_id,
750                course_name: row.course_name,
751                course_module_name: row.course_module_name,
752                uh_course_code: row.uh_course_code.clone(),
753                completion_date: row.completion_date,
754                pressed_at: row.pressed_at,
755                enrolment_route: row.enrolment_route,
756                last_fetched_at: code.and_then(|code| code.last_fetched_at),
757                last_mailing_fetch_started_at: code
758                    .and_then(|code| code.last_mailing_fetch_started_at),
759                next_fetch_at: code.map(|code| code.next_fetch_at),
760                linking_emails_on_code_since_press: row.linking_emails_on_code_since_press,
761                last_linking_email_on_code_at: row.last_linking_email_on_code_at,
762                is_enrolment_list_empty: code.is_some_and(|code| code.is_enrolment_list_empty),
763                is_fetch_failing: code.is_some_and(|code| code.is_fetch_failing),
764                is_stuck: stuck_needs_attention.is_some(),
765                needs_attention: stuck_needs_attention.unwrap_or(false),
766            }
767        })
768        .collect();
769    pressers.sort_by_key(|presser| (!presser.is_stuck, presser.pressed_at));
770
771    let mut engagement_counts = AccountLinkingEngagementCounts {
772        pressed: 0,
773        pressed_stuck: pressers.iter().filter(|presser| presser.is_stuck).count() as i64,
774        visited: 0,
775        not_started: 0,
776    };
777    for row in credit_registrations::count_by_step_and_engagement(&mut conn, account_linking_since)
778        .await?
779        .into_iter()
780        .filter(|row| row.step == TimelineStep::WaitingForStudentNumber)
781    {
782        match row.engagement {
783            Some(Engagement::Pressed) => engagement_counts.pressed += row.count,
784            Some(Engagement::Visited) => engagement_counts.visited += row.count,
785            Some(Engagement::NotStarted) => engagement_counts.not_started += row.count,
786            None => {}
787        }
788    }
789
790    let now = Utc::now();
791    let processing_phases = credit_registration_phase_state::get_all(&mut conn)
792        .await?
793        .into_iter()
794        .filter(|row| {
795            [
796                CreditRegistrationPhase::EnrolmentDiscovery,
797                CreditRegistrationPhase::LinkEmails,
798            ]
799            .iter()
800            .any(|phase| phase.as_str() == row.phase)
801        })
802        .map(|row| to_phase_status(row, now))
803        .collect();
804    let unused_links =
805        credit_registration_account_linking_emails::get_unused_links(&mut conn, UNUSED_LINK_LIMIT)
806            .await?
807            .into_iter()
808            .map(|row| AccountLinkingUnusedLink {
809                id: row.id,
810                course_id: row.course_id,
811                course_name: row.course_name,
812                uh_course_code: row.uh_course_code,
813                emailed_to: row.emailed_to.expose_secret().to_owned(),
814                claimed_at: row.claimed_at,
815                expires_at: row.expires_at,
816            })
817            .collect();
818    let recent_links =
819        verified_student_numbers::get_recent_linked_by_email_or_hand(&mut conn, RECENT_LINK_LIMIT)
820            .await?
821            .into_iter()
822            .map(|row| AccountLinkingRecentLink {
823                user_id: row.user_id,
824                first_name: row.first_name,
825                last_name: row.last_name,
826                email: row.email,
827                verified_at: row.verified_at,
828                verified_via: row.verified_via,
829                verified_from_course_id: row.verified_from_course_id,
830                verified_from_course_name: row.verified_from_course_name,
831            })
832            .collect();
833
834    token.authorized_ok(web::Json(AccountLinkingStats {
835        processing_phases,
836        pressers,
837        engagement_counts,
838        unused_links,
839        recent_links,
840        account_linking_enabled: app_conf.suotar_configuration.is_account_linking_enabled(),
841        account_linking_since,
842        window_secs,
843        funnel,
844        send_status_totals,
845        hard_failure_domains,
846        course_codes,
847        recent_linking_emails,
848        stale_addresses,
849        links_total_by_method,
850        links_in_window_by_method,
851        waiting_for_student_number_count,
852        waiting_students,
853        waiting_students_total,
854        max_mails_per_person_and_course: MAX_LINKING_MAILS_PER_PERSON_AND_COURSE,
855        quiet_period_secs: LINKING_MAIL_QUIET_PERIOD.num_seconds(),
856        study_registry_conflicts,
857    }))
858}
859
860/**
861POST `/api/v0/main-frontend/credit-registration-admin/account-linking/fetch-enrolment-list` - Makes
862one course code's enrolment list due at its next grid point.
863
864The fetch still waits for the rate limiter and any failure backoff, like any other.
865*/
866#[instrument(skip(pool, payload, app_conf))]
867#[utoipa::path(
868    post,
869    path = "/account-linking/fetch-enrolment-list",
870    operation_id = "adminRequestEnrolmentListFetch",
871    tag = "credit-registration-admin",
872    request_body = AdminRequestEnrolmentListFetchPayload,
873    responses(
874        (status = 200, description = "When the list is now due", body = AdminRequestEnrolmentListFetchResult),
875        (status = 404, description = "No active module has the course code")
876    )
877)]
878pub async fn admin_request_enrolment_list_fetch(
879    user: AuthUser,
880    pool: web::Data<PgPool>,
881    payload: web::Json<AdminRequestEnrolmentListFetchPayload>,
882    app_conf: web::Data<ApplicationConfiguration>,
883) -> ControllerResult<web::Json<AdminRequestEnrolmentListFetchResult>> {
884    let mut conn = pool.acquire().await?;
885    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
886
887    let course_code = payload.course_code.trim();
888    let mut schedule = credit_registration_roster_schedules::get_schedules(
889        &mut conn,
890        None,
891        ScheduleSelection::Code(course_code),
892        app_conf.suotar_configuration.account_linking_since,
893    )
894    .await?
895    .into_iter()
896    .next()
897    .ok_or_else(|| {
898        controller_err!(
899            NotFound,
900            "No module with credit registration has this course code.".to_string()
901        )
902    })?;
903
904    let mut tx = conn.begin().await?;
905    let schedule_id = credit_registration_roster_schedules::request_fetch(&mut tx, course_code)
906        .await?
907        .ok_or_else(|| controller_err!(NotFound, "No such course code.".to_string()))?;
908    models::credit_registration_admin_actions::record(
909        &mut tx,
910        &NewCreditRegistrationAdminAction {
911            target_id: Some(schedule_id),
912            details: Some(serde_json::json!({ "course_code": course_code })),
913            ..NewCreditRegistrationAdminAction::new(
914                CreditRegistrationAdminAction::RequestEnrolmentListFetch,
915                CreditRegistrationAdminActionTarget::RosterSchedule,
916                user.id,
917                GLOBAL_ADMIN_ROLE,
918            )
919        },
920    )
921    .await?;
922    tx.commit().await?;
923    info!(actor = %user.id, course_code, "Admin requested an enrolment list fetch");
924
925    let now = Utc::now();
926    schedule.fetch_requested_at = Some(now);
927    token.authorized_ok(web::Json(AdminRequestEnrolmentListFetchResult {
928        next_fetch_at: schedule.next_fetch_at(now),
929    }))
930}
931
932/**
933POST `/api/v0/main-frontend/credit-registration-admin/account-linking/study-registry-conflicts/{conflict_id}/dismiss` -
934Takes a student number clash off the list for good.
935
936The links stay as they are. A reason is required, so the request carries a body.
937*/
938#[instrument(skip(pool, payload))]
939#[utoipa::path(
940    post,
941    path = "/account-linking/study-registry-conflicts/{conflict_id}/dismiss",
942    operation_id = "adminDismissStudyRegistryConflict",
943    tag = "credit-registration-admin",
944    params(("conflict_id" = Uuid, Path, description = "The clash's id")),
945    request_body = AdminDismissStudyRegistryConflictPayload,
946    responses(
947        (status = 200, description = "Dismissed"),
948        (status = 422, description = "No reason given"),
949        (status = 404, description = "No such clash, or already dismissed")
950    )
951)]
952pub async fn admin_dismiss_study_registry_conflict(
953    user: AuthUser,
954    pool: web::Data<PgPool>,
955    conflict_id: web::Path<Uuid>,
956    payload: web::Json<AdminDismissStudyRegistryConflictPayload>,
957) -> ControllerResult<web::Json<()>> {
958    let mut conn = pool.acquire().await?;
959    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
960
961    let reason = required_reason(&payload.reason)?;
962    let id = *conflict_id;
963    let mut tx = conn.begin().await?;
964    if !study_registry_student_number_conflicts::dismiss(&mut tx, id).await? {
965        return Err(controller_err!(
966            NotFound,
967            "No such student number clash.".to_string()
968        ));
969    }
970    models::credit_registration_admin_actions::record(
971        &mut tx,
972        &NewCreditRegistrationAdminAction {
973            target_id: Some(id),
974            reason: Some(reason.to_string()),
975            ..NewCreditRegistrationAdminAction::new(
976                CreditRegistrationAdminAction::DismissStudyRegistryConflict,
977                CreditRegistrationAdminActionTarget::StudyRegistryStudentNumberConflict,
978                user.id,
979                GLOBAL_ADMIN_ROLE,
980            )
981        },
982    )
983    .await?;
984    tx.commit().await?;
985    info!(actor = %user.id, conflict_id = %id, "Admin dismissed a student number clash");
986
987    token.authorized_ok(web::Json(()))
988}
989
990/**
991POST `/api/v0/main-frontend/credit-registration-admin/account-linking/resend` - Sets off another
992account-linking mail for one person on one course.
993
994The mail goes to the addresses the study registry holds, and the recipient still has to open the link
995while signed in, so the ownership proof is intact. An override does not ask a cap for an exemption: it
996retires the ledger rows the cap is counting, as its own audited action, then runs the ordinary path.
997*/
998#[instrument(skip(pool, payload, app_conf, suotar_client))]
999#[utoipa::path(
1000    post,
1001    path = "/account-linking/resend",
1002    operation_id = "adminResendAccountLinkingEmail",
1003    tag = "credit-registration-admin",
1004    request_body = AdminResendAccountLinkingEmailPayload,
1005    responses(
1006        (status = 200, description = "What the attempt did", body = AdminResendAccountLinkingEmailResult),
1007        (status = 422, description = "An override without a reason")
1008    )
1009)]
1010pub async fn admin_resend_account_linking_email(
1011    user: AuthUser,
1012    pool: web::Data<PgPool>,
1013    payload: web::Json<AdminResendAccountLinkingEmailPayload>,
1014    app_conf: web::Data<ApplicationConfiguration>,
1015    suotar_client: web::Data<headless_lms_utils::services::suotar::SuotarClient>,
1016) -> ControllerResult<web::Json<AdminResendAccountLinkingEmailResult>> {
1017    let mut conn = pool.acquire().await?;
1018    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
1019    ensure_resend_possible(&mut conn, &app_conf, payload.course_id).await?;
1020
1021    let student_number = required_student_number(&payload.student_number)?;
1022    let override_reason = if payload.override_rate_caps {
1023        Some(required_reason(payload.reason.as_deref().unwrap_or(""))?.to_string())
1024    } else {
1025        None
1026    };
1027
1028    if let Some(credit_registration_id) = payload.credit_registration_id
1029        && credit_registrations::get_by_id(&mut conn, credit_registration_id)
1030            .await?
1031            .course_id
1032            != payload.course_id
1033    {
1034        return Err(controller_err!(
1035            BadRequest,
1036            "The registration is on another course.".to_string()
1037        ));
1038    }
1039
1040    let ctx = ManualActionContext::new(&pool, &suotar_client, RESEND_CALLER);
1041    // Released so the Suotar call does not pin a pool connection for its whole timeout.
1042    drop(conn);
1043    info!(
1044        actor = %user.id,
1045        course_id = %payload.course_id,
1046        override_rate_caps = payload.override_rate_caps,
1047        "Admin requested a linking mail resend"
1048    );
1049    let rate_cap_override = override_reason.as_deref().map(|reason| RateCapOverride {
1050        actor_user_id: user.id,
1051        actor_role: GLOBAL_ADMIN_ROLE,
1052        reason,
1053    });
1054    let attempt =
1055        resend_linking_mail_for_target(&ctx, payload.course_id, &student_number, rate_cap_override)
1056            .await?;
1057    let mut conn = pool.acquire().await?;
1058    let outcome = ResendOutcome::from(attempt.outcome);
1059    info!(
1060        ?outcome,
1061        retired_mail_count = attempt.retired_mail_count,
1062        "Admin linking mail resend finished"
1063    );
1064
1065    finish_resend(
1066        &mut conn,
1067        &user,
1068        &payload,
1069        &student_number,
1070        outcome,
1071        &attempt,
1072        token,
1073    )
1074    .await
1075}
1076
1077/**
1078GET `/api/v0/main-frontend/credit-registration-admin/registrations/{credit_registration_id}/linking-candidates`
1079- Who on the code's enrolment list a student stuck waiting for a student number may be.
1080
1081Lists the roster live and keeps the people no account is linked to who enrolled before account
1082linking began, whom no linking email went to. Ranked by resemblance to the student's account, which
1083is only a hint. Stores nothing beyond the call log row every study registry call writes.
1084*/
1085#[instrument(skip(pool, app_conf, suotar_client))]
1086#[utoipa::path(
1087    get,
1088    path = "/registrations/{credit_registration_id}/linking-candidates",
1089    operation_id = "getCreditRegistrationLinkingCandidates",
1090    tag = "credit-registration-admin",
1091    params(("credit_registration_id" = Uuid, Path, description = "Credit registration id")),
1092    responses(
1093        (status = 200, description = "The candidates", body = AdminLinkingCandidates),
1094        (status = 400, description = "Account linking is off, or the row is not stuck waiting for a student number")
1095    )
1096)]
1097pub async fn get_credit_registration_linking_candidates(
1098    user: AuthUser,
1099    pool: web::Data<PgPool>,
1100    credit_registration_id: web::Path<Uuid>,
1101    app_conf: web::Data<ApplicationConfiguration>,
1102    suotar_client: web::Data<headless_lms_utils::services::suotar::SuotarClient>,
1103) -> ControllerResult<web::Json<AdminLinkingCandidates>> {
1104    let mut conn = pool.acquire().await?;
1105    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
1106
1107    let Some(since) = app_conf.suotar_configuration.account_linking_since else {
1108        return Err(controller_err!(
1109            BadRequest,
1110            "Account linking is switched off.".to_string()
1111        ));
1112    };
1113    let rules = attention_rules(&mut conn, &app_conf).await?;
1114    let row = credit_registrations::get_attention_items(
1115        &mut conn,
1116        &rules,
1117        Some(&[*credit_registration_id]),
1118    )
1119    .await?
1120    .into_iter()
1121    .next()
1122    .filter(|row| row.reasons.contains(&AttentionReason::StudentNumberStuck));
1123    let Some((row, course_code)) =
1124        row.and_then(|row| row.uh_course_code.clone().map(|code| (row, code)))
1125    else {
1126        return Err(controller_err!(
1127            BadRequest,
1128            "This registration is not stuck waiting for a student number.".to_string()
1129        ));
1130    };
1131
1132    let ctx = ManualActionContext::new(&pool, &suotar_client, CANDIDATES_CALLER);
1133    // Released so the Suotar call does not pin a pool connection for its whole timeout.
1134    drop(conn);
1135    info!(actor = %user.id, credit_registration_id = %row.id, "Admin listing linking candidates");
1136    let people = list_unlinked_enrolled_before(&ctx, &course_code, since).await?;
1137    let mut conn = pool.acquire().await?;
1138
1139    let study_registry_unavailable = people.is_none();
1140    let people = people.unwrap_or_default();
1141    let history = linking_email_history(&mut conn, &people, row.course_id).await?;
1142
1143    let account = AccountIdentity {
1144        first_name: row.first_name.as_deref(),
1145        last_name: row.last_name.as_deref(),
1146        email: row.email.as_deref(),
1147    };
1148    let mut ranked: Vec<(u32, AdminLinkingCandidate)> = people
1149        .iter()
1150        .map(|person| {
1151            let found = similarities(&account, person);
1152            let score = similarity_score(&found);
1153            (score, to_linking_candidate(person, &history, found))
1154        })
1155        .collect();
1156    ranked.sort_by(|(a_score, a), (b_score, b)| b_score.cmp(a_score).then_with(|| by_name(a, b)));
1157
1158    token.authorized_ok(web::Json(AdminLinkingCandidates {
1159        course_id: row.course_id,
1160        course_name: row.course_name,
1161        course_code,
1162        account_linking_since: since,
1163        study_registry_unavailable,
1164        candidates: ranked.into_iter().map(|(_, candidate)| candidate).collect(),
1165    }))
1166}
1167
1168/**
1169GET `/api/v0/main-frontend/credit-registration-admin/account-linking/courses` - The courses support
1170can send a linking email for by hand.
1171*/
1172#[instrument(skip(pool))]
1173#[utoipa::path(
1174    get,
1175    path = "/account-linking/courses",
1176    operation_id = "getAccountLinkingCourses",
1177    tag = "credit-registration-admin",
1178    responses(
1179        (status = 200, description = "By course name", body = Vec<AdminLinkingCourse>)
1180    )
1181)]
1182pub async fn get_account_linking_courses(
1183    user: AuthUser,
1184    pool: web::Data<PgPool>,
1185) -> ControllerResult<web::Json<Vec<AdminLinkingCourse>>> {
1186    let mut conn = pool.acquire().await?;
1187    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
1188
1189    let mut courses: IndexMap<Uuid, AdminLinkingCourse> = IndexMap::new();
1190    let mut seen_codes: HashSet<(Uuid, String)> = HashSet::new();
1191    for row in course_module_suotar_configurations::get_active_discovery_reports(&mut conn).await? {
1192        let Some(code) = row
1193            .uh_course_code
1194            .as_deref()
1195            .map(str::trim)
1196            .filter(|code| !code.is_empty())
1197        else {
1198            continue;
1199        };
1200        let course = courses
1201            .entry(row.course_id)
1202            .or_insert_with(|| AdminLinkingCourse {
1203                course_id: row.course_id,
1204                course_name: row.course_name.clone(),
1205                course_codes: Vec::new(),
1206            });
1207        if seen_codes.insert((row.course_id, code.to_string())) {
1208            course.course_codes.push(code.to_string());
1209        }
1210    }
1211    let courses: Vec<AdminLinkingCourse> = courses.into_values().collect();
1212
1213    token.authorized_ok(web::Json(courses))
1214}
1215
1216/**
1217GET `/api/v0/main-frontend/credit-registration-admin/account-linking/courses/{course_id}/unlinked-enrolees`
1218- Everyone on the course's enrolment lists whom no account is linked to, for sending one of them a
1219linking email by hand.
1220
1221Lists the rosters live. Stores nothing beyond the call log row every study registry call writes.
1222*/
1223#[instrument(skip(pool, app_conf, suotar_client))]
1224#[utoipa::path(
1225    get,
1226    path = "/account-linking/courses/{course_id}/unlinked-enrolees",
1227    operation_id = "getAccountLinkingUnlinkedEnrolees",
1228    tag = "credit-registration-admin",
1229    params(("course_id" = Uuid, Path, description = "Course id")),
1230    responses(
1231        (status = 200, description = "The unlinked people", body = AdminUnlinkedEnrolees),
1232        (status = 400, description = "Account linking is off, or the course takes no part in credit registration")
1233    )
1234)]
1235pub async fn get_account_linking_unlinked_enrolees(
1236    user: AuthUser,
1237    pool: web::Data<PgPool>,
1238    course_id: web::Path<Uuid>,
1239    app_conf: web::Data<ApplicationConfiguration>,
1240    suotar_client: web::Data<headless_lms_utils::services::suotar::SuotarClient>,
1241) -> ControllerResult<web::Json<AdminUnlinkedEnrolees>> {
1242    let mut conn = pool.acquire().await?;
1243    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
1244    let course_id = course_id.into_inner();
1245    ensure_resend_possible(&mut conn, &app_conf, course_id).await?;
1246
1247    let ctx = ManualActionContext::new(&pool, &suotar_client, UNLINKED_ENROLEES_CALLER);
1248    // Released so the Suotar call does not pin a pool connection for its whole timeout.
1249    drop(conn);
1250    info!(actor = %user.id, course_id = %course_id, "Admin listing unlinked enrolees");
1251    let people = list_unlinked_on_course_rosters(&ctx, course_id).await?;
1252    let mut conn = pool.acquire().await?;
1253
1254    let study_registry_unavailable = people.is_none();
1255    let people = people.unwrap_or_default();
1256    let history = linking_email_history(&mut conn, &people, course_id).await?;
1257    let mut people: Vec<AdminLinkingCandidate> = people
1258        .iter()
1259        .map(|person| to_linking_candidate(person, &history, Vec::new()))
1260        .collect();
1261    people.sort_by(by_name);
1262
1263    token.authorized_ok(web::Json(AdminUnlinkedEnrolees {
1264        study_registry_unavailable,
1265        people,
1266    }))
1267}
1268
1269/**
1270POST `/api/v0/main-frontend/credit-registration-admin/account-linking/resolve-person` - Looks one
1271student number up in the study registry without changing anything.
1272
1273The preview a manual link is gated on. Writes nothing but the call log row every study registry call
1274writes.
1275*/
1276#[instrument(skip(pool, payload, suotar_client))]
1277#[utoipa::path(
1278    post,
1279    path = "/account-linking/resolve-person",
1280    operation_id = "adminResolveStudentNumberForLinking",
1281    tag = "credit-registration-admin",
1282    request_body = AdminResolveStudentNumberPayload,
1283    responses(
1284        (status = 200, description = "Who the study registry says the number belongs to", body = AdminResolveStudentNumberResult),
1285        (status = 422, description = "No student number given")
1286    )
1287)]
1288pub async fn admin_resolve_student_number_for_linking(
1289    user: AuthUser,
1290    pool: web::Data<PgPool>,
1291    payload: web::Json<AdminResolveStudentNumberPayload>,
1292    suotar_client: web::Data<headless_lms_utils::services::suotar::SuotarClient>,
1293) -> ControllerResult<web::Json<AdminResolveStudentNumberResult>> {
1294    let mut conn = pool.acquire().await?;
1295    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
1296
1297    let student_number = typed_student_number(&payload.student_number)?;
1298
1299    let ctx = ManualActionContext::new(&pool, &suotar_client, RESOLVE_CALLER);
1300    // Released so the Suotar call does not pin a pool connection for its whole timeout.
1301    drop(conn);
1302    info!(actor = %user.id, "Admin resolving a student number for linking");
1303    let resolved = look_up_person(&ctx, &student_number).await;
1304    debug!(
1305        found = resolved.as_ref().is_ok_and(Option::is_some),
1306        "Student number resolution result"
1307    );
1308    let mut conn = pool.acquire().await?;
1309    let existing =
1310        verified_student_numbers::get_by_student_number(&mut conn, student_number.expose_secret())
1311            .await?
1312            .or(match &resolved {
1313                Ok(Some(person)) => verified_student_numbers::get_by_sisu_person_ids(
1314                    &mut conn,
1315                    &[person.sisu_person_id.expose_secret().to_owned()],
1316                )
1317                .await?
1318                .into_iter()
1319                .next(),
1320                _ => None,
1321            });
1322    let already_linked_to_user_email = match &existing {
1323        Some(link) => {
1324            match models::user_details::get_user_details_by_user_id(&mut conn, link.user_id).await {
1325                Ok(details) => Some(details.email),
1326                Err(error)
1327                    if matches!(
1328                        error.error_type(),
1329                        models::ModelErrorType::RecordNotFound | models::ModelErrorType::NotFound
1330                    ) =>
1331                {
1332                    None
1333                }
1334                Err(error) => return Err(error.into()),
1335            }
1336        }
1337        None => None,
1338    };
1339
1340    let mails = match &resolved {
1341        Ok(Some(person)) => {
1342            credit_registration_account_linking_emails::get_by_sisu_person_id(
1343                &mut conn,
1344                person.sisu_person_id.expose_secret(),
1345            )
1346            .await?
1347        }
1348        _ => Vec::new(),
1349    };
1350    let linking_emails = build_linking_emails(&mut conn, mails).await?;
1351
1352    let shared = AdminResolveStudentNumberResult {
1353        found: false,
1354        student_number: student_number.expose_secret().to_owned(),
1355        sisu_person_id: None,
1356        first_names: None,
1357        last_name: None,
1358        code: None,
1359        study_registry_unavailable: false,
1360        lookup_error_code: None,
1361        already_linked_to_user_id: existing.as_ref().map(|link| link.user_id),
1362        already_linked_to_user_email,
1363        already_linked_via: existing.as_ref().map(|link| link.verified_via),
1364        linking_emails,
1365    };
1366    let result = match resolved {
1367        Ok(Some(person)) => AdminResolveStudentNumberResult {
1368            found: true,
1369            sisu_person_id: Some(person.sisu_person_id.expose_secret().to_owned()),
1370            first_names: expose_option(&person.first_names).map(str::to_owned),
1371            last_name: expose_option(&person.last_name).map(str::to_owned),
1372            code: Some(person.code),
1373            ..shared
1374        },
1375        Ok(None) => AdminResolveStudentNumberResult { ..shared },
1376        Err(PersonLookupError::UnexpectedAnswer { code }) => AdminResolveStudentNumberResult {
1377            lookup_error_code: Some(code),
1378            ..shared
1379        },
1380        Err(
1381            PersonLookupError::StudyRegistryUnavailable
1382            | PersonLookupError::ItemMissingFromResponse,
1383        ) => AdminResolveStudentNumberResult {
1384            study_registry_unavailable: true,
1385            ..shared
1386        },
1387    };
1388
1389    token.authorized_ok(web::Json(result))
1390}
1391
1392/**
1393POST `/api/v0/main-frontend/credit-registration-admin/account-linking/manual-link` - Links a student
1394number to an account on an admin's judgement.
1395
1396The last resort, for a student whose mailbox host will not accept our mail at all. An admin's judgement
1397stands in for proof of mailbox control, so the link is marked `admin_manual` forever, carries the
1398reason and names the admin.
1399*/
1400#[instrument(skip(pool, payload, suotar_client))]
1401#[utoipa::path(
1402    post,
1403    path = "/account-linking/manual-link",
1404    operation_id = "adminManuallyLinkStudentNumber",
1405    tag = "credit-registration-admin",
1406    request_body = AdminManuallyLinkStudentNumberPayload,
1407    responses(
1408        (status = 200, description = "What the attempt did", body = AdminManuallyLinkStudentNumberResult),
1409        (status = 422, description = "No reason, no student number, or no person id from the preview")
1410    )
1411)]
1412pub async fn admin_manually_link_student_number(
1413    user: AuthUser,
1414    pool: web::Data<PgPool>,
1415    payload: web::Json<AdminManuallyLinkStudentNumberPayload>,
1416    suotar_client: web::Data<headless_lms_utils::services::suotar::SuotarClient>,
1417) -> ControllerResult<web::Json<AdminManuallyLinkStudentNumberResult>> {
1418    let mut conn = pool.acquire().await?;
1419    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
1420
1421    let ManualLinkRequest {
1422        reason,
1423        student_number,
1424        previewed_person_id,
1425    } = manual_link_request(&payload)?;
1426    let reason = reason.to_string();
1427
1428    let refused = |outcome: AdminManualLinkOutcome| {
1429        debug!(?outcome, "Admin manual link refused");
1430        AdminManuallyLinkStudentNumberResult {
1431            outcome,
1432            verified_student_number_id: None,
1433            affected_registration_count: 0,
1434        }
1435    };
1436    let ctx = ManualActionContext::new(&pool, &suotar_client, MANUAL_LINK_CALLER);
1437    // Released so the Suotar call does not pin a pool connection for its whole timeout.
1438    drop(conn);
1439    info!(actor = %user.id, target_user_id = %payload.user_id, "Admin manually linking a student number");
1440    let person: RegistryPerson = match look_up_person(&ctx, &student_number).await {
1441        Ok(Some(person)) => person,
1442        Ok(None) => {
1443            return token.authorized_ok(web::Json(refused(
1444                AdminManualLinkOutcome::StudentNumberNotFound,
1445            )));
1446        }
1447        Err(PersonLookupError::UnexpectedAnswer { .. }) => {
1448            return token.authorized_ok(web::Json(refused(
1449                AdminManualLinkOutcome::UnexpectedStudyRegistryAnswer,
1450            )));
1451        }
1452        Err(
1453            PersonLookupError::StudyRegistryUnavailable
1454            | PersonLookupError::ItemMissingFromResponse,
1455        ) => {
1456            return token.authorized_ok(web::Json(refused(
1457                AdminManualLinkOutcome::StudyRegistryUnavailable,
1458            )));
1459        }
1460    };
1461    if person.sisu_person_id.expose_secret() != previewed_person_id.expose_secret() {
1462        return token.authorized_ok(web::Json(refused(AdminManualLinkOutcome::PreviewMismatch)));
1463    }
1464    let mut conn = pool.acquire().await?;
1465
1466    if let Some(conflict) = verified_student_numbers::find_link_conflict(
1467        &mut conn,
1468        student_number.expose_secret(),
1469        person.sisu_person_id.expose_secret(),
1470        payload.user_id,
1471    )
1472    .await?
1473    {
1474        let outcome = match conflict {
1475            LinkConflict::SameAccount => AdminManualLinkOutcome::AlreadyLinkedToThisAccount,
1476            LinkConflict::AnotherAccount => AdminManualLinkOutcome::AlreadyLinkedToAnotherAccount,
1477        };
1478        return token.authorized_ok(web::Json(refused(outcome)));
1479    }
1480
1481    let mut tx = conn.begin().await?;
1482    // A student who changed programmes has a new number; the old link is retired, not deleted, so the
1483    // audit trail survives.
1484    let current_link_id = verified_student_numbers::get_by_user_id(&mut tx, payload.user_id)
1485        .await?
1486        .map(|current| current.id);
1487    let (verified_student_number_id, affected_registration_count) =
1488        verified_student_numbers::replace_verified_student_number(
1489            &mut tx,
1490            current_link_id,
1491            &NewVerifiedStudentNumber {
1492                user_id: payload.user_id,
1493                student_number: student_number.clone().into(),
1494                sisu_person_id: person.sisu_person_id.clone().into(),
1495                first_names: person.first_names.clone().map(Into::into),
1496                last_name: person.last_name.clone().map(Into::into),
1497                verified_via: StudentNumberVerificationMethod::AdminManual,
1498                // No mailbox was proved, so there is no address the proof could rest on.
1499                verified_via_email: None,
1500                linked_by_user_id: Some(user.id),
1501                link_reason: Some(reason.clone()),
1502                verified_from_course_id: None,
1503            },
1504            Some(user.id),
1505            models::credit_registration_events::CreditRegistrationEventKind::AdminAction,
1506            "An administrator linked this student number by hand.",
1507        )
1508        .await?;
1509    models::credit_registration_admin_actions::record(
1510        &mut tx,
1511        &NewCreditRegistrationAdminAction {
1512            target_id: Some(verified_student_number_id),
1513            reason: Some(reason),
1514            details: Some(serde_json::json!({
1515                "user_id": payload.user_id,
1516                "student_number": student_number.expose_secret(),
1517            })),
1518            affected_row_count: Some(
1519                i32::try_from(affected_registration_count).unwrap_or(i32::MAX),
1520            ),
1521            ..NewCreditRegistrationAdminAction::new(
1522                CreditRegistrationAdminAction::ManualLinkStudentNumber,
1523                CreditRegistrationAdminActionTarget::VerifiedStudentNumber,
1524                user.id,
1525                GLOBAL_ADMIN_ROLE,
1526            )
1527        },
1528    )
1529    .await?;
1530    tx.commit().await?;
1531    info!(
1532        verified_student_number_id = %verified_student_number_id,
1533        affected_registration_count,
1534        "Admin manual link finished"
1535    );
1536
1537    token.authorized_ok(web::Json(AdminManuallyLinkStudentNumberResult {
1538        outcome: AdminManualLinkOutcome::Linked,
1539        verified_student_number_id: Some(verified_student_number_id),
1540        affected_registration_count,
1541    }))
1542}
1543
1544/// The three values a manual link may not be attempted without.
1545struct ManualLinkRequest<'a> {
1546    reason: &'a str,
1547    student_number: SecretString,
1548    previewed_person_id: SecretString,
1549}
1550
1551/// Refuses a manual link that skipped the preview or gave no reason, before anything is asked of the
1552/// study registry. The person id can only have come from the preview: it is the registry's own
1553/// identifier, not something a caller could produce from the student number in front of them.
1554fn manual_link_request(
1555    payload: &AdminManuallyLinkStudentNumberPayload,
1556) -> Result<ManualLinkRequest<'_>, ControllerError> {
1557    let reason = required_reason(&payload.reason)?;
1558    let student_number = typed_student_number(&payload.student_number)?;
1559    let previewed_person_id = SecretString::from(payload.sisu_person_id.expose_secret().trim());
1560    if previewed_person_id.expose_secret().is_empty() {
1561        return Err(controller_err!(
1562            BadRequest,
1563            "Check the number in the study registry first.".to_string()
1564        ));
1565    }
1566    Ok(ManualLinkRequest {
1567        reason,
1568        student_number,
1569        previewed_person_id,
1570    })
1571}
1572
1573fn required_student_number(raw: &SecretString) -> Result<SecretString, ControllerError> {
1574    let student_number = SecretString::from(raw.expose_secret().trim());
1575    if student_number.expose_secret().is_empty() {
1576        return Err(controller_err!(
1577            BadRequest,
1578            "Name a student number.".to_string()
1579        ));
1580    }
1581    Ok(student_number)
1582}
1583
1584/// [`required_student_number`] for a number an admin typed to verify a link, which must also pass
1585/// [`parse_student_number`]: a typo would otherwise link a stranger's number. Numbers from the
1586/// study registry are never held to this, as their format is Sisu's to change.
1587fn typed_student_number(raw: &SecretString) -> Result<SecretString, ControllerError> {
1588    required_student_number(raw)?;
1589    parse_student_number(raw.expose_secret())
1590        .map(SecretString::from)
1591        .map_err(|invalid| controller_err!(BadRequest, invalid.message().to_string()))
1592}
1593
1594/// The linking emails listed people have had for one course.
1595struct LinkingEmailHistory {
1596    /// By Sisu person id, replaced ones included.
1597    mail_counts: HashMap<String, i64>,
1598    /// By student number, newest first.
1599    manual_sends: HashMap<String, Vec<AdminManualLinkingEmailSend>>,
1600}
1601
1602async fn linking_email_history(
1603    conn: &mut PgConnection,
1604    people: &[RosterPerson],
1605    course_id: Uuid,
1606) -> Result<LinkingEmailHistory, ControllerError> {
1607    let person_ids: Vec<String> = people
1608        .iter()
1609        .map(|person| person.person_id.expose_secret().to_owned())
1610        .collect();
1611    let mail_counts =
1612        credit_registration_account_linking_emails::count_mails_per_person_for_course(
1613            conn,
1614            &person_ids,
1615            course_id,
1616        )
1617        .await?;
1618    let mut manual_sends: HashMap<String, Vec<AdminManualLinkingEmailSend>> = HashMap::new();
1619    for send in
1620        credit_registration_admin_actions::get_manual_linking_email_sends(conn, &[course_id])
1621            .await?
1622    {
1623        if let Some(student_number) = send.student_number.clone() {
1624            manual_sends
1625                .entry(student_number)
1626                .or_default()
1627                .push(send.into());
1628        }
1629    }
1630    Ok(LinkingEmailHistory {
1631        mail_counts,
1632        manual_sends,
1633    })
1634}
1635
1636fn to_linking_candidate(
1637    person: &RosterPerson,
1638    history: &LinkingEmailHistory,
1639    similarities: Vec<LinkingCandidateSimilarity>,
1640) -> AdminLinkingCandidate {
1641    AdminLinkingCandidate {
1642        student_number: person.student_number.expose_secret().to_owned(),
1643        first_names: person
1644            .first_names
1645            .as_ref()
1646            .map(|names| names.expose_secret().to_owned()),
1647        last_name: person
1648            .last_name
1649            .as_ref()
1650            .map(|name| name.expose_secret().to_owned()),
1651        email: mail_address(person).map(|address| address.expose_secret().to_owned()),
1652        enrolled_at: person.enrolled_at(),
1653        linking_emails_for_course: history
1654            .mail_counts
1655            .get(person.person_id.expose_secret())
1656            .copied()
1657            .unwrap_or(0),
1658        manual_sends: history
1659            .manual_sends
1660            .get(person.student_number.expose_secret())
1661            .cloned()
1662            .unwrap_or_default(),
1663        similarities,
1664    }
1665}
1666
1667fn by_name(a: &AdminLinkingCandidate, b: &AdminLinkingCandidate) -> std::cmp::Ordering {
1668    let folded = |name: &Option<String>| name.as_ref().map(|name| name.to_lowercase());
1669    // `None` sorts first, so reversing the flag puts people with no last name last.
1670    let (a_last, b_last) = (folded(&a.last_name), folded(&b.last_name));
1671    a_last
1672        .is_none()
1673        .cmp(&b_last.is_none())
1674        .then_with(|| a_last.cmp(&b_last))
1675        .then_with(|| folded(&a.first_names).cmp(&folded(&b.first_names)))
1676}
1677
1678/// Audits the resend whatever it did, and reports where this person's mails now stand.
1679async fn finish_resend(
1680    conn: &mut PgConnection,
1681    user: &AuthUser,
1682    payload: &AdminResendAccountLinkingEmailPayload,
1683    student_number: &SecretString,
1684    outcome: ResendOutcome,
1685    attempt: &ResendAttempt,
1686    token: crate::domain::authorization::AuthorizationToken,
1687) -> ControllerResult<web::Json<AdminResendAccountLinkingEmailResult>> {
1688    let (mails, mails_sent_for_this_course) = record_resend_and_fetch_mails(
1689        conn,
1690        payload.course_id,
1691        Some(student_number.expose_secret()),
1692        user.id,
1693        GLOBAL_ADMIN_ROLE,
1694        None,
1695        payload.reason.clone(),
1696        serde_json::json!({
1697            "outcome": outcome,
1698            "student_number": student_number.expose_secret(),
1699            "override_rate_caps": payload.override_rate_caps,
1700            "retired_mail_count": attempt.retired_mail_count,
1701            "credit_registration_id": payload.credit_registration_id,
1702            "emailed_to": attempt.emailed_to.as_ref().map(ExposeSecret::expose_secret),
1703        }),
1704    )
1705    .await?;
1706    let linking_emails = build_linking_emails(conn, mails).await?;
1707    let manual_send = if outcome == ResendOutcome::Queued {
1708        let sender = models::user_details::get_user_details_by_user_id(conn, user.id).await?;
1709        Some(AdminManualLinkingEmailSend {
1710            sent_at: Utc::now(),
1711            sent_by_user_id: user.id,
1712            sent_by_first_name: sender.first_name,
1713            sent_by_last_name: sender.last_name,
1714            sent_by_email: Some(sender.email),
1715            emailed_to: attempt
1716                .emailed_to
1717                .as_ref()
1718                .map(|address| address.expose_secret().to_owned()),
1719        })
1720    } else {
1721        None
1722    };
1723
1724    token.authorized_ok(web::Json(AdminResendAccountLinkingEmailResult {
1725        outcome,
1726        retired_mail_count: attempt.retired_mail_count,
1727        linking_emails,
1728        mails_sent_for_this_course,
1729        max_mails_per_person_and_course: MAX_LINKING_MAILS_PER_PERSON_AND_COURSE,
1730        quiet_period_secs: LINKING_MAIL_QUIET_PERIOD.num_seconds(),
1731        manual_send,
1732    }))
1733}
1734
1735/// Every active course code's schedule, with the modules that share its enrolment list.
1736async fn build_course_codes(
1737    conn: &mut PgConnection,
1738    account_linking_since: Option<DateTime<Utc>>,
1739    pressers: &[WaitingPresser],
1740    unused_by_code: &[(String, i64)],
1741) -> Result<Vec<AccountLinkingCourseCode>, ControllerError> {
1742    let mut modules_by_code: HashMap<String, Vec<AccountLinkingCodeModule>> = HashMap::new();
1743    for row in course_module_suotar_configurations::get_active_discovery_reports(conn).await? {
1744        let Some(code) = row.uh_course_code.as_deref().map(str::trim) else {
1745            continue;
1746        };
1747        modules_by_code
1748            .entry(code.to_string())
1749            .or_default()
1750            .push(AccountLinkingCodeModule {
1751                course_id: row.course_id,
1752                course_name: row.course_name,
1753                course_module_id: row.course_module_id,
1754                course_module_name: row.course_module_name,
1755                last_listed_at: row.last_listed_at,
1756            });
1757    }
1758    let now = Utc::now();
1759    let schedules = credit_registration_roster_schedules::get_schedules(
1760        conn,
1761        None,
1762        ScheduleSelection::Every,
1763        account_linking_since,
1764    )
1765    .await?;
1766    Ok(schedules
1767        .into_iter()
1768        .map(|schedule: RosterSchedule| AccountLinkingCourseCode {
1769            pressed_waiting_count: pressers
1770                .iter()
1771                .filter(|presser| {
1772                    presser.uh_course_code.as_deref() == Some(schedule.course_code.as_str())
1773                })
1774                .count() as i64,
1775            unused_link_count: unused_by_code
1776                .iter()
1777                .find(|(code, _)| *code == schedule.course_code)
1778                .map_or(0, |(_, count)| *count),
1779            is_enrolment_list_empty: schedule.last_listed_person_count == Some(0),
1780            is_fetch_failing: schedule.consecutive_failures > 0,
1781            last_mailing_fetch_started_at: schedule.last_mailing_fetch_started_at,
1782            modules: modules_by_code
1783                .remove(&schedule.course_code)
1784                .unwrap_or_default(),
1785            next_fetch_at: schedule.next_fetch_at(now),
1786            fetch_requested_at: schedule.unserved_fetch_request_at(),
1787            last_fetched_at: schedule.last_fetched_at,
1788            waiting_count: schedule.waiting_count,
1789            last_listed_person_count: schedule.last_listed_person_count,
1790            consecutive_failures: schedule.consecutive_failures,
1791            retry_not_before: schedule.retry_not_before,
1792            last_error: schedule.last_error,
1793            linking: schedule.linking_counters,
1794            unlinked_enrolled_before_count: schedule.unlinked_enrolled_before_count,
1795            course_code: schedule.course_code,
1796        })
1797        .collect())
1798}
1799
1800async fn build_stale_addresses(
1801    conn: &mut PgConnection,
1802    rows: Vec<StaleUnclaimedLinkingMails>,
1803) -> Result<Vec<AccountLinkingStaleAddress>, ControllerError> {
1804    let ids: Vec<Uuid> = rows.iter().flat_map(|row| row.mail_ids.clone()).collect();
1805    let reports =
1806        credit_registration_account_linking_emails::get_send_status_reports(conn, &ids).await?;
1807    Ok(rows
1808        .into_iter()
1809        .map(|row| {
1810            let sends = row
1811                .mail_ids
1812                .iter()
1813                .zip(row.addresses)
1814                .map(|(id, address)| AccountLinkingSendOutcome {
1815                    address: address.expose_secret().to_owned(),
1816                    send_status: reports
1817                        .get(id)
1818                        .map(|report| report.email_send_status)
1819                        .unwrap_or(EmailSendStatus::Queued),
1820                })
1821                .collect();
1822            AccountLinkingStaleAddress {
1823                sends,
1824                student_number: row.student_number.expose_secret().to_owned(),
1825                sisu_person_id: row.sisu_person_id.expose_secret().to_owned(),
1826                course_id: row.course_id,
1827                course_name: row.course_name,
1828                mail_count: row.mail_count,
1829                first_sent_at: row.first_sent_at,
1830                last_sent_at: row.last_sent_at,
1831            }
1832        })
1833        .collect())
1834}
1835
1836/**
1837GET `/api/v0/main-frontend/credit-registration-admin/account-linking/emails` - A page of every
1838linking email, newest first.
1839
1840An `address` search covers every status, so a mail is found whatever became of it.
1841*/
1842#[instrument(skip(pool, query))]
1843#[utoipa::path(
1844    get,
1845    path = "/account-linking/emails",
1846    operation_id = "listAccountLinkingEmails",
1847    tag = "credit-registration-admin",
1848    params(
1849        ("page" = Option<u32>, Query, description = "Page number, from 1"),
1850        ("limit" = Option<u32>, Query, description = "Rows per page"),
1851        ("status" = Option<LinkingEmailListStatus>, Query, description = "What became of the link or the send; ignored with `address`"),
1852        ("address" = Option<String>, Query, description = "Part of the recipient address, ignoring case")
1853    ),
1854    responses(
1855        (status = 200, description = "A page of linking emails", body = Page<AccountLinkingEmailRow>)
1856    )
1857)]
1858pub async fn list_account_linking_emails(
1859    user: AuthUser,
1860    pool: web::Data<PgPool>,
1861    query: web::Query<ListAccountLinkingEmailsQuery>,
1862) -> ControllerResult<web::Json<Page<AccountLinkingEmailRow>>> {
1863    let mut conn = pool.acquire().await?;
1864    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
1865
1866    let pagination = parse_pagination(query.page, query.limit, 50)?;
1867    let address = query
1868        .address
1869        .as_deref()
1870        .map(str::trim)
1871        .filter(|address| !address.is_empty());
1872    let rows = credit_registration_account_linking_emails::get_page(
1873        &mut conn,
1874        query.status,
1875        address,
1876        pagination.limit(),
1877        pagination.offset(),
1878    )
1879    .await?;
1880    let total_count = rows.first().map_or(0, |row| row.total_count);
1881
1882    token.authorized_ok(web::Json(Page::new(
1883        pagination,
1884        rows.into_iter().map(to_linking_email_row).collect(),
1885        total_count,
1886    )))
1887}
1888
1889/// The admin list row for a mail, with the Sisu name joined and the student's secrets exposed.
1890fn to_linking_email_row(row: LinkingEmailListRow) -> AccountLinkingEmailRow {
1891    let sisu_full_name = [
1892        expose_option(&row.first_names),
1893        expose_option(&row.last_name),
1894    ]
1895    .into_iter()
1896    .flatten()
1897    .map(str::trim)
1898    .filter(|part| !part.is_empty())
1899    .collect::<Vec<_>>()
1900    .join(" ");
1901    AccountLinkingEmailRow {
1902        id: row.id,
1903        course_id: row.course_id,
1904        course_name: row.course_name,
1905        emailed_to: row.emailed_to.expose_secret().to_owned(),
1906        student_number: row.student_number.expose_secret().to_owned(),
1907        sisu_full_name: (!sisu_full_name.is_empty()).then_some(sisu_full_name),
1908        claimed_at: row.claimed_at,
1909        queued_at: row.queued_at,
1910        send_status: row.send_status,
1911        last_error_message: row.last_error_message,
1912        link_state: row.link_state,
1913        link_expires_at: row.link_expires_at,
1914        link_used_at: row.link_used_at,
1915        claimed_by_user_id: row.claimed_by_user_id,
1916        claimed_by_email: row.claimed_by_email,
1917    }
1918}
1919
1920pub fn _add_routes(cfg: &mut ServiceConfig) {
1921    cfg.route("/account-linking", web::get().to(get_account_linking_stats))
1922        .route(
1923            "/account-linking/emails",
1924            web::get().to(list_account_linking_emails),
1925        )
1926        .route(
1927            "/account-linking/resend",
1928            web::post().to(admin_resend_account_linking_email),
1929        )
1930        .route(
1931            "/account-linking/resolve-person",
1932            web::post().to(admin_resolve_student_number_for_linking),
1933        )
1934        .route(
1935            "/account-linking/manual-link",
1936            web::post().to(admin_manually_link_student_number),
1937        )
1938        .route(
1939            "/account-linking/fetch-enrolment-list",
1940            web::post().to(admin_request_enrolment_list_fetch),
1941        )
1942        .route(
1943            "/account-linking/study-registry-conflicts/{conflict_id}/dismiss",
1944            web::post().to(admin_dismiss_study_registry_conflict),
1945        )
1946        .route(
1947            "/account-linking/courses",
1948            web::get().to(get_account_linking_courses),
1949        )
1950        .route(
1951            "/account-linking/courses/{course_id}/unlinked-enrolees",
1952            web::get().to(get_account_linking_unlinked_enrolees),
1953        )
1954        .route(
1955            "/registrations/{credit_registration_id}/linking-candidates",
1956            web::get().to(get_credit_registration_linking_candidates),
1957        );
1958}
1959
1960#[cfg(test)]
1961mod tests {
1962    use super::*;
1963
1964    fn manual_link_payload(
1965        reason: &str,
1966        student_number: &str,
1967        sisu_person_id: &str,
1968    ) -> AdminManuallyLinkStudentNumberPayload {
1969        AdminManuallyLinkStudentNumberPayload {
1970            user_id: Uuid::new_v4(),
1971            student_number: student_number.into(),
1972            sisu_person_id: sisu_person_id.into(),
1973            reason: reason.to_string(),
1974        }
1975    }
1976
1977    #[test]
1978    fn a_manual_link_is_refused_without_a_preview_a_reason_or_a_valid_number() {
1979        assert!(
1980            manual_link_request(&manual_link_payload(
1981                "Host bounces our mail.",
1982                "012345672",
1983                ""
1984            ))
1985            .is_err()
1986        );
1987        assert!(manual_link_request(&manual_link_payload("   ", "012345672", "hy-hlo-1")).is_err());
1988        assert!(manual_link_request(&manual_link_payload("", "012345672", "hy-hlo-1")).is_err());
1989        assert!(
1990            manual_link_request(&manual_link_payload(
1991                "Host bounces our mail.",
1992                "",
1993                "hy-hlo-1"
1994            ))
1995            .is_err()
1996        );
1997        for mistyped in ["012345678", "12345672", "0123456721"] {
1998            assert!(
1999                manual_link_request(&manual_link_payload(
2000                    "Host bounces our mail.",
2001                    mistyped,
2002                    "hy-hlo-1"
2003                ))
2004                .is_err(),
2005                "{mistyped}"
2006            );
2007        }
2008        let payload =
2009            manual_link_payload("  Host bounces our mail.  ", " 012345672 ", " hy-hlo-1 ");
2010        let allowed = manual_link_request(&payload)
2011            .expect("a reason, a number and a previewed person id are all there");
2012        assert_eq!(allowed.reason, "Host bounces our mail.");
2013        assert_eq!(allowed.student_number.expose_secret(), "012345672");
2014        assert_eq!(allowed.previewed_person_id.expose_secret(), "hy-hlo-1");
2015    }
2016}