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