Skip to main content

headless_lms_credit_registration/use_cases/
mail_queue.rs

1//! What the two mail phases share: the template lookup, and the accounting of what one iteration
2//! queued and skipped.
3
4use headless_lms_base::error::backend_error::BackendError;
5use headless_lms_models::email_templates::{
6    EmailTemplateType, get_generic_email_template_by_type_and_language,
7};
8use sqlx::PgConnection;
9use std::collections::{BTreeSet, HashMap};
10use uuid::Uuid;
11
12use crate::error::CreditRegistrationResult;
13use crate::workflow::Counts;
14
15/// One template lookup per type and language per iteration rather than per mail. `None` means no
16/// template exists, which a mail phase reports rather than failing the batch it was found in.
17#[derive(Default)]
18pub(super) struct TemplateCache(HashMap<(EmailTemplateType, String), Option<Uuid>>);
19
20impl TemplateCache {
21    pub(super) async fn id_for(
22        &mut self,
23        conn: &mut PgConnection,
24        template_type: EmailTemplateType,
25        language: &str,
26    ) -> CreditRegistrationResult<Option<Uuid>> {
27        let key = (template_type, language.to_string());
28        if let Some(id) = self.0.get(&key) {
29            return Ok(*id);
30        }
31        let found =
32            match get_generic_email_template_by_type_and_language(conn, template_type, language)
33                .await
34            {
35                Ok(template) => Some(template.id),
36                Err(error)
37                    if matches!(
38                        error.error_type(),
39                        headless_lms_models::ModelErrorType::RecordNotFound
40                    ) =>
41                {
42                    None
43                }
44                Err(error) => return Err(error.into()),
45            };
46        self.0.insert(key, found);
47        Ok(found)
48    }
49}
50
51/// What one mail iteration claimed and could not queue. A mail with no template is skipped rather
52/// than failing the iteration: the batch is one transaction, so an error would roll back every mail
53/// that could be queued, and the claimed rows stay claimable.
54pub(super) struct MailQueueSummary {
55    claimed: usize,
56    skipped: usize,
57    missing_templates: BTreeSet<String>,
58}
59
60impl MailQueueSummary {
61    pub(super) fn new(claimed: usize) -> Self {
62        Self {
63            claimed,
64            skipped: 0,
65            missing_templates: BTreeSet::new(),
66        }
67    }
68
69    /// A mail left unqueued for want of a template; `label` names it in the iteration's finding.
70    pub(super) fn skip_missing_template(
71        &mut self,
72        template_type: EmailTemplateType,
73        language: &str,
74        label: impl Into<String>,
75    ) {
76        debug!(
77            ?template_type,
78            language = %language,
79            "No email template for mail; skipping"
80        );
81        self.missing_templates.insert(label.into());
82        self.skipped += 1;
83    }
84
85    /// Logs the iteration and turns it into its counts, with the missing templates listed after
86    /// `missing_templates_prefix` as the finding.
87    pub(super) fn finish(self, missing_templates_prefix: &str) -> Counts {
88        let claimed = self.claimed;
89        let skipped = self.skipped;
90        if claimed > 0 {
91            let queued = claimed - skipped;
92            info!(
93                claimed,
94                queued, skipped, "queued {queued} mails, skipped {skipped}"
95            );
96        }
97        let finding = (!self.missing_templates.is_empty()).then(|| {
98            format!(
99                "{missing_templates_prefix} {}.",
100                self.missing_templates
101                    .into_iter()
102                    .collect::<Vec<_>>()
103                    .join(", ")
104            )
105        });
106        Counts::processed_with_failures(
107            i32::try_from(claimed).unwrap_or(i32::MAX),
108            i32::try_from(skipped).unwrap_or(i32::MAX),
109        )
110        .with_finding(finding)
111    }
112}
113
114/// Templates are stored per language and courses carry a locale. The course's language, not the
115/// recipient's: the linking mail's recipient may have no account here, and an account records no UI
116/// language to prefer.
117pub(super) fn template_language(course_language_code: &str) -> String {
118    course_language_code
119        .split(['-', '_'])
120        .next()
121        .unwrap_or(course_language_code)
122        .to_lowercase()
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn a_locale_narrows_to_the_language_the_templates_are_stored_under() {
131        assert_eq!(template_language("fi-FI"), "fi");
132        assert_eq!(template_language("en_US"), "en");
133        assert_eq!(template_language("en"), "en");
134    }
135}