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