Skip to main content

headless_lms_credit_registration/use_cases/
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, and a withdrawn one
6//! was the student's own decision.
7
8use headless_lms_models::email_deliveries::insert_email_delivery_with_placeholders;
9use headless_lms_models::library::credit_registration::student_notifications::{
10    STUDENT_NOTIFICATION_LIMIT, StudentNotificationToQueue, claim_unnotified, set_email_delivery_id,
11};
12use serde_json::json;
13use sqlx::{Connection, PgPool};
14use uuid::Uuid;
15
16use crate::error::CreditRegistrationResult;
17use crate::use_cases::mail_queue::{MailQueueSummary, TemplateCache, template_language};
18use crate::workflow::Counts;
19use headless_lms_models::credit_registrations::RegistrationScope;
20
21/// `base_url` is the absolute base for the mail's links, which outlive the process that wrote them.
22pub(crate) async fn run(
23    pool: &PgPool,
24    scope: &RegistrationScope,
25    base_url: &str,
26    is_account_linking_enabled: bool,
27) -> CreditRegistrationResult<Counts> {
28    let mut conn = pool.acquire().await?;
29    let mut tx = conn.begin().await?;
30    let claimed = claim_unnotified(
31        &mut tx,
32        scope,
33        STUDENT_NOTIFICATION_LIMIT,
34        is_account_linking_enabled,
35    )
36    .await?;
37    let mut templates = TemplateCache::default();
38    let mut summary = MailQueueSummary::new(claimed.len());
39    for notification in &claimed {
40        let template_type = notification.kind.email_template_type();
41        let language = template_language(&notification.course_language_code);
42        let Some(template_id) = templates.id_for(&mut tx, template_type, &language).await? else {
43            summary.skip_missing_template(
44                template_type,
45                &language,
46                format!("{template_type:?} in {language}"),
47            );
48            continue;
49        };
50        let delivery = insert_email_delivery_with_placeholders(
51            &mut tx,
52            notification.user_id,
53            template_id,
54            &placeholders(base_url, notification, &language),
55        )
56        .await?;
57        set_email_delivery_id(
58            &mut tx,
59            notification.credit_registration_id,
60            notification.kind,
61            delivery,
62        )
63        .await?;
64    }
65    tx.commit().await?;
66    Ok(summary.finish("No student notification email template for:"))
67}
68
69/// Stored on the delivery row, so the sender needs no lookup of its own.
70fn placeholders(
71    base_url: &str,
72    notification: &StudentNotificationToQueue,
73    language: &str,
74) -> serde_json::Value {
75    json!({
76        "NAME": notification.first_name.as_deref().unwrap_or_default(),
77        "COURSE_NAME": notification.course_name,
78        "MODULE_NAME": notification.course_module_name.as_deref().unwrap_or_default(),
79        "CREDITS": notification
80            .credits
81            .map(|credits| format_credits(credits, language))
82            .unwrap_or_default(),
83        "ENROLMENT_LINK": notification.enrolment_link.as_deref().unwrap_or_default(),
84        "STATUS_LINK": status_page_url(base_url, notification.course_module_id),
85    })
86}
87
88/// `credits` as the mail's language writes a number: at most two decimals, none when whole, and a
89/// decimal comma in Finnish and Swedish.
90fn format_credits(credits: f32, language: &str) -> String {
91    let formatted = format!("{credits:.2}");
92    let formatted = formatted.trim_end_matches('0').trim_end_matches('.');
93    match language {
94        "fi" | "sv" => formatted.replace('.', ","),
95        _ => formatted.to_string(),
96    }
97}
98
99/// The page the mail sends the student to, which is where every next step already lives.
100fn status_page_url(base_url: &str, course_module_id: Uuid) -> String {
101    format!(
102        "{}/completion-registration/{course_module_id}",
103        base_url.trim_end_matches('/')
104    )
105}