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 *
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 *
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 *
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 *
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 *
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 *
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 *
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 *
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 *
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                ai_policy: course_before_update.ai_policy,
484                course_material_ai_instructions: course_before_update
485                    .course_material_ai_instructions,
486            },
487        )
488        .await
489        .unwrap();
490    }
491
492    #[tokio::test]
493    async fn get_status_returns_none_when_no_status_exists() {
494        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
495        let chapter = crate::chapters::insert(
496            tx.as_mut(),
497            PKeyPolicy::Generate,
498            &crate::chapters::NewChapter {
499                name: "Test Chapter".to_string(),
500                color: None,
501                course_id: course,
502                chapter_number: 1,
503                front_page_id: None,
504                opens_at: None,
505                deadline: None,
506                course_module_id: Some(course_module.id),
507            },
508        )
509        .await
510        .unwrap();
511
512        let status = get_or_init_status(tx.as_mut(), user, chapter, None, None)
513            .await
514            .unwrap();
515        assert_eq!(status, None);
516    }
517
518    #[tokio::test]
519    async fn unlock_chapter_creates_unlocked_status() {
520        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
521        let chapter = crate::chapters::insert(
522            tx.as_mut(),
523            PKeyPolicy::Generate,
524            &crate::chapters::NewChapter {
525                name: "Test Chapter".to_string(),
526                color: None,
527                course_id: course,
528                chapter_number: 1,
529                front_page_id: None,
530                opens_at: None,
531                deadline: None,
532                course_module_id: Some(course_module.id),
533            },
534        )
535        .await
536        .unwrap();
537
538        let status = unlock_chapter(tx.as_mut(), user, chapter, course)
539            .await
540            .unwrap();
541        assert_eq!(status.status, ChapterLockingStatus::Unlocked);
542        assert_eq!(status.user_id, user);
543        assert_eq!(status.chapter_id, chapter);
544
545        let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
546            .await
547            .unwrap();
548        assert_eq!(retrieved_status, Some(ChapterLockingStatus::Unlocked));
549    }
550
551    #[tokio::test]
552    async fn complete_and_lock_chapter_creates_completed_status() {
553        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
554        let chapter = crate::chapters::insert(
555            tx.as_mut(),
556            PKeyPolicy::Generate,
557            &crate::chapters::NewChapter {
558                name: "Test Chapter".to_string(),
559                color: None,
560                course_id: course,
561                chapter_number: 1,
562                front_page_id: None,
563                opens_at: None,
564                deadline: None,
565                course_module_id: Some(course_module.id),
566            },
567        )
568        .await
569        .unwrap();
570
571        let status = complete_and_lock_chapter(tx.as_mut(), user, chapter, course)
572            .await
573            .unwrap();
574        assert_eq!(status.status, ChapterLockingStatus::CompletedAndLocked);
575        assert_eq!(status.user_id, user);
576        assert_eq!(status.chapter_id, chapter);
577
578        let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
579            .await
580            .unwrap();
581        assert_eq!(
582            retrieved_status,
583            Some(ChapterLockingStatus::CompletedAndLocked)
584        );
585    }
586
587    #[tokio::test]
588    async fn unlock_then_complete_chapter_updates_status() {
589        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
590        let chapter = crate::chapters::insert(
591            tx.as_mut(),
592            PKeyPolicy::Generate,
593            &crate::chapters::NewChapter {
594                name: "Test Chapter".to_string(),
595                color: None,
596                course_id: course,
597                chapter_number: 1,
598                front_page_id: None,
599                opens_at: None,
600                deadline: None,
601                course_module_id: Some(course_module.id),
602            },
603        )
604        .await
605        .unwrap();
606
607        let existing_course = crate::courses::get_course(tx.as_mut(), course)
608            .await
609            .unwrap();
610        crate::courses::update_course(
611            tx.as_mut(),
612            course,
613            crate::courses::CourseUpdate {
614                name: existing_course.name,
615                description: existing_course.description,
616                is_draft: existing_course.is_draft,
617                is_test_mode: existing_course.is_test_mode,
618                can_add_chatbot: existing_course.can_add_chatbot,
619                is_unlisted: existing_course.is_unlisted,
620                is_joinable_by_code_only: existing_course.is_joinable_by_code_only,
621                ask_marketing_consent: existing_course.ask_marketing_consent,
622                flagged_answers_threshold: existing_course.flagged_answers_threshold.unwrap_or(1),
623                flagged_answers_skip_manual_review_and_allow_retry: existing_course
624                    .flagged_answers_skip_manual_review_and_allow_retry,
625                closed_at: existing_course.closed_at,
626                closed_additional_message: existing_course.closed_additional_message,
627                closed_course_successor_id: existing_course.closed_course_successor_id,
628                chapter_locking_enabled: true,
629                ai_policy: existing_course.ai_policy,
630                course_material_ai_instructions: existing_course.course_material_ai_instructions,
631            },
632        )
633        .await
634        .unwrap();
635
636        unlock_chapter(tx.as_mut(), user, chapter, course)
637            .await
638            .unwrap();
639        let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
640            .await
641            .unwrap();
642        assert_eq!(status, Some(ChapterLockingStatus::Unlocked));
643
644        complete_and_lock_chapter(tx.as_mut(), user, chapter, course)
645            .await
646            .unwrap();
647        let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
648            .await
649            .unwrap();
650        assert_eq!(status, Some(ChapterLockingStatus::CompletedAndLocked));
651    }
652
653    #[tokio::test]
654    async fn get_or_init_all_for_course_returns_all_statuses() {
655        insert_data!(:tx, :user, :org, course: course);
656        // Use the base module (order_number == 0) so that the unlocking logic,
657        // which operates on the base module, affects these chapters.
658        let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
659            .await
660            .unwrap();
661        let base_module = all_modules
662            .into_iter()
663            .find(|m| m.order_number == 0)
664            .unwrap();
665
666        let chapter1 = crate::chapters::insert(
667            tx.as_mut(),
668            PKeyPolicy::Generate,
669            &crate::chapters::NewChapter {
670                name: "Chapter 1".to_string(),
671                color: None,
672                course_id: course,
673                chapter_number: 1,
674                front_page_id: None,
675                opens_at: None,
676                deadline: None,
677                course_module_id: Some(base_module.id),
678            },
679        )
680        .await
681        .unwrap();
682        let chapter2 = crate::chapters::insert(
683            tx.as_mut(),
684            PKeyPolicy::Generate,
685            &crate::chapters::NewChapter {
686                name: "Chapter 2".to_string(),
687                color: None,
688                course_id: course,
689                chapter_number: 2,
690                front_page_id: None,
691                opens_at: None,
692                deadline: None,
693                course_module_id: Some(base_module.id),
694            },
695        )
696        .await
697        .unwrap();
698
699        unlock_chapter(tx.as_mut(), user, chapter1, course)
700            .await
701            .unwrap();
702        complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
703            .await
704            .unwrap();
705
706        let statuses = get_or_init_all_for_course(tx.as_mut(), user, course)
707            .await
708            .unwrap();
709        assert_eq!(statuses.len(), 2);
710        assert!(
711            statuses
712                .iter()
713                .any(|s| s.chapter_id == chapter1 && s.status == ChapterLockingStatus::Unlocked)
714        );
715        assert!(
716            statuses.iter().any(|s| s.chapter_id == chapter2
717                && s.status == ChapterLockingStatus::CompletedAndLocked)
718        );
719    }
720
721    #[tokio::test]
722    async fn get_or_init_all_for_course_unlocks_first_chapter_when_all_not_unlocked_yet() {
723        insert_data!(:tx, :user, :org, course: course);
724
725        let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
726            .await
727            .unwrap();
728        let base_module = all_modules
729            .into_iter()
730            .find(|m| m.order_number == 0)
731            .unwrap();
732
733        let chapter1 = crate::chapters::insert(
734            tx.as_mut(),
735            PKeyPolicy::Generate,
736            &crate::chapters::NewChapter {
737                name: "Chapter 1".to_string(),
738                color: None,
739                course_id: course,
740                chapter_number: 1,
741                front_page_id: None,
742                opens_at: None,
743                deadline: None,
744                course_module_id: Some(base_module.id),
745            },
746        )
747        .await
748        .unwrap();
749
750        // insert a second chapter to ensure only the first is auto-unlocked
751        let chapter2 = crate::chapters::insert(
752            tx.as_mut(),
753            PKeyPolicy::Generate,
754            &crate::chapters::NewChapter {
755                name: "Chapter 2".to_string(),
756                color: None,
757                course_id: course,
758                chapter_number: 2,
759                front_page_id: None,
760                opens_at: None,
761                deadline: None,
762                course_module_id: Some(base_module.id),
763            },
764        )
765        .await
766        .unwrap();
767
768        // Enable chapter locking for the course
769        let existing_course = crate::courses::get_course(tx.as_mut(), course)
770            .await
771            .unwrap();
772
773        crate::courses::update_course(
774            tx.as_mut(),
775            course,
776            crate::courses::CourseUpdate {
777                name: existing_course.name,
778                description: existing_course.description,
779                is_draft: existing_course.is_draft,
780                is_test_mode: existing_course.is_test_mode,
781                can_add_chatbot: existing_course.can_add_chatbot,
782                is_unlisted: existing_course.is_unlisted,
783                is_joinable_by_code_only: existing_course.is_joinable_by_code_only,
784                ask_marketing_consent: existing_course.ask_marketing_consent,
785                flagged_answers_threshold: existing_course.flagged_answers_threshold.unwrap_or(1),
786                flagged_answers_skip_manual_review_and_allow_retry: existing_course
787                    .flagged_answers_skip_manual_review_and_allow_retry,
788                closed_at: existing_course.closed_at,
789                closed_additional_message: existing_course.closed_additional_message,
790                closed_course_successor_id: existing_course.closed_course_successor_id,
791                chapter_locking_enabled: true,
792                ai_policy: existing_course.ai_policy,
793                course_material_ai_instructions: existing_course.course_material_ai_instructions,
794            },
795        )
796        .await
797        .unwrap();
798
799        // Ensure we start from a state where all chapters are not_unlocked_yet
800        let _ = ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter1, course)
801            .await
802            .unwrap();
803        let _ = ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter2, course)
804            .await
805            .unwrap();
806
807        let statuses = get_or_init_all_for_course(tx.as_mut(), user, course)
808            .await
809            .unwrap();
810
811        assert!(!statuses.is_empty());
812        assert!(
813            statuses
814                .iter()
815                .any(|s| s.chapter_id == chapter1 && s.status == ChapterLockingStatus::Unlocked)
816        );
817    }
818
819    #[tokio::test]
820    async fn get_all_for_course_returns_existing_statuses_without_initializing_missing_rows() {
821        insert_data!(
822            :tx,
823            :user,
824            :org,
825            course: course,
826            instance: _instance,
827            :course_module
828        );
829        set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
830        let user_2 = crate::users::insert(
831            tx.as_mut(),
832            PKeyPolicy::Generate,
833            &format!("{}@example.com", Uuid::new_v4()),
834            None,
835            None,
836        )
837        .await
838        .unwrap();
839        let chapter = crate::chapters::insert(
840            tx.as_mut(),
841            PKeyPolicy::Generate,
842            &crate::chapters::NewChapter {
843                name: "Chapter 1".to_string(),
844                color: None,
845                course_id: course,
846                chapter_number: 1,
847                front_page_id: None,
848                opens_at: None,
849                deadline: None,
850                course_module_id: Some(course_module.id),
851            },
852        )
853        .await
854        .unwrap();
855
856        unlock_chapter(tx.as_mut(), user, chapter, course)
857            .await
858            .unwrap();
859
860        let course = crate::courses::get_course(tx.as_mut(), course)
861            .await
862            .unwrap();
863        let statuses = get_all_for_course(tx.as_mut(), &course).await.unwrap();
864
865        assert_eq!(statuses.len(), 1);
866        assert_eq!(statuses[0].user_id, user);
867        assert_eq!(statuses[0].chapter_id, chapter);
868        assert_eq!(statuses[0].status, ChapterLockingStatus::Unlocked);
869        assert!(statuses.iter().all(|status| status.user_id != user_2));
870    }
871
872    #[tokio::test]
873    async fn unlock_chapters_for_user_only_updates_selected_chapters() {
874        insert_data!(:tx, :user, :org, course: course);
875        set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
876
877        let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
878            .await
879            .unwrap();
880        let base_module = all_modules
881            .into_iter()
882            .find(|m| m.order_number == 0)
883            .unwrap();
884
885        let chapter1 = crate::chapters::insert(
886            tx.as_mut(),
887            PKeyPolicy::Generate,
888            &crate::chapters::NewChapter {
889                name: "Chapter 1".to_string(),
890                color: None,
891                course_id: course,
892                chapter_number: 1,
893                front_page_id: None,
894                opens_at: None,
895                deadline: None,
896                course_module_id: Some(base_module.id),
897            },
898        )
899        .await
900        .unwrap();
901        let chapter2 = crate::chapters::insert(
902            tx.as_mut(),
903            PKeyPolicy::Generate,
904            &crate::chapters::NewChapter {
905                name: "Chapter 2".to_string(),
906                color: None,
907                course_id: course,
908                chapter_number: 2,
909                front_page_id: None,
910                opens_at: None,
911                deadline: None,
912                course_module_id: Some(base_module.id),
913            },
914        )
915        .await
916        .unwrap();
917
918        complete_and_lock_chapter(tx.as_mut(), user, chapter1, course)
919            .await
920            .unwrap();
921        complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
922            .await
923            .unwrap();
924
925        unlock_chapters_for_user(tx.as_mut(), user, course, &[chapter1])
926            .await
927            .unwrap();
928
929        let chapter1_status = get_or_init_status(tx.as_mut(), user, chapter1, Some(course), None)
930            .await
931            .unwrap();
932        let chapter2_status = get_or_init_status(tx.as_mut(), user, chapter2, Some(course), None)
933            .await
934            .unwrap();
935
936        assert_eq!(chapter1_status, Some(ChapterLockingStatus::Unlocked));
937        assert_eq!(
938            chapter2_status,
939            Some(ChapterLockingStatus::CompletedAndLocked)
940        );
941    }
942
943    #[tokio::test]
944    async fn unlock_chapters_for_user_does_not_unlock_not_unlocked_yet_statuses() {
945        insert_data!(:tx, :user, :org, course: course);
946        set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
947
948        let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
949            .await
950            .unwrap();
951        let base_module = all_modules
952            .into_iter()
953            .find(|m| m.order_number == 0)
954            .unwrap();
955
956        let chapter1 = crate::chapters::insert(
957            tx.as_mut(),
958            PKeyPolicy::Generate,
959            &crate::chapters::NewChapter {
960                name: "Chapter 1".to_string(),
961                color: None,
962                course_id: course,
963                chapter_number: 1,
964                front_page_id: None,
965                opens_at: None,
966                deadline: None,
967                course_module_id: Some(base_module.id),
968            },
969        )
970        .await
971        .unwrap();
972        let chapter2 = crate::chapters::insert(
973            tx.as_mut(),
974            PKeyPolicy::Generate,
975            &crate::chapters::NewChapter {
976                name: "Chapter 2".to_string(),
977                color: None,
978                course_id: course,
979                chapter_number: 2,
980                front_page_id: None,
981                opens_at: None,
982                deadline: None,
983                course_module_id: Some(base_module.id),
984            },
985        )
986        .await
987        .unwrap();
988
989        complete_and_lock_chapter(tx.as_mut(), user, chapter1, course)
990            .await
991            .unwrap();
992        ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter2, course)
993            .await
994            .unwrap();
995
996        unlock_chapters_for_user(tx.as_mut(), user, course, &[chapter1, chapter2])
997            .await
998            .unwrap();
999
1000        let chapter1_status = get_or_init_status(tx.as_mut(), user, chapter1, Some(course), None)
1001            .await
1002            .unwrap();
1003        let chapter2_status = get_or_init_status(tx.as_mut(), user, chapter2, Some(course), None)
1004            .await
1005            .unwrap();
1006
1007        assert_eq!(chapter1_status, Some(ChapterLockingStatus::Unlocked));
1008        assert_eq!(chapter2_status, Some(ChapterLockingStatus::NotUnlockedYet));
1009    }
1010
1011    #[tokio::test]
1012    async fn unlock_chapters_for_user_soft_deletes_rows_when_locking_is_disabled() {
1013        insert_data!(:tx, :user, :org, course: course);
1014        set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
1015
1016        let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
1017            .await
1018            .unwrap();
1019        let base_module = all_modules
1020            .into_iter()
1021            .find(|m| m.order_number == 0)
1022            .unwrap();
1023
1024        let chapter1 = crate::chapters::insert(
1025            tx.as_mut(),
1026            PKeyPolicy::Generate,
1027            &crate::chapters::NewChapter {
1028                name: "Chapter 1".to_string(),
1029                color: None,
1030                course_id: course,
1031                chapter_number: 1,
1032                front_page_id: None,
1033                opens_at: None,
1034                deadline: None,
1035                course_module_id: Some(base_module.id),
1036            },
1037        )
1038        .await
1039        .unwrap();
1040        let chapter2 = crate::chapters::insert(
1041            tx.as_mut(),
1042            PKeyPolicy::Generate,
1043            &crate::chapters::NewChapter {
1044                name: "Chapter 2".to_string(),
1045                color: None,
1046                course_id: course,
1047                chapter_number: 2,
1048                front_page_id: None,
1049                opens_at: None,
1050                deadline: None,
1051                course_module_id: Some(base_module.id),
1052            },
1053        )
1054        .await
1055        .unwrap();
1056
1057        complete_and_lock_chapter(tx.as_mut(), user, chapter1, course)
1058            .await
1059            .unwrap();
1060        complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
1061            .await
1062            .unwrap();
1063
1064        set_course_chapter_locking_enabled(tx.as_mut(), course, false).await;
1065
1066        unlock_chapters_for_user(tx.as_mut(), user, course, &[chapter1])
1067            .await
1068            .unwrap();
1069
1070        set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
1071        let enabled_course = crate::courses::get_course(tx.as_mut(), course)
1072            .await
1073            .unwrap();
1074        let statuses = get_for_user_and_course(tx.as_mut(), user, &enabled_course)
1075            .await
1076            .unwrap();
1077
1078        assert_eq!(statuses.len(), 1);
1079        assert_eq!(statuses[0].chapter_id, chapter2);
1080        assert_eq!(statuses[0].status, ChapterLockingStatus::CompletedAndLocked);
1081    }
1082}