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 the given users in a course.
322pub async fn get_for_users_and_course(
323    conn: &mut PgConnection,
324    user_ids: &[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 course_id = $1
337  AND user_id = ANY($2::uuid[])
338  AND deleted_at IS NULL
339        "#,
340        course.id,
341        user_ids
342    )
343    .fetch_all(&mut *conn)
344    .await?;
345
346    Ok(rows)
347}
348
349/// Returns all chapter locking statuses for a specific user in a course.
350pub async fn get_for_user_and_course(
351    conn: &mut PgConnection,
352    user_id: Uuid,
353    course: &crate::courses::Course,
354) -> ModelResult<Vec<UserChapterLockingStatus>> {
355    if !course.chapter_locking_enabled {
356        return Ok(Vec::new());
357    }
358
359    let rows = sqlx::query_as!(
360        UserChapterLockingStatus,
361        r#"
362SELECT *
363FROM user_chapter_locking_statuses
364WHERE user_id = $1
365  AND course_id = $2
366  AND deleted_at IS NULL
367        "#,
368        user_id,
369        course.id
370    )
371    .fetch_all(&mut *conn)
372    .await?;
373
374    Ok(rows)
375}
376
377/// Creates a status row with `not_unlocked_yet` status if one doesn't exist.
378/// If a row already exists (with any status), returns the existing row without modifying it.
379/// This function does not overwrite existing statuses.
380pub async fn ensure_not_unlocked_yet_status(
381    conn: &mut PgConnection,
382    user_id: Uuid,
383    chapter_id: Uuid,
384    course_id: Uuid,
385) -> ModelResult<UserChapterLockingStatus> {
386    let res: Option<UserChapterLockingStatus> = sqlx::query_as!(
387        UserChapterLockingStatus,
388        r#"
389INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
390VALUES ($1, $2, $3, 'not_unlocked_yet'::chapter_locking_status, NULL)
391ON CONFLICT (user_id, chapter_id, deleted_at) DO NOTHING
392RETURNING *
393        "#,
394        user_id,
395        chapter_id,
396        course_id
397    )
398    .fetch_optional(&mut *conn)
399    .await?;
400
401    if let Some(status) = res {
402        return Ok(status);
403    }
404
405    let retrieved = sqlx::query_as!(
406        UserChapterLockingStatus,
407        r#"
408SELECT *
409FROM user_chapter_locking_statuses
410WHERE user_id = $1
411  AND chapter_id = $2
412  AND deleted_at IS NULL
413        "#,
414        user_id,
415        chapter_id
416    )
417    .fetch_optional(&mut *conn)
418    .await?;
419
420    retrieved.ok_or_else(missing_model_error(
421        ModelErrorType::NotFound,
422        "Failed to ensure not_unlocked_yet status",
423    ))
424}
425
426/// Unlocks the provided chapters for a user within a course.
427pub async fn unlock_chapters_for_user(
428    conn: &mut PgConnection,
429    user_id: Uuid,
430    course_id: Uuid,
431    chapter_ids: &[Uuid],
432) -> ModelResult<()> {
433    if chapter_ids.is_empty() {
434        return Ok(());
435    }
436
437    let course = crate::courses::get_course(conn, course_id).await?;
438    if !course.chapter_locking_enabled {
439        sqlx::query!(
440            r#"
441UPDATE user_chapter_locking_statuses
442SET deleted_at = NOW()
443WHERE user_id = $1
444  AND course_id = $2
445  AND chapter_id = ANY($3)
446  AND deleted_at IS NULL
447            "#,
448            user_id,
449            course_id,
450            chapter_ids
451        )
452        .execute(&mut *conn)
453        .await?;
454
455        return Ok(());
456    }
457
458    sqlx::query!(
459        r#"
460UPDATE user_chapter_locking_statuses
461SET status = 'unlocked'::chapter_locking_status, deleted_at = NULL
462WHERE user_id = $1
463  AND course_id = $2
464  AND chapter_id = ANY($3)
465  AND status = 'completed_and_locked'::chapter_locking_status
466  AND deleted_at IS NULL
467        "#,
468        user_id,
469        course_id,
470        chapter_ids
471    )
472    .execute(&mut *conn)
473    .await?;
474
475    Ok(())
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use crate::test_helper::*;
482
483    /// Updates chapter locking on a course for test setup.
484    async fn set_course_chapter_locking_enabled(
485        tx: &mut PgConnection,
486        course_id: Uuid,
487        chapter_locking_enabled: bool,
488    ) {
489        let course_before_update = crate::courses::get_course(tx, course_id).await.unwrap();
490        crate::courses::update_course(
491            tx,
492            course_id,
493            crate::courses::CourseUpdate {
494                chapter_locking_enabled,
495                name: course_before_update.name,
496                description: course_before_update.description,
497                is_draft: course_before_update.is_draft,
498                is_test_mode: course_before_update.is_test_mode,
499                can_add_chatbot: course_before_update.can_add_chatbot,
500                is_unlisted: course_before_update.is_unlisted,
501                is_joinable_by_code_only: course_before_update.is_joinable_by_code_only,
502                ask_marketing_consent: course_before_update.ask_marketing_consent,
503                flagged_answers_threshold: course_before_update
504                    .flagged_answers_threshold
505                    .unwrap_or_default(),
506                flagged_answers_skip_manual_review_and_allow_retry: course_before_update
507                    .flagged_answers_skip_manual_review_and_allow_retry,
508                closed_at: course_before_update.closed_at,
509                closed_additional_message: course_before_update.closed_additional_message,
510                closed_course_successor_id: course_before_update.closed_course_successor_id,
511                ai_policy: course_before_update.ai_policy,
512                course_material_ai_instructions: course_before_update
513                    .course_material_ai_instructions,
514            },
515        )
516        .await
517        .unwrap();
518    }
519
520    #[tokio::test]
521    async fn get_status_returns_none_when_no_status_exists() {
522        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
523        let chapter = crate::chapters::insert(
524            tx.as_mut(),
525            PKeyPolicy::Generate,
526            &crate::chapters::NewChapter {
527                name: "Test Chapter".to_string(),
528                color: None,
529                course_id: course,
530                chapter_number: 1,
531                front_page_id: None,
532                opens_at: None,
533                deadline: None,
534                course_module_id: Some(course_module.id),
535            },
536        )
537        .await
538        .unwrap();
539
540        let status = get_or_init_status(tx.as_mut(), user, chapter, None, None)
541            .await
542            .unwrap();
543        assert_eq!(status, None);
544    }
545
546    #[tokio::test]
547    async fn unlock_chapter_creates_unlocked_status() {
548        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
549        let chapter = crate::chapters::insert(
550            tx.as_mut(),
551            PKeyPolicy::Generate,
552            &crate::chapters::NewChapter {
553                name: "Test Chapter".to_string(),
554                color: None,
555                course_id: course,
556                chapter_number: 1,
557                front_page_id: None,
558                opens_at: None,
559                deadline: None,
560                course_module_id: Some(course_module.id),
561            },
562        )
563        .await
564        .unwrap();
565
566        let status = unlock_chapter(tx.as_mut(), user, chapter, course)
567            .await
568            .unwrap();
569        assert_eq!(status.status, ChapterLockingStatus::Unlocked);
570        assert_eq!(status.user_id, user);
571        assert_eq!(status.chapter_id, chapter);
572
573        let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
574            .await
575            .unwrap();
576        assert_eq!(retrieved_status, Some(ChapterLockingStatus::Unlocked));
577    }
578
579    #[tokio::test]
580    async fn complete_and_lock_chapter_creates_completed_status() {
581        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
582        let chapter = crate::chapters::insert(
583            tx.as_mut(),
584            PKeyPolicy::Generate,
585            &crate::chapters::NewChapter {
586                name: "Test Chapter".to_string(),
587                color: None,
588                course_id: course,
589                chapter_number: 1,
590                front_page_id: None,
591                opens_at: None,
592                deadline: None,
593                course_module_id: Some(course_module.id),
594            },
595        )
596        .await
597        .unwrap();
598
599        let status = complete_and_lock_chapter(tx.as_mut(), user, chapter, course)
600            .await
601            .unwrap();
602        assert_eq!(status.status, ChapterLockingStatus::CompletedAndLocked);
603        assert_eq!(status.user_id, user);
604        assert_eq!(status.chapter_id, chapter);
605
606        let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
607            .await
608            .unwrap();
609        assert_eq!(
610            retrieved_status,
611            Some(ChapterLockingStatus::CompletedAndLocked)
612        );
613    }
614
615    #[tokio::test]
616    async fn unlock_then_complete_chapter_updates_status() {
617        insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
618        let chapter = crate::chapters::insert(
619            tx.as_mut(),
620            PKeyPolicy::Generate,
621            &crate::chapters::NewChapter {
622                name: "Test Chapter".to_string(),
623                color: None,
624                course_id: course,
625                chapter_number: 1,
626                front_page_id: None,
627                opens_at: None,
628                deadline: None,
629                course_module_id: Some(course_module.id),
630            },
631        )
632        .await
633        .unwrap();
634
635        let existing_course = crate::courses::get_course(tx.as_mut(), course)
636            .await
637            .unwrap();
638        crate::courses::update_course(
639            tx.as_mut(),
640            course,
641            crate::courses::CourseUpdate {
642                name: existing_course.name,
643                description: existing_course.description,
644                is_draft: existing_course.is_draft,
645                is_test_mode: existing_course.is_test_mode,
646                can_add_chatbot: existing_course.can_add_chatbot,
647                is_unlisted: existing_course.is_unlisted,
648                is_joinable_by_code_only: existing_course.is_joinable_by_code_only,
649                ask_marketing_consent: existing_course.ask_marketing_consent,
650                flagged_answers_threshold: existing_course.flagged_answers_threshold.unwrap_or(1),
651                flagged_answers_skip_manual_review_and_allow_retry: existing_course
652                    .flagged_answers_skip_manual_review_and_allow_retry,
653                closed_at: existing_course.closed_at,
654                closed_additional_message: existing_course.closed_additional_message,
655                closed_course_successor_id: existing_course.closed_course_successor_id,
656                chapter_locking_enabled: true,
657                ai_policy: existing_course.ai_policy,
658                course_material_ai_instructions: existing_course.course_material_ai_instructions,
659            },
660        )
661        .await
662        .unwrap();
663
664        unlock_chapter(tx.as_mut(), user, chapter, course)
665            .await
666            .unwrap();
667        let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
668            .await
669            .unwrap();
670        assert_eq!(status, Some(ChapterLockingStatus::Unlocked));
671
672        complete_and_lock_chapter(tx.as_mut(), user, chapter, course)
673            .await
674            .unwrap();
675        let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
676            .await
677            .unwrap();
678        assert_eq!(status, Some(ChapterLockingStatus::CompletedAndLocked));
679    }
680
681    #[tokio::test]
682    async fn get_or_init_all_for_course_returns_all_statuses() {
683        insert_data!(:tx, :user, :org, course: course);
684        // Use the base module (order_number == 0) so that the unlocking logic,
685        // which operates on the base module, affects these chapters.
686        let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
687            .await
688            .unwrap();
689        let base_module = all_modules
690            .into_iter()
691            .find(|m| m.order_number == 0)
692            .unwrap();
693
694        let chapter1 = crate::chapters::insert(
695            tx.as_mut(),
696            PKeyPolicy::Generate,
697            &crate::chapters::NewChapter {
698                name: "Chapter 1".to_string(),
699                color: None,
700                course_id: course,
701                chapter_number: 1,
702                front_page_id: None,
703                opens_at: None,
704                deadline: None,
705                course_module_id: Some(base_module.id),
706            },
707        )
708        .await
709        .unwrap();
710        let chapter2 = crate::chapters::insert(
711            tx.as_mut(),
712            PKeyPolicy::Generate,
713            &crate::chapters::NewChapter {
714                name: "Chapter 2".to_string(),
715                color: None,
716                course_id: course,
717                chapter_number: 2,
718                front_page_id: None,
719                opens_at: None,
720                deadline: None,
721                course_module_id: Some(base_module.id),
722            },
723        )
724        .await
725        .unwrap();
726
727        unlock_chapter(tx.as_mut(), user, chapter1, course)
728            .await
729            .unwrap();
730        complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
731            .await
732            .unwrap();
733
734        let statuses = get_or_init_all_for_course(tx.as_mut(), user, course)
735            .await
736            .unwrap();
737        assert_eq!(statuses.len(), 2);
738        assert!(
739            statuses
740                .iter()
741                .any(|s| s.chapter_id == chapter1 && s.status == ChapterLockingStatus::Unlocked)
742        );
743        assert!(
744            statuses.iter().any(|s| s.chapter_id == chapter2
745                && s.status == ChapterLockingStatus::CompletedAndLocked)
746        );
747    }
748
749    #[tokio::test]
750    async fn get_or_init_all_for_course_unlocks_first_chapter_when_all_not_unlocked_yet() {
751        insert_data!(:tx, :user, :org, course: course);
752
753        let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
754            .await
755            .unwrap();
756        let base_module = all_modules
757            .into_iter()
758            .find(|m| m.order_number == 0)
759            .unwrap();
760
761        let chapter1 = crate::chapters::insert(
762            tx.as_mut(),
763            PKeyPolicy::Generate,
764            &crate::chapters::NewChapter {
765                name: "Chapter 1".to_string(),
766                color: None,
767                course_id: course,
768                chapter_number: 1,
769                front_page_id: None,
770                opens_at: None,
771                deadline: None,
772                course_module_id: Some(base_module.id),
773            },
774        )
775        .await
776        .unwrap();
777
778        // insert a second chapter to ensure only the first is auto-unlocked
779        let chapter2 = crate::chapters::insert(
780            tx.as_mut(),
781            PKeyPolicy::Generate,
782            &crate::chapters::NewChapter {
783                name: "Chapter 2".to_string(),
784                color: None,
785                course_id: course,
786                chapter_number: 2,
787                front_page_id: None,
788                opens_at: None,
789                deadline: None,
790                course_module_id: Some(base_module.id),
791            },
792        )
793        .await
794        .unwrap();
795
796        // Enable chapter locking for the course
797        let existing_course = crate::courses::get_course(tx.as_mut(), course)
798            .await
799            .unwrap();
800
801        crate::courses::update_course(
802            tx.as_mut(),
803            course,
804            crate::courses::CourseUpdate {
805                name: existing_course.name,
806                description: existing_course.description,
807                is_draft: existing_course.is_draft,
808                is_test_mode: existing_course.is_test_mode,
809                can_add_chatbot: existing_course.can_add_chatbot,
810                is_unlisted: existing_course.is_unlisted,
811                is_joinable_by_code_only: existing_course.is_joinable_by_code_only,
812                ask_marketing_consent: existing_course.ask_marketing_consent,
813                flagged_answers_threshold: existing_course.flagged_answers_threshold.unwrap_or(1),
814                flagged_answers_skip_manual_review_and_allow_retry: existing_course
815                    .flagged_answers_skip_manual_review_and_allow_retry,
816                closed_at: existing_course.closed_at,
817                closed_additional_message: existing_course.closed_additional_message,
818                closed_course_successor_id: existing_course.closed_course_successor_id,
819                chapter_locking_enabled: true,
820                ai_policy: existing_course.ai_policy,
821                course_material_ai_instructions: existing_course.course_material_ai_instructions,
822            },
823        )
824        .await
825        .unwrap();
826
827        // Ensure we start from a state where all chapters are not_unlocked_yet
828        let _ = ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter1, course)
829            .await
830            .unwrap();
831        let _ = ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter2, course)
832            .await
833            .unwrap();
834
835        let statuses = get_or_init_all_for_course(tx.as_mut(), user, course)
836            .await
837            .unwrap();
838
839        assert!(!statuses.is_empty());
840        assert!(
841            statuses
842                .iter()
843                .any(|s| s.chapter_id == chapter1 && s.status == ChapterLockingStatus::Unlocked)
844        );
845    }
846
847    #[tokio::test]
848    async fn get_all_for_course_returns_existing_statuses_without_initializing_missing_rows() {
849        insert_data!(
850            :tx,
851            :user,
852            :org,
853            course: course,
854            instance: _instance,
855            :course_module
856        );
857        set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
858        let user_2 = crate::users::insert(
859            tx.as_mut(),
860            PKeyPolicy::Generate,
861            &format!("{}@example.com", Uuid::new_v4()),
862            None,
863            None,
864        )
865        .await
866        .unwrap();
867        let chapter = crate::chapters::insert(
868            tx.as_mut(),
869            PKeyPolicy::Generate,
870            &crate::chapters::NewChapter {
871                name: "Chapter 1".to_string(),
872                color: None,
873                course_id: course,
874                chapter_number: 1,
875                front_page_id: None,
876                opens_at: None,
877                deadline: None,
878                course_module_id: Some(course_module.id),
879            },
880        )
881        .await
882        .unwrap();
883
884        unlock_chapter(tx.as_mut(), user, chapter, course)
885            .await
886            .unwrap();
887
888        let course = crate::courses::get_course(tx.as_mut(), course)
889            .await
890            .unwrap();
891        let statuses = get_all_for_course(tx.as_mut(), &course).await.unwrap();
892
893        assert_eq!(statuses.len(), 1);
894        assert_eq!(statuses[0].user_id, user);
895        assert_eq!(statuses[0].chapter_id, chapter);
896        assert_eq!(statuses[0].status, ChapterLockingStatus::Unlocked);
897        assert!(statuses.iter().all(|status| status.user_id != user_2));
898    }
899
900    #[tokio::test]
901    async fn unlock_chapters_for_user_only_updates_selected_chapters() {
902        insert_data!(:tx, :user, :org, course: course);
903        set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
904
905        let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
906            .await
907            .unwrap();
908        let base_module = all_modules
909            .into_iter()
910            .find(|m| m.order_number == 0)
911            .unwrap();
912
913        let chapter1 = crate::chapters::insert(
914            tx.as_mut(),
915            PKeyPolicy::Generate,
916            &crate::chapters::NewChapter {
917                name: "Chapter 1".to_string(),
918                color: None,
919                course_id: course,
920                chapter_number: 1,
921                front_page_id: None,
922                opens_at: None,
923                deadline: None,
924                course_module_id: Some(base_module.id),
925            },
926        )
927        .await
928        .unwrap();
929        let chapter2 = crate::chapters::insert(
930            tx.as_mut(),
931            PKeyPolicy::Generate,
932            &crate::chapters::NewChapter {
933                name: "Chapter 2".to_string(),
934                color: None,
935                course_id: course,
936                chapter_number: 2,
937                front_page_id: None,
938                opens_at: None,
939                deadline: None,
940                course_module_id: Some(base_module.id),
941            },
942        )
943        .await
944        .unwrap();
945
946        complete_and_lock_chapter(tx.as_mut(), user, chapter1, course)
947            .await
948            .unwrap();
949        complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
950            .await
951            .unwrap();
952
953        unlock_chapters_for_user(tx.as_mut(), user, course, &[chapter1])
954            .await
955            .unwrap();
956
957        let chapter1_status = get_or_init_status(tx.as_mut(), user, chapter1, Some(course), None)
958            .await
959            .unwrap();
960        let chapter2_status = get_or_init_status(tx.as_mut(), user, chapter2, Some(course), None)
961            .await
962            .unwrap();
963
964        assert_eq!(chapter1_status, Some(ChapterLockingStatus::Unlocked));
965        assert_eq!(
966            chapter2_status,
967            Some(ChapterLockingStatus::CompletedAndLocked)
968        );
969    }
970
971    #[tokio::test]
972    async fn unlock_chapters_for_user_does_not_unlock_not_unlocked_yet_statuses() {
973        insert_data!(:tx, :user, :org, course: course);
974        set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
975
976        let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
977            .await
978            .unwrap();
979        let base_module = all_modules
980            .into_iter()
981            .find(|m| m.order_number == 0)
982            .unwrap();
983
984        let chapter1 = crate::chapters::insert(
985            tx.as_mut(),
986            PKeyPolicy::Generate,
987            &crate::chapters::NewChapter {
988                name: "Chapter 1".to_string(),
989                color: None,
990                course_id: course,
991                chapter_number: 1,
992                front_page_id: None,
993                opens_at: None,
994                deadline: None,
995                course_module_id: Some(base_module.id),
996            },
997        )
998        .await
999        .unwrap();
1000        let chapter2 = crate::chapters::insert(
1001            tx.as_mut(),
1002            PKeyPolicy::Generate,
1003            &crate::chapters::NewChapter {
1004                name: "Chapter 2".to_string(),
1005                color: None,
1006                course_id: course,
1007                chapter_number: 2,
1008                front_page_id: None,
1009                opens_at: None,
1010                deadline: None,
1011                course_module_id: Some(base_module.id),
1012            },
1013        )
1014        .await
1015        .unwrap();
1016
1017        complete_and_lock_chapter(tx.as_mut(), user, chapter1, course)
1018            .await
1019            .unwrap();
1020        ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter2, course)
1021            .await
1022            .unwrap();
1023
1024        unlock_chapters_for_user(tx.as_mut(), user, course, &[chapter1, chapter2])
1025            .await
1026            .unwrap();
1027
1028        let chapter1_status = get_or_init_status(tx.as_mut(), user, chapter1, Some(course), None)
1029            .await
1030            .unwrap();
1031        let chapter2_status = get_or_init_status(tx.as_mut(), user, chapter2, Some(course), None)
1032            .await
1033            .unwrap();
1034
1035        assert_eq!(chapter1_status, Some(ChapterLockingStatus::Unlocked));
1036        assert_eq!(chapter2_status, Some(ChapterLockingStatus::NotUnlockedYet));
1037    }
1038
1039    #[tokio::test]
1040    async fn unlock_chapters_for_user_soft_deletes_rows_when_locking_is_disabled() {
1041        insert_data!(:tx, :user, :org, course: course);
1042        set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
1043
1044        let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
1045            .await
1046            .unwrap();
1047        let base_module = all_modules
1048            .into_iter()
1049            .find(|m| m.order_number == 0)
1050            .unwrap();
1051
1052        let chapter1 = crate::chapters::insert(
1053            tx.as_mut(),
1054            PKeyPolicy::Generate,
1055            &crate::chapters::NewChapter {
1056                name: "Chapter 1".to_string(),
1057                color: None,
1058                course_id: course,
1059                chapter_number: 1,
1060                front_page_id: None,
1061                opens_at: None,
1062                deadline: None,
1063                course_module_id: Some(base_module.id),
1064            },
1065        )
1066        .await
1067        .unwrap();
1068        let chapter2 = crate::chapters::insert(
1069            tx.as_mut(),
1070            PKeyPolicy::Generate,
1071            &crate::chapters::NewChapter {
1072                name: "Chapter 2".to_string(),
1073                color: None,
1074                course_id: course,
1075                chapter_number: 2,
1076                front_page_id: None,
1077                opens_at: None,
1078                deadline: None,
1079                course_module_id: Some(base_module.id),
1080            },
1081        )
1082        .await
1083        .unwrap();
1084
1085        complete_and_lock_chapter(tx.as_mut(), user, chapter1, course)
1086            .await
1087            .unwrap();
1088        complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
1089            .await
1090            .unwrap();
1091
1092        set_course_chapter_locking_enabled(tx.as_mut(), course, false).await;
1093
1094        unlock_chapters_for_user(tx.as_mut(), user, course, &[chapter1])
1095            .await
1096            .unwrap();
1097
1098        set_course_chapter_locking_enabled(tx.as_mut(), course, true).await;
1099        let enabled_course = crate::courses::get_course(tx.as_mut(), course)
1100            .await
1101            .unwrap();
1102        let statuses = get_for_user_and_course(tx.as_mut(), user, &enabled_course)
1103            .await
1104            .unwrap();
1105
1106        assert_eq!(statuses.len(), 1);
1107        assert_eq!(statuses[0].chapter_id, chapter2);
1108        assert_eq!(statuses[0].status, ChapterLockingStatus::CompletedAndLocked);
1109    }
1110}