1use std::collections::HashMap;
9
10use chrono::TimeDelta;
11use secrecy::ExposeSecret;
12
13use crate::credit_registration_account_linking_emails::{
14 self, ExistingLinkingMailFact, NewAccountLinkingEmail, claim_send_slots,
15 get_existing_facts_for_persons,
16};
17use crate::credit_registration_admin_actions::{
18 CreditRegistrationAdminAction, CreditRegistrationAdminActionTarget,
19 NewCreditRegistrationAdminAction,
20};
21use crate::error::missing_model_error;
22use crate::prelude::*;
23use crate::student_number_verification_tokens::{
24 NewStudentNumberVerificationToken, insert_batch as insert_tokens_batch,
25};
26
27use super::study_registry::RosterPerson;
28
29pub const LINK_STUDENT_NUMBER_PATH: &str = "/link-student-number";
31
32pub fn link_student_number_url(base_url: &str, token: &str) -> String {
34 format!(
35 "{}{LINK_STUDENT_NUMBER_PATH}/{token}",
36 base_url.trim_end_matches('/')
37 )
38}
39
40pub const LINKING_MAIL_QUIET_PERIOD: TimeDelta = TimeDelta::days(1);
42
43pub const MAX_LINKING_MAILS_PER_PERSON_AND_COURSE: i64 = 3;
46
47#[derive(Debug, Clone)]
49pub struct DiscoveredPerson {
50 pub sisu_person_id: DbSecret,
51 pub student_number: DbSecret,
52 pub first_names: Option<DbSecret>,
53 pub last_name: Option<DbSecret>,
54 pub course_id: Uuid,
55 pub addresses: Vec<DbSecret>,
57}
58
59impl DiscoveredPerson {
60 pub fn listed(person: &RosterPerson, course_id: Uuid) -> Self {
63 Self {
64 sisu_person_id: person.person_id.clone().into(),
65 student_number: person.student_number.clone().into(),
66 first_names: person.first_names.clone().map(Into::into),
67 last_name: person.last_name.clone().map(Into::into),
68 course_id,
69 addresses: listed_person_addresses(person),
70 }
71 }
72}
73
74fn listed_person_addresses(person: &RosterPerson) -> Vec<DbSecret> {
77 [&person.primary_email, &person.secondary_email]
78 .into_iter()
79 .flatten()
80 .filter(|address| !address.expose_secret().trim().is_empty())
81 .map(|address| DbSecret::from(address.clone()))
82 .collect()
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
87pub struct ClaimedLinkingMails {
88 pub claimed: i32,
89 pub suppressed_by_dedup: i32,
90 pub suppressed_by_rate_cap: i32,
91}
92
93pub async fn claim_linking_mails(
95 conn: &mut PgConnection,
96 person: &DiscoveredPerson,
97) -> ModelResult<ClaimedLinkingMails> {
98 claim_linking_mails_batch(conn, std::slice::from_ref(person))
99 .await?
100 .into_iter()
101 .next()
102 .ok_or_else(missing_model_error(
103 ModelErrorType::Generic,
104 "Claiming linking mails answered nothing for the one person asked about.",
105 ))
106}
107
108pub async fn claim_linking_mails_batch(
112 conn: &mut PgConnection,
113 people: &[DiscoveredPerson],
114) -> ModelResult<Vec<ClaimedLinkingMails>> {
115 let mut outcomes = vec![ClaimedLinkingMails::default(); people.len()];
116 let per_person_addresses: Vec<Vec<DbSecret>> = people
117 .iter()
118 .map(|person| distinct_addresses(&person.addresses))
119 .collect();
120
121 let sisu_person_ids: Vec<String> = people
122 .iter()
123 .zip(&per_person_addresses)
124 .filter(|(_, addresses)| !addresses.is_empty())
125 .map(|(person, _)| person.sisu_person_id.expose_secret().to_owned())
126 .collect();
127 if sisu_person_ids.is_empty() {
128 return Ok(outcomes);
129 }
130 let facts = get_existing_facts_for_persons(conn, &sisu_person_ids).await?;
131 let mut by_person: HashMap<&str, Vec<&ExistingLinkingMailFact>> = HashMap::new();
132 for fact in &facts {
133 by_person
134 .entry(fact.sisu_person_id.expose_secret())
135 .or_default()
136 .push(fact);
137 }
138 let quiet_since = Utc::now() - LINKING_MAIL_QUIET_PERIOD;
139
140 let mut to_claim: Vec<(usize, &DbSecret)> = Vec::new();
141 for (i, (person, addresses)) in people.iter().zip(&per_person_addresses).enumerate() {
142 if addresses.is_empty() {
143 continue;
144 }
145 let person_facts = by_person
146 .get(person.sisu_person_id.expose_secret())
147 .map(Vec::as_slice)
148 .unwrap_or(&[]);
149 let mut allowance = remaining_allowance(person, person_facts, quiet_since);
150 for address in addresses {
151 if already_mailed(person_facts, person.course_id, address.expose_secret()) {
152 outcomes[i].suppressed_by_dedup += 1;
153 continue;
154 }
155 if allowance == 0 {
156 outcomes[i].suppressed_by_rate_cap += 1;
157 continue;
158 }
159 allowance -= 1;
160 to_claim.push((i, address));
161 }
162 }
163 if to_claim.is_empty() {
164 return Ok(outcomes);
165 }
166
167 let mut new_tokens = Vec::with_capacity(to_claim.len());
169 let mut new_slots = Vec::with_capacity(to_claim.len());
170 let mut token_ids = Vec::with_capacity(to_claim.len());
171 let mut token_owner: HashMap<Uuid, usize> = HashMap::new();
172 for (person_index, address) in &to_claim {
173 let person = &people[*person_index];
174 let token_id = Uuid::new_v4();
175 new_tokens.push(NewStudentNumberVerificationToken {
176 student_number: person.student_number.clone(),
177 sisu_person_id: person.sisu_person_id.clone(),
178 first_names: person.first_names.clone(),
179 last_name: person.last_name.clone(),
180 emailed_to: (*address).clone(),
181 course_id: Some(person.course_id),
182 });
183 token_owner.insert(token_id, *person_index);
184 token_ids.push(token_id);
185 new_slots.push(NewAccountLinkingEmail {
186 student_number: person.student_number.clone(),
187 sisu_person_id: person.sisu_person_id.clone(),
188 course_id: person.course_id,
189 emailed_to: (*address).clone(),
190 student_number_verification_token_id: Some(token_id),
191 email_delivery_id: None,
192 });
193 }
194 insert_tokens_batch(conn, &token_ids, &new_tokens).await?;
195
196 let claimed_token_ids = claim_send_slots(conn, &new_slots, &token_ids).await?;
197 let lost_token_ids: Vec<Uuid> = token_owner
198 .keys()
199 .filter(|id| !claimed_token_ids.contains(id))
200 .copied()
201 .collect();
202 void_tokens(conn, &lost_token_ids).await?;
204 for (token_id, person_index) in &token_owner {
205 if claimed_token_ids.contains(token_id) {
206 outcomes[*person_index].claimed += 1;
207 } else {
208 outcomes[*person_index].suppressed_by_dedup += 1;
210 }
211 }
212 Ok(outcomes)
213}
214
215pub async fn retire_capped_mails(
219 conn: &mut PgConnection,
220 actor_user_id: Uuid,
221 actor_role: &str,
222 course_id: Uuid,
223 student_number: &str,
224 reason: &str,
225) -> ModelResult<i64> {
226 let Some(person_id) = person_id_of_mails(conn, course_id, student_number).await? else {
227 return Ok(0);
228 };
229 let quiet_since = Utc::now() - LINKING_MAIL_QUIET_PERIOD;
230 let mails = credit_registration_account_linking_emails::get_by_sisu_person_id(
231 conn,
232 person_id.expose_secret(),
233 )
234 .await?;
235 let retired: Vec<Uuid> = mails
238 .iter()
239 .filter(|mail| mail.course_id == course_id || mail.sent_at >= quiet_since)
240 .map(|mail| mail.id)
241 .collect();
242 if retired.is_empty() {
243 return Ok(0);
244 }
245
246 let mut tx = conn.begin().await?;
247 credit_registration_account_linking_emails::soft_delete_batch(&mut tx, &retired).await?;
248 crate::credit_registration_admin_actions::record(
249 &mut tx,
250 &NewCreditRegistrationAdminAction {
251 target_id: Some(course_id),
252 reason: Some(reason.to_string()),
253 details: Some(serde_json::json!({
254 "student_number": student_number,
255 "retired_linking_email_ids": retired,
256 })),
257 affected_row_count: Some(i32::try_from(retired.len()).unwrap_or(i32::MAX)),
258 ..NewCreditRegistrationAdminAction::new(
259 CreditRegistrationAdminAction::OverrideRateCap,
260 CreditRegistrationAdminActionTarget::Course,
261 actor_user_id,
262 actor_role,
263 )
264 },
265 )
266 .await?;
267 tx.commit().await?;
268 Ok(retired.len() as i64)
269}
270
271async fn person_id_of_mails(
272 conn: &mut PgConnection,
273 course_id: Uuid,
274 student_number: &str,
275) -> ModelResult<Option<DbSecret>> {
276 let mails = credit_registration_account_linking_emails::get_by_course_id_and_student_number(
277 conn,
278 course_id,
279 student_number,
280 )
281 .await?;
282 Ok(mails.into_iter().next().map(|mail| mail.sisu_person_id))
283}
284
285fn remaining_allowance(
287 person: &DiscoveredPerson,
288 facts: &[&ExistingLinkingMailFact],
289 quiet_since: DateTime<Utc>,
290) -> i64 {
291 if facts.iter().any(|fact| fact.sent_at >= quiet_since) {
293 return 0;
294 }
295 let already_sent = facts
296 .iter()
297 .filter(|fact| fact.course_id == person.course_id)
298 .count() as i64;
299 (MAX_LINKING_MAILS_PER_PERSON_AND_COURSE - already_sent).max(0)
300}
301
302fn already_mailed(facts: &[&ExistingLinkingMailFact], course_id: Uuid, address: &str) -> bool {
305 facts.iter().any(|fact| {
306 fact.course_id == course_id
307 && fact
308 .emailed_to
309 .expose_secret()
310 .eq_ignore_ascii_case(address)
311 })
312}
313
314async fn void_tokens(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
316 if ids.is_empty() {
317 return Ok(());
318 }
319 sqlx::query!(
320 r#"
321UPDATE student_number_verification_tokens
322SET deleted_at = now()
323WHERE id = ANY($1::uuid [])
324 AND deleted_at IS NULL
325 "#,
326 ids,
327 )
328 .execute(conn)
329 .await?;
330 Ok(())
331}
332
333fn distinct_addresses(addresses: &[DbSecret]) -> Vec<DbSecret> {
336 let mut kept: Vec<DbSecret> = Vec::new();
337 for address in addresses {
338 let trimmed = address.expose_secret().trim();
339 if trimmed.is_empty() {
340 continue;
341 }
342 if kept
343 .iter()
344 .any(|seen| seen.expose_secret().eq_ignore_ascii_case(trimmed))
345 {
346 continue;
347 }
348 kept.push(DbSecret::new(trimmed));
349 }
350 kept
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356 use crate::credit_registration_account_linking_emails::{
357 count_sent_for_person_and_course, get_by_sisu_person_id,
358 };
359 use crate::credit_registration_admin_actions::{self, GLOBAL_ADMIN_ROLE};
360 use crate::student_number_verification_tokens::{claim, get_by_ids};
361 use crate::test_helper::*;
362
363 fn person(course_id: Uuid, addresses: &[&str]) -> DiscoveredPerson {
364 DiscoveredPerson {
365 sisu_person_id: "hy-hlo-1".to_string().into(),
366 student_number: "012345678".to_string().into(),
367 first_names: Some("Aada Maria".to_string().into()),
368 last_name: Some("Virtanen".to_string().into()),
369 course_id,
370 addresses: addresses.iter().map(|a| DbSecret::new(*a)).collect(),
371 }
372 }
373
374 #[tokio::test]
375 async fn each_address_of_a_person_gets_its_own_mail_and_token() {
376 insert_data!(:tx, :user, :org, :course);
377 let claimed = claim_linking_mails(
378 tx.as_mut(),
379 &person(course, &["aada.uni@example.com", "aada@example.com"]),
380 )
381 .await
382 .unwrap();
383 assert_eq!(claimed.claimed, 2);
384 assert_eq!(claimed.suppressed_by_dedup, 0);
385 let rows = get_by_sisu_person_id(tx.as_mut(), "hy-hlo-1")
386 .await
387 .unwrap();
388 assert_eq!(rows.len(), 2);
389 for row in rows {
390 assert!(row.student_number_verification_token_id.is_some());
391 assert!(row.email_delivery_id.is_none());
392 }
393 }
394
395 #[tokio::test]
396 async fn one_address_repeated_is_claimed_once() {
397 insert_data!(:tx, :user, :org, :course);
398 let claimed = claim_linking_mails(
399 tx.as_mut(),
400 &person(
401 course,
402 &["Aada.Uni@Example.com", "aada.uni@example.com", " "],
403 ),
404 )
405 .await
406 .unwrap();
407 assert_eq!(claimed.claimed, 1);
408 assert_eq!(claimed.suppressed_by_rate_cap, 0);
409 }
410
411 #[tokio::test]
412 async fn mailing_the_same_address_twice_is_refused_as_a_duplicate() {
413 insert_data!(:tx, :user, :org, :course);
414 let discovered = person(course, &["aada.uni@example.com"]);
415 assert_eq!(
416 claim_linking_mails(tx.as_mut(), &discovered).await.unwrap(),
417 ClaimedLinkingMails {
418 claimed: 1,
419 ..ClaimedLinkingMails::default()
420 }
421 );
422 assert_eq!(
423 claim_linking_mails(tx.as_mut(), &discovered).await.unwrap(),
424 ClaimedLinkingMails {
425 suppressed_by_dedup: 1,
426 ..ClaimedLinkingMails::default()
427 }
428 );
429 assert_eq!(
430 get_by_sisu_person_id(tx.as_mut(), "hy-hlo-1")
431 .await
432 .unwrap()
433 .len(),
434 1
435 );
436 }
437
438 #[tokio::test]
439 async fn a_person_mailed_today_is_left_alone_even_at_another_address() {
440 insert_data!(:tx, :user, :org, :course);
441 claim_linking_mails(tx.as_mut(), &person(course, &["aada.uni@example.com"]))
442 .await
443 .unwrap();
444 let claimed = claim_linking_mails(tx.as_mut(), &person(course, &["aada@example.com"]))
445 .await
446 .unwrap();
447 assert_eq!(
448 claimed,
449 ClaimedLinkingMails {
450 suppressed_by_rate_cap: 1,
451 ..ClaimedLinkingMails::default()
452 }
453 );
454 }
455
456 #[tokio::test]
457 async fn a_person_and_course_are_never_mailed_more_than_the_cap() {
458 insert_data!(:tx, :user, :org, :course);
459 let cap = usize::try_from(MAX_LINKING_MAILS_PER_PERSON_AND_COURSE).unwrap();
460 let addresses: Vec<DbSecret> = (0..cap + 2)
461 .map(|i| DbSecret::new(format!("aada{i}@example.com")))
462 .collect();
463 let claimed = claim_linking_mails(
464 tx.as_mut(),
465 &DiscoveredPerson {
466 addresses,
467 ..person(course, &[])
468 },
469 )
470 .await
471 .unwrap();
472 assert_eq!(
473 i64::from(claimed.claimed),
474 MAX_LINKING_MAILS_PER_PERSON_AND_COURSE
475 );
476 assert_eq!(claimed.suppressed_by_rate_cap, 2);
477 }
478
479 #[tokio::test]
480 async fn the_token_is_created_unbound_and_can_be_claimed_only_once() {
481 insert_data!(:tx, :user, :org, :course);
482 claim_linking_mails(tx.as_mut(), &person(course, &["aada.uni@example.com"]))
483 .await
484 .unwrap();
485 let slot = get_by_sisu_person_id(tx.as_mut(), "hy-hlo-1")
486 .await
487 .unwrap()
488 .pop()
489 .expect("the claim wrote a slot");
490 let token_id = slot.student_number_verification_token_id.unwrap();
491 let token = get_by_ids(tx.as_mut(), &[token_id])
492 .await
493 .unwrap()
494 .remove(&token_id)
495 .expect("the claim minted a token");
496 assert_eq!(token.claimed_by_user_id, None);
497 assert_eq!(token.used_at, None);
498 assert!(token.expires_at > Utc::now());
499
500 assert!(claim(tx.as_mut(), &token.token, user).await.unwrap());
501 assert!(!claim(tx.as_mut(), &token.token, user).await.unwrap());
502 }
503
504 #[tokio::test]
505 async fn the_rate_cap_override_retires_the_ledger_rows_and_audits_itself() {
506 insert_data!(:tx, :user, :org, :course);
507
508 let claimed = claim_linking_mails(
509 tx.as_mut(),
510 &DiscoveredPerson {
511 sisu_person_id: "hy-hlo-1".to_string().into(),
512 student_number: "012345678".to_string().into(),
513 first_names: Some("Aada Maria".to_string().into()),
514 last_name: Some("Virtanen".to_string().into()),
515 course_id: course,
516 addresses: vec![DbSecret::new("aada.uni@example.com")],
517 },
518 )
519 .await
520 .unwrap();
521 assert_eq!(claimed.claimed, 1);
522 assert_eq!(
523 count_sent_for_person_and_course(tx.as_mut(), "hy-hlo-1", course)
524 .await
525 .unwrap(),
526 1
527 );
528
529 let retired = retire_capped_mails(
530 tx.as_mut(),
531 user,
532 GLOBAL_ADMIN_ROLE,
533 course,
534 "012345678",
535 "The recipient's mail host rejects everything we send.",
536 )
537 .await
538 .unwrap();
539 assert_eq!(retired, 1);
540 assert_eq!(
541 count_sent_for_person_and_course(tx.as_mut(), "hy-hlo-1", course)
542 .await
543 .unwrap(),
544 0
545 );
546
547 let actions = credit_registration_admin_actions::get_by_actor(tx.as_mut(), user, 10)
548 .await
549 .unwrap();
550 assert_eq!(actions.len(), 1);
551 let action = &actions[0];
552 assert_eq!(
553 action.action,
554 CreditRegistrationAdminAction::OverrideRateCap
555 );
556 assert_eq!(action.actor_role, GLOBAL_ADMIN_ROLE);
557 assert_eq!(
558 action.reason.as_deref(),
559 Some("The recipient's mail host rejects everything we send.")
560 );
561 assert_eq!(action.affected_row_count, Some(1));
562 }
563
564 #[tokio::test]
565 async fn an_override_with_nothing_to_retire_writes_nothing() {
566 insert_data!(:tx, :user, :org, :course);
567
568 let retired = retire_capped_mails(
569 tx.as_mut(),
570 user,
571 GLOBAL_ADMIN_ROLE,
572 course,
573 "012345678",
574 "No mails yet.",
575 )
576 .await
577 .unwrap();
578 assert_eq!(retired, 0);
579 assert!(
580 credit_registration_admin_actions::get_by_actor(tx.as_mut(), user, 10)
581 .await
582 .unwrap()
583 .is_empty()
584 );
585 }
586}