1use std::collections::{HashMap, HashSet};
8
9use utoipa::ToSchema;
10
11use crate::email_deliveries::{
12 EmailSendStatus, EmailSendStatusFacts, EmailSendStatusReport, derive_email_send_status,
13 get_send_statuses, is_hard_send_failure,
14};
15use crate::prelude::*;
16
17#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
18pub struct CreditRegistrationAccountLinkingEmail {
19 pub id: Uuid,
20 pub created_at: DateTime<Utc>,
21 pub updated_at: DateTime<Utc>,
22 pub deleted_at: Option<DateTime<Utc>>,
23 pub student_number: String,
24 pub sisu_person_id: String,
25 pub course_id: Uuid,
26 pub emailed_to: String,
27 pub student_number_verification_token_id: Option<Uuid>,
28 pub email_delivery_id: Option<Uuid>,
29 pub sent_at: DateTime<Utc>,
30}
31
32#[derive(Debug, Clone, PartialEq)]
33pub struct NewAccountLinkingEmail {
34 pub student_number: String,
35 pub sisu_person_id: String,
36 pub course_id: Uuid,
37 pub emailed_to: String,
38 pub student_number_verification_token_id: Option<Uuid>,
39 pub email_delivery_id: Option<Uuid>,
40}
41
42pub async fn claim_send_slot(
45 conn: &mut PgConnection,
46 new: &NewAccountLinkingEmail,
47) -> ModelResult<Option<Uuid>> {
48 let res = sqlx::query!(
49 r#"
50INSERT INTO credit_registration_account_linking_emails (
51 student_number,
52 sisu_person_id,
53 course_id,
54 emailed_to,
55 student_number_verification_token_id,
56 email_delivery_id
57 )
58VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING
59RETURNING id
60 "#,
61 new.student_number,
62 new.sisu_person_id,
63 new.course_id,
64 new.emailed_to,
65 new.student_number_verification_token_id,
66 new.email_delivery_id,
67 )
68 .fetch_optional(conn)
69 .await?;
70 Ok(res.map(|r| r.id))
71}
72
73#[derive(Debug, Clone, PartialEq)]
75pub struct ExistingLinkingMailFact {
76 pub sisu_person_id: String,
77 pub course_id: Uuid,
78 pub emailed_to: String,
79 pub sent_at: DateTime<Utc>,
80}
81
82pub async fn get_existing_facts_for_persons(
85 conn: &mut PgConnection,
86 sisu_person_ids: &[String],
87) -> ModelResult<Vec<ExistingLinkingMailFact>> {
88 let res = sqlx::query_as!(
89 ExistingLinkingMailFact,
90 r#"
91SELECT sisu_person_id,
92 course_id,
93 emailed_to,
94 sent_at
95FROM credit_registration_account_linking_emails
96WHERE sisu_person_id = ANY($1::text [])
97 AND deleted_at IS NULL
98 "#,
99 sisu_person_ids
100 )
101 .fetch_all(conn)
102 .await?;
103 Ok(res)
104}
105
106pub async fn claim_send_slots(
114 conn: &mut PgConnection,
115 new: &[NewAccountLinkingEmail],
116 token_ids: &[Uuid],
117) -> ModelResult<HashSet<Uuid>> {
118 if new.is_empty() {
119 return Ok(HashSet::new());
120 }
121 let student_numbers: Vec<String> = new.iter().map(|n| n.student_number.clone()).collect();
122 let sisu_person_ids: Vec<String> = new.iter().map(|n| n.sisu_person_id.clone()).collect();
123 let course_ids: Vec<Uuid> = new.iter().map(|n| n.course_id).collect();
124 let emailed_tos: Vec<String> = new.iter().map(|n| n.emailed_to.clone()).collect();
125
126 let claimed = sqlx::query_scalar!(
127 r#"
128INSERT INTO credit_registration_account_linking_emails (
129 student_number,
130 sisu_person_id,
131 course_id,
132 emailed_to,
133 student_number_verification_token_id
134 )
135SELECT * FROM UNNEST($1::text [], $2::text [], $3::uuid [], $4::text [], $5::uuid []) ON CONFLICT DO NOTHING
136RETURNING student_number_verification_token_id AS "token_id!"
137 "#,
138 &student_numbers,
139 &sisu_person_ids,
140 &course_ids,
141 &emailed_tos,
142 token_ids,
143 )
144 .fetch_all(conn)
145 .await?;
146 Ok(claimed.into_iter().collect())
147}
148
149pub async fn get_latest_by_course_and_persons(
151 conn: &mut PgConnection,
152 course_id: Uuid,
153 sisu_person_ids: &[String],
154) -> ModelResult<HashMap<String, CreditRegistrationAccountLinkingEmail>> {
155 let res = sqlx::query_as!(
156 CreditRegistrationAccountLinkingEmail,
157 r#"
158SELECT DISTINCT ON (sisu_person_id) *
159FROM credit_registration_account_linking_emails
160WHERE course_id = $1
161 AND sisu_person_id = ANY($2::text [])
162 AND deleted_at IS NULL
163ORDER BY sisu_person_id,
164 sent_at DESC
165 "#,
166 course_id,
167 sisu_person_ids,
168 )
169 .fetch_all(conn)
170 .await?;
171 Ok(res
172 .into_iter()
173 .map(|row| (row.sisu_person_id.clone(), row))
174 .collect())
175}
176
177pub async fn get_by_course_id_and_student_number(
182 conn: &mut PgConnection,
183 course_id: Uuid,
184 student_number: &str,
185) -> ModelResult<Vec<CreditRegistrationAccountLinkingEmail>> {
186 let res = sqlx::query_as!(
187 CreditRegistrationAccountLinkingEmail,
188 r#"
189SELECT *
190FROM credit_registration_account_linking_emails
191WHERE course_id = $1
192 AND student_number = $2
193 AND deleted_at IS NULL
194ORDER BY sent_at DESC
195 "#,
196 course_id,
197 student_number,
198 )
199 .fetch_all(conn)
200 .await?;
201 Ok(res)
202}
203
204pub async fn get_by_sisu_person_id(
205 conn: &mut PgConnection,
206 sisu_person_id: &str,
207) -> ModelResult<Vec<CreditRegistrationAccountLinkingEmail>> {
208 let res = sqlx::query_as!(
209 CreditRegistrationAccountLinkingEmail,
210 r#"
211SELECT *
212FROM credit_registration_account_linking_emails
213WHERE sisu_person_id = $1
214 AND deleted_at IS NULL
215ORDER BY sent_at DESC
216 "#,
217 sisu_person_id
218 )
219 .fetch_all(conn)
220 .await?;
221 Ok(res)
222}
223
224pub async fn count_sent_for_person_and_course(
227 conn: &mut PgConnection,
228 sisu_person_id: &str,
229 course_id: Uuid,
230) -> ModelResult<i64> {
231 let count = sqlx::query_scalar!(
232 r#"
233SELECT COUNT(*) AS "count!"
234FROM credit_registration_account_linking_emails
235WHERE sisu_person_id = $1
236 AND course_id = $2
237 AND deleted_at IS NULL
238 "#,
239 sisu_person_id,
240 course_id,
241 )
242 .fetch_one(conn)
243 .await?;
244 Ok(count)
245}
246
247#[derive(Debug, Clone)]
249pub struct LinkingMailToQueue {
250 pub id: Uuid,
251 pub emailed_to: String,
252 pub student_number: String,
253 pub first_names: Option<String>,
254 pub token: DbSecret,
256 pub course_name: String,
257 pub course_language_code: String,
258}
259
260pub async fn claim_unqueued(
269 conn: &mut PgConnection,
270 limit: i64,
271 course_id: Option<Uuid>,
272) -> ModelResult<Vec<LinkingMailToQueue>> {
273 let res = sqlx::query_as!(
274 LinkingMailToQueue,
275 r#"
276SELECT e.id AS "id!",
277 e.emailed_to AS "emailed_to!",
278 e.student_number AS "student_number!",
279 t.first_names AS "first_names?",
280 t.token AS "token!: DbSecret",
281 c.name AS "course_name!",
282 c.language_code AS "course_language_code!"
283FROM credit_registration_account_linking_emails e
284 JOIN student_number_verification_tokens t ON t.id = e.student_number_verification_token_id
285 JOIN courses c ON c.id = e.course_id
286WHERE e.email_delivery_id IS NULL
287 AND e.deleted_at IS NULL
288 AND t.deleted_at IS NULL
289 AND t.used_at IS NULL
290 AND t.expires_at > now()
291 AND ($2::uuid IS NULL OR e.course_id = $2)
292ORDER BY e.sent_at
293FOR UPDATE OF e SKIP LOCKED
294LIMIT $1
295 "#,
296 limit,
297 course_id,
298 )
299 .fetch_all(conn)
300 .await?;
301 Ok(res)
302}
303
304pub async fn set_email_delivery_id(
306 conn: &mut PgConnection,
307 id: Uuid,
308 email_delivery_id: Uuid,
309) -> ModelResult<()> {
310 sqlx::query!(
311 r#"
312UPDATE credit_registration_account_linking_emails
313SET email_delivery_id = $2
314WHERE id = $1
315 AND deleted_at IS NULL
316 "#,
317 id,
318 email_delivery_id,
319 )
320 .execute(conn)
321 .await?;
322 Ok(())
323}
324
325pub async fn get_send_status_reports(
328 conn: &mut PgConnection,
329 ids: &[Uuid],
330) -> ModelResult<HashMap<Uuid, EmailSendStatusReport>> {
331 let rows = sqlx::query!(
332 r#"
333SELECT id,
334 email_delivery_id
335FROM credit_registration_account_linking_emails
336WHERE id = ANY($1::uuid [])
337 AND deleted_at IS NULL
338 "#,
339 ids
340 )
341 .fetch_all(&mut *conn)
342 .await?;
343 let delivery_ids: Vec<Uuid> = rows
344 .iter()
345 .filter_map(|row| row.email_delivery_id)
346 .collect();
347 let mut deliveries = get_send_statuses(conn, &delivery_ids).await?;
348 let res = rows
349 .into_iter()
350 .map(|row| {
351 let report = row
352 .email_delivery_id
353 .and_then(|id| deliveries.remove(&id))
354 .unwrap_or_else(not_handed_over_yet);
355 (row.id, report)
356 })
357 .collect();
358 Ok(res)
359}
360
361pub fn not_handed_over_yet() -> EmailSendStatusReport {
364 EmailSendStatusReport {
365 email_send_status: EmailSendStatus::Queued,
366 sent_at: None,
367 last_attempt_at: None,
368 retry_count: 0,
369 next_retry_at: None,
370 failure_code: None,
371 failure_is_transient: None,
372 }
373}
374
375pub async fn count_sent_since(conn: &mut PgConnection, since: DateTime<Utc>) -> ModelResult<i64> {
378 let count = sqlx::query_scalar!(
379 r#"
380SELECT COUNT(*) AS "count!"
381FROM credit_registration_account_linking_emails
382WHERE sent_at >= $1
383 AND deleted_at IS NULL
384 "#,
385 since,
386 )
387 .fetch_one(conn)
388 .await?;
389 Ok(count)
390}
391
392pub async fn get_sent_since(
394 conn: &mut PgConnection,
395 since: DateTime<Utc>,
396) -> ModelResult<Vec<CreditRegistrationAccountLinkingEmail>> {
397 let res = sqlx::query_as!(
398 CreditRegistrationAccountLinkingEmail,
399 r#"
400SELECT *
401FROM credit_registration_account_linking_emails
402WHERE sent_at >= $1
403 AND deleted_at IS NULL
404ORDER BY sent_at DESC
405 "#,
406 since,
407 )
408 .fetch_all(conn)
409 .await?;
410 Ok(res)
411}
412
413#[derive(Debug, Clone, PartialEq, Default)]
417pub struct LinkingMailSendStatusTotals {
418 pub mails_in_window: i64,
419 pub queued: i64,
420 pub retrying: i64,
421 pub sent: i64,
422 pub send_failed: i64,
423 pub last_send_failed_at: Option<DateTime<Utc>>,
425}
426
427pub async fn get_send_status_totals_since(
428 conn: &mut PgConnection,
429 since: DateTime<Utc>,
430 now: DateTime<Utc>,
431) -> ModelResult<LinkingMailSendStatusTotals> {
432 struct Row {
433 sent_at: DateTime<Utc>,
434 email_delivery_id: Option<Uuid>,
435 delivery_sent: Option<bool>,
436 retryable: Option<bool>,
437 first_failed_at: Option<DateTime<Utc>>,
438 retry_count: Option<i32>,
439 }
440 let rows = sqlx::query_as!(
441 Row,
442 r#"
443SELECT
444 e.sent_at AS "sent_at!",
445 e.email_delivery_id,
446 ed.sent AS delivery_sent,
447 ed.retryable,
448 ed.first_failed_at,
449 ed.retry_count
450FROM credit_registration_account_linking_emails e
451 LEFT JOIN email_deliveries ed ON ed.id = e.email_delivery_id
452WHERE e.sent_at >= $1
453 AND e.deleted_at IS NULL
454 "#,
455 since,
456 )
457 .fetch_all(conn)
458 .await?;
459
460 let mut totals = LinkingMailSendStatusTotals {
461 mails_in_window: rows.len() as i64,
462 ..Default::default()
463 };
464 for row in rows {
465 let status = match row.email_delivery_id {
466 None => EmailSendStatus::Queued,
467 Some(_) => {
468 let facts = EmailSendStatusFacts {
469 sent: row.delivery_sent.unwrap_or(false),
470 retryable: row.retryable.unwrap_or(false),
471 retry_count: row.retry_count.unwrap_or(0),
472 next_retry_at: None,
473 first_failed_at: row.first_failed_at,
474 last_attempt_at: None,
475 failure_code: None,
476 failure_is_transient: None,
477 };
478 derive_email_send_status(&facts, now).email_send_status
479 }
480 };
481 match status {
482 EmailSendStatus::Queued => totals.queued += 1,
483 EmailSendStatus::Retrying => totals.retrying += 1,
484 EmailSendStatus::Sent => totals.sent += 1,
485 EmailSendStatus::SendFailed => {
486 totals.send_failed += 1;
487 totals.last_send_failed_at = Some(
488 totals
489 .last_send_failed_at
490 .map_or(row.sent_at, |prev| prev.max(row.sent_at)),
491 );
492 }
493 }
494 }
495 Ok(totals)
496}
497
498#[derive(Debug, Clone, PartialEq)]
499pub struct LinkingMailFailureDomain {
500 pub domain: String,
501 pub count: i64,
502}
503
504pub async fn get_send_failure_domains_since(
508 conn: &mut PgConnection,
509 since: DateTime<Utc>,
510 now: DateTime<Utc>,
511) -> ModelResult<Vec<LinkingMailFailureDomain>> {
512 struct Row {
513 emailed_to: String,
514 retryable: bool,
515 first_failed_at: Option<DateTime<Utc>>,
516 }
517 let rows = sqlx::query_as!(
518 Row,
519 r#"
520SELECT e.emailed_to, ed.retryable, ed.first_failed_at
521FROM credit_registration_account_linking_emails e
522 JOIN email_deliveries ed ON ed.id = e.email_delivery_id
523WHERE e.sent_at >= $1
524 AND e.deleted_at IS NULL
525 AND position('@' IN e.emailed_to) > 0
526 AND NOT ed.sent
527 "#,
528 since,
529 )
530 .fetch_all(conn)
531 .await?;
532
533 let mut counts: HashMap<String, i64> = HashMap::new();
534 for row in rows {
535 if !is_hard_send_failure(row.retryable, row.first_failed_at, now) {
536 continue;
537 }
538 let Some(at) = row.emailed_to.find('@') else {
539 continue;
540 };
541 *counts
542 .entry(row.emailed_to[at + 1..].to_string())
543 .or_insert(0) += 1;
544 }
545
546 let mut domains: Vec<LinkingMailFailureDomain> = counts
547 .into_iter()
548 .map(|(domain, count)| LinkingMailFailureDomain { domain, count })
549 .collect();
550 domains.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.domain.cmp(&b.domain)));
551 Ok(domains)
552}
553
554pub async fn count_send_failed_for_course(
557 conn: &mut PgConnection,
558 course_id: Uuid,
559 now: DateTime<Utc>,
560) -> ModelResult<i64> {
561 struct Row {
562 retryable: bool,
563 first_failed_at: Option<DateTime<Utc>>,
564 }
565 let rows = sqlx::query_as!(
566 Row,
567 r#"
568SELECT ed.retryable, ed.first_failed_at
569FROM credit_registration_account_linking_emails e
570 JOIN email_deliveries ed ON ed.id = e.email_delivery_id
571WHERE e.course_id = $1
572 AND e.deleted_at IS NULL
573 AND NOT ed.sent
574 "#,
575 course_id,
576 )
577 .fetch_all(conn)
578 .await?;
579 Ok(rows
580 .into_iter()
581 .filter(|row| is_hard_send_failure(row.retryable, row.first_failed_at, now))
582 .count() as i64)
583}
584
585#[derive(Debug, Clone, PartialEq)]
588pub struct StaleUnclaimedLinkingMails {
589 pub student_number: String,
590 pub sisu_person_id: String,
591 pub course_id: Uuid,
592 pub course_name: String,
593 pub mail_count: i64,
594 pub first_sent_at: DateTime<Utc>,
595 pub last_sent_at: DateTime<Utc>,
596 pub mail_ids: Vec<Uuid>,
597 pub addresses: Vec<String>,
599}
600
601pub async fn get_stale_unclaimed(
602 conn: &mut PgConnection,
603 min_mail_count: i64,
604 limit: i64,
605) -> ModelResult<Vec<StaleUnclaimedLinkingMails>> {
606 let res = sqlx::query_as!(
607 StaleUnclaimedLinkingMails,
608 r#"
609SELECT e.student_number AS "student_number!",
610 e.sisu_person_id AS "sisu_person_id!",
611 e.course_id AS "course_id!",
612 c.name AS "course_name!",
613 COUNT(*) AS "mail_count!",
614 MIN(e.sent_at) AS "first_sent_at!",
615 MAX(e.sent_at) AS "last_sent_at!",
616 ARRAY_AGG(
617 e.id
618 ORDER BY e.sent_at
619 ) AS "mail_ids!",
620 ARRAY_AGG(
621 e.emailed_to
622 ORDER BY e.sent_at
623 ) AS "addresses!"
624FROM credit_registration_account_linking_emails e
625 JOIN courses c ON c.id = e.course_id
626WHERE e.deleted_at IS NULL
627 AND NOT EXISTS (
628 SELECT 1
629 FROM verified_student_numbers vsn
630 WHERE vsn.sisu_person_id = e.sisu_person_id
631 AND vsn.deleted_at IS NULL
632 )
633GROUP BY e.student_number,
634 e.sisu_person_id,
635 e.course_id,
636 c.name
637HAVING COUNT(*) >= $1
638ORDER BY MAX(e.sent_at) DESC
639LIMIT $2
640 "#,
641 min_mail_count,
642 limit,
643 )
644 .fetch_all(conn)
645 .await?;
646 Ok(res)
647}
648
649pub async fn soft_delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
651 soft_delete_batch(conn, std::slice::from_ref(&id)).await
652}
653
654pub async fn soft_delete_batch(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
656 if ids.is_empty() {
657 return Ok(());
658 }
659 sqlx::query!(
660 r#"
661UPDATE credit_registration_account_linking_emails
662SET deleted_at = now()
663WHERE id = ANY($1)
664 AND deleted_at IS NULL
665 "#,
666 ids
667 )
668 .execute(conn)
669 .await?;
670 Ok(())
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676 use crate::email_deliveries::insert_email_delivery_to_address;
677 use crate::email_templates::{EmailTemplateNew, EmailTemplateType, insert_email_template};
678 use crate::library::credit_registration::account_linking::{
679 DiscoveredPerson, claim_linking_mails,
680 };
681 use crate::test_helper::*;
682
683 async fn claim_a_mail(conn: &mut PgConnection, course_id: Uuid) -> Uuid {
684 claim_linking_mails(
685 conn,
686 &DiscoveredPerson {
687 sisu_person_id: "hy-hlo-1".to_string(),
688 student_number: "012345678".to_string(),
689 first_names: Some("Aada".to_string()),
690 last_name: Some("Virtanen".to_string()),
691 course_id,
692 addresses: vec!["aada@helsinki.fi".to_string()],
693 },
694 )
695 .await
696 .unwrap();
697 get_by_sisu_person_id(conn, "hy-hlo-1")
698 .await
699 .unwrap()
700 .pop()
701 .expect("the claim wrote a slot")
702 .id
703 }
704
705 async fn seed_template(conn: &mut PgConnection) -> Uuid {
706 insert_email_template(
707 conn,
708 None,
709 EmailTemplateNew {
710 template_type: EmailTemplateType::CreditRegistrationAccountLinking,
711 language: Some("en".to_string()),
712 content: Some(serde_json::json!([])),
713 subject: Some("Link your student number".to_string()),
714 },
715 None,
716 )
717 .await
718 .unwrap()
719 .id
720 }
721
722 #[tokio::test]
725 async fn a_claimed_slot_leaves_the_queue_once_its_delivery_exists() {
726 insert_data!(:tx, :user, :org, :course);
727 let slot = claim_a_mail(tx.as_mut(), course).await;
728 let template = seed_template(tx.as_mut()).await;
729
730 let claimed = claim_unqueued(tx.as_mut(), 10, None).await.unwrap();
731 assert_eq!(claimed.len(), 1);
732 assert_eq!(claimed[0].id, slot);
733 assert_eq!(claimed[0].emailed_to, "aada@helsinki.fi");
734
735 let delivery = insert_email_delivery_to_address(
736 tx.as_mut(),
737 &claimed[0].emailed_to,
738 template,
739 &serde_json::json!({ "NAME": "Aada" }),
740 )
741 .await
742 .unwrap();
743 set_email_delivery_id(tx.as_mut(), slot, delivery)
744 .await
745 .unwrap();
746
747 assert!(
748 claim_unqueued(tx.as_mut(), 10, None)
749 .await
750 .unwrap()
751 .is_empty()
752 );
753 }
754
755 #[tokio::test]
756 async fn a_slot_with_no_delivery_yet_reports_as_queued() {
757 insert_data!(:tx, :user, :org, :course);
758 let slot = claim_a_mail(tx.as_mut(), course).await;
759 let reports = get_send_status_reports(tx.as_mut(), &[slot]).await.unwrap();
760 assert_eq!(
761 reports.get(&slot).map(|report| report.email_send_status),
762 Some(EmailSendStatus::Queued)
763 );
764 }
765}