Skip to main content

headless_lms_models/
generated_certificates.rs

1use crate::prelude::*;
2use headless_lms_utils as utils;
3use utoipa::ToSchema;
4
5#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
6
7pub struct GeneratedCertificate {
8    pub id: Uuid,
9    pub created_at: DateTime<Utc>,
10    pub updated_at: DateTime<Utc>,
11    pub deleted_at: Option<DateTime<Utc>>,
12    pub user_id: Uuid,
13    pub name_on_certificate: String,
14    pub verification_id: String,
15    pub certificate_configuration_id: Uuid,
16}
17
18pub async fn get_certificate_for_user(
19    conn: &mut PgConnection,
20    user_id: Uuid,
21    certificate_configuration_id: Uuid,
22) -> ModelResult<GeneratedCertificate> {
23    let res = sqlx::query_as!(
24        GeneratedCertificate,
25        "
26SELECT *
27FROM generated_certificates
28WHERE user_id = $1
29  AND certificate_configuration_id = $2
30  AND deleted_at IS NULL
31",
32        user_id,
33        certificate_configuration_id
34    )
35    .fetch_one(conn)
36    .await?;
37    Ok(res)
38}
39/// Verifies that the user has completed the given module and creates the certificate in the database.
40pub async fn generate_and_insert(
41    conn: &mut PgConnection,
42    user_id: Uuid,
43    name_on_certificate: &str,
44    certificate_configuration_id: Uuid,
45) -> ModelResult<GeneratedCertificate> {
46    let requirements = crate::certificate_configuration_to_requirements::get_all_requirements_for_certificate_configuration(conn, certificate_configuration_id).await?;
47    // Verify that the user has completed the module in the course instance
48    if !requirements
49        .has_user_completed_all_requirements(conn, user_id)
50        .await?
51    {
52        return Err(ModelError::new(
53            ModelErrorType::PreconditionFailed,
54            "User has not completed all the requirements to be eligible for this certificate."
55                .to_string(),
56            None,
57        ));
58    }
59
60    // Verify that a certificate doesn't already exist
61    if sqlx::query!(
62        "
63SELECT id
64FROM generated_certificates
65WHERE user_id = $1
66    AND certificate_configuration_id = $2
67    AND deleted_at IS NULL
68",
69        user_id,
70        certificate_configuration_id,
71    )
72    .fetch_optional(&mut *conn)
73    .await?
74    .is_some()
75    {
76        // Certificate already exists
77        return Err(ModelError::new(
78            ModelErrorType::PreconditionFailed,
79            "User already has a certificate for the given module and course instance".to_string(),
80            None,
81        ));
82    }
83
84    let verification_id = generate_verification_id();
85    let res = sqlx::query_as!(
86        GeneratedCertificate,
87        "
88INSERT INTO generated_certificates (
89    user_id,
90    certificate_configuration_id,
91    name_on_certificate,
92    verification_id
93  )
94VALUES ($1, $2, $3, $4)
95RETURNING *
96",
97        user_id,
98        certificate_configuration_id,
99        name_on_certificate,
100        verification_id,
101    )
102    .fetch_one(conn)
103    .await?;
104    Ok(res)
105}
106
107pub async fn get_certificate_by_verification_id(
108    conn: &mut PgConnection,
109    certificate_verification_id: &str,
110) -> ModelResult<GeneratedCertificate> {
111    let res = sqlx::query_as!(
112        GeneratedCertificate,
113        "
114SELECT *
115FROM generated_certificates
116WHERE verification_id = $1
117  AND deleted_at IS NULL
118",
119        certificate_verification_id
120    )
121    .fetch_one(conn)
122    .await?;
123    Ok(res)
124}
125
126fn generate_verification_id() -> String {
127    utils::strings::generate_easily_writable_random_string(15)
128}
129
130#[derive(Debug, Deserialize, Serialize, ToSchema)]
131
132pub struct CertificateUpdateRequest {
133    pub date_issued: DateTime<Utc>,
134    pub name_on_certificate: Option<String>,
135}
136
137/// Rewrites what an issued certificate prints: its holder-facing name and the date it is dated.
138///
139/// The issue date is `created_at`, which is the column the certificate and every listing of it
140/// read, so `date_issued` is written unconditionally; pass the row's current value to leave it be.
141/// `name_on_certificate` of `None` leaves the name as it is.
142///
143/// `expected_updated_at` makes this a compare-and-swap: pass the `updated_at` of the row the
144/// caller read and the write lands only if nothing has touched the row since, returning `None`
145/// when something has. `None` skips the check, for a caller with no earlier read to protect.
146/// Without it a name-only update silently restores the date another admin had just corrected.
147pub async fn update_certificate(
148    conn: &mut PgConnection,
149    certificate_id: Uuid,
150    date_issued: DateTime<Utc>,
151    name_on_certificate: Option<String>,
152    expected_updated_at: Option<DateTime<Utc>>,
153) -> ModelResult<Option<GeneratedCertificate>> {
154    let updated = sqlx::query_as!(
155        GeneratedCertificate,
156        r#"
157UPDATE generated_certificates
158SET created_at = $2,
159  name_on_certificate = COALESCE($3, name_on_certificate),
160  updated_at = NOW()
161WHERE id = $1
162  AND deleted_at IS NULL
163  AND (
164    $4::timestamptz IS NULL
165    OR updated_at = $4
166  )
167RETURNING *
168        "#,
169        certificate_id,
170        date_issued,
171        name_on_certificate,
172        expected_updated_at
173    )
174    .fetch_optional(conn)
175    .await?;
176    Ok(updated)
177}
178
179pub async fn get_by_id(
180    conn: &mut PgConnection,
181    certificate_id: Uuid,
182) -> ModelResult<GeneratedCertificate> {
183    let res = sqlx::query_as!(
184        GeneratedCertificate,
185        r#"
186        SELECT *
187        FROM generated_certificates
188        WHERE id = $1
189          AND deleted_at IS NULL
190        "#,
191        certificate_id
192    )
193    .fetch_one(conn)
194    .await?;
195
196    Ok(res)
197}
198
199/// A certificate with the course it was earned on, as a profile listing or support tooling needs it.
200#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
201pub struct UserCertificate {
202    pub id: Uuid,
203    pub user_id: Uuid,
204    pub name_on_certificate: String,
205    /// Addresses the public validation page, which is also how the holder views the image.
206    pub verification_id: String,
207    pub created_at: DateTime<Utc>,
208    pub course_id: Uuid,
209    pub course_name: String,
210    /// `None` for a course's default module.
211    pub course_module_name: Option<String>,
212}
213
214/// Every certificate the user holds, newest first.
215///
216/// One row per certificate even when its configuration requires several modules: the row names the
217/// first required module, which is the whole requirement for every configuration a course editor can
218/// currently build.
219pub async fn get_all_by_user_id(
220    conn: &mut PgConnection,
221    user_id: Uuid,
222) -> ModelResult<Vec<UserCertificate>> {
223    let res = sqlx::query_as!(
224        UserCertificate,
225        r#"
226SELECT DISTINCT ON (gc.id) gc.id,
227  gc.user_id,
228  gc.name_on_certificate,
229  gc.verification_id,
230  gc.created_at,
231  c.id AS course_id,
232  c.name AS course_name,
233  cm.name AS course_module_name
234FROM generated_certificates gc
235  JOIN certificate_configuration_to_requirements cctr ON cctr.certificate_configuration_id = gc.certificate_configuration_id
236  AND cctr.deleted_at IS NULL
237  JOIN course_modules cm ON cm.id = cctr.course_module_id
238  AND cm.deleted_at IS NULL
239  JOIN courses c ON c.id = cm.course_id
240  AND c.deleted_at IS NULL
241WHERE gc.user_id = $1
242  AND gc.deleted_at IS NULL
243ORDER BY gc.id,
244  cm.order_number
245        "#,
246        user_id
247    )
248    .fetch_all(conn)
249    .await?;
250    // `DISTINCT ON` dictates the query's own ordering, so the newest-first order is applied here.
251    let mut res = res;
252    res.sort_by_key(|certificate| std::cmp::Reverse(certificate.created_at));
253    Ok(res)
254}
255
256/// The certificate a verification id addresses, or `None` when no active certificate has that id.
257///
258/// Unlike [get_certificate_by_verification_id] this carries the owning course, which is what an
259/// admin acting on a certificate has to be authorized against.
260pub async fn get_by_verification_id(
261    conn: &mut PgConnection,
262    verification_id: &str,
263) -> ModelResult<Option<UserCertificate>> {
264    let res = sqlx::query_as!(
265        UserCertificate,
266        r#"
267SELECT DISTINCT ON (gc.id) gc.id,
268  gc.user_id,
269  gc.name_on_certificate,
270  gc.verification_id,
271  gc.created_at,
272  c.id AS course_id,
273  c.name AS course_name,
274  cm.name AS course_module_name
275FROM generated_certificates gc
276  JOIN certificate_configuration_to_requirements cctr ON cctr.certificate_configuration_id = gc.certificate_configuration_id
277  AND cctr.deleted_at IS NULL
278  JOIN course_modules cm ON cm.id = cctr.course_module_id
279  AND cm.deleted_at IS NULL
280  JOIN courses c ON c.id = cm.course_id
281  AND c.deleted_at IS NULL
282WHERE gc.verification_id = $1
283  AND gc.deleted_at IS NULL
284ORDER BY gc.id,
285  cm.order_number
286        "#,
287        verification_id
288    )
289    .fetch_optional(conn)
290    .await?;
291    Ok(res)
292}
293
294pub async fn find_existing(
295    conn: &mut PgConnection,
296    user_id: Uuid,
297    config_id: Uuid,
298) -> ModelResult<Option<Uuid>> {
299    let row = sqlx::query!(
300        r#"
301        SELECT id
302        FROM generated_certificates
303        WHERE user_id = $1
304          AND certificate_configuration_id = $2
305          AND deleted_at IS NULL
306        "#,
307        user_id,
308        config_id
309    )
310    .fetch_optional(conn)
311    .await?;
312
313    Ok(row.map(|r| r.id))
314}
315
316pub async fn insert_raw(
317    conn: &mut PgConnection,
318    user_id: Uuid,
319    config_id: Uuid,
320    name: &str,
321    verification_id: &str,
322) -> ModelResult<Uuid> {
323    let row = sqlx::query!(
324        r#"
325        INSERT INTO generated_certificates (
326            user_id,
327            certificate_configuration_id,
328            name_on_certificate,
329            verification_id
330        )
331        VALUES ($1, $2, $3, $4)
332        RETURNING *
333        "#,
334        user_id,
335        config_id,
336        name,
337        verification_id
338    )
339    .fetch_one(conn)
340    .await?;
341
342    Ok(row.id)
343}