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, 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::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) -> CreditRegistrationResult<Counts> {
27    let mut conn = pool.acquire().await?;
28    let mut tx = conn.begin().await?;
29    let claimed = claim_unnotified(&mut tx, scope, STUDENT_NOTIFICATION_LIMIT).await?;
30    let mut templates = TemplateCache::default();
31    let mut summary = MailQueueSummary::new(claimed.len());
32    for notification in &claimed {
33        let template_type = notification.kind.email_template_type();
34        let language = template_language(&notification.course_language_code);
35        let Some(template_id) = templates.id_for(&mut tx, template_type, &language).await? else {
36            summary.skip_missing_template(
37                template_type,
38                &language,
39                format!("{template_type:?} in {language}"),
40            );
41            continue;
42        };
43        let delivery = insert_email_delivery_with_placeholders(
44            &mut tx,
45            notification.user_id,
46            template_id,
47            &placeholders(base_url, notification, &language),
48        )
49        .await?;
50        set_email_delivery_id(
51            &mut tx,
52            notification.credit_registration_id,
53            notification.kind,
54            delivery,
55        )
56        .await?;
57    }
58    tx.commit().await?;
59    Ok(summary.finish("No student notification email template for:"))
60}
61
62/// Stored on the delivery row, so the sender needs no lookup of its own.
63fn placeholders(
64    base_url: &str,
65    notification: &StudentNotificationToQueue,
66    language: &str,
67) -> serde_json::Value {
68    json!({
69        "NAME": notification.first_name.as_deref().unwrap_or_default(),
70        "COURSE_NAME": notification.course_name,
71        "MODULE_NAME": notification.course_module_name.as_deref().unwrap_or_default(),
72        "CREDITS": notification
73            .credits
74            .map(|credits| format_credits(credits, language))
75            .unwrap_or_default(),
76        "STATUS_LINK": status_page_url(base_url, notification.course_module_id),
77    })
78}
79
80/// `credits` as the mail's language writes a number: at most two decimals, none when whole, and a
81/// decimal comma in Finnish and Swedish.
82fn format_credits(credits: f32, language: &str) -> String {
83    let formatted = format!("{credits:.2}");
84    let formatted = formatted.trim_end_matches('0').trim_end_matches('.');
85    match language {
86        "fi" | "sv" => formatted.replace('.', ","),
87        _ => formatted.to_string(),
88    }
89}
90
91/// The page the mail sends the student to, which is where every next step already lives.
92fn status_page_url(base_url: &str, course_module_id: Uuid) -> String {
93    format!(
94        "{}/completion-registration/{course_module_id}",
95        base_url.trim_end_matches('/')
96    )
97}