Skip to main content

headless_lms_models/
email_templates.rs

1use std::collections::HashMap;
2
3use crate::library::credit_registration::account_linking::link_student_number_url;
4use crate::prelude::*;
5use utoipa::ToSchema;
6
7#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Clone, Copy, sqlx::Type, ToSchema)]
8#[sqlx(type_name = "email_template_type", rename_all = "snake_case")]
9#[serde(rename_all = "snake_case")]
10pub enum EmailTemplateType {
11    ResetPasswordEmail,
12    DeleteUserEmail,
13    ConfirmEmailCode,
14    Generic,
15    CreditRegistrationAccountLinking,
16    VerifyEmailAddress,
17    CreditRegistrationActionNeeded,
18    CreditRegistrationRegistered,
19}
20
21impl EmailTemplateType {
22    /// Whether substitutions come from `email_deliveries.placeholders` instead of the recipient's
23    /// account, which may not exist.
24    pub fn uses_placeholder_bag(self) -> bool {
25        matches!(
26            self,
27            Self::CreditRegistrationAccountLinking
28                | Self::CreditRegistrationActionNeeded
29                | Self::CreditRegistrationRegistered
30        )
31    }
32
33    /// Made-up values for every placeholder the sender fills in mails of this type, for previews
34    /// and test sends. Keys must match what the sender and the credit registration jobs put in.
35    pub fn sample_placeholders(self, base_url: &str) -> HashMap<String, String> {
36        let base_url = base_url.trim_end_matches('/');
37        let sample_id = Uuid::nil();
38        let values: Vec<(&str, String)> = match self {
39            Self::Generic => vec![],
40            Self::ResetPasswordEmail => vec![(
41                "RESET_LINK",
42                format!("{base_url}/reset-user-password/{sample_id}"),
43            )],
44            Self::DeleteUserEmail | Self::ConfirmEmailCode | Self::VerifyEmailAddress => {
45                vec![("CODE", "123456".to_string())]
46            }
47            Self::CreditRegistrationAccountLinking => vec![
48                (
49                    "LINK",
50                    link_student_number_url(base_url, &sample_id.to_string()),
51                ),
52                ("NAME", "Alex".to_string()),
53                ("STUDENT_NUMBER", "012345678".to_string()),
54                ("COURSE_NAME", "Introduction to Programming".to_string()),
55            ],
56            Self::CreditRegistrationActionNeeded | Self::CreditRegistrationRegistered => vec![
57                ("NAME", "Alex".to_string()),
58                ("COURSE_NAME", "Introduction to Programming".to_string()),
59                ("MODULE_NAME", "Part 2".to_string()),
60                ("CREDITS", "5".to_string()),
61                ("ENROLMENT_LINK", "https://example.com/enrol".to_string()),
62                (
63                    "STATUS_LINK",
64                    format!("{base_url}/completion-registration/{sample_id}"),
65                ),
66            ],
67        };
68        values
69            .into_iter()
70            .map(|(key, value)| (key.to_string(), value))
71            .collect()
72    }
73}
74
75#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
76
77pub struct EmailTemplate {
78    pub id: Uuid,
79    pub created_at: DateTime<Utc>,
80    pub updated_at: DateTime<Utc>,
81    pub deleted_at: Option<DateTime<Utc>>,
82    pub content: Option<serde_json::Value>,
83    #[serde(rename = "template_type")]
84    #[schema(rename = "template_type")]
85    pub email_template_type: EmailTemplateType,
86    pub subject: Option<String>,
87    pub exercise_completions_threshold: Option<i32>,
88    pub points_threshold: Option<i32>,
89    pub course_id: Option<Uuid>,
90    pub language: Option<String>,
91}
92
93#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
94
95pub struct EmailTemplateNew {
96    pub template_type: EmailTemplateType,
97    pub language: Option<String>,
98    pub content: Option<serde_json::Value>,
99    pub subject: Option<String>,
100}
101
102#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
103
104pub struct EmailTemplateUpdate {
105    pub template_type: EmailTemplateType,
106    pub subject: String,
107    pub content: serde_json::Value,
108    pub exercise_completions_threshold: Option<i32>,
109    pub points_threshold: Option<i32>,
110}
111
112pub async fn get_email_templates(
113    conn: &mut PgConnection,
114    course_id: Uuid,
115) -> ModelResult<Vec<EmailTemplate>> {
116    let res = sqlx::query_as!(
117        EmailTemplate,
118        r#"
119SELECT *
120FROM email_templates
121WHERE course_id = $1
122  AND deleted_at IS NULL
123        "#,
124        course_id
125    )
126    .fetch_all(conn)
127    .await?;
128    Ok(res)
129}
130
131pub async fn get_all_email_templates(conn: &mut PgConnection) -> ModelResult<Vec<EmailTemplate>> {
132    let res = sqlx::query_as!(
133        EmailTemplate,
134        r#"
135SELECT *
136FROM email_templates
137WHERE deleted_at IS NULL
138        "#,
139    )
140    .fetch_all(conn)
141    .await?;
142    Ok(res)
143}
144
145pub async fn get_generic_email_template_by_type_and_language(
146    conn: &mut PgConnection,
147    template_type: EmailTemplateType,
148    language: &str,
149) -> ModelResult<EmailTemplate> {
150    let res = sqlx::query_as!(
151        EmailTemplate,
152        r#"
153SELECT *
154FROM email_templates
155WHERE email_template_type = $1
156  AND course_id IS NULL
157  AND deleted_at IS NULL
158  AND (
159    language = $2
160    OR language = 'en'
161    OR language IS NULL
162  )
163ORDER BY CASE
164    WHEN language = $2 THEN 0
165    WHEN language = 'en' THEN 1
166    WHEN language IS NULL THEN 2
167    ELSE 3
168  END
169LIMIT 1
170        "#,
171        template_type as EmailTemplateType,
172        language
173    )
174    .fetch_one(conn)
175    .await?;
176    Ok(res)
177}
178
179/// Whether a generic template of this type would actually be found: checks only the `"en"` and
180/// `NULL` language rows, the two the lookup above always falls back to regardless of the caller's
181/// UI language.
182pub async fn generic_email_template_exists(
183    conn: &mut PgConnection,
184    template_type: EmailTemplateType,
185) -> ModelResult<bool> {
186    let res = sqlx::query_scalar!(
187        r#"
188SELECT EXISTS(
189  SELECT 1
190  FROM email_templates
191  WHERE email_template_type = $1
192    AND course_id IS NULL
193    AND deleted_at IS NULL
194    AND (language = 'en' OR language IS NULL)
195) AS "exists!"
196        "#,
197        template_type as EmailTemplateType,
198    )
199    .fetch_one(conn)
200    .await?;
201    Ok(res)
202}
203
204pub async fn insert_email_template(
205    conn: &mut PgConnection,
206    course_id: Option<Uuid>,
207    email_template: EmailTemplateNew,
208    subject: Option<&'_ str>,
209) -> ModelResult<EmailTemplate> {
210    let subject_to_use = email_template.subject.as_deref().or(subject);
211    let res = sqlx::query_as!(
212        EmailTemplate,
213        r#"
214INSERT INTO email_templates (
215    email_template_type,
216    course_id,
217    subject,
218    language,
219    content
220  )
221VALUES ($1, $2, $3, $4, $5) ON CONFLICT (email_template_type, language, deleted_at)
222WHERE course_id IS NULL
223  AND deleted_at IS NULL DO
224UPDATE
225SET subject = COALESCE(EXCLUDED.subject, email_templates.subject),
226  content = COALESCE(EXCLUDED.content, email_templates.content),
227  updated_at = NOW()
228RETURNING *
229        "#,
230        email_template.template_type as EmailTemplateType,
231        course_id,
232        subject_to_use,
233        email_template.language,
234        email_template.content,
235    )
236    .fetch_one(conn)
237    .await?;
238    Ok(res)
239}
240
241/// Inserts a global template, or returns `None` if a live one of the same type and language exists.
242/// Unlike [`insert_email_template`], never overwrites the existing row.
243pub async fn insert_global_email_template_if_absent(
244    conn: &mut PgConnection,
245    email_template: EmailTemplateNew,
246) -> ModelResult<Option<EmailTemplate>> {
247    let res = sqlx::query_as!(
248        EmailTemplate,
249        r#"
250INSERT INTO email_templates (
251    email_template_type,
252    subject,
253    language,
254    content
255  )
256VALUES ($1, $2, $3, $4) ON CONFLICT (email_template_type, language, deleted_at)
257WHERE course_id IS NULL
258  AND deleted_at IS NULL DO NOTHING
259RETURNING *
260        "#,
261        email_template.template_type as EmailTemplateType,
262        email_template.subject,
263        email_template.language,
264        email_template.content,
265    )
266    .fetch_optional(conn)
267    .await?;
268    Ok(res)
269}
270
271pub async fn get_email_template(
272    conn: &mut PgConnection,
273    email_template_id: Uuid,
274) -> ModelResult<EmailTemplate> {
275    let res = sqlx::query_as!(
276        EmailTemplate,
277        r#"
278SELECT *
279FROM email_templates
280WHERE id = $1
281  AND deleted_at IS NULL
282        "#,
283        email_template_id
284    )
285    .fetch_one(conn)
286    .await?;
287    Ok(res)
288}
289
290/// The language a template's mails are rendered in: its own, else its course's. `None` for a global
291/// template without one.
292pub async fn get_language(
293    conn: &mut PgConnection,
294    email_template_id: Uuid,
295) -> ModelResult<Option<String>> {
296    let res = sqlx::query_scalar!(
297        r#"
298SELECT email_template_language(et) AS language
299FROM email_templates et
300WHERE et.id = $1
301  AND et.deleted_at IS NULL
302        "#,
303        email_template_id
304    )
305    .fetch_one(conn)
306    .await?;
307    Ok(res)
308}
309
310pub async fn update_email_template(
311    conn: &mut PgConnection,
312    email_template_id: Uuid,
313    email_template_update: EmailTemplateUpdate,
314) -> ModelResult<EmailTemplate> {
315    let res = sqlx::query_as!(
316        EmailTemplate,
317        r#"
318UPDATE email_templates
319SET email_template_type = $1,
320  subject = $2,
321  content = $3,
322  exercise_completions_threshold = $4,
323  points_threshold = $5
324WHERE id = $6
325  AND deleted_at IS NULL
326RETURNING *
327  "#,
328        email_template_update.template_type as EmailTemplateType,
329        email_template_update.subject,
330        email_template_update.content,
331        email_template_update.exercise_completions_threshold,
332        email_template_update.points_threshold,
333        email_template_id
334    )
335    .fetch_one(conn)
336    .await?;
337    Ok(res)
338}
339
340pub async fn delete_email_template(
341    conn: &mut PgConnection,
342    email_template_id: Uuid,
343) -> ModelResult<EmailTemplate> {
344    let deleted = sqlx::query_as!(
345        EmailTemplate,
346        r#"
347UPDATE email_templates
348SET deleted_at = NOW()
349WHERE id = $1
350  AND deleted_at IS NULL
351RETURNING *
352  "#,
353        email_template_id
354    )
355    .fetch_one(&mut *conn)
356    .await?;
357    // The sender claims only deliveries of live templates, so unsent ones would stay queued forever.
358    sqlx::query!(
359        "
360UPDATE email_deliveries
361SET deleted_at = NOW()
362WHERE email_template_id = $1
363  AND sent = FALSE
364  AND deleted_at IS NULL
365        ",
366        email_template_id
367    )
368    .execute(conn)
369    .await?;
370    Ok(deleted)
371}