headless_lms_models/
courses.rs

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/** A subset of the `Course` struct that contains the fields that are allowed to be shown to all students on the course materials. */
61#[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/** All the necessary info that can be used to switch the user's browser to a different language version of the course. */
113#[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    /// Creates a new `CourseLanguageVersionNavigationInfo` from a course and page language group navigation info.
127    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/// Represents the subset of page fields that are required to create a new course.
156#[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    /// Name of the teacher who is responsible for the course. Must be a valid name.
164    pub teacher_in_charge_name: String,
165    /// Email of the teacher who is responsible for the course. Must be a valid email.
166    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    /// If true, copies all user permissions from the original course to the new one.
172    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
599/// Gets full course structure including all the pages.
600pub 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// Represents the subset of page fields that one is allowed to update in a course
710#[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}