Skip to main content

headless_lms_models/
email_templates.rs

1use crate::prelude::*;
2use utoipa::ToSchema;
3
4#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Clone, Copy, sqlx::Type, ToSchema)]
5#[sqlx(type_name = "email_template_type", rename_all = "snake_case")]
6#[serde(rename_all = "snake_case")]
7pub enum EmailTemplateType {
8    ResetPasswordEmail,
9    DeleteUserEmail,
10    ConfirmEmailCode,
11    Generic,
12    CreditRegistrationAccountLinking,
13    VerifyEmailAddress,
14    CreditRegistrationActionNeeded,
15    CreditRegistrationRegistered,
16}
17
18impl EmailTemplateType {
19    /// Whether substitutions come from `email_deliveries.placeholders` instead of the recipient's
20    /// account, which may not exist.
21    pub fn uses_placeholder_bag(self) -> bool {
22        matches!(
23            self,
24            Self::CreditRegistrationAccountLinking
25                | Self::CreditRegistrationActionNeeded
26                | Self::CreditRegistrationRegistered
27        )
28    }
29}
30
31#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
32
33pub struct EmailTemplate {
34    pub id: Uuid,
35    pub created_at: DateTime<Utc>,
36    pub updated_at: DateTime<Utc>,
37    pub deleted_at: Option<DateTime<Utc>>,
38    pub content: Option<serde_json::Value>,
39    #[serde(rename = "template_type")]
40    #[schema(rename = "template_type")]
41    pub email_template_type: EmailTemplateType,
42    pub subject: Option<String>,
43    pub exercise_completions_threshold: Option<i32>,
44    pub points_threshold: Option<i32>,
45    pub course_id: Option<Uuid>,
46    pub language: Option<String>,
47}
48
49#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
50
51pub struct EmailTemplateNew {
52    pub template_type: EmailTemplateType,
53    pub language: Option<String>,
54    pub content: Option<serde_json::Value>,
55    pub subject: Option<String>,
56}
57
58#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
59
60pub struct EmailTemplateUpdate {
61    pub template_type: EmailTemplateType,
62    pub subject: String,
63    pub content: serde_json::Value,
64    pub exercise_completions_threshold: Option<i32>,
65    pub points_threshold: Option<i32>,
66}
67
68pub async fn get_email_templates(
69    conn: &mut PgConnection,
70    course_id: Uuid,
71) -> ModelResult<Vec<EmailTemplate>> {
72    let res = sqlx::query_as!(
73        EmailTemplate,
74        r#"
75SELECT *
76FROM email_templates
77WHERE course_id = $1
78  AND deleted_at IS NULL
79        "#,
80        course_id
81    )
82    .fetch_all(conn)
83    .await?;
84    Ok(res)
85}
86
87pub async fn get_all_email_templates(conn: &mut PgConnection) -> ModelResult<Vec<EmailTemplate>> {
88    let res = sqlx::query_as!(
89        EmailTemplate,
90        r#"
91SELECT *
92FROM email_templates
93WHERE deleted_at IS NULL
94        "#,
95    )
96    .fetch_all(conn)
97    .await?;
98    Ok(res)
99}
100
101pub async fn get_generic_email_template_by_type_and_language(
102    conn: &mut PgConnection,
103    template_type: EmailTemplateType,
104    language: &str,
105) -> ModelResult<EmailTemplate> {
106    let res = sqlx::query_as!(
107        EmailTemplate,
108        r#"
109SELECT *
110FROM email_templates
111WHERE email_template_type = $1
112  AND course_id IS NULL
113  AND deleted_at IS NULL
114  AND (
115    language = $2
116    OR language = 'en'
117    OR language IS NULL
118  )
119ORDER BY CASE
120    WHEN language = $2 THEN 0
121    WHEN language = 'en' THEN 1
122    WHEN language IS NULL THEN 2
123    ELSE 3
124  END
125LIMIT 1
126        "#,
127        template_type as EmailTemplateType,
128        language
129    )
130    .fetch_one(conn)
131    .await?;
132    Ok(res)
133}
134
135/// Whether a generic template of this type would actually be found: checks only the `"en"` and
136/// `NULL` language rows, the two the lookup above always falls back to regardless of the caller's
137/// UI language.
138pub async fn generic_email_template_exists(
139    conn: &mut PgConnection,
140    template_type: EmailTemplateType,
141) -> ModelResult<bool> {
142    let res = sqlx::query_scalar!(
143        r#"
144SELECT EXISTS(
145  SELECT 1
146  FROM email_templates
147  WHERE email_template_type = $1
148    AND course_id IS NULL
149    AND deleted_at IS NULL
150    AND (language = 'en' OR language IS NULL)
151) AS "exists!"
152        "#,
153        template_type as EmailTemplateType,
154    )
155    .fetch_one(conn)
156    .await?;
157    Ok(res)
158}
159
160pub async fn insert_email_template(
161    conn: &mut PgConnection,
162    course_id: Option<Uuid>,
163    email_template: EmailTemplateNew,
164    subject: Option<&'_ str>,
165) -> ModelResult<EmailTemplate> {
166    let subject_to_use = email_template.subject.as_deref().or(subject);
167    let res = sqlx::query_as!(
168        EmailTemplate,
169        r#"
170INSERT INTO email_templates (
171    email_template_type,
172    course_id,
173    subject,
174    language,
175    content
176  )
177VALUES ($1, $2, $3, $4, $5) ON CONFLICT (email_template_type, language, deleted_at)
178WHERE course_id IS NULL
179  AND deleted_at IS NULL DO
180UPDATE
181SET subject = COALESCE(EXCLUDED.subject, email_templates.subject),
182  content = COALESCE(EXCLUDED.content, email_templates.content),
183  updated_at = NOW()
184RETURNING *
185        "#,
186        email_template.template_type as EmailTemplateType,
187        course_id,
188        subject_to_use,
189        email_template.language,
190        email_template.content,
191    )
192    .fetch_one(conn)
193    .await?;
194    Ok(res)
195}
196
197/// Inserts a global template, or returns `None` if a live one of the same type and language exists.
198/// Unlike [`insert_email_template`], never overwrites the existing row.
199pub async fn insert_global_email_template_if_absent(
200    conn: &mut PgConnection,
201    email_template: EmailTemplateNew,
202) -> ModelResult<Option<EmailTemplate>> {
203    let res = sqlx::query_as!(
204        EmailTemplate,
205        r#"
206INSERT INTO email_templates (
207    email_template_type,
208    subject,
209    language,
210    content
211  )
212VALUES ($1, $2, $3, $4) ON CONFLICT (email_template_type, language, deleted_at)
213WHERE course_id IS NULL
214  AND deleted_at IS NULL DO NOTHING
215RETURNING *
216        "#,
217        email_template.template_type as EmailTemplateType,
218        email_template.subject,
219        email_template.language,
220        email_template.content,
221    )
222    .fetch_optional(conn)
223    .await?;
224    Ok(res)
225}
226
227pub async fn get_email_template(
228    conn: &mut PgConnection,
229    email_template_id: Uuid,
230) -> ModelResult<EmailTemplate> {
231    let res = sqlx::query_as!(
232        EmailTemplate,
233        r#"
234SELECT *
235FROM email_templates
236WHERE id = $1
237  AND deleted_at IS NULL
238        "#,
239        email_template_id
240    )
241    .fetch_one(conn)
242    .await?;
243    Ok(res)
244}
245
246pub async fn update_email_template(
247    conn: &mut PgConnection,
248    email_template_id: Uuid,
249    email_template_update: EmailTemplateUpdate,
250) -> ModelResult<EmailTemplate> {
251    let res = sqlx::query_as!(
252        EmailTemplate,
253        r#"
254UPDATE email_templates
255SET email_template_type = $1,
256  subject = $2,
257  content = $3,
258  exercise_completions_threshold = $4,
259  points_threshold = $5
260WHERE id = $6
261  AND deleted_at IS NULL
262RETURNING *
263  "#,
264        email_template_update.template_type as EmailTemplateType,
265        email_template_update.subject,
266        email_template_update.content,
267        email_template_update.exercise_completions_threshold,
268        email_template_update.points_threshold,
269        email_template_id
270    )
271    .fetch_one(conn)
272    .await?;
273    Ok(res)
274}
275
276pub async fn delete_email_template(
277    conn: &mut PgConnection,
278    email_template_id: Uuid,
279) -> ModelResult<EmailTemplate> {
280    let deleted = sqlx::query_as!(
281        EmailTemplate,
282        r#"
283UPDATE email_templates
284SET deleted_at = NOW()
285WHERE id = $1
286  AND deleted_at IS NULL
287RETURNING *
288  "#,
289        email_template_id
290    )
291    .fetch_one(conn)
292    .await?;
293    Ok(deleted)
294}