1use 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
67const 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#[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 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 pub waiting_for_link_emails: i64,
95 pub waiting_for_email_worker: i64,
97 pub retrying: i64,
98 pub sent: i64,
99 pub send_failed: i64,
100 pub used: i64,
102}
103
104#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
106pub struct AccountLinkingFailureDomain {
107 pub domain: String,
108 pub count: i64,
109}
110
111#[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 pub last_listed_at: Option<DateTime<Utc>>,
120}
121
122#[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 pub last_mailing_fetch_started_at: Option<DateTime<Utc>>,
130 pub next_fetch_at: DateTime<Utc>,
132 pub fetch_requested_at: Option<DateTime<Utc>>,
134 pub waiting_count: i64,
136 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 pub linking: Option<AccountLinkingCodeCounters>,
143 pub unlinked_enrolled_before_count: Option<i32>,
146 pub pressed_waiting_count: i64,
148 pub unused_link_count: i64,
150 pub is_enrolment_list_empty: bool,
151 pub is_fetch_failing: bool,
152}
153
154#[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 pub enrolment_route: CreditRegistrationEnrolmentRoute,
170 pub last_fetched_at: Option<DateTime<Utc>>,
171 pub last_mailing_fetch_started_at: Option<DateTime<Utc>>,
173 pub next_fetch_at: Option<DateTime<Utc>>,
174 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 pub is_stuck: bool,
181 pub needs_attention: bool,
183}
184
185#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
188pub struct AccountLinkingEngagementCounts {
189 pub pressed: i64,
190 pub pressed_stuck: i64,
192 pub visited: i64,
193 pub not_started: i64,
194}
195
196#[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 pub emailed_to: String,
205 pub claimed_at: DateTime<Utc>,
206 pub expires_at: DateTime<Utc>,
207}
208
209#[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#[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 pub emailed_to: String,
230 pub student_number: String,
231 pub sisu_full_name: Option<String>,
233 pub claimed_at: DateTime<Utc>,
234 pub queued_at: Option<DateTime<Utc>>,
236 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#[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#[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 pub last_check_requested_at: Option<DateTime<Utc>>,
272 pub engagement: Engagement,
274}
275
276#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
278pub struct AccountLinkingSendOutcome {
279 pub address: String,
280 pub send_status: EmailSendStatus,
281}
282
283#[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 pub sends: Vec<AccountLinkingSendOutcome>,
295}
296
297#[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 pub course_id: Uuid,
310 pub course_name: String,
311 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 pub account_linking_enabled: bool,
329 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 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 pub waiting_for_student_number_count: i64,
343 pub waiting_students: Vec<AccountLinkingWaitingStudent>,
345 pub waiting_students_total: i64,
347 pub max_mails_per_person_and_course: i64,
348 pub quiet_period_secs: i64,
349 pub study_registry_conflicts: Vec<StudyRegistryStudentNumberConflict>,
351 pub processing_phases: Vec<CreditRegistrationPhaseStatus>,
354 pub pressers: Vec<AccountLinkingPresser>,
357 pub engagement_counts: AccountLinkingEngagementCounts,
358 pub unused_links: Vec<AccountLinkingUnusedLink>,
360 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 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 pub override_rate_caps: bool,
392 pub reason: Option<String>,
393 pub credit_registration_id: Option<Uuid>,
396}
397
398#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
399pub struct AdminResendAccountLinkingEmailResult {
400 pub outcome: ResendOutcome,
401 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 pub manual_send: Option<AdminManualLinkingEmailSend>,
409}
410
411#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
414pub struct AdminLinkingCandidate {
415 pub student_number: String,
417 pub first_names: Option<String>,
418 pub last_name: Option<String>,
419 pub email: Option<String>,
421 pub enrolled_at: Option<DateTime<Utc>>,
423 pub linking_emails_for_course: i64,
426 pub manual_sends: Vec<AdminManualLinkingEmailSend>,
429 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 pub study_registry_unavailable: bool,
442 pub candidates: Vec<AdminLinkingCandidate>,
444}
445
446#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
449pub struct AdminLinkingCourse {
450 pub course_id: Uuid,
451 pub course_name: String,
452 pub course_codes: Vec<String>,
454}
455
456#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
458pub struct AdminUnlinkedEnrolees {
459 pub study_registry_unavailable: bool,
461 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#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
474pub struct AdminResolveStudentNumberResult {
475 pub found: bool,
476 pub student_number: String,
477 pub sisu_person_id: Option<String>,
479 pub first_names: Option<String>,
480 pub last_name: Option<String>,
481 pub code: Option<String>,
483 pub study_registry_unavailable: bool,
484 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 #[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 StudentNumberNotFound,
511 PreviewMismatch,
513 AlreadyLinkedToAnotherAccount,
515 AlreadyLinkedToThisAccount,
516 StudyRegistryUnavailable,
517 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 pub affected_registration_count: i64,
527}
528
529#[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#[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#[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#[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 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#[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 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#[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#[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 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#[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 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#[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 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 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 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
1544struct ManualLinkRequest<'a> {
1546 reason: &'a str,
1547 student_number: SecretString,
1548 previewed_person_id: SecretString,
1549}
1550
1551fn 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
1584fn 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
1594struct LinkingEmailHistory {
1596 mail_counts: HashMap<String, i64>,
1598 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 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
1678async 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
1735async 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#[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
1889fn 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}