1use crate::prelude::*;
2use std::convert::TryFrom;
3
4#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy)]
5#[cfg_attr(feature = "ts_rs", derive(TS))]
6#[serde(rename_all = "snake_case")]
7pub enum ChapterLockingStatus {
8 Unlocked,
10 CompletedAndLocked,
12 NotUnlockedYet,
14}
15
16impl ChapterLockingStatus {
17 pub fn from_db(s: &str) -> ModelResult<Self> {
18 match s {
19 "unlocked" => Ok(ChapterLockingStatus::Unlocked),
20 "completed_and_locked" => Ok(ChapterLockingStatus::CompletedAndLocked),
21 "not_unlocked_yet" => Ok(ChapterLockingStatus::NotUnlockedYet),
22 _ => Err(ModelError::new(
23 ModelErrorType::Database,
24 format!("Invalid chapter locking status from database: {}", s),
25 None,
26 )),
27 }
28 }
29}
30
31#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
32#[cfg_attr(feature = "ts_rs", derive(TS))]
33pub struct UserChapterLockingStatus {
34 pub id: Uuid,
35 pub created_at: DateTime<Utc>,
36 pub updated_at: DateTime<Utc>,
37 pub deleted_at: Option<DateTime<Utc>>,
38 pub user_id: Uuid,
39 pub chapter_id: Uuid,
40 pub course_id: Uuid,
41 pub status: ChapterLockingStatus,
42}
43
44struct DatabaseRow {
45 id: Uuid,
46 created_at: DateTime<Utc>,
47 updated_at: DateTime<Utc>,
48 deleted_at: Option<DateTime<Utc>>,
49 user_id: Uuid,
50 chapter_id: Uuid,
51 course_id: Uuid,
52 status: String,
53}
54
55impl TryFrom<DatabaseRow> for UserChapterLockingStatus {
56 type Error = ModelError;
57
58 fn try_from(row: DatabaseRow) -> Result<Self, Self::Error> {
59 let status = ChapterLockingStatus::from_db(&row.status)?;
60 Ok(UserChapterLockingStatus {
61 id: row.id,
62 created_at: row.created_at,
63 updated_at: row.updated_at,
64 deleted_at: row.deleted_at,
65 user_id: row.user_id,
66 chapter_id: row.chapter_id,
67 course_id: row.course_id,
68 status,
69 })
70 }
71}
72
73async fn get_or_init_status_row(
74 conn: &mut PgConnection,
75 user_id: Uuid,
76 chapter_id: Uuid,
77 course_id: Option<Uuid>,
78 course_locking_enabled: Option<bool>,
79) -> ModelResult<Option<UserChapterLockingStatus>> {
80 let res = sqlx::query_as!(
81 DatabaseRow,
82 r#"
83SELECT id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
84FROM user_chapter_locking_statuses
85WHERE user_id = $1
86 AND chapter_id = $2
87 AND deleted_at IS NULL
88 "#,
89 user_id,
90 chapter_id
91 )
92 .fetch_optional(&mut *conn)
93 .await?;
94
95 if let Some(row) = res {
96 return row.try_into().map(Some);
97 }
98
99 if let (Some(course_id), Some(true)) = (course_id, course_locking_enabled) {
100 return Ok(Some(
101 ensure_not_unlocked_yet_status(&mut *conn, user_id, chapter_id, course_id).await?,
102 ));
103 }
104
105 Ok(None)
106}
107
108pub async fn get_or_init_status(
109 conn: &mut PgConnection,
110 user_id: Uuid,
111 chapter_id: Uuid,
112 course_id: Option<Uuid>,
113 course_locking_enabled: Option<bool>,
114) -> ModelResult<Option<ChapterLockingStatus>> {
115 get_or_init_status_row(conn, user_id, chapter_id, course_id, course_locking_enabled)
116 .await?
117 .map(|s| Ok(s.status))
118 .transpose()
119}
120
121pub async fn is_chapter_accessible(
122 conn: &mut PgConnection,
123 user_id: Uuid,
124 chapter_id: Uuid,
125 course_id: Uuid,
126) -> ModelResult<bool> {
127 use crate::courses;
128
129 let course = courses::get_course(conn, course_id).await?;
130
131 if !course.chapter_locking_enabled {
132 return Ok(true);
133 }
134
135 let status = get_or_init_status(
136 conn,
137 user_id,
138 chapter_id,
139 Some(course_id),
140 Some(course.chapter_locking_enabled),
141 )
142 .await?;
143 match status {
144 None => Ok(false),
145 Some(ChapterLockingStatus::Unlocked) => Ok(true),
146 Some(ChapterLockingStatus::CompletedAndLocked) => Ok(true),
147 Some(ChapterLockingStatus::NotUnlockedYet) => Ok(false),
148 }
149}
150
151pub async fn is_chapter_exercises_locked(
152 conn: &mut PgConnection,
153 user_id: Uuid,
154 chapter_id: Uuid,
155 course_id: Uuid,
156) -> ModelResult<bool> {
157 use crate::courses;
158
159 let course = courses::get_course(conn, course_id).await?;
160
161 if !course.chapter_locking_enabled {
162 return Ok(false);
163 }
164
165 let status = get_or_init_status(
166 conn,
167 user_id,
168 chapter_id,
169 Some(course_id),
170 Some(course.chapter_locking_enabled),
171 )
172 .await?;
173
174 match status {
175 None => Ok(true),
176 Some(ChapterLockingStatus::Unlocked) => Ok(false),
177 Some(ChapterLockingStatus::CompletedAndLocked) => Ok(true),
178 Some(ChapterLockingStatus::NotUnlockedYet) => Ok(true),
179 }
180}
181
182pub async fn unlock_chapter(
183 conn: &mut PgConnection,
184 user_id: Uuid,
185 chapter_id: Uuid,
186 course_id: Uuid,
187) -> ModelResult<UserChapterLockingStatus> {
188 let res = sqlx::query_as!(
189 DatabaseRow,
190 r#"
191INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
192VALUES ($1, $2, $3, 'unlocked'::chapter_locking_status, NULL)
193ON CONFLICT ON CONSTRAINT idx_user_chapter_locking_statuses_user_chapter_active DO UPDATE
194SET status = 'unlocked'::chapter_locking_status, deleted_at = NULL
195RETURNING id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
196 "#,
197 user_id,
198 chapter_id,
199 course_id
200 )
201 .fetch_optional(&mut *conn)
202 .await?;
203
204 res.map(|s| s.try_into())
205 .transpose()?
206 .ok_or_else(|| ModelError::new(ModelErrorType::NotFound, "Failed to unlock chapter", None))
207}
208
209pub async fn complete_and_lock_chapter(
210 conn: &mut PgConnection,
211 user_id: Uuid,
212 chapter_id: Uuid,
213 course_id: Uuid,
214) -> ModelResult<UserChapterLockingStatus> {
215 let res = sqlx::query_as!(
216 DatabaseRow,
217 r#"
218INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
219VALUES ($1, $2, $3, 'completed_and_locked'::chapter_locking_status, NULL)
220ON CONFLICT ON CONSTRAINT idx_user_chapter_locking_statuses_user_chapter_active DO UPDATE
221SET status = 'completed_and_locked'::chapter_locking_status, deleted_at = NULL
222RETURNING id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
223 "#,
224 user_id,
225 chapter_id,
226 course_id
227 )
228 .fetch_optional(&mut *conn)
229 .await?;
230
231 res.map(|s| s.try_into()).transpose()?.ok_or_else(|| {
232 ModelError::new(ModelErrorType::NotFound, "Failed to complete chapter", None)
233 })
234}
235
236pub async fn get_or_init_all_for_course(
237 conn: &mut PgConnection,
238 user_id: Uuid,
239 course_id: Uuid,
240) -> ModelResult<Vec<UserChapterLockingStatus>> {
241 let course = crate::courses::get_course(conn, course_id).await?;
242 let course_locking_enabled = course.chapter_locking_enabled;
243
244 if course_locking_enabled {
245 sqlx::query!(
246 r#"
247INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
248SELECT $1, chapters.id, $2, 'not_unlocked_yet'::chapter_locking_status, NULL
249FROM chapters
250WHERE chapters.course_id = $2
251 AND chapters.deleted_at IS NULL
252 AND NOT EXISTS (
253 SELECT 1
254 FROM user_chapter_locking_statuses
255 WHERE user_chapter_locking_statuses.user_id = $1
256 AND user_chapter_locking_statuses.chapter_id = chapters.id
257 AND user_chapter_locking_statuses.deleted_at IS NULL
258 )
259ON CONFLICT (user_id, chapter_id, deleted_at) DO NOTHING
260 "#,
261 user_id,
262 course_id
263 )
264 .execute(&mut *conn)
265 .await?;
266 }
267
268 async fn get_statuses_for_user_and_course(
269 conn: &mut PgConnection,
270 user_id: Uuid,
271 course_id: Uuid,
272 ) -> ModelResult<Vec<UserChapterLockingStatus>> {
273 let rows = sqlx::query_as!(
274 DatabaseRow,
275 r#"
276SELECT id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
277FROM user_chapter_locking_statuses
278WHERE user_id = $1
279 AND course_id = $2
280 AND deleted_at IS NULL
281 "#,
282 user_id,
283 course_id
284 )
285 .fetch_all(&mut *conn)
286 .await?;
287
288 rows.into_iter()
289 .map(|r| r.try_into())
290 .collect::<ModelResult<Vec<_>>>()
291 }
292
293 let mut statuses = get_statuses_for_user_and_course(conn, user_id, course_id).await?;
294
295 if course_locking_enabled
296 && !statuses.is_empty()
297 && statuses
298 .iter()
299 .all(|s| matches!(s.status, ChapterLockingStatus::NotUnlockedYet))
300 {
301 crate::chapters::unlock_first_chapters_for_user(conn, user_id, course_id).await?;
302
303 statuses = get_statuses_for_user_and_course(conn, user_id, course_id).await?;
304 }
305
306 Ok(statuses)
307}
308
309pub async fn ensure_not_unlocked_yet_status(
313 conn: &mut PgConnection,
314 user_id: Uuid,
315 chapter_id: Uuid,
316 course_id: Uuid,
317) -> ModelResult<UserChapterLockingStatus> {
318 let res: Option<DatabaseRow> = sqlx::query_as!(
319 DatabaseRow,
320 r#"
321INSERT INTO user_chapter_locking_statuses (user_id, chapter_id, course_id, status, deleted_at)
322VALUES ($1, $2, $3, 'not_unlocked_yet'::chapter_locking_status, NULL)
323ON CONFLICT (user_id, chapter_id, deleted_at) DO NOTHING
324RETURNING id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
325 "#,
326 user_id,
327 chapter_id,
328 course_id
329 )
330 .fetch_optional(&mut *conn)
331 .await?;
332
333 if let Some(status) = res {
334 return status.try_into();
335 }
336
337 let retrieved = sqlx::query_as!(
338 DatabaseRow,
339 r#"
340SELECT id, created_at, updated_at, deleted_at, user_id, chapter_id, course_id, status::text as "status!"
341FROM user_chapter_locking_statuses
342WHERE user_id = $1
343 AND chapter_id = $2
344 AND deleted_at IS NULL
345 "#,
346 user_id,
347 chapter_id
348 )
349 .fetch_optional(&mut *conn)
350 .await?;
351
352 retrieved.map(|r| r.try_into()).transpose()?.ok_or_else(|| {
353 ModelError::new(
354 ModelErrorType::NotFound,
355 "Failed to ensure not_unlocked_yet status",
356 None,
357 )
358 })
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::test_helper::*;
365
366 #[tokio::test]
367 async fn get_status_returns_none_when_no_status_exists() {
368 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
369 let chapter = crate::chapters::insert(
370 tx.as_mut(),
371 PKeyPolicy::Generate,
372 &crate::chapters::NewChapter {
373 name: "Test Chapter".to_string(),
374 color: None,
375 course_id: course,
376 chapter_number: 1,
377 front_page_id: None,
378 opens_at: None,
379 deadline: None,
380 course_module_id: Some(course_module.id),
381 },
382 )
383 .await
384 .unwrap();
385
386 let status = get_or_init_status(tx.as_mut(), user, chapter, None, None)
387 .await
388 .unwrap();
389 assert_eq!(status, None);
390 }
391
392 #[tokio::test]
393 async fn unlock_chapter_creates_unlocked_status() {
394 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
395 let chapter = crate::chapters::insert(
396 tx.as_mut(),
397 PKeyPolicy::Generate,
398 &crate::chapters::NewChapter {
399 name: "Test Chapter".to_string(),
400 color: None,
401 course_id: course,
402 chapter_number: 1,
403 front_page_id: None,
404 opens_at: None,
405 deadline: None,
406 course_module_id: Some(course_module.id),
407 },
408 )
409 .await
410 .unwrap();
411
412 let status = unlock_chapter(tx.as_mut(), user, chapter, course)
413 .await
414 .unwrap();
415 assert_eq!(status.status, ChapterLockingStatus::Unlocked);
416 assert_eq!(status.user_id, user);
417 assert_eq!(status.chapter_id, chapter);
418
419 let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
420 .await
421 .unwrap();
422 assert_eq!(retrieved_status, Some(ChapterLockingStatus::Unlocked));
423 }
424
425 #[tokio::test]
426 async fn complete_and_lock_chapter_creates_completed_status() {
427 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
428 let chapter = crate::chapters::insert(
429 tx.as_mut(),
430 PKeyPolicy::Generate,
431 &crate::chapters::NewChapter {
432 name: "Test Chapter".to_string(),
433 color: None,
434 course_id: course,
435 chapter_number: 1,
436 front_page_id: None,
437 opens_at: None,
438 deadline: None,
439 course_module_id: Some(course_module.id),
440 },
441 )
442 .await
443 .unwrap();
444
445 let status = complete_and_lock_chapter(tx.as_mut(), user, chapter, course)
446 .await
447 .unwrap();
448 assert_eq!(status.status, ChapterLockingStatus::CompletedAndLocked);
449 assert_eq!(status.user_id, user);
450 assert_eq!(status.chapter_id, chapter);
451
452 let retrieved_status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
453 .await
454 .unwrap();
455 assert_eq!(
456 retrieved_status,
457 Some(ChapterLockingStatus::CompletedAndLocked)
458 );
459 }
460
461 #[tokio::test]
462 async fn unlock_then_complete_chapter_updates_status() {
463 insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module);
464 let chapter = crate::chapters::insert(
465 tx.as_mut(),
466 PKeyPolicy::Generate,
467 &crate::chapters::NewChapter {
468 name: "Test Chapter".to_string(),
469 color: None,
470 course_id: course,
471 chapter_number: 1,
472 front_page_id: None,
473 opens_at: None,
474 deadline: None,
475 course_module_id: Some(course_module.id),
476 },
477 )
478 .await
479 .unwrap();
480
481 unlock_chapter(tx.as_mut(), user, chapter, course)
482 .await
483 .unwrap();
484 let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
485 .await
486 .unwrap();
487 assert_eq!(status, Some(ChapterLockingStatus::Unlocked));
488
489 complete_and_lock_chapter(tx.as_mut(), user, chapter, course)
490 .await
491 .unwrap();
492 let status = get_or_init_status(tx.as_mut(), user, chapter, Some(course), None)
493 .await
494 .unwrap();
495 assert_eq!(status, Some(ChapterLockingStatus::CompletedAndLocked));
496 }
497
498 #[tokio::test]
499 async fn get_or_init_all_for_course_returns_all_statuses() {
500 insert_data!(:tx, :user, :org, course: course);
501 let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
504 .await
505 .unwrap();
506 let base_module = all_modules
507 .into_iter()
508 .find(|m| m.order_number == 0)
509 .unwrap();
510
511 let chapter1 = crate::chapters::insert(
512 tx.as_mut(),
513 PKeyPolicy::Generate,
514 &crate::chapters::NewChapter {
515 name: "Chapter 1".to_string(),
516 color: None,
517 course_id: course,
518 chapter_number: 1,
519 front_page_id: None,
520 opens_at: None,
521 deadline: None,
522 course_module_id: Some(base_module.id),
523 },
524 )
525 .await
526 .unwrap();
527 let chapter2 = crate::chapters::insert(
528 tx.as_mut(),
529 PKeyPolicy::Generate,
530 &crate::chapters::NewChapter {
531 name: "Chapter 2".to_string(),
532 color: None,
533 course_id: course,
534 chapter_number: 2,
535 front_page_id: None,
536 opens_at: None,
537 deadline: None,
538 course_module_id: Some(base_module.id),
539 },
540 )
541 .await
542 .unwrap();
543
544 unlock_chapter(tx.as_mut(), user, chapter1, course)
545 .await
546 .unwrap();
547 complete_and_lock_chapter(tx.as_mut(), user, chapter2, course)
548 .await
549 .unwrap();
550
551 let statuses = get_or_init_all_for_course(tx.as_mut(), user, course)
552 .await
553 .unwrap();
554 assert_eq!(statuses.len(), 2);
555 assert!(
556 statuses
557 .iter()
558 .any(|s| s.chapter_id == chapter1 && s.status == ChapterLockingStatus::Unlocked)
559 );
560 assert!(
561 statuses.iter().any(|s| s.chapter_id == chapter2
562 && s.status == ChapterLockingStatus::CompletedAndLocked)
563 );
564 }
565
566 #[tokio::test]
567 async fn get_or_init_all_for_course_unlocks_first_chapter_when_all_not_unlocked_yet() {
568 insert_data!(:tx, :user, :org, course: course);
569
570 let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course)
571 .await
572 .unwrap();
573 let base_module = all_modules
574 .into_iter()
575 .find(|m| m.order_number == 0)
576 .unwrap();
577
578 let chapter1 = crate::chapters::insert(
579 tx.as_mut(),
580 PKeyPolicy::Generate,
581 &crate::chapters::NewChapter {
582 name: "Chapter 1".to_string(),
583 color: None,
584 course_id: course,
585 chapter_number: 1,
586 front_page_id: None,
587 opens_at: None,
588 deadline: None,
589 course_module_id: Some(base_module.id),
590 },
591 )
592 .await
593 .unwrap();
594
595 let chapter2 = crate::chapters::insert(
597 tx.as_mut(),
598 PKeyPolicy::Generate,
599 &crate::chapters::NewChapter {
600 name: "Chapter 2".to_string(),
601 color: None,
602 course_id: course,
603 chapter_number: 2,
604 front_page_id: None,
605 opens_at: None,
606 deadline: None,
607 course_module_id: Some(base_module.id),
608 },
609 )
610 .await
611 .unwrap();
612
613 let existing_course = crate::courses::get_course(tx.as_mut(), course)
615 .await
616 .unwrap();
617
618 crate::courses::update_course(
619 tx.as_mut(),
620 course,
621 crate::courses::CourseUpdate {
622 name: existing_course.name,
623 description: existing_course.description,
624 is_draft: existing_course.is_draft,
625 is_test_mode: existing_course.is_test_mode,
626 can_add_chatbot: existing_course.can_add_chatbot,
627 is_unlisted: existing_course.is_unlisted,
628 is_joinable_by_code_only: existing_course.is_joinable_by_code_only,
629 ask_marketing_consent: existing_course.ask_marketing_consent,
630 flagged_answers_threshold: existing_course.flagged_answers_threshold.unwrap_or(1),
631 flagged_answers_skip_manual_review_and_allow_retry: existing_course
632 .flagged_answers_skip_manual_review_and_allow_retry,
633 closed_at: existing_course.closed_at,
634 closed_additional_message: existing_course.closed_additional_message,
635 closed_course_successor_id: existing_course.closed_course_successor_id,
636 chapter_locking_enabled: true,
637 },
638 )
639 .await
640 .unwrap();
641
642 let _ = ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter1, course)
644 .await
645 .unwrap();
646 let _ = ensure_not_unlocked_yet_status(tx.as_mut(), user, chapter2, course)
647 .await
648 .unwrap();
649
650 let statuses = get_or_init_all_for_course(tx.as_mut(), user, course)
651 .await
652 .unwrap();
653
654 assert!(!statuses.is_empty());
655 assert!(
656 statuses
657 .iter()
658 .any(|s| s.chapter_id == chapter1 && s.status == ChapterLockingStatus::Unlocked)
659 );
660 }
661}