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.
51#[derive(Debug, Clone, PartialEq)]
52pub struct StudentNotificationToQueue {
53    pub credit_registration_id: Uuid,
54    pub kind: CreditRegistrationNotificationKind,
55    pub user_id: Uuid,
56    pub course_module_id: Uuid,
57    pub course_name: String,
58    pub course_language_code: String,
59    pub course_module_name: Option<String>,
60    pub first_name: Option<String>,
61    /// The credits frozen on the row, which may have been clamped to the enrolment's range, else the
62    /// module's.
63    pub credits: Option<f32>,
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. Nor a `duplicate` or `not_improved` row whose student already has, or is owed, the
69/// registered mail for another row of the module.
70pub async fn claim_unnotified(
71    conn: &mut PgConnection,
72    scope: &RegistrationScope,
73    limit: i64,
74) -> ModelResult<Vec<StudentNotificationToQueue>> {
75    let res = sqlx::query!(
76        r#"
77SELECT cr.id AS "credit_registration_id!",
78  cr.state AS "state!: CreditRegistrationState",
79  cr.user_id AS "user_id!",
80  cr.course_module_id AS "course_module_id!",
81  c.name AS "course_name!",
82  c.language_code AS "course_language_code!",
83  cm.name AS "course_module_name?",
84  ud.first_name AS "first_name?",
85  COALESCE(cr.credits, cm.ects_credits) AS "credits?"
86FROM credit_registrations cr
87  JOIN courses c ON c.id = cr.course_id AND c.deleted_at IS NULL
88  JOIN course_modules cm ON cm.id = cr.course_module_id AND cm.deleted_at IS NULL
89  LEFT JOIN user_details ud ON ud.user_id = cr.user_id
90WHERE cr.deleted_at IS NULL
91  AND (
92    (
93      cr.state = 'no_usable_enrolment'
94      AND cr.action_needed_email_delivery_id IS NULL
95    )
96    OR (
97      cr.state = ANY($5::credit_registration_state [])
98      AND cr.registered_email_delivery_id IS NULL
99      -- A credit found already recorded is no news to a student told of one for the module.
100      AND NOT (
101        cr.state = ANY($6::credit_registration_state [])
102        AND EXISTS (
103          SELECT 1
104          FROM credit_registrations told
105          WHERE told.user_id = cr.user_id
106            AND told.course_module_id = cr.course_module_id
107            AND told.id <> cr.id
108            AND told.deleted_at IS NULL
109            AND (
110              told.registered_email_delivery_id IS NOT NULL
111              OR told.state = 'registered'
112            )
113        )
114      )
115    )
116  )
117  AND ($2::uuid IS NULL OR cr.course_id = $2)
118  AND ($3::uuid IS NULL OR cr.user_id = $3)
119  AND (
120    cardinality($4::uuid []) = 0
121    OR cr.id = ANY($4::uuid [])
122  )
123ORDER BY cr.state_entered_at
124FOR UPDATE OF cr SKIP LOCKED
125LIMIT $1
126        "#,
127        limit,
128        scope.course_id,
129        scope.user_id,
130        &scope.credit_registration_ids,
131        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
132        &CreditRegistrationState::OTHER_SUCCESS_STATES as &[CreditRegistrationState],
133    )
134    .fetch_all(conn)
135    .await?;
136
137    Ok(res
138        .into_iter()
139        .map(|row| StudentNotificationToQueue {
140            kind: CreditRegistrationNotificationKind::for_state(row.state)
141                .unwrap_or(CreditRegistrationNotificationKind::Registered),
142            credit_registration_id: row.credit_registration_id,
143            user_id: row.user_id,
144            course_module_id: row.course_module_id,
145            course_name: row.course_name,
146            course_language_code: row.course_language_code,
147            course_module_name: row.course_module_name,
148            first_name: row.first_name,
149            credits: row.credits,
150        })
151        .collect())
152}
153
154/// Records which delivery carries the mail, which is also what takes the row out of the queue.
155pub async fn set_email_delivery_id(
156    conn: &mut PgConnection,
157    credit_registration_id: Uuid,
158    kind: CreditRegistrationNotificationKind,
159    email_delivery_id: Uuid,
160) -> ModelResult<()> {
161    let action_needed = kind == CreditRegistrationNotificationKind::ActionNeeded;
162    sqlx::query!(
163        r#"
164UPDATE credit_registrations
165SET action_needed_email_delivery_id = CASE
166    WHEN $3 THEN $2
167    ELSE action_needed_email_delivery_id
168  END,
169  registered_email_delivery_id = CASE
170    WHEN $3 THEN registered_email_delivery_id
171    ELSE $2
172  END
173WHERE id = $1
174  AND deleted_at IS NULL
175        "#,
176        credit_registration_id,
177        email_delivery_id,
178        action_needed,
179    )
180    .execute(conn)
181    .await?;
182    Ok(())
183}
184
185/// One queued student mail and what we can honestly say about it.
186#[derive(Debug, Clone, PartialEq)]
187pub struct RegistrationNotificationEmail {
188    pub credit_registration_id: Uuid,
189    pub kind: CreditRegistrationNotificationKind,
190    /// The delivery the registration is pinned to. Stable for the life of the row: it is what stops a
191    /// second mail of this kind, so a changed id here means the guard was bypassed.
192    pub email_delivery_id: Uuid,
193    pub send_status: EmailSendStatusReport,
194}
195
196/// The mails queued for these rows, for the student, teacher and admin views that report on them.
197/// A row with neither mail queued yet contributes nothing.
198pub async fn get_for_registrations(
199    conn: &mut PgConnection,
200    credit_registration_ids: &[Uuid],
201) -> ModelResult<Vec<RegistrationNotificationEmail>> {
202    let rows = sqlx::query!(
203        r#"
204SELECT id,
205  action_needed_email_delivery_id,
206  registered_email_delivery_id
207FROM credit_registrations
208WHERE id = ANY($1::uuid [])
209  AND deleted_at IS NULL
210  AND (
211    action_needed_email_delivery_id IS NOT NULL
212    OR registered_email_delivery_id IS NOT NULL
213  )
214        "#,
215        credit_registration_ids
216    )
217    .fetch_all(&mut *conn)
218    .await?;
219
220    let delivery_ids: Vec<Uuid> = rows
221        .iter()
222        .flat_map(|row| {
223            [
224                row.action_needed_email_delivery_id,
225                row.registered_email_delivery_id,
226            ]
227        })
228        .flatten()
229        .collect();
230    let reports: HashMap<Uuid, EmailSendStatusReport> =
231        get_send_statuses(conn, &delivery_ids).await?;
232
233    let mut res = Vec::new();
234    for row in rows {
235        for (kind, delivery_id) in [
236            (
237                CreditRegistrationNotificationKind::ActionNeeded,
238                row.action_needed_email_delivery_id,
239            ),
240            (
241                CreditRegistrationNotificationKind::Registered,
242                row.registered_email_delivery_id,
243            ),
244        ] {
245            let Some((delivery_id, report)) =
246                delivery_id.and_then(|id| reports.get(&id).map(|report| (id, report)))
247            else {
248                continue;
249            };
250            res.push(RegistrationNotificationEmail {
251                credit_registration_id: row.id,
252                kind,
253                email_delivery_id: delivery_id,
254                send_status: report.clone(),
255            });
256        }
257    }
258    Ok(res)
259}