headless_lms_server/domain/credit_registration_phases/
student_notifications.rs1use 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#[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
90async 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
118fn 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}