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_or_init_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_or_init_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_and_lock_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_or_init_all_for_course(
237    conn: &mut PgConnection,
238    user_id: Uuid,
239    course_id: Uuid,
240) -> ModelResult<Vec<UserChapterLockingStatus>> {
241    let course = crate::courses::get_course(conn, course_id).await?;
242    let course_locking_enabled = course.chapter_locking_enabled;
243
244    if course_locking_enabled {
245        sqlx::query!(
246            r#"
247INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
248SELECT $1, chapters.id, $2, 'not_unlocked_yet'::chapter_locking_status, NULL
249FROM chapters
250WHERE chapters.course_id = $2
251  AND chapters.deleted_at IS NULL
252  AND NOT EXISTS (
253    SELECT 1
254    FROM user_chapter_locking_statuses
255    WHERE user_chapter_locking_statuses.user_id = $1
256      AND user_chapter_locking_statuses.chapter_id = chapters.id
257      AND user_chapter_locking_statuses.deleted_at IS NULL
258  )
259ON CONFLICT (user_id, chapter_id, deleted_at) DO NOTHING
260            "#,
261            user_id,
262            course_id
263        )
264        .execute(&mut *conn)
265        .await?;
266    }
267
268    async fn get_statuses_for_user_and_course(
269        conn: &mut PgConnection,
270        user_id: Uuid,
271        course_id: Uuid,
272    ) -> ModelResult<Vec<UserChapterLockingStatus>> {
273        let rows = sqlx::query_as!(
274            DatabaseRow,
275            r#"
276SELECT id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
277FROM user_chapter_locking_statuses
278WHERE user_id = $1
279  AND course_id = $2
280  AND deleted_at IS NULL
281            "#,
282            user_id,
283            course_id
284        )
285        .fetch_all(&mut *conn)
286        .await?;
287
288        rows.into_iter()
289            .map(|r| r.try_into())
290            .collect::<ModelResult<Vec<_>>>()
291    }
292
293    let mut statuses = get_statuses_for_user_and_course(conn, user_id, course_id).await?;
294
295    if course_locking_enabled
296        && !statuses.is_empty()
297        && statuses
298            .iter()
299            .all(|s| matches!(s.status, ChapterLockingStatus::NotUnlockedYet))
300    {
301        crate::chapters::unlock_first_chapters_for_user(conn, user_id, course_id).await?;
302
303        statuses = get_statuses_for_user_and_course(conn, user_id, course_id).await?;
304    }
305
306    Ok(statuses)
307}
308
309/// Creates a status row with `not_unlocked_yet` status if one doesn't exist.
310/// If a row already exists (with any status), returns the existing row without modifying it.
311/// This function does not overwrite existing statuses.
312pub async fn ensure_not_unlocked_yet_status(
313    conn: &mut PgConnection,
314    user_id: Uuid,
315    chapter_id: Uuid,
316    course_id: Uuid,
317) -> ModelResult<UserChapterLockingStatus> {
318    let res: Option<DatabaseRow> = sqlx::query_as!(
319        DatabaseRow,
320        r#"
321INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
322VALUES ($1, $2, $3, 'not_unlocked_yet'::chapter_locking_status, NULL)
323ON CONFLICT (user_id, chapter_id, deleted_at) DO NOTHING
324RETURNING id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
325        "#,
326        user_id,
327        chapter_id,
328        course_id
329    )
330    .fetch_optional(&mut *conn)
331    .await?;
332
333    if let Some(status) = res {
334        return status.try_into();
335    }
336
337    let retrieved = sqlx::query_as!(
338        DatabaseRow,
339        r#"
340SELECT id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
341FROM user_chapter_locking_statuses
342WHERE user_id = $1
343  AND chapter_id = $2
344  AND deleted_at IS NULL
345        "#,
346        user_id,
347        chapter_id
348    )
349    .fetch_optional(&mut *conn)
350    .await?;
351
352    retrieved.map(|r| r.try_into()).transpose()?.ok_or_else(|| {
353        ModelError::new(
354            ModelErrorType::NotFound,
355            "Failed to ensure not_unlocked_yet status",
356            None,
357        )
358    })
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::test_helper::*;
365
366    #[tokio::test]
367    async fn get_status_returns_none_when_no_status_exists() {
368        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
369        let chapter = crate::chapters::insert(
370            tx.as_mut(),
371            PKeyPolicy::Generate,
372            &crate::chapters::NewChapter {
373                name: "Test Chapter".to_string(),
374                color: None,
375                course_id: course,
376                chapter_number: 1,
377                front_page_id: None,
378                opens_at: None,
379                deadline: None,
380                course_module_id: Some(course_module.id),
381            },
382        )
383        .await
384        .unwrap();
385
386        let status = get_or_init_status(tx.as_mut(), user, chapter, None, None)
387            .await
388            .unwrap();
389        assert_eq!(status, None);
390    }
391
392    #[tokio::test]
393    async fn unlock_chapter_creates_unlocked_status() {
394        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
395        let chapter = crate::chapters::insert(
396            tx.as_mut(),
397            PKeyPolicy::Generate,
398            &crate::chapters::NewChapter {
399                name: "Test Chapter".to_string(),
400                color: None,
401                course_id: course,
402                chapter_number: 1,
403                front_page_id: None,
404                opens_at: None,
405                deadline: None,
406                course_module_id: Some(course_module.id),
407            },
408        )
409        .await
410        .unwrap();
411
412        let status = unlock_chapter(tx.as_mut(), user, chapter, course)
413            .await
414            .unwrap();
415        assert_eq!(status.status, ChapterLockingStatus::Unlocked);
416        assert_eq!(status.user_id, user);
417        assert_eq!(status.chapter_id, chapter);
418
419        let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
420            .await
421            .unwrap();
422        assert_eq!(retrieved_status, Some(ChapterLockingStatus::Unlocked));
423    }
424
425    #[tokio::test]
426    async fn complete_and_lock_chapter_creates_completed_status() {
427        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
428        let chapter = crate::chapters::insert(
429            tx.as_mut(),
430            PKeyPolicy::Generate,
431            &crate::chapters::NewChapter {
432                name: "Test Chapter".to_string(),
433                color: None,
434                course_id: course,
435                chapter_number: 1,
436                front_page_id: None,
437                opens_at: None,
438                deadline: None,
439                course_module_id: Some(course_module.id),
440            },
441        )
442        .await
443        .unwrap();
444
445        let status = complete_and_lock_chapter(tx.as_mut(), user, chapter, course)
446            .await
447            .unwrap();
448        assert_eq!(status.status, ChapterLockingStatus::CompletedAndLocked);
449        assert_eq!(status.user_id, user);
450        assert_eq!(status.chapter_id, chapter);
451
452        let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
453            .await
454            .unwrap();
455        assert_eq!(
456            retrieved_status,
457            Some(ChapterLockingStatus::CompletedAndLocked)
458        );
459    }
460
461    #[tokio::test]
462    async fn unlock_then_complete_chapter_updates_status() {
463        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
464        let chapter = crate::chapters::insert(
465            tx.as_mut(),
466            PKeyPolicy::Generate,
467            &crate::chapters::NewChapter {
468                name: "Test Chapter".to_string(),
469                color: None,
470                course_id: course,
471                chapter_number: 1,
472                front_page_id: None,
473                opens_at: None,
474                deadline: None,
475                course_module_id: Some(course_module.id),
476            },
477        )
478        .await
479        .unwrap();
480
481        unlock_chapter(tx.as_mut(), user, chapter, course)
482            .await
483            .unwrap();
484        let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
485            .await
486            .unwrap();
487        assert_eq!(status, Some(ChapterLockingStatus::Unlocked));
488
489        complete_and_lock_chapter(tx.as_mut(), user, chapter, course)
490            .await
491            .unwrap();
492        let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
493            .await
494            .unwrap();
495        assert_eq!(status, Some(ChapterLockingStatus::CompletedAndLocked));
496    }
497
498    #[tokio::test]
499    async fn get_or_init_all_for_course_returns_all_statuses() {
500        insert_data!(:tx, :user, :org, course: course);
501        // Use the base module (order_number == 0) so that the unlocking logic,
502        // which operates on the base module, affects these chapters.
503        let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
504            .await
505            .unwrap();
506        let base_module = all_modules
507            .into_iter()
508            .find(|m| m.order_number == 0)
509            .unwrap();
510
511        let chapter1 = crate::chapters::insert(
512            tx.as_mut(),
513            PKeyPolicy::Generate,
514            &crate::chapters::NewChapter {
515                name: "Chapter 1".to_string(),
516                color: None,
517                course_id: course,
518                chapter_number: 1,
519                front_page_id: None,
520                opens_at: None,
521                deadline: None,
522                course_module_id: Some(base_module.id),
523            },
524        )
525        .await
526        .unwrap();
527        let chapter2 = crate::chapters::insert(
528            tx.as_mut(),
529            PKeyPolicy::Generate,
530            &crate::chapters::NewChapter {
531                name: "Chapter 2".to_string(),
532                color: None,
533                course_id: course,
534                chapter_number: 2,
535                front_page_id: None,
536                opens_at: None,
537                deadline: None,
538                course_module_id: Some(base_module.id),
539            },
540        )
541        .await
542        .unwrap();
543
544        unlock_chapter(tx.as_mut(), user, chapter1, course)
545            .await
546            .unwrap();
547        complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
548            .await
549            .unwrap();
550
551        let statuses = get_or_init_all_for_course(tx.as_mut(), user, course)
552            .await
553            .unwrap();
554        assert_eq!(statuses.len(), 2);
555        assert!(
556            statuses
557                .iter()
558                .any(|s| s.chapter_id == chapter1 && s.status == ChapterLockingStatus::Unlocked)
559        );
560        assert!(
561            statuses.iter().any(|s| s.chapter_id == chapter2
562                && s.status == ChapterLockingStatus::CompletedAndLocked)
563        );
564    }
565
566    #[tokio::test]
567    async fn get_or_init_all_for_course_unlocks_first_chapter_when_all_not_unlocked_yet() {
568        insert_data!(:tx, :user, :org, course: course);
569
570        let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
571            .await
572            .unwrap();
573        let base_module = all_modules
574            .into_iter()
575            .find(|m| m.order_number == 0)
576            .unwrap();
577
578        let chapter1 = crate::chapters::insert(
579            tx.as_mut(),
580            PKeyPolicy::Generate,
581            &crate::chapters::NewChapter {
582                name: "Chapter 1".to_string(),
583                color: None,
584                course_id: course,
585                chapter_number: 1,
586                front_page_id: None,
587                opens_at: None,
588                deadline: None,
589                course_module_id: Some(base_module.id),
590            },
591        )
592        .await
593        .unwrap();
594
595        // insert a second chapter to ensure only the first is auto-unlocked
596        let chapter2 = crate::chapters::insert(
597            tx.as_mut(),
598            PKeyPolicy::Generate,
599            &crate::chapters::NewChapter {
600                name: "Chapter 2".to_string(),
601                color: None,
602                course_id: course,
603                chapter_number: 2,
604                front_page_id: None,
605                opens_at: None,
606                deadline: None,
607                course_module_id: Some(base_module.id),
608            },
609        )
610        .await
611        .unwrap();
612
613        // Enable chapter locking for the course
614        let existing_course = crate::courses::get_course(tx.as_mut(), course)
615            .await
616            .unwrap();
617
618        crate::courses::update_course(
619            tx.as_mut(),
620            course,
621            crate::courses::CourseUpdate {
622                name: existing_course.name,
623                description: existing_course.description,
624                is_draft: existing_course.is_draft,
625                is_test_mode: existing_course.is_test_mode,
626                can_add_chatbot: existing_course.can_add_chatbot,
627                is_unlisted: existing_course.is_unlisted,
628                is_joinable_by_code_only: existing_course.is_joinable_by_code_only,
629                ask_marketing_consent: existing_course.ask_marketing_consent,
630                flagged_answers_threshold: existing_course.flagged_answers_threshold.unwrap_or(1),
631                flagged_answers_skip_manual_review_and_allow_retry: existing_course
632                    .flagged_answers_skip_manual_review_and_allow_retry,
633                closed_at: existing_course.closed_at,
634                closed_additional_message: existing_course.closed_additional_message,
635                closed_course_successor_id: existing_course.closed_course_successor_id,
636                chapter_locking_enabled: true,
637            },
638        )
639        .await
640        .unwrap();
641
642        // Ensure we start from a state where all chapters are not_unlocked_yet
643        let _ = ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter1, course)
644            .await
645            .unwrap();
646        let _ = ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter2, course)
647            .await
648            .unwrap();
649
650        let statuses = get_or_init_all_for_course(tx.as_mut(), user, course)
651            .await
652            .unwrap();
653
654        assert!(!statuses.is_empty());
655        assert!(
656            statuses
657                .iter()
658                .any(|s| s.chapter_id == chapter1 && s.status == ChapterLockingStatus::Unlocked)
659        );
660    }
661}