1use std::collections::{HashMap, HashSet};
9
10use secrecy::ExposeSecret;
11use utoipa::ToSchema;
12
13use crate::email_deliveries::{
14 EmailSendStatus, EmailSendStatusFacts, EmailSendStatusReport, derive_email_send_status,
15 get_send_statuses, is_hard_send_failure,
16};
17use crate::prelude::*;
18
19#[derive(Debug, Clone)]
20pub struct CreditRegistrationAccountLinkingEmail {
21 pub id: Uuid,
22 pub created_at: DateTime<Utc>,
23 pub updated_at: DateTime<Utc>,
24 pub deleted_at: Option<DateTime<Utc>>,
25 pub student_number: DbSecret,
26 pub sisu_person_id: DbSecret,
27 pub course_id: Uuid,
28 pub emailed_to: DbSecret,
29 pub student_number_verification_token_id: Option<Uuid>,
30 pub email_delivery_id: Option<Uuid>,
31 pub sent_at: DateTime<Utc>,
32 pub replaced_at: Option<DateTime<Utc>>,
35}
36
37#[derive(Debug, Clone)]
38pub struct NewAccountLinkingEmail {
39 pub student_number: DbSecret,
40 pub sisu_person_id: DbSecret,
41 pub course_id: Uuid,
42 pub emailed_to: DbSecret,
43 pub student_number_verification_token_id: Option<Uuid>,
44 pub email_delivery_id: Option<Uuid>,
45}
46
47pub async fn claim_send_slot(
51 conn: &mut PgConnection,
52 new: &NewAccountLinkingEmail,
53) -> ModelResult<Option<Uuid>> {
54 let res = sqlx::query!(
55 r#"
56INSERT INTO credit_registration_account_linking_emails (
57 student_number,
58 sisu_person_id,
59 course_id,
60 emailed_to,
61 student_number_verification_token_id,
62 email_delivery_id
63 )
64VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING
65RETURNING id
66 "#,
67 new.student_number.expose_secret(),
68 new.sisu_person_id.expose_secret(),
69 new.course_id,
70 new.emailed_to.expose_secret(),
71 new.student_number_verification_token_id,
72 new.email_delivery_id,
73 )
74 .fetch_optional(conn)
75 .await?;
76 Ok(res.map(|r| r.id))
77}
78
79pub async fn backdate(conn: &mut PgConnection, id: Uuid, age: chrono::Duration) -> ModelResult<()> {
83 sqlx::query!(
84 "UPDATE credit_registration_account_linking_emails SET sent_at = now() - $2::interval WHERE id = $1",
85 id,
86 age as chrono::Duration
87 )
88 .execute(conn)
89 .await?;
90 Ok(())
91}
92
93#[derive(Debug, Clone)]
95pub struct ExistingLinkingMailFact {
96 pub sisu_person_id: DbSecret,
97 pub course_id: Uuid,
98 pub emailed_to: DbSecret,
99 pub sent_at: DateTime<Utc>,
100 pub holds_address: bool,
103}
104
105pub async fn get_existing_facts_for_persons(
108 conn: &mut PgConnection,
109 sisu_person_ids: &[String],
110) -> ModelResult<Vec<ExistingLinkingMailFact>> {
111 let res = sqlx::query_as!(
112 ExistingLinkingMailFact,
113 r#"
114SELECT e.sisu_person_id AS "sisu_person_id!",
115 e.course_id AS "course_id!",
116 e.emailed_to AS "emailed_to!",
117 e.sent_at AS "sent_at!",
118 (
119 e.replaced_at IS NULL
120 AND (
121 e.student_number_verification_token_id IS NULL
122 OR is_usable_verification_token(t)
123 )
124 ) AS "holds_address!"
125FROM credit_registration_account_linking_emails e
126 LEFT JOIN student_number_verification_tokens t ON t.id = e.student_number_verification_token_id
127WHERE e.sisu_person_id = ANY($1::text [])
128 AND e.deleted_at IS NULL
129 "#,
130 sisu_person_ids
131 )
132 .fetch_all(conn)
133 .await?;
134 Ok(res)
135}
136
137pub async fn count_mails_per_person_for_course(
139 conn: &mut PgConnection,
140 sisu_person_ids: &[String],
141 course_id: Uuid,
142) -> ModelResult<HashMap<String, i64>> {
143 let res = sqlx::query!(
144 r#"
145SELECT sisu_person_id AS "sisu_person_id!",
146 COUNT(*) AS "mail_count!"
147FROM credit_registration_account_linking_emails
148WHERE sisu_person_id = ANY($1::text [])
149 AND course_id = $2
150 AND deleted_at IS NULL
151GROUP BY sisu_person_id
152 "#,
153 sisu_person_ids,
154 course_id
155 )
156 .fetch_all(conn)
157 .await?;
158 Ok(res
159 .into_iter()
160 .map(|row| {
161 (
162 row.sisu_person_id.expose_secret().to_owned(),
163 row.mail_count,
164 )
165 })
166 .collect())
167}
168
169pub async fn replace_lapsed_slots(
172 conn: &mut PgConnection,
173 new: &[NewAccountLinkingEmail],
174) -> ModelResult<()> {
175 if new.is_empty() {
176 return Ok(());
177 }
178 let sisu_person_ids: Vec<String> = new
179 .iter()
180 .map(|n| n.sisu_person_id.expose_secret().to_owned())
181 .collect();
182 let course_ids: Vec<Uuid> = new.iter().map(|n| n.course_id).collect();
183 let emailed_tos: Vec<String> = new
184 .iter()
185 .map(|n| n.emailed_to.expose_secret().to_owned())
186 .collect();
187 sqlx::query!(
188 r#"
189UPDATE credit_registration_account_linking_emails e
190SET replaced_at = now()
191FROM UNNEST($1::text [], $2::uuid [], $3::text []) AS slot(sisu_person_id, course_id, emailed_to)
192WHERE e.sisu_person_id = slot.sisu_person_id
193 AND e.course_id = slot.course_id
194 AND LOWER(e.emailed_to) = LOWER(slot.emailed_to)
195 AND e.deleted_at IS NULL
196 AND e.replaced_at IS NULL
197 AND e.student_number_verification_token_id IS NOT NULL
198 AND NOT EXISTS (
199 SELECT 1
200 FROM student_number_verification_tokens t
201 WHERE t.id = e.student_number_verification_token_id
202 AND is_usable_verification_token(t)
203 )
204 "#,
205 &sisu_person_ids,
206 &course_ids,
207 &emailed_tos,
208 )
209 .execute(conn)
210 .await?;
211 Ok(())
212}
213
214pub async fn claim_send_slots(
222 conn: &mut PgConnection,
223 new: &[NewAccountLinkingEmail],
224 token_ids: &[Uuid],
225) -> ModelResult<HashSet<Uuid>> {
226 if new.is_empty() {
227 return Ok(HashSet::new());
228 }
229 let student_numbers: Vec<String> = new
230 .iter()
231 .map(|n| n.student_number.expose_secret().to_owned())
232 .collect();
233 let sisu_person_ids: Vec<String> = new
234 .iter()
235 .map(|n| n.sisu_person_id.expose_secret().to_owned())
236 .collect();
237 let course_ids: Vec<Uuid> = new.iter().map(|n| n.course_id).collect();
238 let emailed_tos: Vec<String> = new
239 .iter()
240 .map(|n| n.emailed_to.expose_secret().to_owned())
241 .collect();
242
243 let claimed = sqlx::query_scalar!(
244 r#"
245INSERT INTO credit_registration_account_linking_emails (
246 student_number,
247 sisu_person_id,
248 course_id,
249 emailed_to,
250 student_number_verification_token_id
251 )
252SELECT * FROM UNNEST($1::text [], $2::text [], $3::uuid [], $4::text [], $5::uuid []) ON CONFLICT DO NOTHING
253RETURNING student_number_verification_token_id AS "token_id!"
254 "#,
255 &student_numbers,
256 &sisu_person_ids,
257 &course_ids,
258 &emailed_tos,
259 token_ids,
260 )
261 .fetch_all(conn)
262 .await?;
263 Ok(claimed.into_iter().collect())
264}
265
266pub async fn get_latest_by_course_and_persons(
268 conn: &mut PgConnection,
269 course_id: Uuid,
270 sisu_person_ids: &[String],
271) -> ModelResult<HashMap<String, CreditRegistrationAccountLinkingEmail>> {
272 let res = sqlx::query_as!(
273 CreditRegistrationAccountLinkingEmail,
274 r#"
275SELECT DISTINCT ON (sisu_person_id) *
276FROM credit_registration_account_linking_emails
277WHERE course_id = $1
278 AND sisu_person_id = ANY($2::text [])
279 AND deleted_at IS NULL
280ORDER BY sisu_person_id,
281 sent_at DESC
282 "#,
283 course_id,
284 sisu_person_ids,
285 )
286 .fetch_all(conn)
287 .await?;
288 Ok(res
289 .into_iter()
290 .map(|row| (row.sisu_person_id.expose_secret().to_owned(), row))
291 .collect())
292}
293
294pub async fn get_by_course_id_and_student_number(
299 conn: &mut PgConnection,
300 course_id: Uuid,
301 student_number: &str,
302) -> ModelResult<Vec<CreditRegistrationAccountLinkingEmail>> {
303 let res = sqlx::query_as!(
304 CreditRegistrationAccountLinkingEmail,
305 r#"
306SELECT *
307FROM credit_registration_account_linking_emails
308WHERE course_id = $1
309 AND student_number = $2
310 AND deleted_at IS NULL
311ORDER BY sent_at DESC
312 "#,
313 course_id,
314 student_number,
315 )
316 .fetch_all(conn)
317 .await?;
318 Ok(res)
319}
320
321pub async fn get_by_sisu_person_id(
322 conn: &mut PgConnection,
323 sisu_person_id: &str,
324) -> ModelResult<Vec<CreditRegistrationAccountLinkingEmail>> {
325 let res = sqlx::query_as!(
326 CreditRegistrationAccountLinkingEmail,
327 r#"
328SELECT *
329FROM credit_registration_account_linking_emails
330WHERE sisu_person_id = $1
331 AND deleted_at IS NULL
332ORDER BY sent_at DESC
333 "#,
334 sisu_person_id
335 )
336 .fetch_all(conn)
337 .await?;
338 Ok(res)
339}
340
341pub async fn count_sent_for_person_and_course(
344 conn: &mut PgConnection,
345 sisu_person_id: &str,
346 course_id: Uuid,
347) -> ModelResult<i64> {
348 let count = sqlx::query_scalar!(
349 r#"
350SELECT COUNT(*) AS "count!"
351FROM credit_registration_account_linking_emails
352WHERE sisu_person_id = $1
353 AND course_id = $2
354 AND deleted_at IS NULL
355 "#,
356 sisu_person_id,
357 course_id,
358 )
359 .fetch_one(conn)
360 .await?;
361 Ok(count)
362}
363
364#[derive(Debug, Clone)]
366pub struct LinkingMailToQueue {
367 pub id: Uuid,
368 pub emailed_to: DbSecret,
369 pub student_number: DbSecret,
370 pub first_names: Option<DbSecret>,
371 pub token: DbSecret,
373 pub course_name: String,
374 pub course_language_code: String,
375}
376
377pub async fn claim_unqueued(
385 conn: &mut PgConnection,
386 limit: i64,
387 course_id: Option<Uuid>,
388) -> ModelResult<Vec<LinkingMailToQueue>> {
389 let res = sqlx::query_as!(
390 LinkingMailToQueue,
391 r#"
392SELECT e.id AS "id!",
393 e.emailed_to AS "emailed_to!",
394 e.student_number AS "student_number!",
395 t.first_names AS "first_names?",
396 t.token AS "token!: DbSecret",
397 c.name AS "course_name!",
398 c.language_code AS "course_language_code!"
399FROM credit_registration_account_linking_emails e
400 JOIN student_number_verification_tokens t ON t.id = e.student_number_verification_token_id
401 JOIN courses c ON c.id = e.course_id AND c.deleted_at IS NULL
402WHERE e.email_delivery_id IS NULL
403 AND e.deleted_at IS NULL
404 AND is_usable_verification_token(t)
405 AND ($2::uuid IS NULL OR e.course_id = $2)
406ORDER BY e.sent_at
407FOR UPDATE OF e SKIP LOCKED
408LIMIT $1
409 "#,
410 limit,
411 course_id,
412 )
413 .fetch_all(conn)
414 .await?;
415 Ok(res)
416}
417
418pub async fn set_email_delivery_id(
420 conn: &mut PgConnection,
421 id: Uuid,
422 email_delivery_id: Uuid,
423) -> ModelResult<()> {
424 sqlx::query!(
425 r#"
426UPDATE credit_registration_account_linking_emails
427SET email_delivery_id = $2
428WHERE id = $1
429 AND deleted_at IS NULL
430 "#,
431 id,
432 email_delivery_id,
433 )
434 .execute(conn)
435 .await?;
436 Ok(())
437}
438
439#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
441#[serde(rename_all = "snake_case")]
442pub enum LinkingMailLinkState {
443 Usable,
445 Used,
446 Lapsed,
448}
449
450impl LinkingMailLinkState {
451 fn of(is_usable: bool, is_used: bool) -> Self {
453 if is_usable {
454 Self::Usable
455 } else if is_used {
456 Self::Used
457 } else {
458 Self::Lapsed
459 }
460 }
461}
462
463pub async fn get_link_states(
465 conn: &mut PgConnection,
466 mail_ids: &[Uuid],
467) -> ModelResult<HashMap<Uuid, LinkingMailLinkState>> {
468 let rows = sqlx::query!(
469 r#"
470SELECT e.id AS "id!",
471 (
472 e.student_number_verification_token_id IS NULL
473 OR is_usable_verification_token(t)
474 ) AS "is_usable!",
475 t.used_at IS NOT NULL AS "is_used!"
476FROM credit_registration_account_linking_emails e
477 LEFT JOIN student_number_verification_tokens t ON t.id = e.student_number_verification_token_id
478WHERE e.id = ANY($1::uuid [])
479 "#,
480 mail_ids,
481 )
482 .fetch_all(conn)
483 .await?;
484 Ok(rows
485 .into_iter()
486 .map(|row| (row.id, LinkingMailLinkState::of(row.is_usable, row.is_used)))
487 .collect())
488}
489
490pub async fn get_send_status_reports(
493 conn: &mut PgConnection,
494 ids: &[Uuid],
495) -> ModelResult<HashMap<Uuid, EmailSendStatusReport>> {
496 let rows = sqlx::query!(
497 r#"
498SELECT id,
499 email_delivery_id
500FROM credit_registration_account_linking_emails
501WHERE id = ANY($1::uuid [])
502 AND deleted_at IS NULL
503 "#,
504 ids
505 )
506 .fetch_all(&mut *conn)
507 .await?;
508 let delivery_ids: Vec<Uuid> = rows
509 .iter()
510 .filter_map(|row| row.email_delivery_id)
511 .collect();
512 let mut deliveries = get_send_statuses(conn, &delivery_ids).await?;
513 let res = rows
514 .into_iter()
515 .map(|row| {
516 let report = row
517 .email_delivery_id
518 .and_then(|id| deliveries.remove(&id))
519 .unwrap_or_else(not_handed_over_yet);
520 (row.id, report)
521 })
522 .collect();
523 Ok(res)
524}
525
526pub fn not_handed_over_yet() -> EmailSendStatusReport {
529 EmailSendStatusReport {
530 email_send_status: EmailSendStatus::Queued,
531 sent_at: None,
532 last_attempt_at: None,
533 retry_count: 0,
534 next_retry_at: None,
535 failure_code: None,
536 failure_is_transient: None,
537 }
538}
539
540pub async fn count_sent_since(conn: &mut PgConnection, since: DateTime<Utc>) -> ModelResult<i64> {
543 let count = sqlx::query_scalar!(
544 r#"
545SELECT COUNT(*) AS "count!"
546FROM credit_registration_account_linking_emails
547WHERE sent_at >= $1
548 AND deleted_at IS NULL
549 "#,
550 since,
551 )
552 .fetch_one(conn)
553 .await?;
554 Ok(count)
555}
556
557#[derive(Debug, Clone, PartialEq, Default)]
561pub struct LinkingMailSendStatusTotals {
562 pub mails_in_window: i64,
563 pub waiting_for_link_emails: i64,
565 pub waiting_for_email_worker: i64,
567 pub retrying: i64,
568 pub sent: i64,
569 pub send_failed: i64,
570 pub used: i64,
572 pub last_send_failed_at: Option<DateTime<Utc>>,
574}
575
576pub async fn get_send_status_totals_since(
577 conn: &mut PgConnection,
578 since: DateTime<Utc>,
579 now: DateTime<Utc>,
580) -> ModelResult<LinkingMailSendStatusTotals> {
581 struct Row {
582 sent_at: DateTime<Utc>,
583 email_delivery_id: Option<Uuid>,
584 delivery_sent: Option<bool>,
585 retryable: Option<bool>,
586 first_failed_at: Option<DateTime<Utc>>,
587 retry_count: Option<i32>,
588 is_used: bool,
589 }
590 let rows = sqlx::query_as!(
591 Row,
592 r#"
593SELECT
594 e.sent_at AS "sent_at!",
595 e.email_delivery_id,
596 ed.sent AS "delivery_sent?",
597 ed.retryable AS "retryable?",
598 ed.first_failed_at AS "first_failed_at?",
599 ed.retry_count AS "retry_count?",
600 COALESCE(t.used_at IS NOT NULL, FALSE) AS "is_used!"
601FROM credit_registration_account_linking_emails e
602 LEFT JOIN email_deliveries ed ON ed.id = e.email_delivery_id AND ed.deleted_at IS NULL
603 LEFT JOIN student_number_verification_tokens t ON t.id = e.student_number_verification_token_id
604WHERE e.sent_at >= $1
605 AND e.deleted_at IS NULL
606 "#,
607 since,
608 )
609 .fetch_all(conn)
610 .await?;
611
612 let mut totals = LinkingMailSendStatusTotals {
613 mails_in_window: rows.len() as i64,
614 ..Default::default()
615 };
616 for row in rows {
617 totals.used += i64::from(row.is_used);
618 let status = row.email_delivery_id.map(|_| {
619 let facts = EmailSendStatusFacts {
620 sent: row.delivery_sent.unwrap_or(false),
621 retryable: row.retryable.unwrap_or(false),
622 retry_count: row.retry_count.unwrap_or(0),
623 next_retry_at: None,
624 first_failed_at: row.first_failed_at,
625 last_attempt_at: None,
626 failure_code: None,
627 failure_is_transient: None,
628 };
629 derive_email_send_status(&facts, now).email_send_status
630 });
631 match status {
632 None => totals.waiting_for_link_emails += 1,
633 Some(EmailSendStatus::Queued) => totals.waiting_for_email_worker += 1,
634 Some(EmailSendStatus::Retrying) => totals.retrying += 1,
635 Some(EmailSendStatus::Sent) => totals.sent += 1,
636 Some(EmailSendStatus::SendFailed) => {
637 totals.send_failed += 1;
638 totals.last_send_failed_at = Some(
639 totals
640 .last_send_failed_at
641 .map_or(row.sent_at, |prev| prev.max(row.sent_at)),
642 );
643 }
644 }
645 }
646 Ok(totals)
647}
648
649#[derive(Debug, Clone, PartialEq)]
650pub struct LinkingMailFailureDomain {
651 pub domain: String,
652 pub count: i64,
653}
654
655pub async fn get_send_failure_domains_since(
659 conn: &mut PgConnection,
660 since: DateTime<Utc>,
661 now: DateTime<Utc>,
662) -> ModelResult<Vec<LinkingMailFailureDomain>> {
663 struct Row {
664 emailed_to: DbSecret,
665 retryable: bool,
666 first_failed_at: Option<DateTime<Utc>>,
667 }
668 let rows = sqlx::query_as!(
669 Row,
670 r#"
671SELECT e.emailed_to, ed.retryable, ed.first_failed_at
672FROM credit_registration_account_linking_emails e
673 JOIN email_deliveries ed ON ed.id = e.email_delivery_id AND ed.deleted_at IS NULL
674WHERE e.sent_at >= $1
675 AND e.deleted_at IS NULL
676 AND position('@' IN e.emailed_to) > 0
677 AND NOT ed.sent
678 "#,
679 since,
680 )
681 .fetch_all(conn)
682 .await?;
683
684 let mut counts: HashMap<String, i64> = HashMap::new();
685 for row in rows {
686 if !is_hard_send_failure(row.retryable, row.first_failed_at, now) {
687 continue;
688 }
689 let emailed_to = row.emailed_to.expose_secret();
690 let Some(at) = emailed_to.find('@') else {
691 continue;
692 };
693 *counts.entry(emailed_to[at + 1..].to_string()).or_insert(0) += 1;
694 }
695
696 let mut domains: Vec<LinkingMailFailureDomain> = counts
697 .into_iter()
698 .map(|(domain, count)| LinkingMailFailureDomain { domain, count })
699 .collect();
700 domains.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.domain.cmp(&b.domain)));
701 Ok(domains)
702}
703
704pub async fn count_send_failed_for_course(
707 conn: &mut PgConnection,
708 course_id: Uuid,
709 now: DateTime<Utc>,
710) -> ModelResult<i64> {
711 struct Row {
712 retryable: bool,
713 first_failed_at: Option<DateTime<Utc>>,
714 }
715 let rows = sqlx::query_as!(
716 Row,
717 r#"
718SELECT ed.retryable, ed.first_failed_at
719FROM credit_registration_account_linking_emails e
720 JOIN email_deliveries ed ON ed.id = e.email_delivery_id AND ed.deleted_at IS NULL
721WHERE e.course_id = $1
722 AND e.deleted_at IS NULL
723 AND NOT ed.sent
724 "#,
725 course_id,
726 )
727 .fetch_all(conn)
728 .await?;
729 Ok(rows
730 .into_iter()
731 .filter(|row| is_hard_send_failure(row.retryable, row.first_failed_at, now))
732 .count() as i64)
733}
734
735#[derive(Debug, Clone)]
738pub struct StaleUnclaimedLinkingMails {
739 pub student_number: DbSecret,
740 pub sisu_person_id: DbSecret,
741 pub course_id: Uuid,
742 pub course_name: String,
743 pub mail_count: i64,
744 pub first_sent_at: DateTime<Utc>,
745 pub last_sent_at: DateTime<Utc>,
746 pub mail_ids: Vec<Uuid>,
747 pub addresses: Vec<DbSecret>,
749}
750
751pub async fn get_stale_unclaimed(
752 conn: &mut PgConnection,
753 min_mail_count: i64,
754 limit: i64,
755) -> ModelResult<Vec<StaleUnclaimedLinkingMails>> {
756 let res = sqlx::query_as!(
757 StaleUnclaimedLinkingMails,
758 r#"
759SELECT e.student_number AS "student_number!",
760 e.sisu_person_id AS "sisu_person_id!",
761 e.course_id AS "course_id!",
762 c.name AS "course_name!",
763 COUNT(*) AS "mail_count!",
764 MIN(e.sent_at) AS "first_sent_at!",
765 MAX(e.sent_at) AS "last_sent_at!",
766 ARRAY_AGG(
767 e.id
768 ORDER BY e.sent_at
769 ) AS "mail_ids!",
770 ARRAY_AGG(
771 e.emailed_to
772 ORDER BY e.sent_at
773 ) AS "addresses!: Vec<DbSecret>"
774FROM credit_registration_account_linking_emails e
775 JOIN courses c ON c.id = e.course_id AND c.deleted_at IS NULL
776WHERE e.deleted_at IS NULL
777 AND NOT EXISTS (
778 SELECT 1
779 FROM verified_student_numbers vsn
780 WHERE vsn.sisu_person_id = e.sisu_person_id
781 AND vsn.deleted_at IS NULL
782 )
783GROUP BY e.student_number,
784 e.sisu_person_id,
785 e.course_id,
786 c.name
787HAVING COUNT(*) >= $1
788ORDER BY MAX(e.sent_at) DESC
789LIMIT $2
790 "#,
791 min_mail_count,
792 limit,
793 )
794 .fetch_all(conn)
795 .await?;
796 Ok(res)
797}
798
799pub async fn soft_delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
801 soft_delete_batch(conn, std::slice::from_ref(&id)).await
802}
803
804pub async fn soft_delete_batch(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
806 if ids.is_empty() {
807 return Ok(());
808 }
809 sqlx::query!(
810 r#"
811UPDATE credit_registration_account_linking_emails
812SET deleted_at = now()
813WHERE id = ANY($1)
814 AND deleted_at IS NULL
815 "#,
816 ids
817 )
818 .execute(conn)
819 .await?;
820 Ok(())
821}
822
823pub async fn retire_unused_for_link(
827 conn: &mut PgConnection,
828 verified_student_number_id: Uuid,
829) -> ModelResult<()> {
830 sqlx::query!(
831 r#"
832WITH retired AS (
833 UPDATE credit_registration_account_linking_emails e
834 SET replaced_at = now()
835 FROM verified_student_numbers vsn
836 WHERE vsn.id = $1
837 AND e.deleted_at IS NULL
838 AND e.replaced_at IS NULL
839 AND (
840 e.sisu_person_id = vsn.sisu_person_id
841 OR e.student_number = vsn.student_number
842 )
843 AND NOT EXISTS (
844 SELECT 1
845 FROM student_number_verification_tokens t
846 WHERE t.id = e.student_number_verification_token_id
847 AND t.used_at IS NOT NULL
848 )
849 RETURNING e.student_number_verification_token_id
850)
851UPDATE student_number_verification_tokens t
852SET deleted_at = now()
853FROM retired
854WHERE t.id = retired.student_number_verification_token_id
855 AND t.deleted_at IS NULL
856 "#,
857 verified_student_number_id,
858 )
859 .execute(conn)
860 .await?;
861 Ok(())
862}
863
864#[cfg(test)]
865mod tests {
866 use super::*;
867 use crate::email_deliveries::insert_email_delivery_to_address;
868 use crate::email_templates::{EmailTemplateNew, EmailTemplateType, insert_email_template};
869 use crate::library::credit_registration::account_linking::{
870 DiscoveredPerson, claim_linking_mail,
871 };
872 use crate::test_helper::*;
873
874 async fn claim_a_mail(conn: &mut PgConnection, course_id: Uuid) -> Uuid {
875 claim_linking_mail(
876 conn,
877 &DiscoveredPerson {
878 sisu_person_id: "hy-hlo-1".to_string().into(),
879 student_number: "012345678".to_string().into(),
880 first_names: Some("Aada".to_string().into()),
881 last_name: Some("Virtanen".to_string().into()),
882 course_id,
883 address: Some(DbSecret::new("aada@example.com")),
884 },
885 )
886 .await
887 .unwrap();
888 get_by_sisu_person_id(conn, "hy-hlo-1")
889 .await
890 .unwrap()
891 .pop()
892 .expect("the claim wrote a slot")
893 .id
894 }
895
896 async fn seed_template(conn: &mut PgConnection) -> Uuid {
897 insert_email_template(
898 conn,
899 None,
900 EmailTemplateNew {
901 template_type: EmailTemplateType::CreditRegistrationAccountLinking,
902 language: Some("en".to_string()),
903 content: Some(serde_json::json!([])),
904 subject: Some("Link your student number".to_string()),
905 },
906 None,
907 )
908 .await
909 .unwrap()
910 .id
911 }
912
913 #[tokio::test]
916 async fn a_claimed_slot_leaves_the_queue_once_its_delivery_exists() {
917 insert_data!(:tx, :user, :org, :course);
918 let slot = claim_a_mail(tx.as_mut(), course).await;
919 let template = seed_template(tx.as_mut()).await;
920
921 let claimed = claim_unqueued(tx.as_mut(), 10, None).await.unwrap();
922 assert_eq!(claimed.len(), 1);
923 assert_eq!(claimed[0].id, slot);
924 assert_eq!(claimed[0].emailed_to.expose_secret(), "aada@example.com");
925
926 let delivery = insert_email_delivery_to_address(
927 tx.as_mut(),
928 claimed[0].emailed_to.expose_secret(),
929 template,
930 &serde_json::json!({ "NAME": "Aada" }),
931 )
932 .await
933 .unwrap();
934 set_email_delivery_id(tx.as_mut(), slot, delivery)
935 .await
936 .unwrap();
937
938 assert!(
939 claim_unqueued(tx.as_mut(), 10, None)
940 .await
941 .unwrap()
942 .is_empty()
943 );
944 }
945
946 #[tokio::test]
947 async fn a_slot_with_no_delivery_yet_reports_as_queued() {
948 insert_data!(:tx, :user, :org, :course);
949 let slot = claim_a_mail(tx.as_mut(), course).await;
950 let reports = get_send_status_reports(tx.as_mut(), &[slot]).await.unwrap();
951 assert_eq!(
952 reports.get(&slot).map(|report| report.email_send_status),
953 Some(EmailSendStatus::Queued)
954 );
955 }
956}
957
958#[derive(Debug, Clone)]
960pub struct UnusedLinkingLink {
961 pub id: Uuid,
962 pub course_id: Uuid,
963 pub course_name: String,
964 pub uh_course_code: Option<String>,
966 pub emailed_to: DbSecret,
967 pub claimed_at: DateTime<Utc>,
968 pub expires_at: DateTime<Utc>,
969}
970
971pub async fn get_unused_links(
973 conn: &mut PgConnection,
974 limit: i64,
975) -> ModelResult<Vec<UnusedLinkingLink>> {
976 let res = sqlx::query_as!(
977 UnusedLinkingLink,
978 r#"
979SELECT e.id,
980 e.course_id,
981 c.name AS course_name,
982 (
983 SELECT MIN(TRIM(cm.uh_course_code))
984 FROM course_modules cm
985 WHERE cm.course_id = e.course_id
986 AND cm.deleted_at IS NULL
987 AND TRIM(COALESCE(cm.uh_course_code, '')) <> ''
988 ) AS "uh_course_code?",
989 e.emailed_to,
990 e.sent_at AS claimed_at,
991 t.expires_at
992FROM credit_registration_account_linking_emails e
993 JOIN courses c ON c.id = e.course_id AND c.deleted_at IS NULL
994 JOIN student_number_verification_tokens t ON t.id = e.student_number_verification_token_id
995WHERE e.deleted_at IS NULL
996 AND e.replaced_at IS NULL
997 AND is_usable_verification_token(t)
998ORDER BY e.sent_at,
999 e.id
1000LIMIT $1
1001 "#,
1002 limit,
1003 )
1004 .fetch_all(conn)
1005 .await?;
1006 Ok(res)
1007}
1008
1009pub async fn count_unused_links_by_course_code(
1011 conn: &mut PgConnection,
1012) -> ModelResult<Vec<(String, i64)>> {
1013 let rows = sqlx::query!(
1014 r#"
1015SELECT TRIM(cm.uh_course_code) AS "course_code!",
1016 COUNT(DISTINCT e.id) AS "count!"
1017FROM credit_registration_account_linking_emails e
1018 JOIN courses c ON c.id = e.course_id AND c.deleted_at IS NULL
1019 JOIN student_number_verification_tokens t ON t.id = e.student_number_verification_token_id
1020 JOIN course_modules cm ON cm.course_id = e.course_id
1021 AND cm.deleted_at IS NULL
1022 AND TRIM(COALESCE(cm.uh_course_code, '')) <> ''
1023WHERE e.deleted_at IS NULL
1024 AND e.replaced_at IS NULL
1025 AND is_usable_verification_token(t)
1026GROUP BY TRIM(cm.uh_course_code)
1027 "#,
1028 )
1029 .fetch_all(conn)
1030 .await?;
1031 Ok(rows
1032 .into_iter()
1033 .map(|row| (row.course_code, row.count))
1034 .collect())
1035}
1036
1037#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
1039#[serde(rename_all = "snake_case")]
1040pub enum LinkingEmailListStatus {
1041 Unused,
1043 Used,
1044 Expired,
1046 NotSent,
1048}
1049
1050impl LinkingEmailListStatus {
1051 fn as_sql(self) -> &'static str {
1053 match self {
1054 Self::Unused => "unused",
1055 Self::Used => "used",
1056 Self::Expired => "expired",
1057 Self::NotSent => "not_sent",
1058 }
1059 }
1060}
1061
1062#[derive(Debug, Clone)]
1064pub struct LinkingEmailListRow {
1065 pub id: Uuid,
1066 pub course_id: Uuid,
1067 pub course_name: String,
1068 pub emailed_to: DbSecret,
1069 pub student_number: DbSecret,
1070 pub first_names: Option<DbSecret>,
1071 pub last_name: Option<DbSecret>,
1072 pub claimed_at: DateTime<Utc>,
1073 pub queued_at: Option<DateTime<Utc>>,
1075 pub send_status: Option<EmailSendStatusReport>,
1077 pub last_error_message: Option<String>,
1079 pub link_state: LinkingMailLinkState,
1080 pub link_expires_at: Option<DateTime<Utc>>,
1082 pub link_used_at: Option<DateTime<Utc>>,
1083 pub claimed_by_user_id: Option<Uuid>,
1084 pub claimed_by_email: Option<String>,
1085 pub total_count: i64,
1086}
1087
1088pub async fn get_page(
1093 conn: &mut PgConnection,
1094 status: Option<LinkingEmailListStatus>,
1095 address: Option<&str>,
1096 limit: i64,
1097 offset: i64,
1098) -> ModelResult<Vec<LinkingEmailListRow>> {
1099 let address_pattern = address.map(|address| {
1100 format!(
1101 "%{}%",
1102 crate::library::students_view::escape_like_pattern(address)
1103 )
1104 });
1105 let rows = sqlx::query!(
1106 r#"
1107WITH page AS (
1108 SELECT e.id,
1109 e.sent_at,
1110 c.name AS course_name,
1111 COUNT(*) OVER () AS total_count
1112 FROM credit_registration_account_linking_emails e
1113 JOIN courses c ON c.id = e.course_id
1114 AND c.deleted_at IS NULL
1115 LEFT JOIN student_number_verification_tokens t ON t.id = e.student_number_verification_token_id
1116 LEFT JOIN email_deliveries ed ON ed.id = e.email_delivery_id
1117 AND ed.deleted_at IS NULL
1118 WHERE e.deleted_at IS NULL
1119 AND (
1120 $2::text IS NULL
1121 OR e.emailed_to ILIKE $2 ESCAPE '\'
1122 )
1123 AND (
1124 $1::text IS NULL
1125 OR (
1126 $1 = 'unused'
1127 AND is_usable_verification_token(t)
1128 )
1129 OR (
1130 $1 = 'used'
1131 AND t.used_at IS NOT NULL
1132 )
1133 OR (
1134 $1 = 'expired'
1135 AND e.student_number_verification_token_id IS NOT NULL
1136 AND t.used_at IS NULL
1137 AND NOT is_usable_verification_token(t)
1138 )
1139 OR (
1140 $1 = 'not_sent'
1141 AND ed.sent IS DISTINCT FROM TRUE
1142 )
1143 )
1144 ORDER BY e.sent_at DESC,
1145 e.id
1146 LIMIT $3 OFFSET $4
1147)
1148SELECT e.id,
1149 e.course_id,
1150 page.course_name AS "course_name!",
1151 e.emailed_to,
1152 e.student_number,
1153 t.first_names AS "first_names?",
1154 t.last_name AS "last_name?",
1155 e.sent_at AS claimed_at,
1156 ed.created_at AS "queued_at?",
1157 ed.sent AS "delivery_sent?",
1158 ed.retryable AS "retryable?",
1159 ed.retry_count AS "retry_count?",
1160 ed.next_retry_at AS "next_retry_at?",
1161 ed.first_failed_at AS "first_failed_at?",
1162 ed.last_attempt_at AS "last_attempt_at?",
1163 latest_error.error_code AS "error_code?",
1164 latest_error.error_message AS "error_message?",
1165 latest_error.is_transient AS "is_transient?",
1166 (
1167 e.student_number_verification_token_id IS NULL
1168 OR is_usable_verification_token(t)
1169 ) AS "is_usable!",
1170 t.expires_at AS "link_expires_at?",
1171 t.used_at AS "link_used_at?",
1172 t.claimed_by_user_id AS "claimed_by_user_id?",
1173 claimer.email AS "claimed_by_email?",
1174 page.total_count AS "total_count!"
1175FROM page
1176 JOIN credit_registration_account_linking_emails e ON e.id = page.id
1177 LEFT JOIN student_number_verification_tokens t ON t.id = e.student_number_verification_token_id
1178 LEFT JOIN user_details claimer ON claimer.user_id = t.claimed_by_user_id
1179 LEFT JOIN email_deliveries ed ON ed.id = e.email_delivery_id
1180 AND ed.deleted_at IS NULL
1181 LEFT JOIN LATERAL (
1182 SELECT ede.error_code,
1183 ede.error_message,
1184 ede.is_transient
1185 FROM email_delivery_errors ede
1186 WHERE ede.email_delivery_id = ed.id
1187 AND ede.deleted_at IS NULL
1188 ORDER BY ede.attempt DESC,
1189 ede.created_at DESC
1190 LIMIT 1
1191 ) latest_error ON TRUE
1192ORDER BY page.sent_at DESC,
1193 page.id
1194 "#,
1195 status
1196 .filter(|_| address.is_none())
1197 .map(LinkingEmailListStatus::as_sql),
1198 address_pattern,
1199 limit,
1200 offset,
1201 )
1202 .fetch_all(conn)
1203 .await?;
1204 let now = Utc::now();
1205 Ok(rows
1206 .into_iter()
1207 .map(|row| {
1208 let send_status = row.delivery_sent.map(|sent| {
1209 derive_email_send_status(
1210 &EmailSendStatusFacts {
1211 sent,
1212 retryable: row.retryable.unwrap_or(false),
1213 retry_count: row.retry_count.unwrap_or(0),
1214 next_retry_at: row.next_retry_at,
1215 first_failed_at: row.first_failed_at,
1216 last_attempt_at: row.last_attempt_at,
1217 failure_code: row.error_code,
1218 failure_is_transient: row.is_transient,
1219 },
1220 now,
1221 )
1222 });
1223 LinkingEmailListRow {
1224 id: row.id,
1225 course_id: row.course_id,
1226 course_name: row.course_name,
1227 emailed_to: row.emailed_to,
1228 student_number: row.student_number,
1229 first_names: row.first_names,
1230 last_name: row.last_name,
1231 claimed_at: row.claimed_at,
1232 queued_at: row.queued_at,
1233 send_status,
1234 last_error_message: row.error_message,
1235 link_state: LinkingMailLinkState::of(row.is_usable, row.link_used_at.is_some()),
1236 link_expires_at: row.link_expires_at,
1237 link_used_at: row.link_used_at,
1238 claimed_by_user_id: row.claimed_by_user_id,
1239 claimed_by_email: row.claimed_by_email,
1240 total_count: row.total_count,
1241 }
1242 })
1243 .collect())
1244}