Skip to main content

headless_lms_models/
user_chapter_locking_statuses.rs

1use crate::error::missing_model_error;
2use crate::prelude::*;
3use utoipa::ToSchema;
4
5#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, ToSchema, sqlx::Type)]
6#[serde(rename_all = "snake_case")]
7#[sqlx(type_name = "chapter_locking_status", rename_all = "snake_case")]
8pub enum ChapterLockingStatus {
9    /// Chapter is unlocked and exercises can be submitted.
10    Unlocked,
11    /// Chapter content is accessible, but exercises are locked (chapter has been completed).
12    CompletedAndLocked,
13    /// Chapter is locked because previous chapters are not completed.
14    NotUnlockedYet,
15}
16
17#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema, sqlx::FromRow)]
18
19pub struct UserChapterLockingStatus {
20    pub id: Uuid,
21    pub created_at: DateTime<Utc>,
22    pub updated_at: DateTime<Utc>,
23    pub deleted_at: Option<DateTime<Utc>>,
24    pub user_id: Uuid,
25    pub chapter_id: Uuid,
26    pub course_id: Uuid,
27    pub status: ChapterLockingStatus,
28}
29
30async fn get_or_init_status_row(
31    conn: &mut PgConnection,
32    user_id: Uuid,
33    chapter_id: Uuid,
34    course_id: Option<Uuid>,
35    course_locking_enabled: Option<bool>,
36) -> ModelResult<Option<UserChapterLockingStatus>> {
37    let res = sqlx::query_as!(
38        UserChapterLockingStatus,
39        r#"
40SELECT id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status as "status: ChapterLockingStatus"
41FROM user_chapter_locking_statuses
42WHERE user_id = $1
43  AND chapter_id = $2
44  AND deleted_at IS NULL
45        "#,
46        user_id,
47        chapter_id
48    )
49    .fetch_optional(&mut *conn)
50    .await?;
51
52    if let Some(row) = res {
53        return Ok(Some(row));
54    }
55
56    if let (Some(course_id), Some(true)) = (course_id, course_locking_enabled) {
57        return Ok(Some(
58            ensure_not_unlocked_yet_status(&mut *conn, user_id, chapter_id, course_id).await?,
59        ));
60    }
61
62    Ok(None)
63}
64
65pub async fn get_or_init_status(
66    conn: &mut PgConnection,
67    user_id: Uuid,
68    chapter_id: Uuid,
69    course_id: Option<Uuid>,
70    course_locking_enabled: Option<bool>,
71) -> ModelResult<Option<ChapterLockingStatus>> {
72    Ok(
73        get_or_init_status_row(conn, user_id, chapter_id, course_id, course_locking_enabled)
74            .await?
75            .map(|s| s.status),
76    )
77}
78
79pub async fn is_chapter_accessible(
80    conn: &mut PgConnection,
81    user_id: Uuid,
82    chapter_id: Uuid,
83    course_id: Uuid,
84) -> ModelResult<bool> {
85    use crate::courses;
86
87    let course = courses::get_course(conn, course_id).await?;
88
89    if !course.chapter_locking_enabled {
90        return Ok(true);
91    }
92
93    let status = get_or_init_status(
94        conn,
95        user_id,
96        chapter_id,
97        Some(course_id),
98        Some(course.chapter_locking_enabled),
99    )
100    .await?;
101    match status {
102        None => Ok(false),
103        Some(ChapterLockingStatus::Unlocked) => Ok(true),
104        Some(ChapterLockingStatus::CompletedAndLocked) => Ok(true),
105        Some(ChapterLockingStatus::NotUnlockedYet) => Ok(false),
106    }
107}
108
109pub async fn is_chapter_exercises_locked(
110    conn: &mut PgConnection,
111    user_id: Uuid,
112    chapter_id: Uuid,
113    course_id: Uuid,
114) -> ModelResult<bool> {
115    use crate::courses;
116
117    let course = courses::get_course(conn, course_id).await?;
118
119    if !course.chapter_locking_enabled {
120        return Ok(false);
121    }
122
123    let status = get_or_init_status(
124        conn,
125        user_id,
126        chapter_id,
127        Some(course_id),
128        Some(course.chapter_locking_enabled),
129    )
130    .await?;
131
132    match status {
133        None => Ok(true),
134        Some(ChapterLockingStatus::Unlocked) => Ok(false),
135        Some(ChapterLockingStatus::CompletedAndLocked) => Ok(true),
136        Some(ChapterLockingStatus::NotUnlockedYet) => Ok(true),
137    }
138}
139
140pub async fn unlock_chapter(
141    conn: &mut PgConnection,
142    user_id: Uuid,
143    chapter_id: Uuid,
144    course_id: Uuid,
145) -> ModelResult<UserChapterLockingStatus> {
146    let res = sqlx::query_as!(
147        UserChapterLockingStatus,
148        r#"
149INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
150VALUES ($1, $2, $3, 'unlocked'::chapter_locking_status, NULL)
151ON CONFLICT ON CONSTRAINT idx_user_chapter_locking_statuses_user_chapter_active DO UPDATE
152SET status = 'unlocked'::chapter_locking_status, deleted_at = NULL
153RETURNING id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status as "status: ChapterLockingStatus"
154        "#,
155        user_id,
156        chapter_id,
157        course_id
158    )
159    .fetch_optional(&mut *conn)
160    .await?;
161
162    res.ok_or_else(missing_model_error(
163        ModelErrorType::NotFound,
164        "Failed to unlock chapter",
165    ))
166}
167
168pub async fn complete_and_lock_chapter(
169    conn: &mut PgConnection,
170    user_id: Uuid,
171    chapter_id: Uuid,
172    course_id: Uuid,
173) -> ModelResult<UserChapterLockingStatus> {
174    let res = sqlx::query_as!(
175        UserChapterLockingStatus,
176        r#"
177INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
178VALUES ($1, $2, $3, 'completed_and_locked'::chapter_locking_status, NULL)
179ON CONFLICT ON CONSTRAINT idx_user_chapter_locking_statuses_user_chapter_active DO UPDATE
180SET status = 'completed_and_locked'::chapter_locking_status, deleted_at = NULL
181RETURNING id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status as "status: ChapterLockingStatus"
182        "#,
183        user_id,
184        chapter_id,
185        course_id
186    )
187    .fetch_optional(&mut *conn)
188    .await?;
189
190    res.ok_or_else(missing_model_error(
191        ModelErrorType::NotFound,
192        "Failed to complete chapter",
193    ))
194}
195
196pub async fn set_chapter_status(
197    conn: &mut PgConnection,
198    user_id: Uuid,
199    chapter_id: Uuid,
200    course_id: Uuid,
201    status: ChapterLockingStatus,
202) -> ModelResult<UserChapterLockingStatus> {
203    let res = sqlx::query_as!(
204        UserChapterLockingStatus,
205        r#"
206INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
207VALUES ($1, $2, $3, $4, NULL)
208ON CONFLICT ON CONSTRAINT idx_user_chapter_locking_statuses_user_chapter_active DO UPDATE
209SET status = $4, deleted_at = NULL
210RETURNING id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status as "status: ChapterLockingStatus"
211        "#,
212        user_id,
213        chapter_id,
214        course_id,
215        status as ChapterLockingStatus,
216    )
217    .fetch_optional(&mut *conn)
218    .await?;
219
220    res.ok_or_else(missing_model_error(
221        ModelErrorType::NotFound,
222        "Failed to set chapter status",
223    ))
224}
225
226pub async fn get_or_init_all_for_course(
227    conn: &mut PgConnection,
228    user_id: Uuid,
229    course_id: Uuid,
230) -> ModelResult<Vec<UserChapterLockingStatus>> {
231    let course = crate::courses::get_course(conn, course_id).await?;
232    let course_locking_enabled = course.chapter_locking_enabled;
233
234    if course_locking_enabled {
235        sqlx::query!(
236            r#"
237INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
238SELECT $1, chapters.id, $2, 'not_unlocked_yet'::chapter_locking_status, NULL
239FROM chapters
240WHERE chapters.course_id = $2
241  AND chapters.deleted_at IS NULL
242  AND NOT EXISTS (
243    SELECT 1
244    FROM user_chapter_locking_statuses
245    WHERE user_chapter_locking_statuses.user_id = $1
246      AND user_chapter_locking_statuses.chapter_id = chapters.id
247      AND user_chapter_locking_statuses.deleted_at IS NULL
248  )
249ON CONFLICT (user_id, chapter_id, deleted_at) DO NOTHING
250            "#,
251            user_id,
252            course_id
253        )
254        .execute(&mut *conn)
255        .await?;
256    }
257
258    async fn get_statuses_for_user_and_course(
259        conn: &mut PgConnection,
260        user_id: Uuid,
261        course_id: Uuid,
262    ) -> ModelResult<Vec<UserChapterLockingStatus>> {
263        let rows = sqlx::query_as!(
264            UserChapterLockingStatus,
265            r#"
266SELECT id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status as "status: ChapterLockingStatus"
267FROM user_chapter_locking_statuses
268WHERE user_id = $1
269  AND course_id = $2
270  AND deleted_at IS NULL
271            "#,
272            user_id,
273            course_id
274        )
275        .fetch_all(&mut *conn)
276        .await?;
277
278        Ok(rows)
279    }
280
281    let mut statuses = get_statuses_for_user_and_course(conn, user_id, course_id).await?;
282
283    if course_locking_enabled
284        && !statuses.is_empty()
285        && statuses
286            .iter()
287            .all(|s| matches!(s.status, ChapterLockingStatus::NotUnlockedYet))
288    {
289        crate::chapters::unlock_first_chapters_for_user(conn, user_id, course_id).await?;
290
291        statuses = get_statuses_for_user_and_course(conn, user_id, course_id).await?;
292    }
293
294    Ok(statuses)
295}
296
297pub async fn get_all_for_course(
298    conn: &mut PgConnection,
299    course: &crate::courses::Course,
300) -> ModelResult<Vec<UserChapterLockingStatus>> {
301    if !course.chapter_locking_enabled {
302        return Ok(Vec::new());
303    }
304
305    let rows = sqlx::query_as!(
306        UserChapterLockingStatus,
307        r#"
308SELECT id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status as "status: ChapterLockingStatus"
309FROM user_chapter_locking_statuses
310WHERE course_id = $1
311  AND deleted_at IS NULL
312        "#,
313        course.id
314    )
315    .fetch_all(&mut *conn)
316    .await?;
317
318    Ok(rows)
319}
320
321/// Returns all chapter locking statuses for a specific user in a course.
322pub async fn get_for_user_and_course(
323    conn: &mut PgConnection,
324    user_id: Uuid,
325    course: &crate::courses::Course,
326) -> ModelResult<Vec<UserChapterLockingStatus>> {
327    if !course.chapter_locking_enabled {
328        return Ok(Vec::new());
329    }
330
331    let rows = sqlx::query_as!(
332        UserChapterLockingStatus,
333        r#"
334SELECT id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status as "status: ChapterLockingStatus"
335FROM user_chapter_locking_statuses
336WHERE user_id = $1
337  AND course_id = $2
338  AND deleted_at IS NULL
339        "#,
340        user_id,
341        course.id
342    )
343    .fetch_all(&mut *conn)
344    .await?;
345
346    Ok(rows)
347}
348
349/// Creates a status row with `not_unlocked_yet` status if one doesn't exist.
350/// If a row already exists (with any status), returns the existing row without modifying it.
351/// This function does not overwrite existing statuses.
352pub async fn ensure_not_unlocked_yet_status(
353    conn: &mut PgConnection,
354    user_id: Uuid,
355    chapter_id: Uuid,
356    course_id: Uuid,
357) -> ModelResult<UserChapterLockingStatus> {
358    let res: Option<UserChapterLockingStatus> = sqlx::query_as!(
359        UserChapterLockingStatus,
360        r#"
361INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
362VALUES ($1, $2, $3, 'not_unlocked_yet'::chapter_locking_status, NULL)
363ON CONFLICT (user_id, chapter_id, deleted_at) DO NOTHING
364RETURNING id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status as "status: ChapterLockingStatus"
365        "#,
366        user_id,
367        chapter_id,
368        course_id
369    )
370    .fetch_optional(&mut *conn)
371    .await?;
372
373    if let Some(status) = res {
374        return Ok(status);
375    }
376
377    let retrieved = sqlx::query_as!(
378        UserChapterLockingStatus,
379        r#"
380SELECT id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status as "status: ChapterLockingStatus"
381FROM user_chapter_locking_statuses
382WHERE user_id = $1
383  AND chapter_id = $2
384  AND deleted_at IS NULL
385        "#,
386        user_id,
387        chapter_id
388    )
389    .fetch_optional(&mut *conn)
390    .await?;
391
392    retrieved.ok_or_else(missing_model_error(
393        ModelErrorType::NotFound,
394        "Failed to ensure not_unlocked_yet status",
395    ))
396}
397
398/// Unlocks the provided chapters for a user within a course.
399pub async fn unlock_chapters_for_user(
400    conn: &mut PgConnection,
401    user_id: Uuid,
402    course_id: Uuid,
403    chapter_ids: &[Uuid],
404) -> ModelResult<()> {
405    if chapter_ids.is_empty() {
406        return Ok(());
407    }
408
409    let course = crate::courses::get_course(conn, course_id).await?;
410    if !course.chapter_locking_enabled {
411        sqlx::query!(
412            r#"
413UPDATE user_chapter_locking_statuses
414SET deleted_at = NOW()
415WHERE user_id = $1
416  AND course_id = $2
417  AND chapter_id = ANY($3)
418  AND deleted_at IS NULL
419            "#,
420            user_id,
421            course_id,
422            chapter_ids
423        )
424        .execute(&mut *conn)
425        .await?;
426
427        return Ok(());
428    }
429
430    sqlx::query!(
431        r#"
432UPDATE user_chapter_locking_statuses
433SET status = 'unlocked'::chapter_locking_status, deleted_at = NULL
434WHERE user_id = $1
435  AND course_id = $2
436  AND chapter_id = ANY($3)
437  AND status = 'completed_and_locked'::chapter_locking_status
438  AND deleted_at IS NULL
439        "#,
440        user_id,
441        course_id,
442        chapter_ids
443    )
444    .execute(&mut *conn)
445    .await?;
446
447    Ok(())
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::test_helper::*;
454
455    /// Updates chapter locking on a course for test setup.
456    async fn set_course_chapter_locking_enabled(
457        tx: &mut PgConnection,
458        course_id: Uuid,
459        chapter_locking_enabled: bool,
460    ) {
461        let course_before_update = crate::courses::get_course(tx, course_id).await.unwrap();
462        crate::courses::update_course(
463            tx,
464            course_id,
465            crate::courses::CourseUpdate {
466                chapter_locking_enabled,
467                name: course_before_update.name,
468                description: course_before_update.description,
469                is_draft: course_before_update.is_draft,
470                is_test_mode: course_before_update.is_test_mode,
471                can_add_chatbot: course_before_update.can_add_chatbot,
472                is_unlisted: course_before_update.is_unlisted,
473                is_joinable_by_code_only: course_before_update.is_joinable_by_code_only,
474                ask_marketing_consent: course_before_update.ask_marketing_consent,
475                flagged_answers_threshold: course_before_update
476                    .flagged_answers_threshold
477                    .unwrap_or_default(),
478                flagged_answers_skip_manual_review_and_allow_retry: course_before_update
479                    .flagged_answers_skip_manual_review_and_allow_retry,
480                closed_at: course_before_update.closed_at,
481                closed_additional_message: course_before_update.closed_additional_message,
482                closed_course_successor_id: course_before_update.closed_course_successor_id,
483            },
484        )
485        .await
486        .unwrap();
487    }
488
489    #[tokio::test]
490    async fn get_status_returns_none_when_no_status_exists() {
491        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
492        let chapter = crate::chapters::insert(
493            tx.as_mut(),
494            PKeyPolicy::Generate,
495            &crate::chapters::NewChapter {
496                name: "Test Chapter".to_string(),
497                color: None,
498                course_id: course,
499                chapter_number: 1,
500                front_page_id: None,
501                opens_at: None,
502                deadline: None,
503                course_module_id: Some(course_module.id),
504            },
505        )
506        .await
507        .unwrap();
508
509        let status = get_or_init_status(tx.as_mut(), user, chapter, None, None)
510            .await
511            .unwrap();
512        assert_eq!(status, None);
513    }
514
515    #[tokio::test]
516    async fn unlock_chapter_creates_unlocked_status() {
517        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
518        let chapter = crate::chapters::insert(
519            tx.as_mut(),
520            PKeyPolicy::Generate,
521            &crate::chapters::NewChapter {
522                name: "Test Chapter".to_string(),
523                color: None,
524                course_id: course,
525                chapter_number: 1,
526                front_page_id: None,
527                opens_at: None,
528                deadline: None,
529                course_module_id: Some(course_module.id),
530            },
531        )
532        .await
533        .unwrap();
534
535        let status = unlock_chapter(tx.as_mut(), user, chapter, course)
536            .await
537            .unwrap();
538        assert_eq!(status.status, ChapterLockingStatus::Unlocked);
539        assert_eq!(status.user_id, user);
540        assert_eq!(status.chapter_id, chapter);
541
542        let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
543            .await
544            .unwrap();
545        assert_eq!(retrieved_status, Some(ChapterLockingStatus::Unlocked));
546    }
547
548    #[tokio::test]
549    async fn complete_and_lock_chapter_creates_completed_status() {
550        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
551        let chapter = crate::chapters::insert(
552            tx.as_mut(),
553            PKeyPolicy::Generate,
554            &crate::chapters::NewChapter {
555                name: "Test Chapter".to_string(),
556                color: None,
557                course_id: course,
558                chapter_number: 1,
559                front_page_id: None,
560                opens_at: None,
561                deadline: None,
562                course_module_id: Some(course_module.id),
563            },
564        )
565        .await
566        .unwrap();
567
568        let status = complete_and_lock_chapter(tx.as_mut(), user, chapter, course)
569            .await
570            .unwrap();
571        assert_eq!(status.status, ChapterLockingStatus::CompletedAndLocked);
572        assert_eq!(status.user_id, user);
573        assert_eq!(status.chapter_id, chapter);
574
575        let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
576            .await
577            .unwrap();
578        assert_eq!(
579            retrieved_status,
580            Some(ChapterLockingStatus::CompletedAndLocked)
581        );
582    }
583
584    #[tokio::test]
585    async fn unlock_then_complete_chapter_updates_status() {
586        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
587        let chapter = crate::chapters::insert(
588            tx.as_mut(),
589            PKeyPolicy::Generate,
590            &crate::chapters::NewChapter {
591                name: "Test Chapter".to_string(),
592                color: None,
593                course_id: course,
594                chapter_number: 1,
595                front_page_id: None,
596                opens_at: None,
597                deadline: None,
598                course_module_id: Some(course_module.id),
599            },
600        )
601        .await
602        .unwrap();
603
604        let existing_course = crate::courses::get_course(tx.as_mut(), course)
605            .await
606            .unwrap();
607        crate::courses::update_course(
608            tx.as_mut(),
609            course,
610            crate::courses::CourseUpdate {
611                name: existing_course.name,
612                description: existing_course.description,
613                is_draft: existing_course.is_draft,
614                is_test_mode: existing_course.is_test_mode,
615                can_add_chatbot: existing_course.can_add_chatbot,
616                is_unlisted: existing_course.is_unlisted,
617                is_joinable_by_code_only: existing_course.is_joinable_by_code_only,
618                ask_marketing_consent: existing_course.ask_marketing_consent,
619                flagged_answers_threshold: existing_course.flagged_answers_threshold.unwrap_or(1),
620                flagged_answers_skip_manual_review_and_allow_retry: existing_course
621                    .flagged_answers_skip_manual_review_and_allow_retry,
622                closed_at: existing_course.closed_at,
623                closed_additional_message: existing_course.closed_additional_message,
624                closed_course_successor_id: existing_course.closed_course_successor_id,
625                chapter_locking_enabled: true,
626            },
627        )
628        .await
629        .unwrap();
630
631        unlock_chapter(tx.as_mut(), user, chapter, course)
632            .await
633            .unwrap();
634        let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
635            .await
636            .unwrap();
637        assert_eq!(status, Some(ChapterLockingStatus::Unlocked));
638
639        complete_and_lock_chapter(tx.as_mut(), user, chapter, course)
640            .await
641            .unwrap();
642        let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
643            .await
644            .unwrap();
645        assert_eq!(status, Some(ChapterLockingStatus::CompletedAndLocked));
646    }
647
648    #[tokio::test]
649    async fn get_or_init_all_for_course_returns_all_statuses() {
650        insert_data!(:tx, :user, :org, course: course);
651        // Use the base module (order_number == 0) so that the unlocking logic,
652        // which operates on the base module, affects these chapters.
653        let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
654            .await
655            .unwrap();
656        let base_module = all_modules
657            .into_iter()
658            .find(|m| m.order_number == 0)
659            .unwrap();
660
661        let chapter1 = crate::chapters::insert(
662            tx.as_mut(),
663            PKeyPolicy::Generate,
664            &crate::chapters::NewChapter {
665                name: "Chapter 1".to_string(),
666                color: None,
667                course_id: course,
668                chapter_number: 1,
669                front_page_id: None,
670                opens_at: None,
671                deadline: None,
672                course_module_id: Some(base_module.id),
673            },
674        )
675        .await
676        .unwrap();
677        let chapter2 = crate::chapters::insert(
678            tx.as_mut(),
679            PKeyPolicy::Generate,
680            &crate::chapters::NewChapter {
681                name: "Chapter 2".to_string(),
682                color: None,
683                course_id: course,
684                chapter_number: 2,
685                front_page_id: None,
686                opens_at: None,
687                deadline: None,
688                course_module_id: Some(base_module.id),
689            },
690        )
691        .await
692        .unwrap();
693
694        unlock_chapter(tx.as_mut(), user, chapter1, course)
695            .await
696            .unwrap();
697        complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
698            .await
699            .unwrap();
700
701        let statuses = get_or_init_all_for_course(tx.as_mut(), user, course)
702            .await
703            .unwrap();
704        assert_eq!(statuses.len(), 2);
705        assert!(
706            statuses
707                .iter()
708                .any(|s| s.chapter_id == chapter1 && s.status == ChapterLockingStatus::Unlocked)
709        );
710        assert!(
711            statuses.iter().any(|s| s.chapter_id == chapter2
712                && s.status == ChapterLockingStatus::CompletedAndLocked)
713        );
714    }
715
716    #[tokio::test]
717    async fn get_or_init_all_for_course_unlocks_first_chapter_when_all_not_unlocked_yet() {
718        insert_data!(:tx, :user, :org, course: course);
719
720        let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
721            .await
722            .unwrap();
723        let base_module = all_modules
724            .into_iter()
725            .find(|m| m.order_number == 0)
726            .unwrap();
727
728        let chapter1 = crate::chapters::insert(
729            tx.as_mut(),
730            PKeyPolicy::Generate,
731            &crate::chapters::NewChapter {
732                name: "Chapter 1".to_string(),
733                color: None,
734                course_id: course,
735                chapter_number: 1,
736                front_page_id: None,
737                opens_at: None,
738                deadline: None,
739                course_module_id: Some(base_module.id),
740            },
741        )
742        .await
743        .unwrap();
744
745        // insert a second chapter to ensure only the first is auto-unlocked
746        let chapter2 = crate::chapters::insert(
747            tx.as_mut(),
748            PKeyPolicy::Generate,
749            &crate::chapters::NewChapter {
750                name: "Chapter 2".to_string(),
751                color: None,
752                course_id: course,
753                chapter_number: 2,
754                front_page_id: None,
755                opens_at: None,
756                deadline: None,
757                course_module_id: Some(base_module.id),
758            },
759        )
760        .await
761        .unwrap();
762
763        // Enable chapter locking for the course
764        let existing_course = crate::courses::get_course(tx.as_mut(), course)
765            .await
766            .unwrap();
767
768        crate::courses::update_course(
769            tx.as_mut(),
770            course,
771            crate::courses::CourseUpdate {
772                name: existing_course.name,
773                description: existing_course.description,
774                is_draft: existing_course.is_draft,
775                is_test_mode: existing_course.is_test_mode,
776                can_add_chatbot: existing_course.can_add_chatbot,
777                is_unlisted: existing_course.is_unlisted,
778                is_joinable_by_code_only: existing_course.is_joinable_by_code_only,
779                ask_marketing_consent: existing_course.ask_marketing_consent,
780                flagged_answers_threshold: existing_course.flagged_answers_threshold.unwrap_or(1),
781                flagged_answers_skip_manual_review_and_allow_retry: existing_course
782                    .flagged_answers_skip_manual_review_and_allow_retry,
783                closed_at: existing_course.closed_at,
784                closed_additional_message: existing_course.closed_additional_message,
785                closed_course_successor_id: existing_course.closed_course_successor_id,
786                chapter_locking_enabled: true,
787            },
788        )
789        .await
790        .unwrap();
791
792        // Ensure we start from a state where all chapters are not_unlocked_yet
793        let _ = ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter1, course)
794            .await
795            .unwrap();
796        let _ = ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter2, course)
797            .await
798            .unwrap();
799
800        let statuses = get_or_init_all_for_course(tx.as_mut(), user, course)
801            .await
802            .unwrap();
803
804        assert!(!statuses.is_empty());
805        assert!(
806            statuses
807                .iter()
808                .any(|s| s.chapter_id == chapter1 && s.status == ChapterLockingStatus::Unlocked)
809        );
810    }
811
812    #[tokio::test]
813    async fn get_all_for_course_returns_existing_statuses_without_initializing_missing_rows() {
814        insert_data!(
815            :tx,
816            :user,
817            :org,
818            course: course,
819            instance: _instance,
820            :course_module
821        );
822        set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
823        let user_2 = crate::users::insert(
824            tx.as_mut(),
825            PKeyPolicy::Generate,
826            &format!("{}@example.com", Uuid::new_v4()),
827            None,
828            None,
829        )
830        .await
831        .unwrap();
832        let chapter = crate::chapters::insert(
833            tx.as_mut(),
834            PKeyPolicy::Generate,
835            &crate::chapters::NewChapter {
836                name: "Chapter 1".to_string(),
837                color: None,
838                course_id: course,
839                chapter_number: 1,
840                front_page_id: None,
841                opens_at: None,
842                deadline: None,
843                course_module_id: Some(course_module.id),
844            },
845        )
846        .await
847        .unwrap();
848
849        unlock_chapter(tx.as_mut(), user, chapter, course)
850            .await
851            .unwrap();
852
853        let course = crate::courses::get_course(tx.as_mut(), course)
854            .await
855            .unwrap();
856        let statuses = get_all_for_course(tx.as_mut(), &course).await.unwrap();
857
858        assert_eq!(statuses.len(), 1);
859        assert_eq!(statuses[0].user_id, user);
860        assert_eq!(statuses[0].chapter_id, chapter);
861        assert_eq!(statuses[0].status, ChapterLockingStatus::Unlocked);
862        assert!(statuses.iter().all(|status| status.user_id != user_2));
863    }
864
865    #[tokio::test]
866    async fn unlock_chapters_for_user_only_updates_selected_chapters() {
867        insert_data!(:tx, :user, :org, course: course);
868        set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
869
870        let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
871            .await
872            .unwrap();
873        let base_module = all_modules
874            .into_iter()
875            .find(|m| m.order_number == 0)
876            .unwrap();
877
878        let chapter1 = crate::chapters::insert(
879            tx.as_mut(),
880            PKeyPolicy::Generate,
881            &crate::chapters::NewChapter {
882                name: "Chapter 1".to_string(),
883                color: None,
884                course_id: course,
885                chapter_number: 1,
886                front_page_id: None,
887                opens_at: None,
888                deadline: None,
889                course_module_id: Some(base_module.id),
890            },
891        )
892        .await
893        .unwrap();
894        let chapter2 = crate::chapters::insert(
895            tx.as_mut(),
896            PKeyPolicy::Generate,
897            &crate::chapters::NewChapter {
898                name: "Chapter 2".to_string(),
899                color: None,
900                course_id: course,
901                chapter_number: 2,
902                front_page_id: None,
903                opens_at: None,
904                deadline: None,
905                course_module_id: Some(base_module.id),
906            },
907        )
908        .await
909        .unwrap();
910
911        complete_and_lock_chapter(tx.as_mut(), user, chapter1, course)
912            .await
913            .unwrap();
914        complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
915            .await
916            .unwrap();
917
918        unlock_chapters_for_user(tx.as_mut(), user, course, &[chapter1])
919            .await
920            .unwrap();
921
922        let chapter1_status = get_or_init_status(tx.as_mut(), user, chapter1, Some(course), None)
923            .await
924            .unwrap();
925        let chapter2_status = get_or_init_status(tx.as_mut(), user, chapter2, Some(course), None)
926            .await
927            .unwrap();
928
929        assert_eq!(chapter1_status, Some(ChapterLockingStatus::Unlocked));
930        assert_eq!(
931            chapter2_status,
932            Some(ChapterLockingStatus::CompletedAndLocked)
933        );
934    }
935
936    #[tokio::test]
937    async fn unlock_chapters_for_user_does_not_unlock_not_unlocked_yet_statuses() {
938        insert_data!(:tx, :user, :org, course: course);
939        set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
940
941        let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
942            .await
943            .unwrap();
944        let base_module = all_modules
945            .into_iter()
946            .find(|m| m.order_number == 0)
947            .unwrap();
948
949        let chapter1 = crate::chapters::insert(
950            tx.as_mut(),
951            PKeyPolicy::Generate,
952            &crate::chapters::NewChapter {
953                name: "Chapter 1".to_string(),
954                color: None,
955                course_id: course,
956                chapter_number: 1,
957                front_page_id: None,
958                opens_at: None,
959                deadline: None,
960                course_module_id: Some(base_module.id),
961            },
962        )
963        .await
964        .unwrap();
965        let chapter2 = crate::chapters::insert(
966            tx.as_mut(),
967            PKeyPolicy::Generate,
968            &crate::chapters::NewChapter {
969                name: "Chapter 2".to_string(),
970                color: None,
971                course_id: course,
972                chapter_number: 2,
973                front_page_id: None,
974                opens_at: None,
975                deadline: None,
976                course_module_id: Some(base_module.id),
977            },
978        )
979        .await
980        .unwrap();
981
982        complete_and_lock_chapter(tx.as_mut(), user, chapter1, course)
983            .await
984            .unwrap();
985        ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter2, course)
986            .await
987            .unwrap();
988
989        unlock_chapters_for_user(tx.as_mut(), user, course, &[chapter1, chapter2])
990            .await
991            .unwrap();
992
993        let chapter1_status = get_or_init_status(tx.as_mut(), user, chapter1, Some(course), None)
994            .await
995            .unwrap();
996        let chapter2_status = get_or_init_status(tx.as_mut(), user, chapter2, Some(course), None)
997            .await
998            .unwrap();
999
1000        assert_eq!(chapter1_status, Some(ChapterLockingStatus::Unlocked));
1001        assert_eq!(chapter2_status, Some(ChapterLockingStatus::NotUnlockedYet));
1002    }
1003
1004    #[tokio::test]
1005    async fn unlock_chapters_for_user_soft_deletes_rows_when_locking_is_disabled() {
1006        insert_data!(:tx, :user, :org, course: course);
1007        set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
1008
1009        let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
1010            .await
1011            .unwrap();
1012        let base_module = all_modules
1013            .into_iter()
1014            .find(|m| m.order_number == 0)
1015            .unwrap();
1016
1017        let chapter1 = crate::chapters::insert(
1018            tx.as_mut(),
1019            PKeyPolicy::Generate,
1020            &crate::chapters::NewChapter {
1021                name: "Chapter 1".to_string(),
1022                color: None,
1023                course_id: course,
1024                chapter_number: 1,
1025                front_page_id: None,
1026                opens_at: None,
1027                deadline: None,
1028                course_module_id: Some(base_module.id),
1029            },
1030        )
1031        .await
1032        .unwrap();
1033        let chapter2 = crate::chapters::insert(
1034            tx.as_mut(),
1035            PKeyPolicy::Generate,
1036            &crate::chapters::NewChapter {
1037                name: "Chapter 2".to_string(),
1038                color: None,
1039                course_id: course,
1040                chapter_number: 2,
1041                front_page_id: None,
1042                opens_at: None,
1043                deadline: None,
1044                course_module_id: Some(base_module.id),
1045            },
1046        )
1047        .await
1048        .unwrap();
1049
1050        complete_and_lock_chapter(tx.as_mut(), user, chapter1, course)
1051            .await
1052            .unwrap();
1053        complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
1054            .await
1055            .unwrap();
1056
1057        set_course_chapter_locking_enabled(tx.as_mut(), course, false).await;
1058
1059        unlock_chapters_for_user(tx.as_mut(), user, course, &[chapter1])
1060            .await
1061            .unwrap();
1062
1063        set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
1064        let enabled_course = crate::courses::get_course(tx.as_mut(), course)
1065            .await
1066            .unwrap();
1067        let statuses = get_for_user_and_course(tx.as_mut(), user, &enabled_course)
1068            .await
1069            .unwrap();
1070
1071        assert_eq!(statuses.len(), 1);
1072        assert_eq!(statuses[0].chapter_id, chapter2);
1073        assert_eq!(statuses[0].status, ChapterLockingStatus::CompletedAndLocked);
1074    }
1075}