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 student has to enrol: the study registry had no usable enrolment, or the row waits for a
25    /// student number that only arrives once the student is on a roster.
26    ActionNeeded,
27    /// The credit is in the study registry, whether we put it there or found it already recorded.
28    Registered,
29}
30
31impl CreditRegistrationNotificationKind {
32    pub fn email_template_type(self) -> EmailTemplateType {
33        match self {
34            Self::ActionNeeded => EmailTemplateType::CreditRegistrationActionNeeded,
35            Self::Registered => EmailTemplateType::CreditRegistrationRegistered,
36        }
37    }
38
39    /// The mail a row in this state is owed or already holds; `None` for a state that gets neither
40    /// mail. The single source of truth for the state -> mail mapping — both the queuing query below
41    /// and the student-facing status endpoint derive from this rather than keeping their own copy.
42    pub fn for_state(state: CreditRegistrationState) -> Option<Self> {
43        match state {
44            CreditRegistrationState::NoUsableEnrolment | CreditRegistrationState::Pending => {
45                Some(Self::ActionNeeded)
46            }
47            _ if CreditRegistrationState::SUCCESS_STATES.contains(&state) => Some(Self::Registered),
48            _ => None,
49        }
50    }
51}
52
53/// One row owed a mail, with everything the message renders.
54#[derive(Debug, Clone, PartialEq)]
55pub struct StudentNotificationToQueue {
56    pub credit_registration_id: Uuid,
57    pub kind: CreditRegistrationNotificationKind,
58    pub user_id: Uuid,
59    pub course_module_id: Uuid,
60    pub course_name: String,
61    pub course_language_code: String,
62    pub course_module_name: Option<String>,
63    pub first_name: Option<String>,
64    /// The credits frozen on the row, which may have been clamped to the enrolment's range, else the
65    /// module's.
66    pub credits: Option<f32>,
67    pub enrolment_link: Option<String>,
68}
69
70/// Claims the rows owed a mail, locking them until the caller's transaction ends, so callers must
71/// pass a transaction. Never claims `cancelled`, `blocked` or any failure state: those get
72/// nothing. Nor a `duplicate` or `not_improved` row whose student already has, or is owed, the
73/// registered mail for another row of the module. A `pending` row is owed the action-needed mail
74/// only while its student has no student number and `is_account_linking_enabled`, since only then
75/// does enrolling lead to one.
76pub async fn claim_unnotified(
77    conn: &mut PgConnection,
78    scope: &RegistrationScope,
79    limit: i64,
80    is_account_linking_enabled: bool,
81) -> ModelResult<Vec<StudentNotificationToQueue>> {
82    let res = sqlx::query!(
83        r#"
84SELECT cr.id AS "credit_registration_id!",
85  cr.state AS "state!: CreditRegistrationState",
86  cr.user_id AS "user_id!",
87  cr.course_module_id AS "course_module_id!",
88  c.name AS "course_name!",
89  c.language_code AS "course_language_code!",
90  cm.name AS "course_module_name?",
91  ud.first_name AS "first_name?",
92  COALESCE(cr.credits, cm.ects_credits) AS "credits?",
93  NULLIF(TRIM(cm.completion_registration_link_override), '') AS "enrolment_link?"
94FROM credit_registrations cr
95  JOIN courses c ON c.id = cr.course_id AND c.deleted_at IS NULL
96  JOIN course_modules cm ON cm.id = cr.course_module_id AND cm.deleted_at IS NULL
97  LEFT JOIN user_details ud ON ud.user_id = cr.user_id
98WHERE cr.deleted_at IS NULL
99  AND (
100    (
101      cr.state = 'no_usable_enrolment'
102      AND cr.action_needed_email_delivery_id IS NULL
103    )
104    OR (
105      cr.state = 'pending'
106      AND $7
107      AND cr.action_needed_email_delivery_id IS NULL
108      AND NOT EXISTS (
109        SELECT 1
110        FROM verified_student_numbers vsn
111        WHERE vsn.user_id = cr.user_id
112          AND vsn.deleted_at IS NULL
113      )
114    )
115    OR (
116      cr.state = ANY($5::credit_registration_state [])
117      AND cr.registered_email_delivery_id IS NULL
118      -- A credit found already recorded is no news to a student told of one for the module.
119      AND NOT (
120        cr.state = ANY($6::credit_registration_state [])
121        AND EXISTS (
122          SELECT 1
123          FROM credit_registrations told
124          WHERE told.user_id = cr.user_id
125            AND told.course_module_id = cr.course_module_id
126            AND told.id <> cr.id
127            AND told.deleted_at IS NULL
128            AND (
129              told.registered_email_delivery_id IS NOT NULL
130              OR told.state = 'registered'
131            )
132        )
133      )
134    )
135  )
136  AND ($2::uuid IS NULL OR cr.course_id = $2)
137  AND ($3::uuid IS NULL OR cr.user_id = $3)
138  AND (
139    cardinality($4::uuid []) = 0
140    OR cr.id = ANY($4::uuid [])
141  )
142ORDER BY cr.state_entered_at
143FOR UPDATE OF cr SKIP LOCKED
144LIMIT $1
145        "#,
146        limit,
147        scope.course_id,
148        scope.user_id,
149        &scope.credit_registration_ids,
150        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
151        &CreditRegistrationState::OTHER_SUCCESS_STATES as &[CreditRegistrationState],
152        is_account_linking_enabled,
153    )
154    .fetch_all(conn)
155    .await?;
156
157    Ok(res
158        .into_iter()
159        .map(|row| StudentNotificationToQueue {
160            kind: CreditRegistrationNotificationKind::for_state(row.state)
161                .unwrap_or(CreditRegistrationNotificationKind::Registered),
162            credit_registration_id: row.credit_registration_id,
163            user_id: row.user_id,
164            course_module_id: row.course_module_id,
165            course_name: row.course_name,
166            course_language_code: row.course_language_code,
167            course_module_name: row.course_module_name,
168            first_name: row.first_name,
169            credits: row.credits,
170            enrolment_link: row.enrolment_link,
171        })
172        .collect())
173}
174
175/// Records which delivery carries the mail, which is also what takes the row out of the queue.
176pub async fn set_email_delivery_id(
177    conn: &mut PgConnection,
178    credit_registration_id: Uuid,
179    kind: CreditRegistrationNotificationKind,
180    email_delivery_id: Uuid,
181) -> ModelResult<()> {
182    let action_needed = kind == CreditRegistrationNotificationKind::ActionNeeded;
183    sqlx::query!(
184        r#"
185UPDATE credit_registrations
186SET action_needed_email_delivery_id = CASE
187    WHEN $3 THEN $2
188    ELSE action_needed_email_delivery_id
189  END,
190  registered_email_delivery_id = CASE
191    WHEN $3 THEN registered_email_delivery_id
192    ELSE $2
193  END
194WHERE id = $1
195  AND deleted_at IS NULL
196        "#,
197        credit_registration_id,
198        email_delivery_id,
199        action_needed,
200    )
201    .execute(conn)
202    .await?;
203    Ok(())
204}
205
206/// One queued student mail and what we can honestly say about it.
207#[derive(Debug, Clone, PartialEq)]
208pub struct RegistrationNotificationEmail {
209    pub credit_registration_id: Uuid,
210    pub kind: CreditRegistrationNotificationKind,
211    /// The delivery the registration is pinned to. Stable for the life of the row: it is what stops a
212    /// second mail of this kind, so a changed id here means the guard was bypassed.
213    pub email_delivery_id: Uuid,
214    pub send_status: EmailSendStatusReport,
215}
216
217/// The mails queued for these rows, for the student, teacher and admin views that report on them.
218/// A row with neither mail queued yet contributes nothing.
219pub async fn get_for_registrations(
220    conn: &mut PgConnection,
221    credit_registration_ids: &[Uuid],
222) -> ModelResult<Vec<RegistrationNotificationEmail>> {
223    let rows = sqlx::query!(
224        r#"
225SELECT id,
226  action_needed_email_delivery_id,
227  registered_email_delivery_id
228FROM credit_registrations
229WHERE id = ANY($1::uuid [])
230  AND deleted_at IS NULL
231  AND (
232    action_needed_email_delivery_id IS NOT NULL
233    OR registered_email_delivery_id IS NOT NULL
234  )
235        "#,
236        credit_registration_ids
237    )
238    .fetch_all(&mut *conn)
239    .await?;
240
241    let delivery_ids: Vec<Uuid> = rows
242        .iter()
243        .flat_map(|row| {
244            [
245                row.action_needed_email_delivery_id,
246                row.registered_email_delivery_id,
247            ]
248        })
249        .flatten()
250        .collect();
251    let reports: HashMap<Uuid, EmailSendStatusReport> =
252        get_send_statuses(conn, &delivery_ids).await?;
253
254    let mut res = Vec::new();
255    for row in rows {
256        for (kind, delivery_id) in [
257            (
258                CreditRegistrationNotificationKind::ActionNeeded,
259                row.action_needed_email_delivery_id,
260            ),
261            (
262                CreditRegistrationNotificationKind::Registered,
263                row.registered_email_delivery_id,
264            ),
265        ] {
266            let Some((delivery_id, report)) =
267                delivery_id.and_then(|id| reports.get(&id).map(|report| (id, report)))
268            else {
269                continue;
270            };
271            res.push(RegistrationNotificationEmail {
272                credit_registration_id: row.id,
273                kind,
274                email_delivery_id: delivery_id,
275                send_status: report.clone(),
276            });
277        }
278    }
279    Ok(res)
280}