Skip to main content

headless_lms_models/
credit_registration_account_linking_emails.rs

1//! Dedup ledger for account-linking mails.
2//!
3//! Keyed on the Sisu person id plus the recipient address: at send time there is no account of ours
4//! to key on, and the student number changes when a student moves between programmes.
5use utoipa::ToSchema;
6
7use crate::prelude::*;
8
9#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
10pub struct CreditRegistrationAccountLinkingEmail {
11    pub id: Uuid,
12    pub created_at: DateTime<Utc>,
13    pub updated_at: DateTime<Utc>,
14    pub deleted_at: Option<DateTime<Utc>>,
15    pub student_number: String,
16    pub sisu_person_id: String,
17    pub course_id: Uuid,
18    pub emailed_to: String,
19    pub student_number_verification_token_id: Option<Uuid>,
20    pub email_delivery_id: Option<Uuid>,
21    pub sent_at: DateTime<Utc>,
22}
23
24#[derive(Debug, Clone, PartialEq)]
25pub struct NewAccountLinkingEmail {
26    pub student_number: String,
27    pub sisu_person_id: String,
28    pub course_id: Uuid,
29    pub emailed_to: String,
30    pub student_number_verification_token_id: Option<Uuid>,
31    pub email_delivery_id: Option<Uuid>,
32}
33
34/// Claims the right to mail this (person, course, address) exactly once. `None` means a mail was
35/// already recorded and the caller must not send.
36///
37/// Call in the transaction that mints the token and the delivery row.
38pub async fn claim_send_slot(
39    conn: &mut PgConnection,
40    new: &NewAccountLinkingEmail,
41) -> ModelResult<Option<Uuid>> {
42    let res = sqlx::query!(
43        r#"
44INSERT INTO credit_registration_account_linking_emails (
45    student_number,
46    sisu_person_id,
47    course_id,
48    emailed_to,
49    student_number_verification_token_id,
50    email_delivery_id
51  )
52VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING
53RETURNING id
54        "#,
55        new.student_number,
56        new.sisu_person_id,
57        new.course_id,
58        new.emailed_to,
59        new.student_number_verification_token_id,
60        new.email_delivery_id,
61    )
62    .fetch_optional(conn)
63    .await?;
64    Ok(res.map(|r| r.id))
65}
66
67pub async fn get_by_course_id(
68    conn: &mut PgConnection,
69    course_id: Uuid,
70) -> ModelResult<Vec<CreditRegistrationAccountLinkingEmail>> {
71    let res = sqlx::query_as!(
72        CreditRegistrationAccountLinkingEmail,
73        r#"
74SELECT *
75FROM credit_registration_account_linking_emails
76WHERE course_id = $1
77  AND deleted_at IS NULL
78ORDER BY sent_at DESC
79        "#,
80        course_id
81    )
82    .fetch_all(conn)
83    .await?;
84    Ok(res)
85}
86
87pub async fn get_by_sisu_person_id(
88    conn: &mut PgConnection,
89    sisu_person_id: &str,
90) -> ModelResult<Vec<CreditRegistrationAccountLinkingEmail>> {
91    let res = sqlx::query_as!(
92        CreditRegistrationAccountLinkingEmail,
93        r#"
94SELECT *
95FROM credit_registration_account_linking_emails
96WHERE sisu_person_id = $1
97  AND deleted_at IS NULL
98ORDER BY sent_at DESC
99        "#,
100        sisu_person_id
101    )
102    .fetch_all(conn)
103    .await?;
104    Ok(res)
105}
106
107/// Backs the rate cap of at most one mail per Sisu person per window, across all courses.
108pub async fn count_sent_since(
109    conn: &mut PgConnection,
110    sisu_person_id: &str,
111    since: DateTime<Utc>,
112) -> ModelResult<i64> {
113    let count = sqlx::query_scalar!(
114        r#"
115SELECT COUNT(*) AS "count!"
116FROM credit_registration_account_linking_emails
117WHERE sisu_person_id = $1
118  AND sent_at >= $2
119  AND deleted_at IS NULL
120        "#,
121        sisu_person_id,
122        since,
123    )
124    .fetch_one(conn)
125    .await?;
126    Ok(count)
127}
128
129/// Backs the rate cap of at most a few mails ever per (person, course), even when tokens expire
130/// unused.
131pub async fn count_sent_for_person_and_course(
132    conn: &mut PgConnection,
133    sisu_person_id: &str,
134    course_id: Uuid,
135) -> ModelResult<i64> {
136    let count = sqlx::query_scalar!(
137        r#"
138SELECT COUNT(*) AS "count!"
139FROM credit_registration_account_linking_emails
140WHERE sisu_person_id = $1
141  AND course_id = $2
142  AND deleted_at IS NULL
143        "#,
144        sisu_person_id,
145        course_id,
146    )
147    .fetch_one(conn)
148    .await?;
149    Ok(count)
150}
151
152/// Lets a rate-cap override or an admin resend mail the same address again.
153pub async fn soft_delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
154    sqlx::query!(
155        r#"
156UPDATE credit_registration_account_linking_emails
157SET deleted_at = now()
158WHERE id = $1
159  AND deleted_at IS NULL
160        "#,
161        id
162    )
163    .execute(conn)
164    .await?;
165    Ok(())
166}