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