1use headless_lms_utils::{
2 ApplicationConfiguration, file_store::FileStore, language_tag_to_name::LANGUAGE_TAG_TO_NAME,
3};
4
5use crate::{
6 chapters::{Chapter, course_chapters},
7 course_modules::CourseModule,
8 pages::Page,
9 pages::{PageVisibility, get_all_by_course_id_and_visibility},
10 prelude::*,
11};
12
13pub struct CourseInfo {
14 pub id: Uuid,
15 pub is_draft: bool,
16}
17
18#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq)]
19#[cfg_attr(feature = "ts_rs", derive(TS))]
20pub struct CourseCount {
21 pub count: u32,
22}
23
24pub struct CourseContextData {
25 pub id: Uuid,
26 pub is_test_mode: bool,
27}
28
29#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
30#[cfg_attr(feature = "ts_rs", derive(TS))]
31pub struct Course {
32 pub id: Uuid,
33 pub slug: String,
34 pub created_at: DateTime<Utc>,
35 pub updated_at: DateTime<Utc>,
36 pub name: String,
37 pub description: Option<String>,
38 pub organization_id: Uuid,
39 pub deleted_at: Option<DateTime<Utc>>,
40 pub language_code: String,
41 pub copied_from: Option<Uuid>,
42 pub content_search_language: Option<String>,
43 pub course_language_group_id: Uuid,
44 pub is_draft: bool,
45 pub is_test_mode: bool,
46 pub is_unlisted: bool,
47 pub base_module_completion_requires_n_submodule_completions: i32,
48 pub can_add_chatbot: bool,
49 pub is_joinable_by_code_only: bool,
50 pub join_code: Option<String>,
51 pub ask_marketing_consent: bool,
52 pub flagged_answers_threshold: Option<i32>,
53 pub flagged_answers_skip_manual_review_and_allow_retry: bool,
54 pub closed_at: Option<DateTime<Utc>>,
55 pub closed_additional_message: Option<String>,
56 pub closed_course_successor_id: Option<Uuid>,
57 pub chapter_locking_enabled: bool,
58}
59
60#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
62#[cfg_attr(feature = "ts_rs", derive(TS))]
63pub struct CourseMaterialCourse {
64 pub id: Uuid,
65 pub slug: String,
66 pub name: String,
67 pub description: Option<String>,
68 pub organization_id: Uuid,
69 pub language_code: String,
70 pub copied_from: Option<Uuid>,
71 pub content_search_language: Option<String>,
72 pub course_language_group_id: Uuid,
73 pub is_draft: bool,
74 pub is_test_mode: bool,
75 pub is_unlisted: bool,
76 pub base_module_completion_requires_n_submodule_completions: i32,
77 pub is_joinable_by_code_only: bool,
78 pub ask_marketing_consent: bool,
79 pub closed_at: Option<DateTime<Utc>>,
80 pub closed_additional_message: Option<String>,
81 pub closed_course_successor_id: Option<Uuid>,
82 pub chapter_locking_enabled: bool,
83}
84
85impl From<Course> for CourseMaterialCourse {
86 fn from(course: Course) -> Self {
87 CourseMaterialCourse {
88 id: course.id,
89 slug: course.slug,
90 name: course.name,
91 description: course.description,
92 organization_id: course.organization_id,
93 language_code: course.language_code,
94 copied_from: course.copied_from,
95 content_search_language: course.content_search_language,
96 course_language_group_id: course.course_language_group_id,
97 is_draft: course.is_draft,
98 is_test_mode: course.is_test_mode,
99 is_unlisted: course.is_unlisted,
100 base_module_completion_requires_n_submodule_completions: course
101 .base_module_completion_requires_n_submodule_completions,
102 is_joinable_by_code_only: course.is_joinable_by_code_only,
103 ask_marketing_consent: course.ask_marketing_consent,
104 closed_at: course.closed_at,
105 closed_additional_message: course.closed_additional_message,
106 closed_course_successor_id: course.closed_course_successor_id,
107 chapter_locking_enabled: course.chapter_locking_enabled,
108 }
109 }
110}
111
112#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
114#[cfg_attr(feature = "ts_rs", derive(TS))]
115pub struct CourseLanguageVersionNavigationInfo {
116 pub course_language_group_id: Uuid,
117 pub course_id: Uuid,
118 pub language_code: String,
119 pub course_slug: String,
120 pub page_path: String,
121 pub is_draft: bool,
122 pub current_page_unavailable_in_this_language: bool,
123}
124
125impl CourseLanguageVersionNavigationInfo {
126 pub fn from_course_and_page_info(
128 course: &Course,
129 page_info: Option<&crate::page_language_groups::PageLanguageGroupNavigationInfo>,
130 ) -> Self {
131 Self {
132 course_language_group_id: course.course_language_group_id,
133 course_id: course.id,
134 language_code: course.language_code.clone(),
135 course_slug: course.slug.clone(),
136 page_path: page_info
137 .map(|p| p.page_path.clone())
138 .unwrap_or_else(|| "/".to_string()),
139 is_draft: course.is_draft,
140 current_page_unavailable_in_this_language: page_info.is_none(),
141 }
142 }
143}
144
145#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
146#[cfg_attr(feature = "ts_rs", derive(TS))]
147pub struct CourseBreadcrumbInfo {
148 pub course_id: Uuid,
149 pub course_name: String,
150 pub course_slug: String,
151 pub organization_slug: String,
152 pub organization_name: String,
153}
154
155#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
157#[cfg_attr(feature = "ts_rs", derive(TS))]
158pub struct NewCourse {
159 pub name: String,
160 pub slug: String,
161 pub organization_id: Uuid,
162 pub language_code: String,
163 pub teacher_in_charge_name: String,
165 pub teacher_in_charge_email: String,
167 pub description: String,
168 pub is_draft: bool,
169 pub is_test_mode: bool,
170 pub is_unlisted: bool,
171 pub copy_user_permissions: bool,
173 pub is_joinable_by_code_only: bool,
174 pub join_code: Option<String>,
175 pub ask_marketing_consent: bool,
176 pub flagged_answers_threshold: Option<i32>,
177 pub can_add_chatbot: bool,
178}
179
180pub async fn insert(
181 conn: &mut PgConnection,
182 pkey_policy: PKeyPolicy<Uuid>,
183 course_language_group_id: Uuid,
184 new_course: &NewCourse,
185) -> ModelResult<Uuid> {
186 let res = sqlx::query!(
187 "
188INSERT INTO courses(
189 id,
190 name,
191 description,
192 slug,
193 organization_id,
194 language_code,
195 course_language_group_id,
196 is_draft,
197 is_test_mode,
198 is_joinable_by_code_only,
199 join_code,
200 can_add_chatbot
201 )
202VALUES(
203 $1,
204 $2,
205 $3,
206 $4,
207 $5,
208 $6,
209 $7,
210 $8,
211 $9,
212 $10,
213 $11,
214 $12
215 )
216RETURNING id
217 ",
218 pkey_policy.into_uuid(),
219 new_course.name,
220 new_course.description,
221 new_course.slug,
222 new_course.organization_id,
223 new_course.language_code,
224 course_language_group_id,
225 new_course.is_draft,
226 new_course.is_test_mode,
227 new_course.is_joinable_by_code_only,
228 new_course.join_code,
229 new_course.can_add_chatbot,
230 )
231 .fetch_one(conn)
232 .await?;
233 Ok(res.id)
234}
235
236#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
237#[cfg_attr(feature = "ts_rs", derive(TS))]
238pub struct CourseStructure {
239 pub course: Course,
240 pub pages: Vec<Page>,
241 pub chapters: Vec<Chapter>,
242 pub modules: Vec<CourseModule>,
243}
244
245pub async fn all_courses(conn: &mut PgConnection) -> ModelResult<Vec<Course>> {
246 let courses = sqlx::query_as!(
247 Course,
248 r#"
249SELECT id,
250 name,
251 created_at,
252 updated_at,
253 organization_id,
254 deleted_at,
255 slug,
256 content_search_language::text,
257 language_code,
258 copied_from,
259 course_language_group_id,
260 description,
261 is_draft,
262 is_test_mode,
263 base_module_completion_requires_n_submodule_completions,
264 can_add_chatbot,
265 is_unlisted,
266 is_joinable_by_code_only,
267 join_code,
268 ask_marketing_consent,
269 flagged_answers_threshold,
270 flagged_answers_skip_manual_review_and_allow_retry,
271 closed_at,
272 closed_additional_message,
273 closed_course_successor_id,
274 chapter_locking_enabled
275FROM courses
276WHERE deleted_at IS NULL;
277"#
278 )
279 .fetch_all(conn)
280 .await?;
281 Ok(courses)
282}
283
284pub async fn all_courses_user_enrolled_to(
285 conn: &mut PgConnection,
286 user_id: Uuid,
287) -> ModelResult<Vec<Course>> {
288 let courses = sqlx::query_as!(
289 Course,
290 r#"
291SELECT id,
292 name,
293 created_at,
294 updated_at,
295 organization_id,
296 deleted_at,
297 slug,
298 content_search_language::text,
299 language_code,
300 copied_from,
301 course_language_group_id,
302 description,
303 is_draft,
304 is_test_mode,
305 is_unlisted,
306 base_module_completion_requires_n_submodule_completions,
307 can_add_chatbot,
308 is_joinable_by_code_only,
309 join_code,
310 ask_marketing_consent,
311 flagged_answers_threshold,
312 flagged_answers_skip_manual_review_and_allow_retry,
313 closed_at,
314 closed_additional_message,
315 closed_course_successor_id,
316 chapter_locking_enabled
317FROM courses
318WHERE courses.deleted_at IS NULL
319 AND id IN (
320 SELECT current_course_id
321 FROM user_course_settings
322 WHERE deleted_at IS NULL
323 AND user_id = $1
324 )
325"#,
326 user_id
327 )
328 .fetch_all(conn)
329 .await?;
330 Ok(courses)
331}
332
333pub async fn all_courses_with_roles_for_user(
334 conn: &mut PgConnection,
335 user_id: Uuid,
336) -> ModelResult<Vec<Course>> {
337 let courses = sqlx::query_as!(
338 Course,
339 r#"
340SELECT id,
341 name,
342 created_at,
343 updated_at,
344 organization_id,
345 deleted_at,
346 slug,
347 content_search_language::text,
348 language_code,
349 copied_from,
350 course_language_group_id,
351 description,
352 is_draft,
353 is_test_mode,
354 can_add_chatbot,
355 is_unlisted,
356 base_module_completion_requires_n_submodule_completions,
357 is_joinable_by_code_only,
358 join_code,
359 ask_marketing_consent,
360 flagged_answers_threshold,
361 flagged_answers_skip_manual_review_and_allow_retry,
362 closed_at,
363 closed_additional_message,
364 closed_course_successor_id,
365 chapter_locking_enabled
366FROM courses
367WHERE courses.deleted_at IS NULL
368 AND (
369 id IN (
370 SELECT course_id
371 FROM roles
372 WHERE deleted_at IS NULL
373 AND user_id = $1
374 AND course_id IS NOT NULL
375 )
376 OR (
377 id IN (
378 SELECT ci.course_id
379 FROM course_instances ci
380 JOIN ROLES r ON r.course_instance_id = ci.id
381 WHERE r.user_id = $1
382 AND r.deleted_at IS NULL
383 AND ci.deleted_at IS NULL
384 )
385 )
386 ) "#,
387 user_id
388 )
389 .fetch_all(conn)
390 .await?;
391 Ok(courses)
392}
393
394pub async fn get_all_language_versions_of_course(
395 conn: &mut PgConnection,
396 course: &Course,
397) -> ModelResult<Vec<Course>> {
398 let courses = sqlx::query_as!(
399 Course,
400 "
401SELECT id,
402 name,
403 created_at,
404 updated_at,
405 organization_id,
406 deleted_at,
407 slug,
408 content_search_language::text,
409 language_code,
410 copied_from,
411 course_language_group_id,
412 description,
413 is_draft,
414 is_test_mode,
415 base_module_completion_requires_n_submodule_completions,
416 can_add_chatbot,
417 is_unlisted,
418 is_joinable_by_code_only,
419 join_code,
420 ask_marketing_consent,
421 flagged_answers_threshold,
422 flagged_answers_skip_manual_review_and_allow_retry,
423 closed_at,
424 closed_additional_message,
425 closed_course_successor_id,
426 chapter_locking_enabled
427FROM courses
428WHERE course_language_group_id = $1
429AND deleted_at IS NULL
430 ",
431 course.course_language_group_id,
432 )
433 .fetch_all(conn)
434 .await?;
435 Ok(courses)
436}
437
438pub async fn get_active_courses_for_organization(
439 conn: &mut PgConnection,
440 organization_id: Uuid,
441 pagination: Pagination,
442) -> ModelResult<Vec<Course>> {
443 let course_instances = sqlx::query_as!(
444 Course,
445 r#"
446SELECT
447 DISTINCT(c.id),
448 c.name,
449 c.created_at,
450 c.updated_at,
451 c.organization_id,
452 c.deleted_at,
453 c.slug,
454 c.content_search_language::text,
455 c.language_code,
456 c.copied_from,
457 c.course_language_group_id,
458 c.description,
459 c.is_draft,
460 c.is_test_mode,
461 c.base_module_completion_requires_n_submodule_completions,
462 c.can_add_chatbot,
463 c.is_unlisted,
464 c.is_joinable_by_code_only,
465 c.join_code,
466 c.ask_marketing_consent,
467 c.flagged_answers_threshold,
468 c.flagged_answers_skip_manual_review_and_allow_retry,
469 c.closed_at,
470 c.closed_additional_message,
471 c.closed_course_successor_id,
472 c.chapter_locking_enabled
473FROM courses as c
474 LEFT JOIN course_instances as ci on c.id = ci.course_id
475WHERE
476 c.organization_id = $1 AND
477 ci.starts_at < NOW() AND ci.ends_at > NOW() AND
478 c.deleted_at IS NULL AND ci.deleted_at IS NULL
479 LIMIT $2 OFFSET $3;
480 "#,
481 organization_id,
482 pagination.limit(),
483 pagination.offset()
484 )
485 .fetch_all(conn)
486 .await?;
487 Ok(course_instances)
488}
489
490pub async fn get_active_courses_for_organization_count(
491 conn: &mut PgConnection,
492 organization_id: Uuid,
493) -> ModelResult<CourseCount> {
494 let result = sqlx::query!(
495 r#"
496SELECT
497 COUNT(DISTINCT c.id) as count
498FROM courses as c
499 LEFT JOIN course_instances as ci on c.id = ci.course_id
500WHERE
501 c.organization_id = $1 AND
502 ci.starts_at < NOW() AND ci.ends_at > NOW() AND
503 c.deleted_at IS NULL AND ci.deleted_at IS NULL;
504 "#,
505 organization_id
506 )
507 .fetch_one(conn)
508 .await?;
509 Ok(CourseCount {
510 count: result.count.unwrap_or_default().try_into()?,
511 })
512}
513
514pub async fn get_course(conn: &mut PgConnection, course_id: Uuid) -> ModelResult<Course> {
515 let course = sqlx::query_as!(
516 Course,
517 r#"
518SELECT id,
519 name,
520 created_at,
521 updated_at,
522 organization_id,
523 deleted_at,
524 slug,
525 content_search_language::text,
526 language_code,
527 copied_from,
528 course_language_group_id,
529 description,
530 is_draft,
531 is_test_mode,
532 can_add_chatbot,
533 is_unlisted,
534 base_module_completion_requires_n_submodule_completions,
535 is_joinable_by_code_only,
536 join_code,
537 ask_marketing_consent,
538 flagged_answers_threshold,
539 flagged_answers_skip_manual_review_and_allow_retry,
540 closed_at,
541 closed_additional_message,
542 closed_course_successor_id,
543 chapter_locking_enabled
544FROM courses
545WHERE id = $1;
546 "#,
547 course_id
548 )
549 .fetch_one(conn)
550 .await?;
551 Ok(course)
552}
553
554pub async fn get_course_breadcrumb_info(
555 conn: &mut PgConnection,
556 course_id: Uuid,
557) -> ModelResult<CourseBreadcrumbInfo> {
558 let res = sqlx::query_as!(
559 CourseBreadcrumbInfo,
560 r#"
561SELECT courses.id as course_id,
562 courses.name as course_name,
563 courses.slug as course_slug,
564 organizations.slug as organization_slug,
565 organizations.name as organization_name
566FROM courses
567 JOIN organizations ON (courses.organization_id = organizations.id)
568WHERE courses.id = $1;
569 "#,
570 course_id
571 )
572 .fetch_one(conn)
573 .await?;
574 Ok(res)
575}
576
577pub async fn get_nondeleted_course_id_by_slug(
578 conn: &mut PgConnection,
579 slug: &str,
580) -> ModelResult<CourseContextData> {
581 let data = sqlx::query_as!(
582 CourseContextData,
583 "SELECT id, is_test_mode FROM courses WHERE slug = $1 AND deleted_at IS NULL",
584 slug
585 )
586 .fetch_one(conn)
587 .await?;
588 Ok(data)
589}
590
591pub async fn get_organization_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Uuid> {
592 let organization_id = sqlx::query!("SELECT organization_id FROM courses WHERE id = $1", id)
593 .fetch_one(conn)
594 .await?
595 .organization_id;
596 Ok(organization_id)
597}
598
599pub async fn get_course_structure(
601 conn: &mut PgConnection,
602 course_id: Uuid,
603 file_store: &dyn FileStore,
604 app_conf: &ApplicationConfiguration,
605) -> ModelResult<CourseStructure> {
606 let course = get_course(conn, course_id).await?;
607 let pages = get_all_by_course_id_and_visibility(conn, course_id, PageVisibility::Any).await?;
608 let chapters = course_chapters(conn, course_id)
609 .await?
610 .iter()
611 .map(|chapter| Chapter::from_database_chapter(chapter, file_store, app_conf))
612 .collect();
613 let modules = crate::course_modules::get_by_course_id(conn, course_id).await?;
614 Ok(CourseStructure {
615 course,
616 pages,
617 chapters,
618 modules,
619 })
620}
621
622pub async fn organization_courses_visible_to_user_paginated(
623 conn: &mut PgConnection,
624 organization_id: Uuid,
625 user: Option<Uuid>,
626 pagination: Pagination,
627) -> ModelResult<Vec<Course>> {
628 let courses = sqlx::query_as!(
629 Course,
630 r#"
631SELECT courses.id,
632 courses.name,
633 courses.created_at,
634 courses.updated_at,
635 courses.organization_id,
636 courses.deleted_at,
637 courses.slug,
638 courses.content_search_language::text,
639 courses.language_code,
640 courses.copied_from,
641 courses.course_language_group_id,
642 courses.description,
643 courses.is_draft,
644 courses.is_test_mode,
645 base_module_completion_requires_n_submodule_completions,
646 can_add_chatbot,
647 courses.is_unlisted,
648 courses.is_joinable_by_code_only,
649 courses.join_code,
650 courses.ask_marketing_consent,
651 courses.flagged_answers_threshold,
652 courses.flagged_answers_skip_manual_review_and_allow_retry,
653 courses.closed_at,
654 courses.closed_additional_message,
655 courses.closed_course_successor_id,
656 courses.chapter_locking_enabled
657FROM courses
658WHERE courses.organization_id = $1
659 AND (
660 (
661 courses.is_draft IS FALSE
662 AND courses.is_unlisted IS FALSE
663 )
664 OR EXISTS (
665 SELECT id
666 FROM roles
667 WHERE user_id = $2
668 AND (
669 course_id = courses.id
670 OR roles.organization_id = courses.organization_id
671 OR roles.is_global IS TRUE
672 )
673 )
674 )
675 AND courses.deleted_at IS NULL
676ORDER BY courses.name
677LIMIT $3 OFFSET $4;
678"#,
679 organization_id,
680 user,
681 pagination.limit(),
682 pagination.offset()
683 )
684 .fetch_all(conn)
685 .await?;
686 Ok(courses)
687}
688
689pub async fn organization_course_count(
690 conn: &mut PgConnection,
691 organization_id: Uuid,
692) -> ModelResult<CourseCount> {
693 let course_count = sqlx::query!(
694 r#"
695SELECT
696 COUNT(DISTINCT id) as count
697FROM courses
698WHERE organization_id = $1
699 AND deleted_at IS NULL;
700 "#,
701 organization_id,
702 )
703 .fetch_one(conn)
704 .await?;
705 Ok(CourseCount {
706 count: course_count.count.unwrap_or_default().try_into()?,
707 })
708}
709#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
711#[cfg_attr(feature = "ts_rs", derive(TS))]
712pub struct CourseUpdate {
713 pub name: String,
714 pub description: Option<String>,
715 pub is_draft: bool,
716 pub is_test_mode: bool,
717 pub can_add_chatbot: bool,
718 pub is_unlisted: bool,
719 pub is_joinable_by_code_only: bool,
720 pub ask_marketing_consent: bool,
721 pub flagged_answers_threshold: i32,
722 pub flagged_answers_skip_manual_review_and_allow_retry: bool,
723 pub closed_at: Option<DateTime<Utc>>,
724 pub closed_additional_message: Option<String>,
725 pub closed_course_successor_id: Option<Uuid>,
726 pub chapter_locking_enabled: bool,
727}
728
729pub async fn update_course(
730 conn: &mut PgConnection,
731 course_id: Uuid,
732 course_update: CourseUpdate,
733) -> ModelResult<Course> {
734 let res = sqlx::query_as!(
735 Course,
736 r#"
737UPDATE courses
738SET name = $1,
739 description = $2,
740 is_draft = $3,
741 is_test_mode = $4,
742 can_add_chatbot = $5,
743 is_unlisted = $6,
744 is_joinable_by_code_only = $7,
745 ask_marketing_consent = $8,
746 flagged_answers_threshold = $9,
747 flagged_answers_skip_manual_review_and_allow_retry = $10,
748 closed_at = $11,
749 closed_additional_message = $12,
750 closed_course_successor_id = $13,
751 chapter_locking_enabled = $14
752WHERE id = $15
753 AND deleted_at IS NULL
754RETURNING id,
755 name,
756 created_at,
757 updated_at,
758 organization_id,
759 deleted_at,
760 slug,
761 content_search_language::text,
762 language_code,
763 copied_from,
764 course_language_group_id,
765 description,
766 is_draft,
767 is_test_mode,
768 can_add_chatbot,
769 is_unlisted,
770 base_module_completion_requires_n_submodule_completions,
771 is_joinable_by_code_only,
772 join_code,
773 ask_marketing_consent,
774 flagged_answers_threshold,
775 flagged_answers_skip_manual_review_and_allow_retry,
776 closed_at,
777 closed_additional_message,
778 closed_course_successor_id,
779 chapter_locking_enabled
780 "#,
781 course_update.name,
782 course_update.description,
783 course_update.is_draft,
784 course_update.is_test_mode,
785 course_update.can_add_chatbot,
786 course_update.is_unlisted,
787 course_update.is_joinable_by_code_only,
788 course_update.ask_marketing_consent,
789 course_update.flagged_answers_threshold,
790 course_update.flagged_answers_skip_manual_review_and_allow_retry,
791 course_update.closed_at,
792 course_update.closed_additional_message,
793 course_update.closed_course_successor_id,
794 course_update.chapter_locking_enabled,
795 course_id
796 )
797 .fetch_one(conn)
798 .await?;
799 Ok(res)
800}
801
802pub async fn update_course_base_module_completion_count_requirement(
803 conn: &mut PgConnection,
804 id: Uuid,
805 base_module_completion_requires_n_submodule_completions: i32,
806) -> ModelResult<bool> {
807 let res = sqlx::query!(
808 "
809UPDATE courses
810SET base_module_completion_requires_n_submodule_completions = $1
811WHERE id = $2
812 AND deleted_at IS NULL
813 ",
814 base_module_completion_requires_n_submodule_completions,
815 id,
816 )
817 .execute(conn)
818 .await?;
819 Ok(res.rows_affected() > 0)
820}
821
822pub async fn delete_course(conn: &mut PgConnection, course_id: Uuid) -> ModelResult<Course> {
823 let deleted = sqlx::query_as!(
824 Course,
825 r#"
826UPDATE courses
827SET deleted_at = now()
828WHERE id = $1
829AND deleted_at IS NULL
830RETURNING id,
831 name,
832 created_at,
833 updated_at,
834 organization_id,
835 deleted_at,
836 slug,
837 content_search_language::text,
838 language_code,
839 copied_from,
840 course_language_group_id,
841 description,
842 is_draft,
843 is_test_mode,
844 can_add_chatbot,
845 is_unlisted,
846 base_module_completion_requires_n_submodule_completions,
847 is_joinable_by_code_only,
848 join_code,
849 ask_marketing_consent,
850 flagged_answers_threshold,
851 flagged_answers_skip_manual_review_and_allow_retry,
852 closed_at,
853 closed_additional_message,
854 closed_course_successor_id,
855 chapter_locking_enabled
856 "#,
857 course_id
858 )
859 .fetch_one(conn)
860 .await?;
861 Ok(deleted)
862}
863
864pub async fn get_course_by_slug(conn: &mut PgConnection, course_slug: &str) -> ModelResult<Course> {
865 let course = sqlx::query_as!(
866 Course,
867 "
868SELECT id,
869 name,
870 created_at,
871 updated_at,
872 organization_id,
873 deleted_at,
874 slug,
875 content_search_language::text,
876 language_code,
877 copied_from,
878 course_language_group_id,
879 description,
880 is_draft,
881 is_test_mode,
882 can_add_chatbot,
883 is_unlisted,
884 base_module_completion_requires_n_submodule_completions,
885 is_joinable_by_code_only,
886 join_code,
887 ask_marketing_consent,
888 flagged_answers_threshold,
889 flagged_answers_skip_manual_review_and_allow_retry,
890 closed_at,
891 closed_additional_message,
892 closed_course_successor_id,
893 chapter_locking_enabled
894FROM courses
895WHERE slug = $1
896 AND deleted_at IS NULL
897",
898 course_slug,
899 )
900 .fetch_one(conn)
901 .await?;
902 Ok(course)
903}
904
905pub async fn get_cfgname_by_tag(
906 conn: &mut PgConnection,
907 ietf_language_tag: String,
908) -> ModelResult<String> {
909 let tag = ietf_language_tag
910 .split('-')
911 .next()
912 .unwrap_or_else(|| &ietf_language_tag[..]);
913
914 let lang_name = LANGUAGE_TAG_TO_NAME.get(&tag);
915
916 let name = sqlx::query!(
917 "SELECT cfgname::text FROM pg_ts_config WHERE cfgname = $1",
918 lang_name
919 )
920 .fetch_optional(conn)
921 .await?;
922
923 let res = name
924 .and_then(|n| n.cfgname)
925 .unwrap_or_else(|| "simple".to_string());
926
927 Ok(res)
928}
929
930pub async fn is_draft(conn: &mut PgConnection, id: Uuid) -> ModelResult<bool> {
931 let res = sqlx::query!(
932 "
933SELECT is_draft
934FROM courses
935WHERE id = $1
936",
937 id
938 )
939 .fetch_one(conn)
940 .await?;
941 Ok(res.is_draft)
942}
943
944pub async fn is_joinable_by_code_only(conn: &mut PgConnection, id: Uuid) -> ModelResult<bool> {
945 let res = sqlx::query!(
946 "
947SELECT is_joinable_by_code_only
948FROM courses
949WHERE id = $1
950",
951 id
952 )
953 .fetch_one(conn)
954 .await?;
955 Ok(res.is_joinable_by_code_only)
956}
957
958pub(crate) async fn get_by_ids(
959 conn: &mut PgConnection,
960 course_ids: &[Uuid],
961) -> ModelResult<Vec<Course>> {
962 let courses = sqlx::query_as!(
963 Course,
964 "
965SELECT id,
966 name,
967 created_at,
968 updated_at,
969 organization_id,
970 deleted_at,
971 slug,
972 content_search_language::text,
973 language_code,
974 copied_from,
975 course_language_group_id,
976 description,
977 is_draft,
978 is_test_mode,
979 can_add_chatbot,
980 is_unlisted,
981 base_module_completion_requires_n_submodule_completions,
982 is_joinable_by_code_only,
983 join_code,
984 ask_marketing_consent,
985 flagged_answers_threshold,
986 flagged_answers_skip_manual_review_and_allow_retry,
987 closed_at,
988 closed_additional_message,
989 closed_course_successor_id,
990 chapter_locking_enabled
991FROM courses
992WHERE id IN (SELECT * FROM UNNEST($1::uuid[]))
993 AND deleted_at IS NULL
994 ",
995 course_ids
996 )
997 .fetch_all(conn)
998 .await?;
999 Ok(courses)
1000}
1001
1002pub async fn get_by_organization_id(
1003 conn: &mut PgConnection,
1004 organization_id: Uuid,
1005) -> ModelResult<Vec<Course>> {
1006 let courses = sqlx::query_as!(
1007 Course,
1008 r#"
1009SELECT id,
1010 name,
1011 created_at,
1012 updated_at,
1013 organization_id,
1014 deleted_at,
1015 slug,
1016 content_search_language::text,
1017 language_code,
1018 copied_from,
1019 course_language_group_id,
1020 description,
1021 is_draft,
1022 is_test_mode,
1023 can_add_chatbot,
1024 is_unlisted,
1025 base_module_completion_requires_n_submodule_completions,
1026 is_joinable_by_code_only,
1027 join_code,
1028 ask_marketing_consent,
1029 flagged_answers_threshold,
1030 flagged_answers_skip_manual_review_and_allow_retry,
1031 closed_at,
1032 closed_additional_message,
1033 closed_course_successor_id,
1034 chapter_locking_enabled
1035FROM courses
1036WHERE organization_id = $1
1037 AND deleted_at IS NULL
1038ORDER BY name
1039 "#,
1040 organization_id
1041 )
1042 .fetch_all(conn)
1043 .await?;
1044 Ok(courses)
1045}
1046
1047pub async fn set_join_code_for_course(
1048 conn: &mut PgConnection,
1049 course_id: Uuid,
1050 join_code: String,
1051) -> ModelResult<()> {
1052 sqlx::query!(
1053 "
1054UPDATE courses
1055SET join_code = $2
1056WHERE id = $1
1057",
1058 course_id,
1059 join_code
1060 )
1061 .execute(conn)
1062 .await?;
1063 Ok(())
1064}
1065
1066pub async fn get_course_with_join_code(
1067 conn: &mut PgConnection,
1068 join_code: String,
1069) -> ModelResult<Course> {
1070 let course = sqlx::query_as!(
1071 Course,
1072 r#"
1073SELECT id,
1074 name,
1075 created_at,
1076 updated_at,
1077 organization_id,
1078 deleted_at,
1079 slug,
1080 content_search_language::text,
1081 language_code,
1082 copied_from,
1083 course_language_group_id,
1084 description,
1085 is_draft,
1086 is_test_mode,
1087 can_add_chatbot,
1088 is_unlisted,
1089 base_module_completion_requires_n_submodule_completions,
1090 is_joinable_by_code_only,
1091 join_code,
1092 ask_marketing_consent,
1093 flagged_answers_threshold,
1094 flagged_answers_skip_manual_review_and_allow_retry,
1095 closed_at,
1096 closed_additional_message,
1097 closed_course_successor_id,
1098 chapter_locking_enabled
1099FROM courses
1100WHERE join_code = $1
1101 AND deleted_at IS NULL;
1102 "#,
1103 join_code,
1104 )
1105 .fetch_one(conn)
1106 .await?;
1107 Ok(course)
1108}
1109
1110#[cfg(test)]
1111mod test {
1112 use super::*;
1113 use crate::{course_language_groups, courses, test_helper::*};
1114
1115 mod language_code_validation {
1116 use super::*;
1117
1118 #[tokio::test]
1119 async fn allows_valid_language_code() {
1120 insert_data!(:tx, user: _user, :org);
1121 let course_language_group_id = course_language_groups::insert(
1122 tx.as_mut(),
1123 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1124 "test-clg-allows-valid",
1125 )
1126 .await
1127 .unwrap();
1128 let new_course = create_new_course(org, "en-US");
1129 let res = courses::insert(
1130 tx.as_mut(),
1131 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1132 course_language_group_id,
1133 &new_course,
1134 )
1135 .await;
1136 assert!(res.is_ok());
1137 }
1138
1139 #[tokio::test]
1140 async fn disallows_empty_language_code() {
1141 insert_data!(:tx, user: _user, :org);
1142 let course_language_group_id = course_language_groups::insert(
1143 tx.as_mut(),
1144 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1145 "test-clg-disallows-empty",
1146 )
1147 .await
1148 .unwrap();
1149 let new_course = create_new_course(org, "");
1150 let res = courses::insert(
1151 tx.as_mut(),
1152 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1153 course_language_group_id,
1154 &new_course,
1155 )
1156 .await;
1157 assert!(res.is_err());
1158 }
1159
1160 #[tokio::test]
1161 async fn disallows_wrong_case_language_code() {
1162 insert_data!(:tx, user: _user, :org);
1163 let course_language_group_id = course_language_groups::insert(
1164 tx.as_mut(),
1165 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1166 "test-clg-disallows-wrong-case",
1167 )
1168 .await
1169 .unwrap();
1170 let new_course = create_new_course(org, "en-us");
1171 let res = courses::insert(
1172 tx.as_mut(),
1173 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1174 course_language_group_id,
1175 &new_course,
1176 )
1177 .await;
1178 assert!(res.is_err());
1179 }
1180
1181 #[tokio::test]
1182 async fn disallows_underscore_in_language_code() {
1183 insert_data!(:tx, user: _user, :org);
1184 let course_language_group_id = course_language_groups::insert(
1185 tx.as_mut(),
1186 PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1187 "test-clg-disallows-underscore",
1188 )
1189 .await
1190 .unwrap();
1191 let new_course = create_new_course(org, "en_US");
1192 let res = courses::insert(
1193 tx.as_mut(),
1194 PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1195 course_language_group_id,
1196 &new_course,
1197 )
1198 .await;
1199 assert!(res.is_err());
1200 }
1201
1202 fn create_new_course(organization_id: Uuid, language_code: &str) -> NewCourse {
1203 NewCourse {
1204 name: "".to_string(),
1205 slug: "".to_string(),
1206 organization_id,
1207 language_code: language_code.to_string(),
1208 teacher_in_charge_name: "teacher".to_string(),
1209 teacher_in_charge_email: "teacher@example.com".to_string(),
1210 description: "description".to_string(),
1211 is_draft: false,
1212 is_test_mode: false,
1213 is_unlisted: false,
1214 copy_user_permissions: false,
1215 is_joinable_by_code_only: false,
1216 join_code: None,
1217 ask_marketing_consent: false,
1218 flagged_answers_threshold: Some(3),
1219 can_add_chatbot: false,
1220 }
1221 }
1222 }
1223}