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