1use headless_lms_models::course_module_suotar_configurations;
4use headless_lms_models::credit_registration_account_linking_emails::{
5 self, StaleUnclaimedLinkingMails,
6};
7use headless_lms_models::credit_registration_admin_actions::{
8 CreditRegistrationAdminAction, CreditRegistrationAdminActionTarget, GLOBAL_ADMIN_ROLE,
9 NewCreditRegistrationAdminAction,
10};
11use headless_lms_models::credit_registrations;
12use headless_lms_models::email_deliveries::EmailSendStatus;
13use headless_lms_models::library::credit_registration::account_linking::{
14 LINKING_MAIL_QUIET_PERIOD, MAX_LINKING_MAILS_PER_PERSON_AND_COURSE,
15};
16use headless_lms_models::library::credit_registration::student_number::parse_student_number;
17use headless_lms_models::study_registry_student_number_conflicts;
18use headless_lms_models::verified_student_numbers::{
19 self, LinkConflict, NewVerifiedStudentNumber, StudentNumberVerificationMethod,
20};
21use headless_lms_utils::secret_string::expose_option;
22use secrecy::{ExposeSecret, SecretString};
23use utoipa::ToSchema;
24
25use crate::controllers::main_frontend::course_credit_registrations::record_resend_and_fetch_mails;
26use crate::domain::credit_registration::linking_mail_resend::{
27 ResendOutcome, ensure_resend_possible,
28};
29use crate::prelude::*;
30use headless_lms_base::config::ApplicationConfiguration;
31use headless_lms_credit_registration::account_linking::{
32 ManualActionContext, PersonLookupError, RateCapOverride, RegistryPerson, look_up_person,
33 resend_linking_mail_for_target,
34};
35
36use super::{
37 AdminLinkingEmail, authorize_credit_registration_admin, build_linking_emails, required_reason,
38};
39
40const STALE_UNCLAIMED_LIMIT: i64 = 200;
41const STUDY_REGISTRY_CONFLICT_LIMIT: i64 = 200;
42
43const RESEND_CALLER: &str = "admin-resend";
45const RESOLVE_CALLER: &str = "admin-resolve-person";
46const MANUAL_LINK_CALLER: &str = "admin-manual-link";
47
48const RESEND_QUIET_PERIOD_SECS: i64 = 60;
51
52#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
55pub struct AccountLinkingFunnel {
56 pub persons_discovered_last_run: i64,
57 pub already_linked_last_run: i64,
58 pub mails_claimed_in_window: i64,
59 pub mails_sent_in_window: i64,
60 pub numbers_claimed_in_window: i64,
61 pub manual_links_in_window: i64,
63 pub suppressed_by_dedup_last_run: i64,
64 pub suppressed_by_rate_cap_last_run: i64,
65 pub no_address_in_study_registry_last_run: i64,
66}
67
68#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
69pub struct AccountLinkingSendStatusTotals {
70 pub queued: i64,
71 pub retrying: i64,
72 pub sent: i64,
73 pub send_failed: i64,
74}
75
76#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
78pub struct AccountLinkingFailureDomain {
79 pub domain: String,
80 pub count: i64,
81}
82
83#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
84pub struct AccountLinkingModuleCounters {
85 pub course_id: Uuid,
86 pub course_name: String,
87 pub course_module_id: Uuid,
88 pub course_module_name: Option<String>,
89 pub uh_course_code: Option<String>,
90 pub last_listed_at: Option<DateTime<Utc>>,
93 pub last_listing_attempted_at: Option<DateTime<Utc>>,
94 pub last_listing_error:
97 Option<headless_lms_models::credit_registrations::CreditRegistrationErrorCode>,
98 pub consecutive_listing_failures: i32,
99 pub listed_person_count: Option<i32>,
100 pub already_linked_count: Option<i32>,
101 pub mailed_count: Option<i32>,
102 pub suppressed_by_dedup_count: Option<i32>,
103 pub suppressed_by_rate_cap_count: Option<i32>,
104 pub no_address_count: Option<i32>,
106}
107
108#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
110pub struct AccountLinkingSendOutcome {
111 pub address: String,
112 pub send_status: EmailSendStatus,
113}
114
115#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
117pub struct AccountLinkingStaleAddress {
118 pub student_number: String,
119 pub sisu_person_id: String,
120 pub course_id: Uuid,
121 pub course_name: String,
122 pub mail_count: i64,
123 pub first_sent_at: DateTime<Utc>,
124 pub last_sent_at: DateTime<Utc>,
125 pub sends: Vec<AccountLinkingSendOutcome>,
127}
128
129#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
132pub struct StudyRegistryStudentNumberConflict {
133 pub id: Uuid,
134 pub created_at: DateTime<Utc>,
135 pub user_id: Uuid,
136 pub user_email: Option<String>,
137 pub first_name: Option<String>,
138 pub last_name: Option<String>,
139 pub reported_student_number: String,
140 pub course_id: Uuid,
142 pub course_name: String,
143 pub conflicting_link_user_id: Uuid,
146 pub conflicting_link_user_email: Option<String>,
147 pub conflicting_link_student_number: String,
148 pub conflicting_link_verified_via: StudentNumberVerificationMethod,
149}
150
151#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
152pub struct VerifiedStudentNumberMethodTotal {
153 pub verified_via: StudentNumberVerificationMethod,
154 pub count: i64,
155}
156
157#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
158pub struct AccountLinkingStats {
159 pub account_linking_enabled: bool,
161 pub window_secs: i64,
162 pub funnel: AccountLinkingFunnel,
163 pub send_status_totals: AccountLinkingSendStatusTotals,
164 pub hard_failure_domains: Vec<AccountLinkingFailureDomain>,
165 pub modules: Vec<AccountLinkingModuleCounters>,
166 pub stale_addresses: Vec<AccountLinkingStaleAddress>,
167 pub links_total_by_method: Vec<VerifiedStudentNumberMethodTotal>,
168 pub links_in_window_by_method: Vec<VerifiedStudentNumberMethodTotal>,
169 pub waiting_for_student_number_count: i64,
171 pub max_mails_per_person_and_course: i64,
172 pub quiet_period_secs: i64,
173 pub study_registry_conflicts: Vec<StudyRegistryStudentNumberConflict>,
175}
176
177#[derive(Debug, Deserialize)]
178pub struct AccountLinkingStatsQuery {
179 window_days: Option<u32>,
180}
181
182#[derive(Debug, Deserialize, ToSchema)]
183pub struct AdminResendAccountLinkingEmailPayload {
184 #[schema(value_type = String)]
185 pub student_number: SecretString,
186 pub course_id: Uuid,
187 pub override_rate_caps: bool,
189 pub reason: Option<String>,
190}
191
192#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
193pub struct AdminResendAccountLinkingEmailResult {
194 pub outcome: ResendOutcome,
195 pub retired_mail_count: i64,
197 pub linking_emails: Vec<AdminLinkingEmail>,
198 pub mails_sent_for_this_course: i64,
199 pub max_mails_per_person_and_course: i64,
200 pub quiet_period_secs: i64,
201}
202
203#[derive(Debug, Deserialize, ToSchema)]
204pub struct AdminResolveStudentNumberPayload {
205 #[schema(value_type = String)]
206 pub student_number: SecretString,
207}
208
209#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
212pub struct AdminResolveStudentNumberResult {
213 pub found: bool,
214 pub student_number: String,
215 pub sisu_person_id: Option<String>,
217 pub first_names: Option<String>,
218 pub last_name: Option<String>,
219 pub code: Option<String>,
221 pub study_registry_unavailable: bool,
222 pub lookup_error_code: Option<String>,
225 pub already_linked_to_user_id: Option<Uuid>,
226 pub already_linked_to_user_email: Option<String>,
227 pub already_linked_via: Option<StudentNumberVerificationMethod>,
228 pub linking_emails: Vec<AdminLinkingEmail>,
229}
230
231#[derive(Debug, Deserialize, ToSchema)]
232pub struct AdminManuallyLinkStudentNumberPayload {
233 pub user_id: Uuid,
234 #[schema(value_type = String)]
235 pub student_number: SecretString,
236 #[schema(value_type = String)]
239 pub sisu_person_id: SecretString,
240 pub reason: String,
241}
242
243#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
244#[serde(rename_all = "snake_case")]
245pub enum AdminManualLinkOutcome {
246 Linked,
247 StudentNumberNotFound,
249 PreviewMismatch,
251 AlreadyLinkedToAnotherAccount,
253 AlreadyLinkedToThisAccount,
254 StudyRegistryUnavailable,
255 UnexpectedStudyRegistryAnswer,
257}
258
259#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
260pub struct AdminManuallyLinkStudentNumberResult {
261 pub outcome: AdminManualLinkOutcome,
262 pub verified_student_number_id: Option<Uuid>,
263 pub affected_registration_count: i64,
265}
266
267#[instrument(skip(pool, app_conf))]
272#[utoipa::path(
273 get,
274 path = "/account-linking",
275 operation_id = "getAccountLinkingStats",
276 tag = "credit-registration-admin",
277 params(("window_days" = Option<u32>, Query, description = "Window for the windowed funnel steps, in days")),
278 responses(
279 (status = 200, description = "Where account linking stands", body = AccountLinkingStats)
280 )
281)]
282pub async fn get_account_linking_stats(
283 user: AuthUser,
284 pool: web::Data<PgPool>,
285 query: web::Query<AccountLinkingStatsQuery>,
286 app_conf: web::Data<ApplicationConfiguration>,
287) -> ControllerResult<web::Json<AccountLinkingStats>> {
288 let mut conn = pool.acquire().await?;
289 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
290
291 let window_days = i64::from(query.window_days.unwrap_or(30).clamp(1, 365));
292 let window_secs = window_days * 24 * 60 * 60;
293 let since = Utc::now() - chrono::Duration::days(window_days);
294
295 let modules = course_module_suotar_configurations::get_active_discovery_reports(&mut conn)
296 .await?
297 .into_iter()
298 .map(|row| AccountLinkingModuleCounters {
299 course_id: row.course_id,
300 course_name: row.course_name,
301 course_module_id: row.course_module_id,
302 course_module_name: row.course_module_name,
303 uh_course_code: row.uh_course_code,
304 last_listed_at: row.last_listed_at,
305 last_listing_attempted_at: row.last_listing_attempted_at,
306 last_listing_error: row.last_listing_error,
307 consecutive_listing_failures: row.consecutive_listing_failures,
308 listed_person_count: row.last_listed_person_count,
309 already_linked_count: row.last_already_linked_count,
310 mailed_count: row.last_mailed_count,
311 suppressed_by_dedup_count: row.last_suppressed_by_dedup_count,
312 suppressed_by_rate_cap_count: row.last_suppressed_by_rate_cap_count,
313 no_address_count: row.last_no_address_count,
314 })
315 .collect::<Vec<_>>();
316 let sum = |pick: fn(&AccountLinkingModuleCounters) -> Option<i32>| -> i64 {
317 modules.iter().filter_map(pick).map(i64::from).sum::<i64>()
318 };
319
320 let now = Utc::now();
321 let totals = credit_registration_account_linking_emails::get_send_status_totals_since(
322 &mut conn, since, now,
323 )
324 .await?;
325 let send_status_totals = AccountLinkingSendStatusTotals {
326 queued: totals.queued,
327 retrying: totals.retrying,
328 sent: totals.sent,
329 send_failed: totals.send_failed,
330 };
331 let hard_failure_domains: Vec<AccountLinkingFailureDomain> =
332 credit_registration_account_linking_emails::get_send_failure_domains_since(
333 &mut conn, since, now,
334 )
335 .await?
336 .into_iter()
337 .map(|row| AccountLinkingFailureDomain {
338 domain: row.domain,
339 count: row.count,
340 })
341 .collect();
342
343 let method_counts = verified_student_numbers::count_by_method_since(&mut conn, since).await?;
344 let links_total_by_method = method_counts
345 .iter()
346 .map(
347 |&(verified_via, total, _)| VerifiedStudentNumberMethodTotal {
348 verified_via,
349 count: total,
350 },
351 )
352 .collect::<Vec<_>>();
353 let links_in_window_by_method = method_counts
354 .iter()
355 .map(
356 |&(verified_via, _, in_window)| VerifiedStudentNumberMethodTotal {
357 verified_via,
358 count: in_window,
359 },
360 )
361 .collect::<Vec<_>>();
362 let in_window = |method: StudentNumberVerificationMethod| -> i64 {
363 links_in_window_by_method
364 .iter()
365 .filter(|row| row.verified_via == method)
366 .map(|row| row.count)
367 .sum()
368 };
369
370 let stale = credit_registration_account_linking_emails::get_stale_unclaimed(
371 &mut conn,
372 MAX_LINKING_MAILS_PER_PERSON_AND_COURSE,
373 STALE_UNCLAIMED_LIMIT,
374 )
375 .await?;
376 let stale_addresses = build_stale_addresses(&mut conn, stale).await?;
377
378 let waiting_for_student_number_count = credit_registrations::count_pending_by_reason(&mut conn)
379 .await?
380 .student_number_count;
381
382 let funnel = AccountLinkingFunnel {
383 persons_discovered_last_run: sum(|row| row.listed_person_count),
384 already_linked_last_run: sum(|row| row.already_linked_count),
385 mails_claimed_in_window: totals.mails_in_window,
386 mails_sent_in_window: send_status_totals.sent,
387 numbers_claimed_in_window: in_window(StudentNumberVerificationMethod::EmailedLink),
388 manual_links_in_window: in_window(StudentNumberVerificationMethod::AdminManual),
389 suppressed_by_dedup_last_run: sum(|row| row.suppressed_by_dedup_count),
390 suppressed_by_rate_cap_last_run: sum(|row| row.suppressed_by_rate_cap_count),
391 no_address_in_study_registry_last_run: sum(|row| row.no_address_count),
392 };
393
394 let study_registry_conflicts = study_registry_student_number_conflicts::get_unresolved(
395 &mut conn,
396 STUDY_REGISTRY_CONFLICT_LIMIT,
397 )
398 .await?
399 .into_iter()
400 .map(|row| StudyRegistryStudentNumberConflict {
401 id: row.id,
402 created_at: row.created_at,
403 user_id: row.user_id,
404 user_email: row.user_email,
405 first_name: row.first_name,
406 last_name: row.last_name,
407 reported_student_number: row.reported_student_number.expose_secret().to_owned(),
408 course_id: row.course_id,
409 course_name: row.course_name,
410 conflicting_link_user_id: row.conflicting_link_user_id,
411 conflicting_link_user_email: row.conflicting_link_user_email,
412 conflicting_link_student_number: row
413 .conflicting_link_student_number
414 .expose_secret()
415 .to_owned(),
416 conflicting_link_verified_via: row.conflicting_link_verified_via,
417 })
418 .collect();
419
420 token.authorized_ok(web::Json(AccountLinkingStats {
421 account_linking_enabled: app_conf.suotar_configuration.account_linking_enabled,
422 window_secs,
423 funnel,
424 send_status_totals,
425 hard_failure_domains,
426 modules,
427 stale_addresses,
428 links_total_by_method,
429 links_in_window_by_method,
430 waiting_for_student_number_count,
431 max_mails_per_person_and_course: MAX_LINKING_MAILS_PER_PERSON_AND_COURSE,
432 quiet_period_secs: LINKING_MAIL_QUIET_PERIOD.num_seconds(),
433 study_registry_conflicts,
434 }))
435}
436
437#[instrument(skip(pool, payload, app_conf, suotar_client))]
446#[utoipa::path(
447 post,
448 path = "/account-linking/resend",
449 operation_id = "adminResendAccountLinkingEmail",
450 tag = "credit-registration-admin",
451 request_body = AdminResendAccountLinkingEmailPayload,
452 responses(
453 (status = 200, description = "What the attempt did", body = AdminResendAccountLinkingEmailResult),
454 (status = 422, description = "An override without a reason, or too soon after the last resend")
455 )
456)]
457pub async fn admin_resend_account_linking_email(
458 user: AuthUser,
459 pool: web::Data<PgPool>,
460 payload: web::Json<AdminResendAccountLinkingEmailPayload>,
461 app_conf: web::Data<ApplicationConfiguration>,
462 suotar_client: web::Data<headless_lms_utils::services::suotar::SuotarClient>,
463) -> ControllerResult<web::Json<AdminResendAccountLinkingEmailResult>> {
464 let mut conn = pool.acquire().await?;
465 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
466 ensure_resend_possible(&mut conn, &app_conf, payload.course_id).await?;
467
468 let student_number = required_student_number(&payload.student_number)?;
469 let override_reason = if payload.override_rate_caps {
470 Some(required_reason(payload.reason.as_deref().unwrap_or(""))?.to_string())
471 } else {
472 None
473 };
474
475 let recent = models::credit_registration_admin_actions::count_queued_resends_by_actor_since(
476 &mut conn,
477 user.id,
478 Utc::now() - chrono::Duration::seconds(RESEND_QUIET_PERIOD_SECS),
479 )
480 .await?;
481 if recent > 0 {
482 return Err(controller_err!(
483 BadRequest,
484 "Wait a minute between resends.".to_string()
485 ));
486 }
487
488 let ctx = ManualActionContext::new(&pool, &suotar_client, RESEND_CALLER);
489 drop(conn);
491 info!(
492 actor = %user.id,
493 course_id = %payload.course_id,
494 override_rate_caps = payload.override_rate_caps,
495 "Admin requested a linking mail resend"
496 );
497 let rate_cap_override = override_reason.as_deref().map(|reason| RateCapOverride {
498 actor_user_id: user.id,
499 actor_role: GLOBAL_ADMIN_ROLE,
500 reason,
501 });
502 let attempt =
503 resend_linking_mail_for_target(&ctx, payload.course_id, &student_number, rate_cap_override)
504 .await?;
505 let mut conn = pool.acquire().await?;
506 let outcome = ResendOutcome::from(attempt.outcome);
507 info!(
508 ?outcome,
509 retired_mail_count = attempt.retired_mail_count,
510 "Admin linking mail resend finished"
511 );
512
513 finish_resend(
514 &mut conn,
515 &user,
516 &payload,
517 &student_number,
518 outcome,
519 attempt.retired_mail_count,
520 token,
521 )
522 .await
523}
524
525#[instrument(skip(pool, payload, suotar_client))]
533#[utoipa::path(
534 post,
535 path = "/account-linking/resolve-person",
536 operation_id = "adminResolveStudentNumberForLinking",
537 tag = "credit-registration-admin",
538 request_body = AdminResolveStudentNumberPayload,
539 responses(
540 (status = 200, description = "Who the study registry says the number belongs to", body = AdminResolveStudentNumberResult),
541 (status = 422, description = "No student number given")
542 )
543)]
544pub async fn admin_resolve_student_number_for_linking(
545 user: AuthUser,
546 pool: web::Data<PgPool>,
547 payload: web::Json<AdminResolveStudentNumberPayload>,
548 suotar_client: web::Data<headless_lms_utils::services::suotar::SuotarClient>,
549) -> ControllerResult<web::Json<AdminResolveStudentNumberResult>> {
550 let mut conn = pool.acquire().await?;
551 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
552
553 if !models::course_modules::any_credit_registration_enabled(&mut conn).await? {
554 return Err(controller_err!(
555 BadRequest,
556 "No course has credit registration configured.".to_string()
557 ));
558 }
559
560 let student_number = typed_student_number(&payload.student_number)?;
561
562 let ctx = ManualActionContext::new(&pool, &suotar_client, RESOLVE_CALLER);
563 drop(conn);
565 info!(actor = %user.id, "Admin resolving a student number for linking");
566 let resolved = look_up_person(&ctx, &student_number).await;
567 debug!(
568 found = resolved.as_ref().is_ok_and(Option::is_some),
569 "Student number resolution result"
570 );
571 let mut conn = pool.acquire().await?;
572 let existing =
573 verified_student_numbers::get_by_student_number(&mut conn, student_number.expose_secret())
574 .await?
575 .or(match &resolved {
576 Ok(Some(person)) => verified_student_numbers::get_by_sisu_person_ids(
577 &mut conn,
578 &[person.sisu_person_id.expose_secret().to_owned()],
579 )
580 .await?
581 .into_iter()
582 .next(),
583 _ => None,
584 });
585 let already_linked_to_user_email = match &existing {
586 Some(link) => {
587 match models::user_details::get_user_details_by_user_id(&mut conn, link.user_id).await {
588 Ok(details) => Some(details.email),
589 Err(error)
590 if matches!(
591 error.error_type(),
592 models::ModelErrorType::RecordNotFound | models::ModelErrorType::NotFound
593 ) =>
594 {
595 None
596 }
597 Err(error) => return Err(error.into()),
598 }
599 }
600 None => None,
601 };
602
603 let mails = match &resolved {
604 Ok(Some(person)) => {
605 credit_registration_account_linking_emails::get_by_sisu_person_id(
606 &mut conn,
607 person.sisu_person_id.expose_secret(),
608 )
609 .await?
610 }
611 _ => Vec::new(),
612 };
613 let linking_emails = build_linking_emails(&mut conn, mails).await?;
614
615 let shared = AdminResolveStudentNumberResult {
616 found: false,
617 student_number: student_number.expose_secret().to_owned(),
618 sisu_person_id: None,
619 first_names: None,
620 last_name: None,
621 code: None,
622 study_registry_unavailable: false,
623 lookup_error_code: None,
624 already_linked_to_user_id: existing.as_ref().map(|link| link.user_id),
625 already_linked_to_user_email,
626 already_linked_via: existing.as_ref().map(|link| link.verified_via),
627 linking_emails,
628 };
629 let result = match resolved {
630 Ok(Some(person)) => AdminResolveStudentNumberResult {
631 found: true,
632 sisu_person_id: Some(person.sisu_person_id.expose_secret().to_owned()),
633 first_names: expose_option(&person.first_names).map(str::to_owned),
634 last_name: expose_option(&person.last_name).map(str::to_owned),
635 code: Some(person.code),
636 ..shared
637 },
638 Ok(None) => AdminResolveStudentNumberResult { ..shared },
639 Err(PersonLookupError::UnexpectedAnswer { code }) => AdminResolveStudentNumberResult {
640 lookup_error_code: Some(code),
641 ..shared
642 },
643 Err(
644 PersonLookupError::StudyRegistryUnavailable
645 | PersonLookupError::ItemMissingFromResponse,
646 ) => AdminResolveStudentNumberResult {
647 study_registry_unavailable: true,
648 ..shared
649 },
650 };
651
652 token.authorized_ok(web::Json(result))
653}
654
655#[instrument(skip(pool, payload, suotar_client))]
664#[utoipa::path(
665 post,
666 path = "/account-linking/manual-link",
667 operation_id = "adminManuallyLinkStudentNumber",
668 tag = "credit-registration-admin",
669 request_body = AdminManuallyLinkStudentNumberPayload,
670 responses(
671 (status = 200, description = "What the attempt did", body = AdminManuallyLinkStudentNumberResult),
672 (status = 422, description = "No reason, no student number, or no person id from the preview")
673 )
674)]
675pub async fn admin_manually_link_student_number(
676 user: AuthUser,
677 pool: web::Data<PgPool>,
678 payload: web::Json<AdminManuallyLinkStudentNumberPayload>,
679 suotar_client: web::Data<headless_lms_utils::services::suotar::SuotarClient>,
680) -> ControllerResult<web::Json<AdminManuallyLinkStudentNumberResult>> {
681 let mut conn = pool.acquire().await?;
682 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
683
684 if !models::course_modules::any_credit_registration_enabled(&mut conn).await? {
685 return Err(controller_err!(
686 BadRequest,
687 "No course has credit registration configured.".to_string()
688 ));
689 }
690
691 let ManualLinkRequest {
692 reason,
693 student_number,
694 previewed_person_id,
695 } = manual_link_request(&payload)?;
696 let reason = reason.to_string();
697
698 let refused = |outcome: AdminManualLinkOutcome| {
699 debug!(?outcome, "Admin manual link refused");
700 AdminManuallyLinkStudentNumberResult {
701 outcome,
702 verified_student_number_id: None,
703 affected_registration_count: 0,
704 }
705 };
706 let ctx = ManualActionContext::new(&pool, &suotar_client, MANUAL_LINK_CALLER);
707 drop(conn);
709 info!(actor = %user.id, target_user_id = %payload.user_id, "Admin manually linking a student number");
710 let person: RegistryPerson = match look_up_person(&ctx, &student_number).await {
711 Ok(Some(person)) => person,
712 Ok(None) => {
713 return token.authorized_ok(web::Json(refused(
714 AdminManualLinkOutcome::StudentNumberNotFound,
715 )));
716 }
717 Err(PersonLookupError::UnexpectedAnswer { .. }) => {
718 return token.authorized_ok(web::Json(refused(
719 AdminManualLinkOutcome::UnexpectedStudyRegistryAnswer,
720 )));
721 }
722 Err(
723 PersonLookupError::StudyRegistryUnavailable
724 | PersonLookupError::ItemMissingFromResponse,
725 ) => {
726 return token.authorized_ok(web::Json(refused(
727 AdminManualLinkOutcome::StudyRegistryUnavailable,
728 )));
729 }
730 };
731 if person.sisu_person_id.expose_secret() != previewed_person_id.expose_secret() {
732 return token.authorized_ok(web::Json(refused(AdminManualLinkOutcome::PreviewMismatch)));
733 }
734 let mut conn = pool.acquire().await?;
735
736 if let Some(conflict) = verified_student_numbers::find_link_conflict(
737 &mut conn,
738 student_number.expose_secret(),
739 person.sisu_person_id.expose_secret(),
740 payload.user_id,
741 )
742 .await?
743 {
744 let outcome = match conflict {
745 LinkConflict::SameAccount => AdminManualLinkOutcome::AlreadyLinkedToThisAccount,
746 LinkConflict::AnotherAccount => AdminManualLinkOutcome::AlreadyLinkedToAnotherAccount,
747 };
748 return token.authorized_ok(web::Json(refused(outcome)));
749 }
750
751 let mut tx = conn.begin().await?;
752 let current_link_id = verified_student_numbers::get_by_user_id(&mut tx, payload.user_id)
755 .await?
756 .map(|current| current.id);
757 let (verified_student_number_id, affected_registration_count) =
758 verified_student_numbers::replace_verified_student_number(
759 &mut tx,
760 current_link_id,
761 &NewVerifiedStudentNumber {
762 user_id: payload.user_id,
763 student_number: student_number.clone().into(),
764 sisu_person_id: person.sisu_person_id.clone().into(),
765 first_names: person.first_names.clone().map(Into::into),
766 last_name: person.last_name.clone().map(Into::into),
767 verified_via: StudentNumberVerificationMethod::AdminManual,
768 verified_via_email: None,
770 linked_by_user_id: Some(user.id),
771 link_reason: Some(reason.clone()),
772 verified_from_course_id: None,
773 },
774 Some(user.id),
775 models::credit_registration_events::CreditRegistrationEventKind::AdminAction,
776 "An administrator linked this student number by hand.",
777 )
778 .await?;
779 models::credit_registration_admin_actions::record(
780 &mut tx,
781 &NewCreditRegistrationAdminAction {
782 target_id: Some(verified_student_number_id),
783 reason: Some(reason),
784 details: Some(serde_json::json!({
785 "user_id": payload.user_id,
786 "student_number": student_number.expose_secret(),
787 })),
788 affected_row_count: Some(
789 i32::try_from(affected_registration_count).unwrap_or(i32::MAX),
790 ),
791 ..NewCreditRegistrationAdminAction::new(
792 CreditRegistrationAdminAction::ManualLinkStudentNumber,
793 CreditRegistrationAdminActionTarget::VerifiedStudentNumber,
794 user.id,
795 GLOBAL_ADMIN_ROLE,
796 )
797 },
798 )
799 .await?;
800 tx.commit().await?;
801 info!(
802 verified_student_number_id = %verified_student_number_id,
803 affected_registration_count,
804 "Admin manual link finished"
805 );
806
807 token.authorized_ok(web::Json(AdminManuallyLinkStudentNumberResult {
808 outcome: AdminManualLinkOutcome::Linked,
809 verified_student_number_id: Some(verified_student_number_id),
810 affected_registration_count,
811 }))
812}
813
814struct ManualLinkRequest<'a> {
816 reason: &'a str,
817 student_number: SecretString,
818 previewed_person_id: SecretString,
819}
820
821fn manual_link_request(
825 payload: &AdminManuallyLinkStudentNumberPayload,
826) -> Result<ManualLinkRequest<'_>, ControllerError> {
827 let reason = required_reason(&payload.reason)?;
828 let student_number = typed_student_number(&payload.student_number)?;
829 let previewed_person_id = SecretString::from(payload.sisu_person_id.expose_secret().trim());
830 if previewed_person_id.expose_secret().is_empty() {
831 return Err(controller_err!(
832 BadRequest,
833 "Check the number in the study registry first.".to_string()
834 ));
835 }
836 Ok(ManualLinkRequest {
837 reason,
838 student_number,
839 previewed_person_id,
840 })
841}
842
843fn required_student_number(raw: &SecretString) -> Result<SecretString, ControllerError> {
844 let student_number = SecretString::from(raw.expose_secret().trim());
845 if student_number.expose_secret().is_empty() {
846 return Err(controller_err!(
847 BadRequest,
848 "Name a student number.".to_string()
849 ));
850 }
851 Ok(student_number)
852}
853
854fn typed_student_number(raw: &SecretString) -> Result<SecretString, ControllerError> {
858 required_student_number(raw)?;
859 parse_student_number(raw.expose_secret())
860 .map(SecretString::from)
861 .map_err(|invalid| controller_err!(BadRequest, invalid.message().to_string()))
862}
863
864async fn finish_resend(
866 conn: &mut PgConnection,
867 user: &AuthUser,
868 payload: &AdminResendAccountLinkingEmailPayload,
869 student_number: &SecretString,
870 outcome: ResendOutcome,
871 retired_mail_count: i64,
872 token: crate::domain::authorization::AuthorizationToken,
873) -> ControllerResult<web::Json<AdminResendAccountLinkingEmailResult>> {
874 let (mails, mails_sent_for_this_course) = record_resend_and_fetch_mails(
875 conn,
876 payload.course_id,
877 Some(student_number.expose_secret()),
878 user.id,
879 GLOBAL_ADMIN_ROLE,
880 None,
881 payload.reason.clone(),
882 serde_json::json!({
883 "outcome": outcome,
884 "student_number": student_number.expose_secret(),
885 "override_rate_caps": payload.override_rate_caps,
886 "retired_mail_count": retired_mail_count,
887 }),
888 )
889 .await?;
890 let linking_emails = build_linking_emails(conn, mails).await?;
891
892 token.authorized_ok(web::Json(AdminResendAccountLinkingEmailResult {
893 outcome,
894 retired_mail_count,
895 linking_emails,
896 mails_sent_for_this_course,
897 max_mails_per_person_and_course: MAX_LINKING_MAILS_PER_PERSON_AND_COURSE,
898 quiet_period_secs: LINKING_MAIL_QUIET_PERIOD.num_seconds(),
899 }))
900}
901
902async fn build_stale_addresses(
903 conn: &mut PgConnection,
904 rows: Vec<StaleUnclaimedLinkingMails>,
905) -> Result<Vec<AccountLinkingStaleAddress>, ControllerError> {
906 let ids: Vec<Uuid> = rows.iter().flat_map(|row| row.mail_ids.clone()).collect();
907 let reports =
908 credit_registration_account_linking_emails::get_send_status_reports(conn, &ids).await?;
909 Ok(rows
910 .into_iter()
911 .map(|row| {
912 let sends = row
913 .mail_ids
914 .iter()
915 .zip(row.addresses)
916 .map(|(id, address)| AccountLinkingSendOutcome {
917 address: address.expose_secret().to_owned(),
918 send_status: reports
919 .get(id)
920 .map(|report| report.email_send_status)
921 .unwrap_or(EmailSendStatus::Queued),
922 })
923 .collect();
924 AccountLinkingStaleAddress {
925 sends,
926 student_number: row.student_number.expose_secret().to_owned(),
927 sisu_person_id: row.sisu_person_id.expose_secret().to_owned(),
928 course_id: row.course_id,
929 course_name: row.course_name,
930 mail_count: row.mail_count,
931 first_sent_at: row.first_sent_at,
932 last_sent_at: row.last_sent_at,
933 }
934 })
935 .collect())
936}
937
938pub fn _add_routes(cfg: &mut ServiceConfig) {
939 cfg.route("/account-linking", web::get().to(get_account_linking_stats))
940 .route(
941 "/account-linking/resend",
942 web::post().to(admin_resend_account_linking_email),
943 )
944 .route(
945 "/account-linking/resolve-person",
946 web::post().to(admin_resolve_student_number_for_linking),
947 )
948 .route(
949 "/account-linking/manual-link",
950 web::post().to(admin_manually_link_student_number),
951 );
952}
953
954#[cfg(test)]
955mod tests {
956 use super::*;
957
958 fn manual_link_payload(
959 reason: &str,
960 student_number: &str,
961 sisu_person_id: &str,
962 ) -> AdminManuallyLinkStudentNumberPayload {
963 AdminManuallyLinkStudentNumberPayload {
964 user_id: Uuid::new_v4(),
965 student_number: student_number.into(),
966 sisu_person_id: sisu_person_id.into(),
967 reason: reason.to_string(),
968 }
969 }
970
971 #[test]
972 fn a_manual_link_is_refused_without_a_preview_a_reason_or_a_valid_number() {
973 assert!(
974 manual_link_request(&manual_link_payload(
975 "Host bounces our mail.",
976 "012345672",
977 ""
978 ))
979 .is_err()
980 );
981 assert!(manual_link_request(&manual_link_payload(" ", "012345672", "hy-hlo-1")).is_err());
982 assert!(manual_link_request(&manual_link_payload("", "012345672", "hy-hlo-1")).is_err());
983 assert!(
984 manual_link_request(&manual_link_payload(
985 "Host bounces our mail.",
986 "",
987 "hy-hlo-1"
988 ))
989 .is_err()
990 );
991 for mistyped in ["012345678", "12345672", "0123456721"] {
992 assert!(
993 manual_link_request(&manual_link_payload(
994 "Host bounces our mail.",
995 mistyped,
996 "hy-hlo-1"
997 ))
998 .is_err(),
999 "{mistyped}"
1000 );
1001 }
1002 let payload =
1003 manual_link_payload(" Host bounces our mail. ", " 012345672 ", " hy-hlo-1 ");
1004 let allowed = manual_link_request(&payload)
1005 .expect("a reason, a number and a previewed person id are all there");
1006 assert_eq!(allowed.reason, "Host bounces our mail.");
1007 assert_eq!(allowed.student_number.expose_secret(), "012345672");
1008 assert_eq!(allowed.previewed_person_id.expose_secret(), "hy-hlo-1");
1009 }
1010}