headless_lms_models/
user_chapter_locking_statuses.rs

1use crate::prelude::*;
2use std::convert::TryFrom;
3
4#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy)]
5#[cfg_attr(feature = "ts_rs", derive(TS))]
6#[serde(rename_all = "snake_case")]
7pub enum ChapterLockingStatus {
8    /// Chapter is unlocked and exercises can be submitted.
9    Unlocked,
10    /// Chapter content is accessible, but exercises are locked (chapter has been completed).
11    CompletedAndLocked,
12    /// Chapter is locked because previous chapters are not completed.
13    NotUnlockedYet,
14}
15
16impl ChapterLockingStatus {
17    pub fn from_db(s: &str) -> ModelResult<Self> {
18        match s {
19            "unlocked" => Ok(ChapterLockingStatus::Unlocked),
20            "completed_and_locked" => Ok(ChapterLockingStatus::CompletedAndLocked),
21            "not_unlocked_yet" => Ok(ChapterLockingStatus::NotUnlockedYet),
22            _ => Err(ModelError::new(
23                ModelErrorType::Database,
24                format!("Invalid chapter locking status from database: {}", s),
25                None,
26            )),
27        }
28    }
29}
30
31#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
32#[cfg_attr(feature = "ts_rs", derive(TS))]
33pub struct UserChapterLockingStatus {
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 user_id: Uuid,
39    pub chapter_id: Uuid,
40    pub course_id: Uuid,
41    pub status: ChapterLockingStatus,
42}
43
44struct DatabaseRow {
45    id: Uuid,
46    created_at: DateTime<Utc>,
47    updated_at: DateTime<Utc>,
48    deleted_at: Option<DateTime<Utc>>,
49    user_id: Uuid,
50    chapter_id: Uuid,
51    course_id: Uuid,
52    status: String,
53}
54
55impl TryFrom<DatabaseRow> for UserChapterLockingStatus {
56    type Error = ModelError;
57
58    fn try_from(row: DatabaseRow) -> Result<Self, Self::Error> {
59        let status = ChapterLockingStatus::from_db(&row.status)?;
60        Ok(UserChapterLockingStatus {
61            id: row.id,
62            created_at: row.created_at,
63            updated_at: row.updated_at,
64            deleted_at: row.deleted_at,
65            user_id: row.user_id,
66            chapter_id: row.chapter_id,
67            course_id: row.course_id,
68            status,
69        })
70    }
71}
72
73async fn get_status_row(
74    conn: &mut PgConnection,
75    user_id: Uuid,
76    chapter_id: Uuid,
77    course_id: Option<Uuid>,
78    course_locking_enabled: Option<bool>,
79) -> ModelResult<Option<UserChapterLockingStatus>> {
80    let res = sqlx::query_as!(
81        DatabaseRow,
82        r#"
83SELECT id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
84FROM user_chapter_locking_statuses
85WHERE user_id = $1
86  AND chapter_id = $2
87  AND deleted_at IS NULL
88        "#,
89        user_id,
90        chapter_id
91    )
92    .fetch_optional(&mut *conn)
93    .await?;
94
95    if let Some(row) = res {
96        return row.try_into().map(Some);
97    }
98
99    if let (Some(course_id), Some(true)) = (course_id, course_locking_enabled) {
100        return Ok(Some(
101            ensure_not_unlocked_yet_status(&mut *conn, user_id, chapter_id, course_id).await?,
102        ));
103    }
104
105    Ok(None)
106}
107
108pub async fn get_or_init_status(
109    conn: &mut PgConnection,
110    user_id: Uuid,
111    chapter_id: Uuid,
112    course_id: Option<Uuid>,
113    course_locking_enabled: Option<bool>,
114) -> ModelResult<Option<ChapterLockingStatus>> {
115    get_status_row(conn, user_id, chapter_id, course_id, course_locking_enabled)
116        .await?
117        .map(|s| Ok(s.status))
118        .transpose()
119}
120
121pub async fn is_chapter_accessible(
122    conn: &mut PgConnection,
123    user_id: Uuid,
124    chapter_id: Uuid,
125    course_id: Uuid,
126) -> ModelResult<bool> {
127    use crate::courses;
128
129    let course = courses::get_course(conn, course_id).await?;
130
131    if !course.chapter_locking_enabled {
132        return Ok(true);
133    }
134
135    let status = get_or_init_status(
136        conn,
137        user_id,
138        chapter_id,
139        Some(course_id),
140        Some(course.chapter_locking_enabled),
141    )
142    .await?;
143    match status {
144        None => Ok(false),
145        Some(ChapterLockingStatus::Unlocked) => Ok(true),
146        Some(ChapterLockingStatus::CompletedAndLocked) => Ok(true),
147        Some(ChapterLockingStatus::NotUnlockedYet) => Ok(false),
148    }
149}
150
151pub async fn is_chapter_exercises_locked(
152    conn: &mut PgConnection,
153    user_id: Uuid,
154    chapter_id: Uuid,
155    course_id: Uuid,
156) -> ModelResult<bool> {
157    use crate::courses;
158
159    let course = courses::get_course(conn, course_id).await?;
160
161    if !course.chapter_locking_enabled {
162        return Ok(false);
163    }
164
165    let status = get_or_init_status(
166        conn,
167        user_id,
168        chapter_id,
169        Some(course_id),
170        Some(course.chapter_locking_enabled),
171    )
172    .await?;
173
174    match status {
175        None => Ok(true),
176        Some(ChapterLockingStatus::Unlocked) => Ok(false),
177        Some(ChapterLockingStatus::CompletedAndLocked) => Ok(true),
178        Some(ChapterLockingStatus::NotUnlockedYet) => Ok(true),
179    }
180}
181
182pub async fn unlock_chapter(
183    conn: &mut PgConnection,
184    user_id: Uuid,
185    chapter_id: Uuid,
186    course_id: Uuid,
187) -> ModelResult<UserChapterLockingStatus> {
188    let res = sqlx::query_as!(
189        DatabaseRow,
190        r#"
191INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
192VALUES ($1, $2, $3, 'unlocked'::chapter_locking_status, NULL)
193ON CONFLICT ON CONSTRAINT idx_user_chapter_locking_statuses_user_chapter_active DO UPDATE
194SET status = 'unlocked'::chapter_locking_status, deleted_at = NULL
195RETURNING id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
196        "#,
197        user_id,
198        chapter_id,
199        course_id
200    )
201    .fetch_optional(&mut *conn)
202    .await?;
203
204    res.map(|s| s.try_into())
205        .transpose()?
206        .ok_or_else(|| ModelError::new(ModelErrorType::NotFound, "Failed to unlock chapter", None))
207}
208
209pub async fn complete_chapter(
210    conn: &mut PgConnection,
211    user_id: Uuid,
212    chapter_id: Uuid,
213    course_id: Uuid,
214) -> ModelResult<UserChapterLockingStatus> {
215    let res = sqlx::query_as!(
216        DatabaseRow,
217        r#"
218INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
219VALUES ($1, $2, $3, 'completed_and_locked'::chapter_locking_status, NULL)
220ON CONFLICT ON CONSTRAINT idx_user_chapter_locking_statuses_user_chapter_active DO UPDATE
221SET status = 'completed_and_locked'::chapter_locking_status, deleted_at = NULL
222RETURNING id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
223        "#,
224        user_id,
225        chapter_id,
226        course_id
227    )
228    .fetch_optional(&mut *conn)
229    .await?;
230
231    res.map(|s| s.try_into()).transpose()?.ok_or_else(|| {
232        ModelError::new(ModelErrorType::NotFound, "Failed to complete chapter", None)
233    })
234}
235
236pub async fn get_by_user_and_chapter(
237    conn: &mut PgConnection,
238    user_id: Uuid,
239    chapter_id: Uuid,
240    course_id: Option<Uuid>,
241    course_locking_enabled: Option<bool>,
242) -> ModelResult<Option<UserChapterLockingStatus>> {
243    get_status_row(conn, user_id, chapter_id, course_id, course_locking_enabled).await
244}
245
246pub async fn get_by_user_and_course(
247    conn: &mut PgConnection,
248    user_id: Uuid,
249    course_id: Uuid,
250) -> ModelResult<Vec<UserChapterLockingStatus>> {
251    let course_locking_enabled: bool = sqlx::query!(
252        r#"
253SELECT chapter_locking_enabled
254FROM courses
255WHERE id = $1
256        "#,
257        course_id
258    )
259    .fetch_optional(&mut *conn)
260    .await?
261    .map(|r| r.chapter_locking_enabled)
262    .unwrap_or(false);
263
264    if course_locking_enabled {
265        sqlx::query!(
266            r#"
267INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
268SELECT $1, chapters.id, $2, 'not_unlocked_yet'::chapter_locking_status, NULL
269FROM chapters
270WHERE chapters.course_id = $2
271  AND chapters.deleted_at IS NULL
272  AND NOT EXISTS (
273    SELECT 1
274    FROM user_chapter_locking_statuses
275    WHERE user_chapter_locking_statuses.user_id = $1
276      AND user_chapter_locking_statuses.chapter_id = chapters.id
277      AND user_chapter_locking_statuses.deleted_at IS NULL
278  )
279ON CONFLICT (user_id, chapter_id, deleted_at) DO NOTHING
280            "#,
281            user_id,
282            course_id
283        )
284        .execute(&mut *conn)
285        .await?;
286    }
287
288    let res = sqlx::query_as!(
289        DatabaseRow,
290        r#"
291SELECT id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
292FROM user_chapter_locking_statuses
293WHERE user_id = $1
294  AND course_id = $2
295  AND deleted_at IS NULL
296        "#,
297        user_id,
298        course_id
299    )
300    .fetch_all(&mut *conn)
301    .await?;
302
303    res.into_iter()
304        .map(|r| r.try_into())
305        .collect::<ModelResult<Vec<_>>>()
306}
307
308/// Creates a status row with `not_unlocked_yet` status if one doesn't exist.
309/// If a row already exists (with any status), returns the existing row without modifying it.
310/// This function does not overwrite existing statuses.
311pub async fn ensure_not_unlocked_yet_status(
312    conn: &mut PgConnection,
313    user_id: Uuid,
314    chapter_id: Uuid,
315    course_id: Uuid,
316) -> ModelResult<UserChapterLockingStatus> {
317    let res: Option<DatabaseRow> = sqlx::query_as!(
318        DatabaseRow,
319        r#"
320INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
321VALUES ($1, $2, $3, 'not_unlocked_yet'::chapter_locking_status, NULL)
322ON CONFLICT (user_id, chapter_id, deleted_at) DO NOTHING
323RETURNING id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
324        "#,
325        user_id,
326        chapter_id,
327        course_id
328    )
329    .fetch_optional(&mut *conn)
330    .await?;
331
332    if let Some(status) = res {
333        return status.try_into();
334    }
335
336    let retrieved = sqlx::query_as!(
337        DatabaseRow,
338        r#"
339SELECT id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
340FROM user_chapter_locking_statuses
341WHERE user_id = $1
342  AND chapter_id = $2
343  AND deleted_at IS NULL
344        "#,
345        user_id,
346        chapter_id
347    )
348    .fetch_optional(&mut *conn)
349    .await?;
350
351    retrieved.map(|r| r.try_into()).transpose()?.ok_or_else(|| {
352        ModelError::new(
353            ModelErrorType::NotFound,
354            "Failed to ensure not_unlocked_yet status",
355            None,
356        )
357    })
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::test_helper::*;
364
365    #[tokio::test]
366    async fn get_status_returns_none_when_no_status_exists() {
367        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
368        let chapter = crate::chapters::insert(
369            tx.as_mut(),
370            PKeyPolicy::Generate,
371            &crate::chapters::NewChapter {
372                name: "Test Chapter".to_string(),
373                color: None,
374                course_id: course,
375                chapter_number: 1,
376                front_page_id: None,
377                opens_at: None,
378                deadline: None,
379                course_module_id: Some(course_module.id),
380            },
381        )
382        .await
383        .unwrap();
384
385        let status = get_or_init_status(tx.as_mut(), user, chapter, None, None)
386            .await
387            .unwrap();
388        assert_eq!(status, None);
389    }
390
391    #[tokio::test]
392    async fn unlock_chapter_creates_unlocked_status() {
393        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
394        let chapter = crate::chapters::insert(
395            tx.as_mut(),
396            PKeyPolicy::Generate,
397            &crate::chapters::NewChapter {
398                name: "Test Chapter".to_string(),
399                color: None,
400                course_id: course,
401                chapter_number: 1,
402                front_page_id: None,
403                opens_at: None,
404                deadline: None,
405                course_module_id: Some(course_module.id),
406            },
407        )
408        .await
409        .unwrap();
410
411        let status = unlock_chapter(tx.as_mut(), user, chapter, course)
412            .await
413            .unwrap();
414        assert_eq!(status.status, ChapterLockingStatus::Unlocked);
415        assert_eq!(status.user_id, user);
416        assert_eq!(status.chapter_id, chapter);
417
418        let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
419            .await
420            .unwrap();
421        assert_eq!(retrieved_status, Some(ChapterLockingStatus::Unlocked));
422    }
423
424    #[tokio::test]
425    async fn complete_chapter_creates_completed_status() {
426        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
427        let chapter = crate::chapters::insert(
428            tx.as_mut(),
429            PKeyPolicy::Generate,
430            &crate::chapters::NewChapter {
431                name: "Test Chapter".to_string(),
432                color: None,
433                course_id: course,
434                chapter_number: 1,
435                front_page_id: None,
436                opens_at: None,
437                deadline: None,
438                course_module_id: Some(course_module.id),
439            },
440        )
441        .await
442        .unwrap();
443
444        let status = complete_chapter(tx.as_mut(), user, chapter, course)
445            .await
446            .unwrap();
447        assert_eq!(status.status, ChapterLockingStatus::CompletedAndLocked);
448        assert_eq!(status.user_id, user);
449        assert_eq!(status.chapter_id, chapter);
450
451        let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
452            .await
453            .unwrap();
454        assert_eq!(
455            retrieved_status,
456            Some(ChapterLockingStatus::CompletedAndLocked)
457        );
458    }
459
460    #[tokio::test]
461    async fn unlock_then_complete_chapter_updates_status() {
462        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
463        let chapter = crate::chapters::insert(
464            tx.as_mut(),
465            PKeyPolicy::Generate,
466            &crate::chapters::NewChapter {
467                name: "Test Chapter".to_string(),
468                color: None,
469                course_id: course,
470                chapter_number: 1,
471                front_page_id: None,
472                opens_at: None,
473                deadline: None,
474                course_module_id: Some(course_module.id),
475            },
476        )
477        .await
478        .unwrap();
479
480        unlock_chapter(tx.as_mut(), user, chapter, course)
481            .await
482            .unwrap();
483        let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
484            .await
485            .unwrap();
486        assert_eq!(status, Some(ChapterLockingStatus::Unlocked));
487
488        complete_chapter(tx.as_mut(), user, chapter, course)
489            .await
490            .unwrap();
491        let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
492            .await
493            .unwrap();
494        assert_eq!(status, Some(ChapterLockingStatus::CompletedAndLocked));
495    }
496
497    #[tokio::test]
498    async fn get_by_user_and_course_returns_all_statuses() {
499        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
500        let chapter1 = crate::chapters::insert(
501            tx.as_mut(),
502            PKeyPolicy::Generate,
503            &crate::chapters::NewChapter {
504                name: "Chapter 1".to_string(),
505                color: None,
506                course_id: course,
507                chapter_number: 1,
508                front_page_id: None,
509                opens_at: None,
510                deadline: None,
511                course_module_id: Some(course_module.id),
512            },
513        )
514        .await
515        .unwrap();
516        let chapter2 = crate::chapters::insert(
517            tx.as_mut(),
518            PKeyPolicy::Generate,
519            &crate::chapters::NewChapter {
520                name: "Chapter 2".to_string(),
521                color: None,
522                course_id: course,
523                chapter_number: 2,
524                front_page_id: None,
525                opens_at: None,
526                deadline: None,
527                course_module_id: Some(course_module.id),
528            },
529        )
530        .await
531        .unwrap();
532
533        unlock_chapter(tx.as_mut(), user, chapter1, course)
534            .await
535            .unwrap();
536        complete_chapter(tx.as_mut(), user, chapter2, course)
537            .await
538            .unwrap();
539
540        let statuses = get_by_user_and_course(tx.as_mut(), user, course)
541            .await
542            .unwrap();
543        assert_eq!(statuses.len(), 2);
544        assert!(
545            statuses
546                .iter()
547                .any(|s| s.chapter_id == chapter1 && s.status == ChapterLockingStatus::Unlocked)
548        );
549        assert!(
550            statuses.iter().any(|s| s.chapter_id == chapter2
551                && s.status == ChapterLockingStatus::CompletedAndLocked)
552        );
553    }
554}