Skip to main content

headless_lms_models/library/credit_registration/
student_notifications.rs

1//! The two terminal-state mails a student may get about a credit registration.
2//!
3//! There are exactly two, and each is sent at most once per ledger row. Idempotency lives in the
4//! `credit_registrations.{action_needed,registered}_email_delivery_id` columns rather than in the
5//! phase, so a re-tick, a restart or a row re-entering the state cannot mail twice. A
6//! grade-improvement attempt is a new row and does get its own mail.
7
8use std::collections::HashMap;
9
10use utoipa::ToSchema;
11
12use crate::credit_registrations::{CreditRegistrationState, RegistrationScope};
13use crate::email_deliveries::{EmailSendStatusReport, get_send_statuses};
14use crate::email_templates::EmailTemplateType;
15use crate::prelude::*;
16
17/// How many mails one iteration queues.
18pub const STUDENT_NOTIFICATION_LIMIT: i64 = 200;
19
20/// Which of the two student mails a row is owed, or already holds.
21#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
22#[serde(rename_all = "snake_case")]
23pub enum CreditRegistrationNotificationKind {
24    /// The study registry had no usable enrolment, so the student has to act.
25    ActionNeeded,
26    /// The credit is in the study registry, whether we put it there or found it already recorded.
27    Registered,
28}
29
30impl CreditRegistrationNotificationKind {
31    pub fn email_template_type(self) -> EmailTemplateType {
32        match self {
33            Self::ActionNeeded => EmailTemplateType::CreditRegistrationActionNeeded,
34            Self::Registered => EmailTemplateType::CreditRegistrationRegistered,
35        }
36    }
37
38    /// The mail a row in this state is owed or already holds; `None` for a state that gets neither
39    /// mail. The single source of truth for the state -> mail mapping — both the queuing query below
40    /// and the student-facing status endpoint derive from this rather than keeping their own copy.
41    pub fn for_state(state: CreditRegistrationState) -> Option<Self> {
42        match state {
43            CreditRegistrationState::NoUsableEnrolment => Some(Self::ActionNeeded),
44            _ if CreditRegistrationState::SUCCESS_STATES.contains(&state) => Some(Self::Registered),
45            _ => None,
46        }
47    }
48}
49
50/// One row owed a mail, with everything the message renders. `open_university_product_id` is the
51/// module's configured product, from which the action-needed mail's enrolment link is built.
52#[derive(Debug, Clone, PartialEq)]
53pub struct StudentNotificationToQueue {
54    pub credit_registration_id: Uuid,
55    pub kind: CreditRegistrationNotificationKind,
56    pub user_id: Uuid,
57    pub course_module_id: Uuid,
58    pub course_name: String,
59    pub course_language_code: String,
60    pub course_module_name: Option<String>,
61    pub first_name: Option<String>,
62    pub ects_credits: Option<f32>,
63    pub open_university_product_id: Option<String>,
64}
65
66/// Claims the rows owed a mail, locking them until the caller's transaction ends, so callers must
67/// pass a transaction. Never claims `cancelled`, `blocked` or any failure state: those get
68/// nothing.
69pub async fn claim_unnotified(
70    conn: &mut PgConnection,
71    scope: &RegistrationScope,
72    limit: i64,
73) -> ModelResult<Vec<StudentNotificationToQueue>> {
74    let res = sqlx::query!(
75        r#"
76SELECT cr.id AS "credit_registration_id!",
77  cr.state AS "state!: CreditRegistrationState",
78  cr.user_id AS "user_id!",
79  cr.course_module_id AS "course_module_id!",
80  c.name AS "course_name!",
81  c.language_code AS "course_language_code!",
82  cm.name AS "course_module_name?",
83  ud.first_name AS "first_name?",
84  cm.ects_credits AS "ects_credits?",
85  conf.open_university_product_id AS "open_university_product_id?"
86FROM credit_registrations cr
87  JOIN courses c ON c.id = cr.course_id
88  JOIN course_modules cm ON cm.id = cr.course_module_id
89  LEFT JOIN user_details ud ON ud.user_id = cr.user_id
90  LEFT JOIN course_module_suotar_configurations conf ON conf.course_module_id = cr.course_module_id
91  AND conf.deleted_at IS NULL
92WHERE cr.deleted_at IS NULL
93  AND (
94    (
95      cr.state = 'no_usable_enrolment'
96      AND cr.action_needed_email_delivery_id IS NULL
97    )
98    OR (
99      cr.state = ANY($5::credit_registration_state [])
100      AND cr.registered_email_delivery_id IS NULL
101    )
102  )
103  AND ($2::uuid IS NULL OR cr.course_id = $2)
104  AND ($3::uuid IS NULL OR cr.user_id = $3)
105  AND (
106    cardinality($4::uuid []) = 0
107    OR cr.id = ANY($4::uuid [])
108  )
109ORDER BY cr.state_entered_at
110FOR UPDATE OF cr SKIP LOCKED
111LIMIT $1
112        "#,
113        limit,
114        scope.course_id,
115        scope.user_id,
116        &scope.credit_registration_ids,
117        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
118    )
119    .fetch_all(conn)
120    .await?;
121
122    Ok(res
123        .into_iter()
124        .map(|row| StudentNotificationToQueue {
125            kind: CreditRegistrationNotificationKind::for_state(row.state)
126                .unwrap_or(CreditRegistrationNotificationKind::Registered),
127            credit_registration_id: row.credit_registration_id,
128            user_id: row.user_id,
129            course_module_id: row.course_module_id,
130            course_name: row.course_name,
131            course_language_code: row.course_language_code,
132            course_module_name: row.course_module_name,
133            first_name: row.first_name,
134            ects_credits: row.ects_credits,
135            open_university_product_id: row.open_university_product_id,
136        })
137        .collect())
138}
139
140/// Records which delivery carries the mail, which is also what takes the row out of the queue.
141pub async fn set_email_delivery_id(
142    conn: &mut PgConnection,
143    credit_registration_id: Uuid,
144    kind: CreditRegistrationNotificationKind,
145    email_delivery_id: Uuid,
146) -> ModelResult<()> {
147    let action_needed = kind == CreditRegistrationNotificationKind::ActionNeeded;
148    sqlx::query!(
149        r#"
150UPDATE credit_registrations
151SET action_needed_email_delivery_id = CASE
152    WHEN $3 THEN $2
153    ELSE action_needed_email_delivery_id
154  END,
155  registered_email_delivery_id = CASE
156    WHEN $3 THEN registered_email_delivery_id
157    ELSE $2
158  END
159WHERE id = $1
160  AND deleted_at IS NULL
161        "#,
162        credit_registration_id,
163        email_delivery_id,
164        action_needed,
165    )
166    .execute(conn)
167    .await?;
168    Ok(())
169}
170
171/// One queued student mail and what we can honestly say about it.
172#[derive(Debug, Clone, PartialEq)]
173pub struct RegistrationNotificationEmail {
174    pub credit_registration_id: Uuid,
175    pub kind: CreditRegistrationNotificationKind,
176    /// The delivery the registration is pinned to. Stable for the life of the row: it is what stops a
177    /// second mail of this kind, so a changed id here means the guard was bypassed.
178    pub email_delivery_id: Uuid,
179    pub send_status: EmailSendStatusReport,
180}
181
182/// The mails queued for these rows, for the student, teacher and admin views that report on them.
183/// A row with neither mail queued yet contributes nothing.
184pub async fn get_for_registrations(
185    conn: &mut PgConnection,
186    credit_registration_ids: &[Uuid],
187) -> ModelResult<Vec<RegistrationNotificationEmail>> {
188    let rows = sqlx::query!(
189        r#"
190SELECT id,
191  action_needed_email_delivery_id,
192  registered_email_delivery_id
193FROM credit_registrations
194WHERE id = ANY($1::uuid [])
195  AND deleted_at IS NULL
196  AND (
197    action_needed_email_delivery_id IS NOT NULL
198    OR registered_email_delivery_id IS NOT NULL
199  )
200        "#,
201        credit_registration_ids
202    )
203    .fetch_all(&mut *conn)
204    .await?;
205
206    let delivery_ids: Vec<Uuid> = rows
207        .iter()
208        .flat_map(|row| {
209            [
210                row.action_needed_email_delivery_id,
211                row.registered_email_delivery_id,
212            ]
213        })
214        .flatten()
215        .collect();
216    let reports: HashMap<Uuid, EmailSendStatusReport> =
217        get_send_statuses(conn, &delivery_ids).await?;
218
219    let mut res = Vec::new();
220    for row in rows {
221        for (kind, delivery_id) in [
222            (
223                CreditRegistrationNotificationKind::ActionNeeded,
224                row.action_needed_email_delivery_id,
225            ),
226            (
227                CreditRegistrationNotificationKind::Registered,
228                row.registered_email_delivery_id,
229            ),
230        ] {
231            let Some((delivery_id, report)) =
232                delivery_id.and_then(|id| reports.get(&id).map(|report| (id, report)))
233            else {
234                continue;
235            };
236            res.push(RegistrationNotificationEmail {
237                credit_registration_id: row.id,
238                kind,
239                email_delivery_id: delivery_id,
240                send_status: report.clone(),
241            });
242        }
243    }
244    Ok(res)
245}