Skip to main content

headless_lms_server/domain/credit_registration_phases/
student_notifications.rs

1//! The `student-notifications` phase: the only thing that queues a student mail about a credit
2//! registration.
3//!
4//! Exactly two mails exist and each row gets each at most once. Nothing else is mailed: a
5//! `failed_permanent` row is a configuration problem the student cannot act on, a withdrawn one was
6//! the student's own decision, and the linking mail already covers a missing student number.
7
8use headless_lms_models::credit_registration_phase_state::PhaseRunOutcome;
9use headless_lms_models::email_deliveries::insert_email_delivery_with_placeholders;
10use headless_lms_models::email_templates::EmailTemplateType;
11use headless_lms_models::library::credit_registration::student_notifications::{
12    CreditRegistrationNotificationKind, STUDENT_NOTIFICATION_LIMIT, StudentNotificationToQueue,
13    claim_unnotified, set_email_delivery_id,
14};
15use headless_lms_models::open_university_product_access_tokens::enrolment_url_for_product;
16use serde_json::json;
17use sqlx::PgConnection;
18use std::collections::HashMap;
19use uuid::Uuid;
20
21use super::{MailQueuePhase, PhaseContext, PhaseScope, run_mail_queue_phase, template_language};
22
23/// One [`enrolment_url_for_product`] lookup per product id per phase run, rather than per row: many
24/// claimed rows share the same module's product.
25#[derive(Default)]
26struct ProductUrlCache(HashMap<Option<String>, Option<String>>);
27
28impl ProductUrlCache {
29    async fn url_for(
30        &mut self,
31        conn: &mut PgConnection,
32        open_university_product_id: Option<&str>,
33    ) -> anyhow::Result<Option<String>> {
34        let key = open_university_product_id.map(str::to_string);
35        if let Some(url) = self.0.get(&key) {
36            return Ok(url.clone());
37        }
38        let url = enrolment_url_for_product(conn, open_university_product_id).await?;
39        self.0.insert(key, url.clone());
40        Ok(url)
41    }
42}
43
44pub async fn run(ctx: &PhaseContext<'_>, scope: &PhaseScope) -> anyhow::Result<PhaseRunOutcome> {
45    run_mail_queue_phase::<StudentNotificationsPhase>(ctx, scope).await
46}
47
48struct StudentNotificationsPhase;
49
50impl MailQueuePhase for StudentNotificationsPhase {
51    type Item = StudentNotificationToQueue;
52    type Cache = ProductUrlCache;
53
54    async fn claim(conn: &mut PgConnection, scope: &PhaseScope) -> anyhow::Result<Vec<Self::Item>> {
55        Ok(claim_unnotified(conn, scope, STUDENT_NOTIFICATION_LIMIT).await?)
56    }
57
58    fn template_type(item: &Self::Item) -> EmailTemplateType {
59        item.kind.email_template_type()
60    }
61
62    fn language(item: &Self::Item) -> String {
63        template_language(&item.course_language_code)
64    }
65
66    async fn queue(
67        ctx: &PhaseContext<'_>,
68        conn: &mut PgConnection,
69        item: &Self::Item,
70        template_id: Uuid,
71        cache: &mut Self::Cache,
72    ) -> anyhow::Result<()> {
73        let placeholders = placeholders(ctx.base_url, conn, item, cache).await?;
74        let delivery =
75            insert_email_delivery_with_placeholders(conn, item.user_id, template_id, &placeholders)
76                .await?;
77        set_email_delivery_id(conn, item.credit_registration_id, item.kind, delivery).await?;
78        Ok(())
79    }
80
81    fn missing_template_label(template_type: EmailTemplateType, language: &str) -> String {
82        format!("{template_type:?} in {language}")
83    }
84
85    fn missing_templates_error_prefix() -> &'static str {
86        "No student notification email template for:"
87    }
88}
89
90/// Stored on the delivery row, so the sender needs no lookup of its own.
91///
92/// `ENROLMENT_LINK` is empty when the module has no product or no resolved token; the template's
93/// sentence has to read correctly without it, because a mail that only says "enrol in Sisu" is all
94/// the student gets in that case.
95async fn placeholders(
96    base_url: &str,
97    conn: &mut PgConnection,
98    notification: &StudentNotificationToQueue,
99    product_urls: &mut ProductUrlCache,
100) -> anyhow::Result<serde_json::Value> {
101    let enrolment_link = match notification.kind {
102        CreditRegistrationNotificationKind::ActionNeeded => product_urls
103            .url_for(conn, notification.open_university_product_id.as_deref())
104            .await?
105            .unwrap_or_default(),
106        CreditRegistrationNotificationKind::Registered => String::new(),
107    };
108    Ok(json!({
109        "NAME": notification.first_name.clone().unwrap_or_default(),
110        "COURSE_NAME": notification.course_name,
111        "MODULE_NAME": notification.course_module_name.clone().unwrap_or_default(),
112        "CREDITS": notification.ects_credits.map(|credits| credits.to_string()).unwrap_or_default(),
113        "STATUS_LINK": status_page_url(base_url, notification.course_module_id),
114        "ENROLMENT_LINK": enrolment_link,
115    }))
116}
117
118/// The page the mail sends the student to, which is where every next step already lives.
119fn status_page_url(base_url: &str, course_module_id: Uuid) -> String {
120    format!(
121        "{}/completion-registration/{course_module_id}",
122        base_url.trim_end_matches('/')
123    )
124}