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#[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 pub teacher_in_charge_name: String,
75 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 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
475pub 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#[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}