1use std::collections::{BTreeSet, 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, replace_lapsed_slots,
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 LINKING_MAIL_RESEND_INTERVAL: TimeDelta = TimeDelta::days(7);
45
46pub const MAX_LINKING_MAILS_PER_PERSON_AND_COURSE: i64 = 3;
49
50pub const JUST_CLAIMED_PERIOD: TimeDelta = TimeDelta::minutes(1);
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct RetiredMails {
57 pub retired_count: i64,
58 pub kept_just_claimed: bool,
60}
61
62#[derive(Debug, Clone)]
64pub struct DiscoveredPerson {
65 pub sisu_person_id: DbSecret,
66 pub student_number: DbSecret,
67 pub first_names: Option<DbSecret>,
68 pub last_name: Option<DbSecret>,
69 pub course_id: Uuid,
70 pub address: Option<DbSecret>,
73}
74
75impl DiscoveredPerson {
76 pub fn listed(person: &RosterPerson, course_id: Uuid) -> Self {
79 Self {
80 sisu_person_id: person.person_id.clone().into(),
81 student_number: person.student_number.clone().into(),
82 first_names: person.first_names.clone().map(Into::into),
83 last_name: person.last_name.clone().map(Into::into),
84 course_id,
85 address: mail_address(person),
86 }
87 }
88}
89
90pub fn mail_address(person: &RosterPerson) -> Option<DbSecret> {
97 listed_addresses(person).next().map(DbSecret::new)
98}
99
100pub fn listed_addresses(person: &RosterPerson) -> impl Iterator<Item = &str> {
102 [&person.primary_email, &person.secondary_email]
103 .into_iter()
104 .flatten()
105 .map(|address| address.expose_secret().trim())
106 .filter(|address| !address.is_empty())
107}
108
109pub async fn registerable_completion_courses_by_account_email(
113 conn: &mut PgConnection,
114 course_ids: &[Uuid],
115 since: DateTime<Utc>,
116 addresses: &[String],
117) -> ModelResult<HashMap<String, BTreeSet<Uuid>>> {
118 let rows = sqlx::query!(
119 r#"
120SELECT DISTINCT lower(user_details.email) AS "email!",
121 ec.course_id AS "course_id!"
122FROM credit_registration_eligible_completions ec
123 JOIN user_details ON user_details.user_id = ec.user_id
124WHERE ec.course_id = ANY($1::uuid [])
125 AND ec.created_at >= $2
126 AND ec.fully_eligible
127 AND lower(user_details.email) = ANY($3::text [])
128 "#,
129 course_ids,
130 since,
131 addresses,
132 )
133 .fetch_all(conn)
134 .await?;
135 let mut courses_by_email: HashMap<String, BTreeSet<Uuid>> = HashMap::new();
136 for row in rows {
137 courses_by_email
138 .entry(row.email)
139 .or_default()
140 .insert(row.course_id);
141 }
142 Ok(courses_by_email)
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
148pub enum LinkingMailClaim {
149 NoAddress,
150 SuppressedByRateCap,
151 SuppressedByDedup,
153 Claimed,
155}
156
157pub async fn claim_linking_mail(
159 conn: &mut PgConnection,
160 person: &DiscoveredPerson,
161) -> ModelResult<LinkingMailClaim> {
162 claim_linking_mails_batch(conn, std::slice::from_ref(person))
163 .await?
164 .into_iter()
165 .next()
166 .ok_or_else(missing_model_error(
167 ModelErrorType::Generic,
168 "Claiming linking mails answered nothing for the one person asked about.",
169 ))
170}
171
172pub async fn claim_linking_mails_batch(
175 conn: &mut PgConnection,
176 people: &[DiscoveredPerson],
177) -> ModelResult<Vec<LinkingMailClaim>> {
178 let mut claims = vec![LinkingMailClaim::NoAddress; people.len()];
179 let sisu_person_ids: Vec<String> = people
180 .iter()
181 .filter(|person| person.address.is_some())
182 .map(|person| person.sisu_person_id.expose_secret().to_owned())
183 .collect();
184 if sisu_person_ids.is_empty() {
185 return Ok(claims);
186 }
187 let facts = get_existing_facts_for_persons(conn, &sisu_person_ids).await?;
188 let mut by_person: HashMap<&str, Vec<&ExistingLinkingMailFact>> = HashMap::new();
189 for fact in &facts {
190 by_person
191 .entry(fact.sisu_person_id.expose_secret())
192 .or_default()
193 .push(fact);
194 }
195 let now = Utc::now();
196
197 let mut new_tokens = Vec::new();
199 let mut new_slots = Vec::new();
200 let mut token_ids = Vec::new();
201 let mut token_owner: HashMap<Uuid, usize> = HashMap::new();
202 for (i, person) in people.iter().enumerate() {
203 let Some(address) = &person.address else {
204 continue;
205 };
206 let person_facts = by_person
207 .get(person.sisu_person_id.expose_secret())
208 .map(Vec::as_slice)
209 .unwrap_or(&[]);
210 if holds_address(person_facts, person.course_id, address.expose_secret()) {
211 claims[i] = LinkingMailClaim::SuppressedByDedup;
212 continue;
213 }
214 if !is_within_caps(person, person_facts, now) {
215 claims[i] = LinkingMailClaim::SuppressedByRateCap;
216 continue;
217 }
218 let token_id = Uuid::new_v4();
219 new_tokens.push(NewStudentNumberVerificationToken {
220 student_number: person.student_number.clone(),
221 sisu_person_id: person.sisu_person_id.clone(),
222 first_names: person.first_names.clone(),
223 last_name: person.last_name.clone(),
224 emailed_to: address.clone(),
225 course_id: Some(person.course_id),
226 });
227 token_owner.insert(token_id, i);
228 token_ids.push(token_id);
229 new_slots.push(NewAccountLinkingEmail {
230 student_number: person.student_number.clone(),
231 sisu_person_id: person.sisu_person_id.clone(),
232 course_id: person.course_id,
233 emailed_to: address.clone(),
234 student_number_verification_token_id: Some(token_id),
235 email_delivery_id: None,
236 });
237 }
238 if new_slots.is_empty() {
239 return Ok(claims);
240 }
241 insert_tokens_batch(conn, &token_ids, &new_tokens).await?;
242
243 replace_lapsed_slots(conn, &new_slots).await?;
244 let claimed_token_ids = claim_send_slots(conn, &new_slots, &token_ids).await?;
245 let lost_token_ids: Vec<Uuid> = token_owner
246 .keys()
247 .filter(|id| !claimed_token_ids.contains(id))
248 .copied()
249 .collect();
250 void_tokens(conn, &lost_token_ids).await?;
252 for (token_id, person_index) in &token_owner {
253 claims[*person_index] = if claimed_token_ids.contains(token_id) {
254 LinkingMailClaim::Claimed
255 } else {
256 LinkingMailClaim::SuppressedByDedup
258 };
259 }
260 Ok(claims)
261}
262
263pub async fn retire_capped_mails(
267 conn: &mut PgConnection,
268 actor_user_id: Uuid,
269 actor_role: &str,
270 course_id: Uuid,
271 student_number: &str,
272 reason: &str,
273) -> ModelResult<RetiredMails> {
274 let mut outcome = RetiredMails {
275 retired_count: 0,
276 kept_just_claimed: false,
277 };
278 let Some(person_id) = person_id_of_mails(conn, course_id, student_number).await? else {
279 return Ok(outcome);
280 };
281 let now = Utc::now();
282 let quiet_since = now - LINKING_MAIL_QUIET_PERIOD;
283 let mails = credit_registration_account_linking_emails::get_by_sisu_person_id(
284 conn,
285 person_id.expose_secret(),
286 )
287 .await?;
288 let (just_claimed, retirable): (Vec<_>, Vec<_>) = mails
291 .iter()
292 .filter(|mail| mail.course_id == course_id || mail.sent_at >= quiet_since)
293 .partition(|mail| mail.sent_at >= now - JUST_CLAIMED_PERIOD);
294 outcome.kept_just_claimed = !just_claimed.is_empty();
295 let retired: Vec<Uuid> = retirable.iter().map(|mail| mail.id).collect();
296 if retired.is_empty() {
297 return Ok(outcome);
298 }
299
300 let mut tx = conn.begin().await?;
301 credit_registration_account_linking_emails::soft_delete_batch(&mut tx, &retired).await?;
302 crate::credit_registration_admin_actions::record(
303 &mut tx,
304 &NewCreditRegistrationAdminAction {
305 target_id: Some(course_id),
306 reason: Some(reason.to_string()),
307 details: Some(serde_json::json!({
308 "student_number": student_number,
309 "retired_linking_email_ids": retired,
310 })),
311 affected_row_count: Some(i32::try_from(retired.len()).unwrap_or(i32::MAX)),
312 ..NewCreditRegistrationAdminAction::new(
313 CreditRegistrationAdminAction::OverrideRateCap,
314 CreditRegistrationAdminActionTarget::Course,
315 actor_user_id,
316 actor_role,
317 )
318 },
319 )
320 .await?;
321 tx.commit().await?;
322 outcome.retired_count = retired.len() as i64;
323 Ok(outcome)
324}
325
326async fn person_id_of_mails(
327 conn: &mut PgConnection,
328 course_id: Uuid,
329 student_number: &str,
330) -> ModelResult<Option<DbSecret>> {
331 let mails = credit_registration_account_linking_emails::get_by_course_id_and_student_number(
332 conn,
333 course_id,
334 student_number,
335 )
336 .await?;
337 Ok(mails.into_iter().next().map(|mail| mail.sisu_person_id))
338}
339
340fn is_within_caps(
342 person: &DiscoveredPerson,
343 facts: &[&ExistingLinkingMailFact],
344 now: DateTime<Utc>,
345) -> bool {
346 if facts
348 .iter()
349 .any(|fact| fact.sent_at >= now - LINKING_MAIL_QUIET_PERIOD)
350 {
351 return false;
352 }
353 let course_facts: Vec<_> = facts
354 .iter()
355 .filter(|fact| fact.course_id == person.course_id)
356 .collect();
357 !course_facts
358 .iter()
359 .any(|fact| fact.sent_at >= now - LINKING_MAIL_RESEND_INTERVAL)
360 && (course_facts.len() as i64) < MAX_LINKING_MAILS_PER_PERSON_AND_COURSE
361}
362
363fn holds_address(facts: &[&ExistingLinkingMailFact], course_id: Uuid, address: &str) -> bool {
366 facts.iter().any(|fact| {
367 fact.holds_address
368 && fact.course_id == course_id
369 && fact
370 .emailed_to
371 .expose_secret()
372 .eq_ignore_ascii_case(address)
373 })
374}
375
376async fn void_tokens(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
378 if ids.is_empty() {
379 return Ok(());
380 }
381 sqlx::query!(
382 r#"
383UPDATE student_number_verification_tokens
384SET deleted_at = now()
385WHERE id = ANY($1::uuid [])
386 AND deleted_at IS NULL
387 "#,
388 ids,
389 )
390 .execute(conn)
391 .await?;
392 Ok(())
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use crate::credit_registration_account_linking_emails::{
399 count_sent_for_person_and_course, get_by_sisu_person_id,
400 };
401 use crate::credit_registration_admin_actions::{self, GLOBAL_ADMIN_ROLE};
402 use crate::student_number_verification_tokens::{claim, get_by_ids};
403 use crate::test_helper::*;
404
405 fn person(course_id: Uuid, address: &str) -> DiscoveredPerson {
406 DiscoveredPerson {
407 sisu_person_id: "hy-hlo-1".to_string().into(),
408 student_number: "012345678".to_string().into(),
409 first_names: Some("Aada Maria".to_string().into()),
410 last_name: Some("Virtanen".to_string().into()),
411 course_id,
412 address: Some(DbSecret::new(address)),
413 }
414 }
415
416 #[tokio::test]
417 async fn mailing_the_same_address_twice_is_refused_as_a_duplicate() {
418 insert_data!(:tx, :user, :org, :course);
419 let discovered = person(course, "aada.uni@example.com");
420 assert_eq!(
421 claim_linking_mail(tx.as_mut(), &discovered).await.unwrap(),
422 LinkingMailClaim::Claimed
423 );
424 assert_eq!(
425 claim_linking_mail(tx.as_mut(), &discovered).await.unwrap(),
426 LinkingMailClaim::SuppressedByDedup
427 );
428 assert_eq!(
429 get_by_sisu_person_id(tx.as_mut(), "hy-hlo-1")
430 .await
431 .unwrap()
432 .len(),
433 1
434 );
435 }
436
437 #[tokio::test]
438 async fn a_person_mailed_today_is_left_alone_even_at_another_address() {
439 insert_data!(:tx, :user, :org, :course);
440 claim_linking_mail(tx.as_mut(), &person(course, "aada.uni@example.com"))
441 .await
442 .unwrap();
443 let claimed = claim_linking_mail(tx.as_mut(), &person(course, "aada@example.com"))
444 .await
445 .unwrap();
446 assert_eq!(claimed, LinkingMailClaim::SuppressedByRateCap);
447 }
448
449 #[tokio::test]
450 async fn the_token_is_created_unbound_and_can_be_claimed_only_once() {
451 insert_data!(:tx, :user, :org, :course);
452 claim_linking_mail(tx.as_mut(), &person(course, "aada.uni@example.com"))
453 .await
454 .unwrap();
455 let slot = get_by_sisu_person_id(tx.as_mut(), "hy-hlo-1")
456 .await
457 .unwrap()
458 .pop()
459 .expect("the claim wrote a slot");
460 let token_id = slot.student_number_verification_token_id.unwrap();
461 let token = get_by_ids(tx.as_mut(), &[token_id])
462 .await
463 .unwrap()
464 .remove(&token_id)
465 .expect("the claim minted a token");
466 assert_eq!(token.claimed_by_user_id, None);
467 assert_eq!(token.used_at, None);
468 assert!(token.expires_at > Utc::now());
469
470 assert!(claim(tx.as_mut(), &token.token, user).await.unwrap());
471 assert!(!claim(tx.as_mut(), &token.token, user).await.unwrap());
472 }
473
474 #[tokio::test]
475 async fn the_rate_cap_override_retires_the_ledger_rows_and_audits_itself() {
476 insert_data!(:tx, :user, :org, :course);
477
478 let claimed = claim_linking_mail(tx.as_mut(), &person(course, "aada.uni@example.com"))
479 .await
480 .unwrap();
481 assert_eq!(claimed, LinkingMailClaim::Claimed);
482 assert_eq!(
483 count_sent_for_person_and_course(tx.as_mut(), "hy-hlo-1", course)
484 .await
485 .unwrap(),
486 1
487 );
488 let mails = credit_registration_account_linking_emails::get_by_sisu_person_id(
489 tx.as_mut(),
490 "hy-hlo-1",
491 )
492 .await
493 .unwrap();
494 for mail in mails {
495 credit_registration_account_linking_emails::backdate(
496 tx.as_mut(),
497 mail.id,
498 chrono::Duration::hours(1),
499 )
500 .await
501 .unwrap();
502 }
503
504 let retired = retire_capped_mails(
505 tx.as_mut(),
506 user,
507 GLOBAL_ADMIN_ROLE,
508 course,
509 "012345678",
510 "The recipient's mail host rejects everything we send.",
511 )
512 .await
513 .unwrap();
514 assert_eq!(retired.retired_count, 1);
515 assert_eq!(
516 count_sent_for_person_and_course(tx.as_mut(), "hy-hlo-1", course)
517 .await
518 .unwrap(),
519 0
520 );
521
522 let actions = credit_registration_admin_actions::get_by_actor(tx.as_mut(), user, 10)
523 .await
524 .unwrap();
525 assert_eq!(actions.len(), 1);
526 let action = &actions[0];
527 assert_eq!(
528 action.action,
529 CreditRegistrationAdminAction::OverrideRateCap
530 );
531 assert_eq!(action.actor_role, GLOBAL_ADMIN_ROLE);
532 assert_eq!(
533 action.reason.as_deref(),
534 Some("The recipient's mail host rejects everything we send.")
535 );
536 assert_eq!(action.affected_row_count, Some(1));
537 }
538
539 #[tokio::test]
540 async fn an_override_with_nothing_to_retire_writes_nothing() {
541 insert_data!(:tx, :user, :org, :course);
542
543 let retired = retire_capped_mails(
544 tx.as_mut(),
545 user,
546 GLOBAL_ADMIN_ROLE,
547 course,
548 "012345678",
549 "No mails yet.",
550 )
551 .await
552 .unwrap();
553 assert_eq!(retired.retired_count, 0);
554 assert!(
555 credit_registration_admin_actions::get_by_actor(tx.as_mut(), user, 10)
556 .await
557 .unwrap()
558 .is_empty()
559 );
560 }
561}