1use std::future::Future;
4use std::pin::Pin;
5
6use headless_lms_models::course_module_suotar_realisations;
7use headless_lms_models::credit_registration_account_linking_emails::{
8 self, StaleUnclaimedLinkingMails,
9};
10use headless_lms_models::credit_registration_admin_actions::{
11 CreditRegistrationAdminAction, CreditRegistrationAdminActionTarget, GLOBAL_ADMIN_ROLE,
12 NewCreditRegistrationAdminAction,
13};
14use headless_lms_models::credit_registrations;
15use headless_lms_models::email_deliveries::EmailSendStatus;
16use headless_lms_models::library::credit_registration::account_linking::{
17 LINKING_MAIL_QUIET_PERIOD_SECS, MAX_LINKING_MAILS_PER_PERSON_AND_COURSE, retire_capped_mails,
18};
19use headless_lms_models::verified_student_numbers::{
20 self, NewVerifiedStudentNumber, StudentNumberVerificationMethod,
21};
22use utoipa::ToSchema;
23
24use crate::controllers::main_frontend::course_credit_registrations::record_resend_and_fetch_mails;
25use crate::domain::credit_registration_phases::PhaseContext;
26use crate::domain::credit_registration_phases::linking_mail_resend::{
27 ResendOutcome, ResolvedPerson, resend_linking_mail_for_target, resolve_person,
28};
29use crate::prelude::*;
30use headless_lms_base::config::ApplicationConfiguration;
31
32use super::{
33 AdminLinkingEmail, authorize_credit_registration_admin, build_linking_emails, required_reason,
34};
35
36const STALE_UNCLAIMED_LIMIT: i64 = 200;
37
38const RESEND_CALLER: &str = "admin-resend";
40const RESOLVE_CALLER: &str = "admin-resolve-person";
41
42const RESEND_QUIET_PERIOD_SECS: i64 = 60;
45
46fn phase_context<'a>(
47 pool: &'a web::Data<PgPool>,
48 suotar_client: &'a web::Data<headless_lms_utils::services::suotar::SuotarClient>,
49 app_conf: &'a ApplicationConfiguration,
50 caller: &'a str,
51) -> PhaseContext<'a> {
52 PhaseContext::from_app(pool, suotar_client, app_conf, caller)
53}
54
55#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
58pub struct AccountLinkingFunnel {
59 pub persons_discovered_last_run: i64,
60 pub already_linked_last_run: i64,
61 pub mails_claimed_in_window: i64,
62 pub mails_sent_in_window: i64,
63 pub numbers_claimed_in_window: i64,
64 pub manual_links_in_window: i64,
66 pub suppressed_by_dedup_last_run: i64,
67 pub suppressed_by_rate_cap_last_run: i64,
68 pub no_address_in_study_registry_last_run: i64,
69 pub fast_tracked_in_window: i64,
73 pub fast_tracked_last_run: i64,
74}
75
76#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
77pub struct AccountLinkingSendStatusTotals {
78 pub queued: i64,
79 pub retrying: i64,
80 pub sent: i64,
81 pub send_failed: i64,
82}
83
84#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
86pub struct AccountLinkingFailureDomain {
87 pub domain: String,
88 pub count: i64,
89}
90
91#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
92pub struct AccountLinkingRealisationCounters {
93 pub course_id: Uuid,
94 pub course_name: String,
95 pub course_module_id: Uuid,
96 pub course_module_name: Option<String>,
97 pub course_unit_realisation_id: String,
98 pub label: Option<String>,
99 pub uh_course_code: Option<String>,
100 pub last_listed_at: Option<DateTime<Utc>>,
103 pub last_listing_attempted_at: Option<DateTime<Utc>>,
104 pub last_listing_error:
107 Option<headless_lms_models::credit_registrations::CreditRegistrationErrorCode>,
108 pub consecutive_listing_failures: i32,
109 pub listed_person_count: Option<i32>,
110 pub already_linked_count: Option<i32>,
111 pub mailed_count: Option<i32>,
112 pub suppressed_by_dedup_count: Option<i32>,
113 pub suppressed_by_rate_cap_count: Option<i32>,
114 pub no_address_count: Option<i32>,
116 pub fast_tracked_count: Option<i32>,
117 pub fast_track_skipped_no_account_count: Option<i32>,
118 pub fast_track_skipped_unverified_count: Option<i32>,
121 pub fast_track_skipped_stale_verification_count: Option<i32>,
122 pub fast_track_skipped_name_mismatch_count: Option<i32>,
124 pub fast_track_skipped_account_has_number_count: Option<i32>,
125 pub fast_track_skipped_unlinked_before_count: Option<i32>,
126}
127
128#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
130pub struct AccountLinkingSendOutcome {
131 pub address: String,
132 pub send_status: EmailSendStatus,
133}
134
135#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
137pub struct AccountLinkingStaleAddress {
138 pub student_number: String,
139 pub sisu_person_id: String,
140 pub course_id: Uuid,
141 pub course_name: String,
142 pub mail_count: i64,
143 pub first_sent_at: DateTime<Utc>,
144 pub last_sent_at: DateTime<Utc>,
145 pub sends: Vec<AccountLinkingSendOutcome>,
147}
148
149#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
150pub struct VerifiedStudentNumberMethodTotal {
151 pub verified_via: StudentNumberVerificationMethod,
152 pub count: i64,
153}
154
155#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
156pub struct AccountLinkingStats {
157 pub window_secs: i64,
158 pub funnel: AccountLinkingFunnel,
159 pub send_status_totals: AccountLinkingSendStatusTotals,
160 pub hard_failure_domains: Vec<AccountLinkingFailureDomain>,
161 pub realisations: Vec<AccountLinkingRealisationCounters>,
162 pub stale_addresses: Vec<AccountLinkingStaleAddress>,
163 pub links_total_by_method: Vec<VerifiedStudentNumberMethodTotal>,
164 pub links_in_window_by_method: Vec<VerifiedStudentNumberMethodTotal>,
165 pub waiting_for_student_number_count: i64,
167 pub max_mails_per_person_and_course: i64,
168 pub quiet_period_secs: i64,
169}
170
171#[derive(Debug, Deserialize)]
172pub struct AccountLinkingStatsQuery {
173 window_days: Option<u32>,
174}
175
176#[derive(Debug, Deserialize, ToSchema)]
177pub struct AdminResendAccountLinkingEmailPayload {
178 pub student_number: String,
179 pub course_id: Uuid,
180 pub override_rate_caps: bool,
182 pub reason: Option<String>,
183}
184
185#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
186pub struct AdminResendAccountLinkingEmailResult {
187 pub outcome: ResendOutcome,
188 pub retired_mail_count: i64,
190 pub linking_emails: Vec<AdminLinkingEmail>,
191 pub mails_sent_for_this_course: i64,
192 pub max_mails_per_person_and_course: i64,
193 pub quiet_period_secs: i64,
194}
195
196#[derive(Debug, Deserialize, ToSchema)]
197pub struct AdminResolveStudentNumberPayload {
198 pub student_number: String,
199}
200
201#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
204pub struct AdminResolveStudentNumberResult {
205 pub found: bool,
206 pub student_number: String,
207 pub sisu_person_id: Option<String>,
209 pub first_names: Option<String>,
210 pub last_name: Option<String>,
211 pub code: Option<String>,
213 pub study_registry_unavailable: bool,
214 pub already_linked_to_user_id: Option<Uuid>,
215 pub already_linked_to_user_email: Option<String>,
216 pub already_linked_via: Option<StudentNumberVerificationMethod>,
217 pub linking_emails: Vec<AdminLinkingEmail>,
218}
219
220#[derive(Debug, Deserialize, ToSchema)]
221pub struct AdminManuallyLinkStudentNumberPayload {
222 pub user_id: Uuid,
223 pub student_number: String,
224 pub sisu_person_id: String,
227 pub reason: String,
228}
229
230#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
231#[serde(rename_all = "snake_case")]
232pub enum AdminManualLinkOutcome {
233 Linked,
234 StudentNumberNotFound,
236 PreviewMismatch,
238 AlreadyLinkedToAnotherAccount,
240 AlreadyLinkedToThisAccount,
241 StudyRegistryUnavailable,
242}
243
244#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
245pub struct AdminManuallyLinkStudentNumberResult {
246 pub outcome: AdminManualLinkOutcome,
247 pub verified_student_number_id: Option<Uuid>,
248 pub affected_registration_count: i64,
250}
251
252#[instrument(skip(pool))]
257#[utoipa::path(
258 get,
259 path = "/account-linking",
260 operation_id = "getAccountLinkingStats",
261 tag = "credit-registration-admin",
262 params(("window_days" = Option<u32>, Query, description = "Window for the windowed funnel steps, in days")),
263 responses(
264 (status = 200, description = "Where account linking stands", body = AccountLinkingStats)
265 )
266)]
267pub async fn get_account_linking_stats(
268 user: AuthUser,
269 pool: web::Data<PgPool>,
270 query: web::Query<AccountLinkingStatsQuery>,
271) -> ControllerResult<web::Json<AccountLinkingStats>> {
272 let mut conn = pool.acquire().await?;
273 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
274
275 let window_days = i64::from(query.window_days.unwrap_or(30).clamp(1, 365));
276 let window_secs = window_days * 24 * 60 * 60;
277 let since = Utc::now() - chrono::Duration::days(window_days);
278
279 let realisations = course_module_suotar_realisations::get_active_discovery_reports(&mut conn)
280 .await?
281 .into_iter()
282 .map(|row| AccountLinkingRealisationCounters {
283 course_id: row.course_id,
284 course_name: row.course_name,
285 course_module_id: row.course_module_id,
286 course_module_name: row.course_module_name,
287 course_unit_realisation_id: row.course_unit_realisation_id,
288 label: row.label,
289 uh_course_code: row.uh_course_code,
290 last_listed_at: row.last_listed_at,
291 last_listing_attempted_at: row.last_listing_attempted_at,
292 last_listing_error: row.last_listing_error,
293 consecutive_listing_failures: row.consecutive_listing_failures,
294 listed_person_count: row.last_listed_person_count,
295 already_linked_count: row.last_already_linked_count,
296 mailed_count: row.last_mailed_count,
297 suppressed_by_dedup_count: row.last_suppressed_by_dedup_count,
298 suppressed_by_rate_cap_count: row.last_suppressed_by_rate_cap_count,
299 no_address_count: row.last_no_address_count,
300 fast_tracked_count: row.last_fast_tracked_count,
301 fast_track_skipped_no_account_count: row.last_fast_track_skipped_no_account_count,
302 fast_track_skipped_unverified_count: row.last_fast_track_skipped_unverified_count,
303 fast_track_skipped_stale_verification_count: row
304 .last_fast_track_skipped_stale_verification_count,
305 fast_track_skipped_name_mismatch_count: row.last_fast_track_skipped_name_mismatch_count,
306 fast_track_skipped_account_has_number_count: row
307 .last_fast_track_skipped_account_has_number_count,
308 fast_track_skipped_unlinked_before_count: row
309 .last_fast_track_skipped_unlinked_before_count,
310 })
311 .collect::<Vec<_>>();
312 let sum = |pick: fn(&AccountLinkingRealisationCounters) -> Option<i32>| -> i64 {
313 realisations
314 .iter()
315 .filter_map(pick)
316 .map(i64::from)
317 .sum::<i64>()
318 };
319
320 let now = Utc::now();
321 let totals = credit_registration_account_linking_emails::get_send_status_totals_since(
322 &mut conn, since, now,
323 )
324 .await?;
325 let send_status_totals = AccountLinkingSendStatusTotals {
326 queued: totals.queued,
327 retrying: totals.retrying,
328 sent: totals.sent,
329 send_failed: totals.send_failed,
330 };
331 let hard_failure_domains: Vec<AccountLinkingFailureDomain> =
332 credit_registration_account_linking_emails::get_send_failure_domains_since(
333 &mut conn, since, now,
334 )
335 .await?
336 .into_iter()
337 .map(|row| AccountLinkingFailureDomain {
338 domain: row.domain,
339 count: row.count,
340 })
341 .collect();
342
343 let method_counts = verified_student_numbers::count_by_method_since(&mut conn, since).await?;
344 let links_total_by_method = method_counts
345 .iter()
346 .map(
347 |&(verified_via, total, _)| VerifiedStudentNumberMethodTotal {
348 verified_via,
349 count: total,
350 },
351 )
352 .collect::<Vec<_>>();
353 let links_in_window_by_method = method_counts
354 .iter()
355 .map(
356 |&(verified_via, _, in_window)| VerifiedStudentNumberMethodTotal {
357 verified_via,
358 count: in_window,
359 },
360 )
361 .collect::<Vec<_>>();
362 let in_window = |method: StudentNumberVerificationMethod| -> i64 {
363 links_in_window_by_method
364 .iter()
365 .filter(|row| row.verified_via == method)
366 .map(|row| row.count)
367 .sum()
368 };
369
370 let stale = credit_registration_account_linking_emails::get_stale_unclaimed(
371 &mut conn,
372 MAX_LINKING_MAILS_PER_PERSON_AND_COURSE,
373 STALE_UNCLAIMED_LIMIT,
374 )
375 .await?;
376 let stale_addresses = build_stale_addresses(&mut conn, stale).await?;
377
378 let waiting_for_student_number_count = credit_registrations::count_pending_by_reason(&mut conn)
379 .await?
380 .student_number_count;
381
382 let funnel = AccountLinkingFunnel {
383 persons_discovered_last_run: sum(|row| row.listed_person_count),
384 already_linked_last_run: sum(|row| row.already_linked_count),
385 mails_claimed_in_window: totals.mails_in_window,
386 mails_sent_in_window: send_status_totals.sent,
387 numbers_claimed_in_window: in_window(StudentNumberVerificationMethod::EmailedLink),
388 manual_links_in_window: in_window(StudentNumberVerificationMethod::AdminManual),
389 suppressed_by_dedup_last_run: sum(|row| row.suppressed_by_dedup_count),
390 suppressed_by_rate_cap_last_run: sum(|row| row.suppressed_by_rate_cap_count),
391 no_address_in_study_registry_last_run: sum(|row| row.no_address_count),
392 fast_tracked_in_window: in_window(StudentNumberVerificationMethod::EmailMatchFastTrack),
393 fast_tracked_last_run: sum(|row| row.fast_tracked_count),
394 };
395
396 token.authorized_ok(web::Json(AccountLinkingStats {
397 window_secs,
398 funnel,
399 send_status_totals,
400 hard_failure_domains,
401 realisations,
402 stale_addresses,
403 links_total_by_method,
404 links_in_window_by_method,
405 waiting_for_student_number_count,
406 max_mails_per_person_and_course: MAX_LINKING_MAILS_PER_PERSON_AND_COURSE,
407 quiet_period_secs: LINKING_MAIL_QUIET_PERIOD_SECS,
408 }))
409}
410
411#[instrument(skip(pool, payload, app_conf, suotar_client))]
420#[utoipa::path(
421 post,
422 path = "/account-linking/resend",
423 operation_id = "adminResendAccountLinkingEmail",
424 tag = "credit-registration-admin",
425 request_body = AdminResendAccountLinkingEmailPayload,
426 responses(
427 (status = 200, description = "What the attempt did", body = AdminResendAccountLinkingEmailResult),
428 (status = 422, description = "An override without a reason, or too soon after the last resend")
429 )
430)]
431pub async fn admin_resend_account_linking_email(
432 user: AuthUser,
433 pool: web::Data<PgPool>,
434 payload: web::Json<AdminResendAccountLinkingEmailPayload>,
435 app_conf: web::Data<ApplicationConfiguration>,
436 suotar_client: web::Data<headless_lms_utils::services::suotar::SuotarClient>,
437) -> ControllerResult<web::Json<AdminResendAccountLinkingEmailResult>> {
438 let mut conn = pool.acquire().await?;
439 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
440
441 let enabled_module_ids =
442 models::course_modules::get_credit_registration_enabled_ids_for_course(
443 &mut conn,
444 payload.course_id,
445 )
446 .await?;
447 if enabled_module_ids.is_empty() {
448 return Err(controller_err!(
449 BadRequest,
450 "This course has no credit registration module configured.".to_string()
451 ));
452 }
453
454 let student_number = payload.student_number.trim();
455 if student_number.is_empty() {
456 return Err(controller_err!(
457 BadRequest,
458 "Name a student number.".to_string()
459 ));
460 }
461 let override_reason = if payload.override_rate_caps {
462 Some(required_reason(payload.reason.as_deref().unwrap_or(""))?.to_string())
463 } else {
464 None
465 };
466
467 let recent = models::credit_registration_admin_actions::count_queued_resends_by_actor_since(
468 &mut conn,
469 user.id,
470 Utc::now() - chrono::Duration::seconds(RESEND_QUIET_PERIOD_SECS),
471 )
472 .await?;
473 if recent > 0 {
474 return Err(controller_err!(
475 BadRequest,
476 "Wait a minute between resends.".to_string()
477 ));
478 }
479
480 let ctx = phase_context(&pool, &suotar_client, &app_conf, RESEND_CALLER);
481 let before_send: Pin<Box<dyn Future<Output = anyhow::Result<i64>> + '_>> =
484 match &override_reason {
485 Some(reason) => Box::pin(async {
486 Ok(retire_capped_mails(
487 &mut conn,
488 user.id,
489 GLOBAL_ADMIN_ROLE,
490 payload.course_id,
491 student_number,
492 reason,
493 )
494 .await?)
495 }),
496 None => Box::pin(async { Ok(0) }),
497 };
498 let attempt =
499 resend_linking_mail_for_target(&ctx, payload.course_id, student_number, before_send)
500 .await?;
501 let outcome = ResendOutcome::from(attempt.decision);
502
503 finish_resend(
504 &mut conn,
505 &user,
506 &payload,
507 student_number,
508 outcome,
509 attempt.retired_mail_count,
510 token,
511 )
512 .await
513}
514
515#[instrument(skip(pool, payload, app_conf, suotar_client))]
523#[utoipa::path(
524 post,
525 path = "/account-linking/resolve-person",
526 operation_id = "adminResolveStudentNumberForLinking",
527 tag = "credit-registration-admin",
528 request_body = AdminResolveStudentNumberPayload,
529 responses(
530 (status = 200, description = "Who the study registry says the number belongs to", body = AdminResolveStudentNumberResult),
531 (status = 422, description = "No student number given")
532 )
533)]
534pub async fn admin_resolve_student_number_for_linking(
535 user: AuthUser,
536 pool: web::Data<PgPool>,
537 payload: web::Json<AdminResolveStudentNumberPayload>,
538 app_conf: web::Data<ApplicationConfiguration>,
539 suotar_client: web::Data<headless_lms_utils::services::suotar::SuotarClient>,
540) -> ControllerResult<web::Json<AdminResolveStudentNumberResult>> {
541 let mut conn = pool.acquire().await?;
542 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
543
544 if !models::course_modules::any_credit_registration_enabled(&mut conn).await? {
545 return Err(controller_err!(
546 BadRequest,
547 "No course has credit registration configured.".to_string()
548 ));
549 }
550
551 let student_number = payload.student_number.trim();
552 if student_number.is_empty() {
553 return Err(controller_err!(
554 BadRequest,
555 "Name a student number.".to_string()
556 ));
557 }
558
559 let ctx = phase_context(&pool, &suotar_client, &app_conf, RESOLVE_CALLER);
560 let resolved = resolve_person(&ctx, student_number).await;
561 let existing = verified_student_numbers::get_by_student_number(&mut conn, student_number)
562 .await?
563 .or(match &resolved {
564 Ok(Some(person)) => verified_student_numbers::get_by_sisu_person_ids(
565 &mut conn,
566 std::slice::from_ref(&person.sisu_person_id),
567 )
568 .await?
569 .into_iter()
570 .next(),
571 _ => None,
572 });
573 let already_linked_to_user_email = match &existing {
574 Some(link) => models::user_details::get_user_details_by_user_id(&mut conn, link.user_id)
575 .await
576 .ok()
577 .map(|details| details.email),
578 None => None,
579 };
580
581 let mails = match &resolved {
582 Ok(Some(person)) => {
583 credit_registration_account_linking_emails::get_by_sisu_person_id(
584 &mut conn,
585 &person.sisu_person_id,
586 )
587 .await?
588 }
589 _ => Vec::new(),
590 };
591 let linking_emails = build_linking_emails(&mut conn, mails).await?;
592
593 let shared = AdminResolveStudentNumberResult {
594 found: false,
595 student_number: student_number.to_string(),
596 sisu_person_id: None,
597 first_names: None,
598 last_name: None,
599 code: None,
600 study_registry_unavailable: false,
601 already_linked_to_user_id: existing.as_ref().map(|link| link.user_id),
602 already_linked_to_user_email,
603 already_linked_via: existing.as_ref().map(|link| link.verified_via),
604 linking_emails,
605 };
606 let result = match resolved {
607 Ok(Some(person)) => AdminResolveStudentNumberResult {
608 found: true,
609 sisu_person_id: Some(person.sisu_person_id),
610 first_names: Some(person.first_names),
611 last_name: Some(person.last_name),
612 code: Some(person.code),
613 ..shared
614 },
615 Ok(None) => AdminResolveStudentNumberResult { ..shared },
616 Err(_) => AdminResolveStudentNumberResult {
617 study_registry_unavailable: true,
618 ..shared
619 },
620 };
621
622 token.authorized_ok(web::Json(result))
623}
624
625#[instrument(skip(pool, payload, app_conf, suotar_client))]
634#[utoipa::path(
635 post,
636 path = "/account-linking/manual-link",
637 operation_id = "adminManuallyLinkStudentNumber",
638 tag = "credit-registration-admin",
639 request_body = AdminManuallyLinkStudentNumberPayload,
640 responses(
641 (status = 200, description = "What the attempt did", body = AdminManuallyLinkStudentNumberResult),
642 (status = 422, description = "No reason, no student number, or no person id from the preview")
643 )
644)]
645pub async fn admin_manually_link_student_number(
646 user: AuthUser,
647 pool: web::Data<PgPool>,
648 payload: web::Json<AdminManuallyLinkStudentNumberPayload>,
649 app_conf: web::Data<ApplicationConfiguration>,
650 suotar_client: web::Data<headless_lms_utils::services::suotar::SuotarClient>,
651) -> ControllerResult<web::Json<AdminManuallyLinkStudentNumberResult>> {
652 let mut conn = pool.acquire().await?;
653 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
654
655 if !models::course_modules::any_credit_registration_enabled(&mut conn).await? {
656 return Err(controller_err!(
657 BadRequest,
658 "No course has credit registration configured.".to_string()
659 ));
660 }
661
662 let ManualLinkRequest {
663 reason,
664 student_number,
665 previewed_person_id,
666 } = manual_link_request(&payload)?;
667 let reason = reason.to_string();
668
669 let refused = |outcome| AdminManuallyLinkStudentNumberResult {
670 outcome,
671 verified_student_number_id: None,
672 affected_registration_count: 0,
673 };
674 let ctx = phase_context(&pool, &suotar_client, &app_conf, RESOLVE_CALLER);
675 let person: ResolvedPerson = match resolve_person(&ctx, student_number).await {
676 Ok(Some(person)) => person,
677 Ok(None) => {
678 return token.authorized_ok(web::Json(refused(
679 AdminManualLinkOutcome::StudentNumberNotFound,
680 )));
681 }
682 Err(_) => {
683 return token.authorized_ok(web::Json(refused(
684 AdminManualLinkOutcome::StudyRegistryUnavailable,
685 )));
686 }
687 };
688 if person.sisu_person_id != previewed_person_id {
689 return token.authorized_ok(web::Json(refused(AdminManualLinkOutcome::PreviewMismatch)));
690 }
691
692 let holder = verified_student_numbers::get_by_student_number(&mut conn, student_number).await?;
693 if let Some(holder) = &holder {
694 let outcome = if holder.user_id == payload.user_id {
695 AdminManualLinkOutcome::AlreadyLinkedToThisAccount
696 } else {
697 AdminManualLinkOutcome::AlreadyLinkedToAnotherAccount
698 };
699 return token.authorized_ok(web::Json(refused(outcome)));
700 }
701 let person_holder =
706 verified_student_numbers::get_by_sisu_person_id(&mut conn, &person.sisu_person_id).await?;
707 if let Some(holder) = &person_holder {
708 let outcome = if holder.user_id == payload.user_id {
709 AdminManualLinkOutcome::AlreadyLinkedToThisAccount
710 } else {
711 AdminManualLinkOutcome::AlreadyLinkedToAnotherAccount
712 };
713 return token.authorized_ok(web::Json(refused(outcome)));
714 }
715
716 let mut tx = conn.begin().await?;
717 let current_link_id = verified_student_numbers::get_by_user_id(&mut tx, payload.user_id)
720 .await?
721 .map(|current| current.id);
722 let (verified_student_number_id, affected_registration_count) =
723 verified_student_numbers::replace_verified_student_number(
724 &mut tx,
725 current_link_id,
726 &NewVerifiedStudentNumber {
727 user_id: payload.user_id,
728 student_number: student_number.to_string(),
729 sisu_person_id: person.sisu_person_id.clone(),
730 first_names: Some(person.first_names.clone()),
731 last_name: Some(person.last_name.clone()),
732 verified_via: StudentNumberVerificationMethod::AdminManual,
733 verified_via_email: None,
735 verified_via_email_match_field: None,
736 account_email_verified_at: None,
737 linked_by_user_id: Some(user.id),
738 link_reason: Some(reason.clone()),
739 verified_from_course_id: None,
740 },
741 Some(user.id),
742 models::credit_registration_events::CreditRegistrationEventKind::AdminAction,
743 "An administrator linked this student number by hand.",
744 )
745 .await?;
746 models::credit_registration_admin_actions::record(
747 &mut tx,
748 &NewCreditRegistrationAdminAction {
749 target_id: Some(verified_student_number_id),
750 reason: Some(reason),
751 details: Some(serde_json::json!({
752 "user_id": payload.user_id,
753 "student_number": student_number,
754 })),
755 affected_row_count: Some(
756 i32::try_from(affected_registration_count).unwrap_or(i32::MAX),
757 ),
758 ..NewCreditRegistrationAdminAction::new(
759 CreditRegistrationAdminAction::ManualLinkStudentNumber,
760 CreditRegistrationAdminActionTarget::VerifiedStudentNumber,
761 user.id,
762 GLOBAL_ADMIN_ROLE,
763 )
764 },
765 )
766 .await?;
767 tx.commit().await?;
768
769 token.authorized_ok(web::Json(AdminManuallyLinkStudentNumberResult {
770 outcome: AdminManualLinkOutcome::Linked,
771 verified_student_number_id: Some(verified_student_number_id),
772 affected_registration_count,
773 }))
774}
775
776struct ManualLinkRequest<'a> {
778 reason: &'a str,
779 student_number: &'a str,
780 previewed_person_id: &'a str,
781}
782
783fn manual_link_request(
787 payload: &AdminManuallyLinkStudentNumberPayload,
788) -> Result<ManualLinkRequest<'_>, ControllerError> {
789 let reason = required_reason(&payload.reason)?;
790 let student_number = payload.student_number.trim();
791 if student_number.is_empty() {
792 return Err(controller_err!(
793 BadRequest,
794 "Name a student number.".to_string()
795 ));
796 }
797 let previewed_person_id = payload.sisu_person_id.trim();
798 if previewed_person_id.is_empty() {
799 return Err(controller_err!(
800 BadRequest,
801 "Check the number in the study registry first.".to_string()
802 ));
803 }
804 Ok(ManualLinkRequest {
805 reason,
806 student_number,
807 previewed_person_id,
808 })
809}
810
811async fn finish_resend(
813 conn: &mut PgConnection,
814 user: &AuthUser,
815 payload: &AdminResendAccountLinkingEmailPayload,
816 student_number: &str,
817 outcome: ResendOutcome,
818 retired_mail_count: i64,
819 token: crate::domain::authorization::AuthorizationToken,
820) -> ControllerResult<web::Json<AdminResendAccountLinkingEmailResult>> {
821 let (mails, mails_sent_for_this_course) = record_resend_and_fetch_mails(
822 conn,
823 payload.course_id,
824 Some(student_number),
825 user.id,
826 GLOBAL_ADMIN_ROLE,
827 None,
828 payload.reason.clone(),
829 serde_json::json!({
830 "outcome": outcome,
831 "student_number": student_number,
832 "override_rate_caps": payload.override_rate_caps,
833 "retired_mail_count": retired_mail_count,
834 }),
835 )
836 .await?;
837 let linking_emails = build_linking_emails(conn, mails).await?;
838
839 token.authorized_ok(web::Json(AdminResendAccountLinkingEmailResult {
840 outcome,
841 retired_mail_count,
842 linking_emails,
843 mails_sent_for_this_course,
844 max_mails_per_person_and_course: MAX_LINKING_MAILS_PER_PERSON_AND_COURSE,
845 quiet_period_secs: LINKING_MAIL_QUIET_PERIOD_SECS,
846 }))
847}
848
849async fn build_stale_addresses(
850 conn: &mut PgConnection,
851 rows: Vec<StaleUnclaimedLinkingMails>,
852) -> Result<Vec<AccountLinkingStaleAddress>, ControllerError> {
853 let ids: Vec<Uuid> = rows.iter().flat_map(|row| row.mail_ids.clone()).collect();
854 let reports =
855 credit_registration_account_linking_emails::get_send_status_reports(conn, &ids).await?;
856 Ok(rows
857 .into_iter()
858 .map(|row| {
859 let sends = row
860 .mail_ids
861 .iter()
862 .zip(row.addresses)
863 .map(|(id, address)| AccountLinkingSendOutcome {
864 address,
865 send_status: reports
866 .get(id)
867 .map(|report| report.email_send_status)
868 .unwrap_or(EmailSendStatus::Queued),
869 })
870 .collect();
871 AccountLinkingStaleAddress {
872 sends,
873 student_number: row.student_number,
874 sisu_person_id: row.sisu_person_id,
875 course_id: row.course_id,
876 course_name: row.course_name,
877 mail_count: row.mail_count,
878 first_sent_at: row.first_sent_at,
879 last_sent_at: row.last_sent_at,
880 }
881 })
882 .collect())
883}
884
885pub fn _add_routes(cfg: &mut ServiceConfig) {
886 cfg.route("/account-linking", web::get().to(get_account_linking_stats))
887 .route(
888 "/account-linking/resend",
889 web::post().to(admin_resend_account_linking_email),
890 )
891 .route(
892 "/account-linking/resolve-person",
893 web::post().to(admin_resolve_student_number_for_linking),
894 )
895 .route(
896 "/account-linking/manual-link",
897 web::post().to(admin_manually_link_student_number),
898 );
899}
900
901#[cfg(test)]
902mod tests {
903 use super::*;
904
905 fn manual_link_payload(
906 reason: &str,
907 student_number: &str,
908 sisu_person_id: &str,
909 ) -> AdminManuallyLinkStudentNumberPayload {
910 AdminManuallyLinkStudentNumberPayload {
911 user_id: Uuid::new_v4(),
912 student_number: student_number.to_string(),
913 sisu_person_id: sisu_person_id.to_string(),
914 reason: reason.to_string(),
915 }
916 }
917
918 #[test]
919 fn a_manual_link_is_refused_without_a_preview_and_without_a_reason() {
920 assert!(
921 manual_link_request(&manual_link_payload(
922 "Host bounces our mail.",
923 "012345678",
924 ""
925 ))
926 .is_err()
927 );
928 assert!(manual_link_request(&manual_link_payload(" ", "012345678", "hy-hlo-1")).is_err());
929 assert!(manual_link_request(&manual_link_payload("", "012345678", "hy-hlo-1")).is_err());
930 assert!(
931 manual_link_request(&manual_link_payload(
932 "Host bounces our mail.",
933 "",
934 "hy-hlo-1"
935 ))
936 .is_err()
937 );
938 let payload =
939 manual_link_payload(" Host bounces our mail. ", " 012345678 ", " hy-hlo-1 ");
940 let allowed = manual_link_request(&payload)
941 .expect("a reason, a number and a previewed person id are all there");
942 assert_eq!(allowed.reason, "Host bounces our mail.");
943 assert_eq!(allowed.student_number, "012345678");
944 assert_eq!(allowed.previewed_person_id, "hy-hlo-1");
945 }
946}