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