Skip to main content

headless_lms_models/
courses.rs

1use std::collections::HashMap;
2
3use crate::{
4    chapters::{Chapter, get_course_chapters},
5    course_audiences::{CourseAudience, EditCourseAudience, upsert_course_audiences},
6    course_instances::CourseInstance,
7    course_modules::{CourseAuditingModuleUpdate, CourseModule},
8    course_prerequisites::{
9        CoursePrerequisite, EditCoursePrerequisite, upsert_course_prerequisites,
10    },
11    organizations::DatabaseOrganization,
12    pages::{Page, PageVisibility, get_all_by_course_id_and_visibility},
13    prelude::*,
14};
15use headless_lms_utils::{
16    azure_embedding::create_embeddings, file_store::FileStore,
17    language_tag_to_name::LANGUAGE_TAG_TO_NAME, strings::non_empty_trimmed,
18};
19use itertools::multiunzip;
20use pgvector::Vector;
21use utoipa::ToSchema;
22pub struct CourseInfo {
23    pub id: Uuid,
24    pub is_draft: bool,
25}
26
27#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
28
29pub struct CourseCount {
30    pub count: u32,
31}
32
33pub struct CourseContextData {
34    pub id: Uuid,
35    pub is_test_mode: bool,
36}
37
38#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
39pub struct CourseMetadata {
40    course_description: Option<String>,
41    course_audiences: Vec<CourseAudience>,
42    course_prerequisites: Vec<CoursePrerequisite>,
43    course_updated_at: DateTime<Utc>,
44}
45
46#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
47pub struct CourseMetadataUpdate {
48    course_description: Option<String>,
49    course_audiences: Vec<EditCourseAudience>,
50    course_prerequisites: Vec<EditCoursePrerequisite>,
51}
52
53#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
54pub struct CompleteCourseMetadata {
55    course: Course,
56    course_instances: Vec<CourseInstance>,
57    default_module: CourseModule,
58    course_prerequisites: Vec<CoursePrerequisite>,
59    course_audiences: Vec<CourseAudience>,
60    course_organization: DatabaseOrganization,
61}
62
63/// The AI policy a teacher has selected for a course. Drives which variant of the student-facing
64/// AI usage notice is shown; `NotSet` (the default) keeps the generic default message.
65#[derive(
66    Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Default, sqlx::Type, ToSchema,
67)]
68#[sqlx(type_name = "course_ai_policy", rename_all = "snake_case")]
69pub enum CourseAiPolicy {
70    /// No policy selected; the notice shows the generic default message.
71    #[default]
72    NotSet,
73    /// AI is not allowed at any point.
74    NoAi,
75    /// AI may be used for planning (brainstorming/outlining) but not in the final work.
76    PlanningOnly,
77    /// AI may be used for specific tasks only, with disclosure.
78    Limited,
79    /// AI may be used freely; the student directs it and discloses their use.
80    FullUse,
81    /// AI use is expected or mandatory.
82    Required,
83}
84
85#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
86
87pub struct Course {
88    pub id: Uuid,
89    pub slug: String,
90    pub created_at: DateTime<Utc>,
91    pub updated_at: DateTime<Utc>,
92    pub name: String,
93    pub description: Option<String>,
94    pub organization_id: Uuid,
95    pub deleted_at: Option<DateTime<Utc>>,
96    pub language_code: String,
97    pub copied_from: Option<Uuid>,
98    pub content_search_language: Option<String>,
99    pub course_language_group_id: Uuid,
100    pub is_draft: bool,
101    pub is_test_mode: bool,
102    pub is_unlisted: bool,
103    pub base_module_completion_requires_n_submodule_completions: i32,
104    pub can_add_chatbot: bool,
105    pub is_joinable_by_code_only: bool,
106    pub join_code: Option<String>,
107    pub ask_marketing_consent: bool,
108    pub flagged_answers_threshold: Option<i32>,
109    pub flagged_answers_skip_manual_review_and_allow_retry: bool,
110    pub closed_at: Option<DateTime<Utc>>,
111    pub closed_additional_message: Option<String>,
112    pub closed_course_successor_id: Option<Uuid>,
113    pub chapter_locking_enabled: bool,
114    pub cheater_detection_enabled: bool,
115    pub ai_policy: CourseAiPolicy,
116    pub course_material_ai_instructions: Option<bool>,
117}
118
119/** A subset of the `Course` struct that contains the fields that are allowed to be shown to all students on the course materials. */
120#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
121
122pub struct CourseMaterialCourse {
123    pub id: Uuid,
124    pub slug: String,
125    pub name: String,
126    pub description: Option<String>,
127    pub organization_id: Uuid,
128    pub language_code: String,
129    pub copied_from: Option<Uuid>,
130    pub content_search_language: Option<String>,
131    pub course_language_group_id: Uuid,
132    pub is_draft: bool,
133    pub is_test_mode: bool,
134    pub is_unlisted: bool,
135    pub base_module_completion_requires_n_submodule_completions: i32,
136    pub is_joinable_by_code_only: bool,
137    pub ask_marketing_consent: bool,
138    pub closed_at: Option<DateTime<Utc>>,
139    pub closed_additional_message: Option<String>,
140    pub closed_course_successor_id: Option<Uuid>,
141    pub chapter_locking_enabled: bool,
142    pub ai_policy: CourseAiPolicy,
143    pub course_material_ai_instructions: Option<bool>,
144}
145
146impl From<Course> for CourseMaterialCourse {
147    fn from(course: Course) -> Self {
148        CourseMaterialCourse {
149            id: course.id,
150            slug: course.slug,
151            name: course.name,
152            description: course.description,
153            organization_id: course.organization_id,
154            language_code: course.language_code,
155            copied_from: course.copied_from,
156            content_search_language: course.content_search_language,
157            course_language_group_id: course.course_language_group_id,
158            is_draft: course.is_draft,
159            is_test_mode: course.is_test_mode,
160            is_unlisted: course.is_unlisted,
161            base_module_completion_requires_n_submodule_completions: course
162                .base_module_completion_requires_n_submodule_completions,
163            is_joinable_by_code_only: course.is_joinable_by_code_only,
164            ask_marketing_consent: course.ask_marketing_consent,
165            closed_at: course.closed_at,
166            closed_additional_message: course.closed_additional_message,
167            closed_course_successor_id: course.closed_course_successor_id,
168            chapter_locking_enabled: course.chapter_locking_enabled,
169            ai_policy: course.ai_policy,
170            course_material_ai_instructions: course.course_material_ai_instructions,
171        }
172    }
173}
174
175/** All the necessary info that can be used to switch the user's browser to a different language version of the course. */
176#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
177
178pub struct CourseLanguageVersionNavigationInfo {
179    pub course_language_group_id: Uuid,
180    pub course_id: Uuid,
181    pub language_code: String,
182    pub course_slug: String,
183    pub page_path: String,
184    pub is_draft: bool,
185    pub current_page_unavailable_in_this_language: bool,
186}
187
188impl CourseLanguageVersionNavigationInfo {
189    /// Creates a new `CourseLanguageVersionNavigationInfo` from a course and page language group navigation info.
190    pub fn from_course_and_page_info(
191        course: &Course,
192        page_info: Option<&crate::page_language_groups::PageLanguageGroupNavigationInfo>,
193    ) -> Self {
194        Self {
195            course_language_group_id: course.course_language_group_id,
196            course_id: course.id,
197            language_code: course.language_code.clone(),
198            course_slug: course.slug.clone(),
199            page_path: page_info
200                .map(|p| p.page_path.clone())
201                .unwrap_or_else(|| "/".to_string()),
202            is_draft: course.is_draft,
203            current_page_unavailable_in_this_language: page_info.is_none(),
204        }
205    }
206}
207
208#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
209
210pub struct CourseBreadcrumbInfo {
211    pub course_id: Uuid,
212    pub course_name: String,
213    pub course_slug: String,
214    pub organization_slug: String,
215    pub organization_name: String,
216}
217
218/// Represents the subset of page fields that are required to create a new course.
219#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
220
221pub struct NewCourse {
222    pub name: String,
223    pub slug: String,
224    pub organization_id: Uuid,
225    pub language_code: String,
226    /// Name of the teacher who is responsible for the course. Must be a valid name.
227    pub teacher_in_charge_name: String,
228    /// Email of the teacher who is responsible for the course. Must be a valid email.
229    pub teacher_in_charge_email: String,
230    pub description: String,
231    pub is_draft: bool,
232    pub is_test_mode: bool,
233    pub is_unlisted: bool,
234    /// If true, copies all user permissions from the original course to the new one.
235    pub copy_user_permissions: bool,
236    pub is_joinable_by_code_only: bool,
237    pub join_code: Option<String>,
238    pub ask_marketing_consent: bool,
239    pub flagged_answers_threshold: Option<i32>,
240    pub can_add_chatbot: bool,
241}
242
243#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
244
245pub struct CourseAuditingData {
246    pub id: Uuid,
247    pub name: String,
248    pub slug: String,
249    pub created_at: DateTime<Utc>,
250    pub updated_at: DateTime<Utc>,
251    pub description: Option<String>,
252    pub closed_at: Option<DateTime<Utc>>,
253    pub closed_additional_message: Option<String>,
254    pub closed_course_successor_id: Option<Uuid>,
255    pub organization_id: Uuid,
256    pub organization_name: String,
257    pub organization_slug: String,
258    pub prerequisites: Vec<EditCoursePrerequisite>,
259    pub audiences: Vec<EditCourseAudience>,
260    pub modules: Vec<CourseModule>,
261}
262
263#[derive(Debug, Deserialize, ToSchema)]
264
265pub struct CourseAuditingDataUpdate {
266    pub description: Option<String>,
267    pub closed_at: Option<DateTime<Utc>>,
268    pub closed_additional_message: Option<String>,
269    pub closed_course_successor_id: Option<Uuid>,
270    pub prerequisites: Vec<EditCoursePrerequisite>,
271    pub audiences: Vec<EditCourseAudience>,
272    pub modules: Vec<CourseAuditingModuleUpdate>,
273}
274
275pub async fn insert(
276    conn: &mut PgConnection,
277    app_config: &ApplicationConfiguration,
278    pkey_policy: PKeyPolicy<Uuid>,
279    course_language_group_id: Uuid,
280    new_course: &NewCourse,
281) -> ModelResult<Uuid> {
282    let mut tx = conn.begin().await?;
283
284    let name = non_empty_trimmed(&new_course.name).unwrap_or(&new_course.name);
285    let description = non_empty_trimmed(&new_course.description);
286
287    let res = sqlx::query!(
288        "
289INSERT INTO courses(
290    id,
291    name,
292    description,
293    slug,
294    organization_id,
295    language_code,
296    course_language_group_id,
297    is_draft,
298    is_test_mode,
299    is_joinable_by_code_only,
300    join_code,
301    can_add_chatbot
302  )
303VALUES(
304    $1,
305    $2,
306    $3,
307    $4,
308    $5,
309    $6,
310    $7,
311    $8,
312    $9,
313    $10,
314    $11,
315    $12
316  )
317RETURNING id
318        ",
319        pkey_policy.into_uuid(),
320        name,
321        description,
322        new_course.slug,
323        new_course.organization_id,
324        new_course.language_code,
325        course_language_group_id,
326        new_course.is_draft,
327        new_course.is_test_mode,
328        new_course.is_joinable_by_code_only,
329        new_course.join_code,
330        new_course.can_add_chatbot,
331    )
332    .fetch_one(&mut *tx)
333    .await?;
334    if !app_config.disable_embedding_vector_creation_when_seeding {
335        update_course_embeddings(
336            &mut tx,
337            app_config,
338            res.id,
339            non_empty_trimmed(&new_course.name),
340            description,
341        )
342        .await?
343    }
344    tx.commit().await?;
345    Ok(res.id)
346}
347
348#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
349
350pub struct CourseStructure {
351    pub course: Course,
352    pub pages: Vec<Page>,
353    pub chapters: Vec<Chapter>,
354    pub modules: Vec<CourseModule>,
355}
356
357pub async fn all_courses(conn: &mut PgConnection) -> ModelResult<Vec<Course>> {
358    let courses = sqlx::query_as!(
359        Course,
360        r#"
361SELECT *
362FROM courses
363WHERE deleted_at IS NULL;
364"#
365    )
366    .fetch_all(conn)
367    .await?;
368    Ok(courses)
369}
370
371pub async fn all_courses_for_auditing(
372    conn: &mut PgConnection,
373) -> ModelResult<Vec<CourseAuditingData>> {
374    let courses_data = sqlx::query!(
375        r#"
376SELECT c.id,
377  c.name,
378  c.slug,
379  c.created_at,
380  c.updated_at,
381  c.organization_id,
382  c.description,
383  c.closed_at,
384  c.closed_additional_message,
385  c.closed_course_successor_id,
386  o.name AS organization_name,
387  o.slug AS organization_slug
388FROM courses c
389  JOIN organizations o ON o.id = c.organization_id
390WHERE c.deleted_at IS NULL
391  AND o.deleted_at IS NULL
392GROUP BY c.id,
393  c.name,
394  o.name,
395  o.slug
396"#
397    )
398    .fetch_all(&mut *conn)
399    .await?;
400
401    let modules_data = crate::course_modules::get_all_modules(conn).await?;
402
403    let prerequisites_data =
404        crate::course_prerequisites::get_all_edit_course_prerequisites(conn).await?;
405
406    let audiences_data = crate::course_audiences::get_all_edit_course_audiences(conn).await?;
407
408    let mut modules_by_course_id: HashMap<Uuid, Vec<CourseModule>> = HashMap::new();
409    for module in modules_data {
410        modules_by_course_id
411            .entry(module.course_id)
412            .or_default()
413            .push(module);
414    }
415
416    for course_modules in modules_by_course_id.values_mut() {
417        course_modules.sort_by_key(|c| c.order_number);
418    }
419
420    let mut prerequisites_by_course_id: HashMap<Uuid, Vec<EditCoursePrerequisite>> = HashMap::new();
421    for prerequisite in prerequisites_data {
422        prerequisites_by_course_id
423            .entry(prerequisite.course_id)
424            .or_default()
425            .push(prerequisite);
426    }
427
428    let mut audiences_by_course_id: HashMap<Uuid, Vec<EditCourseAudience>> = HashMap::new();
429    for audience in audiences_data {
430        audiences_by_course_id
431            .entry(audience.course_id)
432            .or_default()
433            .push(audience);
434    }
435
436    let data: Vec<CourseAuditingData> = courses_data
437        .into_iter()
438        .map(|c| CourseAuditingData {
439            id: c.id,
440            name: c.name,
441            slug: c.slug,
442            created_at: c.created_at,
443            updated_at: c.updated_at,
444            organization_id: c.organization_id,
445            description: c.description,
446            closed_at: c.closed_at,
447            closed_additional_message: c.closed_additional_message,
448            closed_course_successor_id: c.closed_course_successor_id,
449            organization_name: c.organization_name,
450            organization_slug: c.organization_slug,
451            modules: modules_by_course_id.remove(&c.id).unwrap_or_default(),
452            prerequisites: prerequisites_by_course_id.remove(&c.id).unwrap_or_default(),
453            audiences: audiences_by_course_id.remove(&c.id).unwrap_or_default(),
454        })
455        .collect();
456
457    Ok(data)
458}
459
460pub async fn course_auditing_data_by_id(
461    conn: &mut PgConnection,
462    course_id: Uuid,
463) -> ModelResult<CourseAuditingData> {
464    let course_data = sqlx::query!(
465        r#"
466SELECT c.id,
467  c.name,
468  c.slug,
469  c.created_at,
470  c.updated_at,
471  c.organization_id,
472  c.description,
473  c.closed_at,
474  c.closed_additional_message,
475  c.closed_course_successor_id,
476  o.name AS organization_name,
477  o.slug AS organization_slug
478FROM courses c
479  JOIN organizations o ON o.id = c.organization_id
480WHERE c.id = $1
481  AND c.deleted_at IS NULL
482  AND o.deleted_at IS NULL
483GROUP BY c.id,
484  c.name,
485  o.name,
486  o.slug
487"#,
488        course_id
489    )
490    .fetch_one(&mut *conn)
491    .await?;
492
493    let mut modules_data = crate::course_modules::get_by_course_id(conn, course_id).await?;
494
495    modules_data.sort_by_key(|c| c.order_number);
496
497    let prerequisites_data =
498        crate::course_prerequisites::get_edit_course_prerequisites_by_course_id(conn, course_id)
499            .await?;
500
501    let audiences_data =
502        crate::course_audiences::get_edit_course_audiences_by_course_id(conn, course_id).await?;
503
504    let data: CourseAuditingData = CourseAuditingData {
505        id: course_data.id,
506        name: course_data.name,
507        slug: course_data.slug,
508        created_at: course_data.created_at,
509        updated_at: course_data.updated_at,
510        organization_id: course_data.organization_id,
511        description: course_data.description,
512        closed_at: course_data.closed_at,
513        closed_additional_message: course_data.closed_additional_message,
514        closed_course_successor_id: course_data.closed_course_successor_id,
515        organization_name: course_data.organization_name,
516        organization_slug: course_data.organization_slug,
517        modules: modules_data,
518        prerequisites: prerequisites_data,
519        audiences: audiences_data,
520    };
521
522    Ok(data)
523}
524
525pub async fn update_course_auditing_data(
526    conn: &mut PgConnection,
527    app_config: &ApplicationConfiguration,
528    course_id: Uuid,
529    data_update: CourseAuditingDataUpdate,
530) -> ModelResult<()> {
531    let (
532        module_ids,
533        uh_course_codes,
534        completion_registration_link_overrides,
535        ects_credits,
536        enable_registering_completion_to_uh_open_university,
537    ): (Vec<_>, Vec<_>, Vec<_>, Vec<_>, Vec<_>) =
538        multiunzip(data_update.modules.into_iter().map(|m| {
539            (
540                m.id,
541                m.uh_course_code,
542                m.completion_registration_link_override,
543                m.ects_credits,
544                m.enable_registering_completion_to_uh_open_university,
545            )
546        }));
547
548    let new_prerequisite_ids: Vec<Uuid> = data_update.prerequisites.iter().map(|p| p.id).collect();
549
550    let old_course = get_course(conn, course_id).await?;
551
552    let old_prerequisites: Vec<CoursePrerequisite> =
553        crate::course_prerequisites::get_by_course_id(conn, course_id).await?;
554
555    let prerequisites_to_delete: Vec<Uuid> = old_prerequisites
556        .iter()
557        .filter(|p| !new_prerequisite_ids.contains(&p.id))
558        .map(|p| p.id.to_owned())
559        .collect();
560
561    let old_prerequisites_hash: HashMap<Uuid, &String> = old_prerequisites
562        .iter()
563        .map(|p| (p.id, &p.prerequisite))
564        .collect();
565
566    let changed_prerequisites: Vec<EditCoursePrerequisite> = data_update
567        .prerequisites
568        .into_iter()
569        .filter(|p| match old_prerequisites_hash.get(&p.id) {
570            Some(old_prerequisite) => **old_prerequisite != p.prerequisite,
571            None => true,
572        })
573        .collect();
574
575    let (updated_prerequisite_ids, updated_prerequisites): (Vec<Uuid>, Vec<String>) =
576        changed_prerequisites
577            .iter()
578            .map(|p| (p.id, p.prerequisite.to_owned()))
579            .unzip();
580
581    let new_audience_ids: Vec<Uuid> = data_update.audiences.iter().map(|a| a.id).collect();
582
583    let old_audiences: Vec<CourseAudience> =
584        crate::course_audiences::get_by_course_id(conn, course_id).await?;
585
586    let audiences_to_delete: Vec<Uuid> = old_audiences
587        .iter()
588        .filter(|a| !new_audience_ids.contains(&a.id))
589        .map(|a| a.id.to_owned())
590        .collect();
591
592    let old_audiences_hash: HashMap<Uuid, &String> =
593        old_audiences.iter().map(|a| (a.id, &a.audience)).collect();
594
595    let changed_audiences: Vec<EditCourseAudience> = data_update
596        .audiences
597        .into_iter()
598        .filter(|a| match old_audiences_hash.get(&a.id) {
599            Some(old_audience) => **old_audience != a.audience,
600            None => true,
601        })
602        .collect();
603
604    let (updated_audience_ids, updated_audiences): (Vec<Uuid>, Vec<String>) = changed_audiences
605        .iter()
606        .map(|a| (a.id, a.audience.to_owned()))
607        .unzip();
608
609    let prerequisite_embeddings = if updated_prerequisites.is_empty() {
610        None
611    } else {
612        Some(create_embeddings(app_config, updated_prerequisites.clone()).await?)
613    };
614
615    let audience_embeddings = if updated_audiences.is_empty() {
616        None
617    } else {
618        Some(create_embeddings(app_config, updated_audiences.clone()).await?)
619    };
620
621    let mut tx = conn.begin().await?;
622
623    sqlx::query_as!(
624        CourseAuditingDataUpdate,
625        r#"
626UPDATE courses
627SET description = $2,
628  closed_at = $3,
629  closed_additional_message = $4,
630  closed_course_successor_id = $5
631WHERE id = $1
632  AND deleted_at IS NULL
633"#,
634        course_id,
635        data_update.description,
636        data_update.closed_at,
637        data_update.closed_additional_message,
638        data_update.closed_course_successor_id
639    )
640    .execute(&mut *tx)
641    .await?;
642
643    if old_course.description != data_update.description
644        && data_update.description.is_some()
645        && data_update.description != Some("".to_string())
646    {
647        update_course_embeddings(
648            &mut tx,
649            app_config,
650            course_id,
651            Some(old_course.name.as_str()),
652            data_update.description.as_deref(),
653        )
654        .await?;
655    };
656
657    sqlx::query!(
658        r#"
659UPDATE course_modules cm
660SET uh_course_code = v.uh_course_code,
661  completion_registration_link_override = v.completion_registration_link_override,
662  ects_credits = v.ects_credits,
663  enable_registering_completion_to_uh_open_university = v.enable_registering_completion_to_uh_open_university
664FROM (
665    SELECT *
666    FROM UNNEST(
667        $1::uuid [],
668        $2::TEXT [],
669        $3::TEXT [],
670        $4::FLOAT8 [],
671        $5::BOOLEAN []
672      ) AS v(
673        id,
674        uh_course_code,
675        completion_registration_link_override,
676        ects_credits,
677        enable_registering_completion_to_uh_open_university
678      )
679  ) AS v
680WHERE cm.id = v.id
681  AND cm.course_id = $6
682  AND cm.deleted_at IS NULL
683"#,
684        &module_ids,
685        &uh_course_codes as &[Option<String>],
686        &completion_registration_link_overrides as &[Option<String>],
687        &ects_credits as &[Option<f32>],
688        &enable_registering_completion_to_uh_open_university,
689        course_id
690    )
691    .execute(&mut *tx)
692    .await?;
693
694    if let Some(embeddings) = prerequisite_embeddings {
695        upsert_course_prerequisites(
696            &mut tx,
697            course_id,
698            updated_prerequisite_ids,
699            updated_prerequisites,
700            embeddings,
701        )
702        .await?
703    } else {
704        vec![]
705    };
706
707    if let Some(embeddings) = audience_embeddings {
708        upsert_course_audiences(
709            &mut tx,
710            course_id,
711            updated_audience_ids,
712            updated_audiences,
713            embeddings,
714        )
715        .await?
716    } else {
717        vec![]
718    };
719
720    crate::course_prerequisites::delete_batch(&mut tx, prerequisites_to_delete).await?;
721
722    crate::course_audiences::delete_batch(&mut tx, audiences_to_delete).await?;
723
724    tx.commit().await?;
725    Ok(())
726}
727
728pub async fn all_courses_user_enrolled_to(
729    conn: &mut PgConnection,
730    user_id: Uuid,
731) -> ModelResult<Vec<Course>> {
732    let courses = sqlx::query_as!(
733        Course,
734        r#"
735SELECT *
736FROM courses
737WHERE courses.deleted_at IS NULL
738  AND id IN (
739    SELECT current_course_id
740    FROM user_course_settings
741    WHERE deleted_at IS NULL
742      AND user_id = $1
743  )
744"#,
745        user_id
746    )
747    .fetch_all(conn)
748    .await?;
749    Ok(courses)
750}
751
752pub async fn all_courses_with_roles_for_user(
753    conn: &mut PgConnection,
754    user_id: Uuid,
755) -> ModelResult<Vec<Course>> {
756    let courses = sqlx::query_as!(
757        Course,
758        r#"
759SELECT *
760FROM courses
761WHERE courses.deleted_at IS NULL
762  AND (
763    id IN (
764      SELECT course_id
765      FROM roles
766      WHERE deleted_at IS NULL
767        AND user_id = $1
768        AND course_id IS NOT NULL
769    )
770    OR (
771      id IN (
772        SELECT ci.course_id
773        FROM course_instances ci
774          JOIN ROLES r ON r.course_instance_id = ci.id
775        WHERE r.user_id = $1
776          AND r.deleted_at IS NULL
777          AND ci.deleted_at IS NULL
778      )
779    )
780  ) "#,
781        user_id
782    )
783    .fetch_all(conn)
784    .await?;
785    Ok(courses)
786}
787
788pub async fn get_all_language_versions_of_course(
789    conn: &mut PgConnection,
790    course: &Course,
791) -> ModelResult<Vec<Course>> {
792    let courses = sqlx::query_as!(
793        Course,
794        r#"
795SELECT *
796FROM courses
797WHERE course_language_group_id = $1
798AND deleted_at IS NULL
799        "#,
800        course.course_language_group_id,
801    )
802    .fetch_all(conn)
803    .await?;
804    Ok(courses)
805}
806
807pub async fn get_active_courses_for_organization(
808    conn: &mut PgConnection,
809    organization_id: Uuid,
810    pagination: Pagination,
811) -> ModelResult<Vec<Course>> {
812    let course_instances = sqlx::query_as!(
813        Course,
814        r#"
815SELECT
816    DISTINCT(c.id),
817    c.name,
818    c.created_at,
819    c.updated_at,
820    c.organization_id,
821    c.deleted_at,
822    c.slug,
823    c.content_search_language::text,
824    c.language_code,
825    c.copied_from,
826    c.course_language_group_id,
827    c.description,
828    c.is_draft,
829    c.is_test_mode,
830    c.base_module_completion_requires_n_submodule_completions,
831    c.can_add_chatbot,
832    c.is_unlisted,
833    c.is_joinable_by_code_only,
834    c.join_code,
835    c.ask_marketing_consent,
836    c.flagged_answers_threshold,
837    c.flagged_answers_skip_manual_review_and_allow_retry,
838    c.closed_at,
839    c.closed_additional_message,
840    c.closed_course_successor_id,
841    c.chapter_locking_enabled,
842    c.cheater_detection_enabled,
843    c.ai_policy,
844    c.course_material_ai_instructions
845FROM courses as c
846    LEFT JOIN course_instances as ci on c.id = ci.course_id
847WHERE
848    c.organization_id = $1 AND
849    ci.starts_at < NOW() AND ci.ends_at > NOW() AND
850    c.deleted_at IS NULL AND ci.deleted_at IS NULL
851    LIMIT $2 OFFSET $3;
852        "#,
853        organization_id,
854        pagination.limit(),
855        pagination.offset()
856    )
857    .fetch_all(conn)
858    .await?;
859    Ok(course_instances)
860}
861
862pub async fn get_active_courses_for_organization_count(
863    conn: &mut PgConnection,
864    organization_id: Uuid,
865) -> ModelResult<CourseCount> {
866    let result = sqlx::query!(
867        r#"
868SELECT
869    COUNT(DISTINCT c.id) as count
870FROM courses as c
871    LEFT JOIN course_instances as ci on c.id = ci.course_id
872WHERE
873    c.organization_id = $1 AND
874    ci.starts_at < NOW() AND ci.ends_at > NOW() AND
875    c.deleted_at IS NULL AND ci.deleted_at IS NULL;
876        "#,
877        organization_id
878    )
879    .fetch_one(conn)
880    .await?;
881    Ok(CourseCount {
882        count: result.count.unwrap_or_default().try_into()?,
883    })
884}
885
886pub async fn get_course(conn: &mut PgConnection, course_id: Uuid) -> ModelResult<Course> {
887    let course = sqlx::query_as!(
888        Course,
889        r#"
890SELECT *
891FROM courses
892WHERE id = $1
893  AND deleted_at IS NULL;
894    "#,
895        course_id
896    )
897    .fetch_one(conn)
898    .await?;
899    Ok(course)
900}
901
902pub async fn get_by_id_and_join_code(
903    conn: &mut PgConnection,
904    course_id: Uuid,
905    join_code: &str,
906) -> ModelResult<Course> {
907    let course = sqlx::query_as!(
908        Course,
909        r#"
910SELECT *
911FROM courses
912WHERE id = $1
913  AND join_code = $2
914  AND deleted_at IS NULL;
915    "#,
916        course_id,
917        join_code,
918    )
919    .fetch_one(conn)
920    .await?;
921    Ok(course)
922}
923
924pub async fn get_course_breadcrumb_info(
925    conn: &mut PgConnection,
926    course_id: Uuid,
927) -> ModelResult<CourseBreadcrumbInfo> {
928    let res = sqlx::query_as!(
929        CourseBreadcrumbInfo,
930        r#"
931SELECT courses.id as course_id,
932  courses.name as course_name,
933  courses.slug as course_slug,
934  organizations.slug as organization_slug,
935  organizations.name as organization_name
936FROM courses
937  JOIN organizations ON (courses.organization_id = organizations.id)
938WHERE courses.id = $1
939  AND courses.deleted_at IS NULL;
940    "#,
941        course_id
942    )
943    .fetch_one(conn)
944    .await?;
945    Ok(res)
946}
947
948pub async fn get_nondeleted_course_id_by_slug(
949    conn: &mut PgConnection,
950    slug: &str,
951) -> ModelResult<CourseContextData> {
952    let data = sqlx::query_as!(
953        CourseContextData,
954        "SELECT id, is_test_mode FROM courses WHERE slug = $1 AND deleted_at IS NULL",
955        slug
956    )
957    .fetch_one(conn)
958    .await?;
959    Ok(data)
960}
961
962/// None when no active course has this slug.
963pub async fn get_active_course_id_by_slug(
964    conn: &mut PgConnection,
965    slug: &str,
966) -> ModelResult<Option<Uuid>> {
967    let id = sqlx::query_scalar!(
968        "SELECT id FROM courses WHERE slug = $1 AND deleted_at IS NULL",
969        slug
970    )
971    .fetch_optional(conn)
972    .await?;
973    Ok(id)
974}
975
976pub async fn get_organization_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Uuid> {
977    let organization_id = sqlx::query!("SELECT organization_id FROM courses WHERE id = $1", id)
978        .fetch_one(conn)
979        .await?
980        .organization_id;
981    Ok(organization_id)
982}
983
984/// Gets full course structure including all the pages.
985pub async fn get_course_structure(
986    conn: &mut PgConnection,
987    course_id: Uuid,
988    file_store: &dyn FileStore,
989    app_conf: &ApplicationConfiguration,
990) -> ModelResult<CourseStructure> {
991    let course = get_course(conn, course_id).await?;
992    let pages = get_all_by_course_id_and_visibility(conn, course_id, PageVisibility::Any).await?;
993    let chapters = get_course_chapters(conn, course_id)
994        .await?
995        .iter()
996        .map(|chapter| Chapter::from_database_chapter(chapter, file_store, app_conf))
997        .collect();
998    let modules = crate::course_modules::get_by_course_id(conn, course_id).await?;
999    Ok(CourseStructure {
1000        course,
1001        pages,
1002        chapters,
1003        modules,
1004    })
1005}
1006
1007pub async fn organization_courses_visible_to_user_paginated(
1008    conn: &mut PgConnection,
1009    organization_id: Uuid,
1010    user: Option<Uuid>,
1011    pagination: Pagination,
1012) -> ModelResult<Vec<Course>> {
1013    let courses = sqlx::query_as!(
1014        Course,
1015        r#"
1016SELECT courses.id,
1017  courses.name,
1018  courses.created_at,
1019  courses.updated_at,
1020  courses.organization_id,
1021  courses.deleted_at,
1022  courses.slug,
1023  courses.content_search_language::text,
1024  courses.language_code,
1025  courses.copied_from,
1026  courses.course_language_group_id,
1027  courses.description,
1028  courses.is_draft,
1029  courses.is_test_mode,
1030  base_module_completion_requires_n_submodule_completions,
1031  can_add_chatbot,
1032  courses.is_unlisted,
1033  courses.is_joinable_by_code_only,
1034  courses.join_code,
1035  courses.ask_marketing_consent,
1036  courses.flagged_answers_threshold,
1037  courses.flagged_answers_skip_manual_review_and_allow_retry,
1038  courses.closed_at,
1039  courses.closed_additional_message,
1040  courses.closed_course_successor_id,
1041  courses.chapter_locking_enabled,
1042  courses.cheater_detection_enabled,
1043  courses.ai_policy,
1044  courses.course_material_ai_instructions
1045FROM courses
1046WHERE courses.organization_id = $1
1047  AND (
1048    (
1049      courses.is_draft IS FALSE
1050      AND courses.is_unlisted IS FALSE
1051    )
1052    OR EXISTS (
1053      SELECT id
1054      FROM roles
1055      WHERE user_id = $2
1056        AND (
1057          course_id = courses.id
1058          OR roles.organization_id = courses.organization_id
1059          OR roles.is_global IS TRUE
1060        )
1061    )
1062  )
1063  AND courses.deleted_at IS NULL
1064ORDER BY courses.name
1065LIMIT $3 OFFSET $4;
1066"#,
1067        organization_id,
1068        user,
1069        pagination.limit(),
1070        pagination.offset()
1071    )
1072    .fetch_all(conn)
1073    .await?;
1074    Ok(courses)
1075}
1076
1077pub async fn organization_course_count(
1078    conn: &mut PgConnection,
1079    organization_id: Uuid,
1080) -> ModelResult<CourseCount> {
1081    let course_count = sqlx::query!(
1082        r#"
1083SELECT
1084    COUNT(DISTINCT id) as count
1085FROM courses
1086WHERE organization_id = $1
1087    AND deleted_at IS NULL;
1088        "#,
1089        organization_id,
1090    )
1091    .fetch_one(conn)
1092    .await?;
1093    Ok(CourseCount {
1094        count: course_count.count.unwrap_or_default().try_into()?,
1095    })
1096}
1097// Represents the subset of page fields that one is allowed to update in a course
1098#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default, ToSchema)]
1099
1100pub struct CourseUpdate {
1101    pub name: String,
1102    pub description: Option<String>,
1103    pub is_draft: bool,
1104    pub is_test_mode: bool,
1105    pub can_add_chatbot: bool,
1106    pub is_unlisted: bool,
1107    pub is_joinable_by_code_only: bool,
1108    pub ask_marketing_consent: bool,
1109    pub flagged_answers_threshold: i32,
1110    pub flagged_answers_skip_manual_review_and_allow_retry: bool,
1111    pub closed_at: Option<DateTime<Utc>>,
1112    pub closed_additional_message: Option<String>,
1113    pub closed_course_successor_id: Option<Uuid>,
1114    pub chapter_locking_enabled: bool,
1115    pub ai_policy: CourseAiPolicy,
1116    pub course_material_ai_instructions: Option<bool>,
1117}
1118
1119pub async fn update_course(
1120    conn: &mut PgConnection,
1121    app_config: &ApplicationConfiguration,
1122    course_id: Uuid,
1123    course_update: CourseUpdate,
1124) -> ModelResult<Course> {
1125    let old_course = get_course(conn, course_id).await?;
1126    let mut tx = conn.begin().await?;
1127    let res = sqlx::query_as!(
1128        Course,
1129        r#"
1130UPDATE courses
1131SET name = $1,
1132  description = $2,
1133  is_draft = $3,
1134  is_test_mode = $4,
1135  can_add_chatbot = $5,
1136  is_unlisted = $6,
1137  is_joinable_by_code_only = $7,
1138  ask_marketing_consent = $8,
1139  flagged_answers_threshold = $9,
1140  flagged_answers_skip_manual_review_and_allow_retry = $10,
1141  closed_at = $11,
1142  closed_additional_message = $12,
1143  closed_course_successor_id = $13,
1144  chapter_locking_enabled = $14,
1145  ai_policy = $15,
1146  course_material_ai_instructions = $16
1147WHERE id = $17
1148  AND deleted_at IS NULL
1149RETURNING *
1150    "#,
1151        course_update.name,
1152        course_update.description,
1153        course_update.is_draft,
1154        course_update.is_test_mode,
1155        course_update.can_add_chatbot,
1156        course_update.is_unlisted,
1157        course_update.is_joinable_by_code_only,
1158        course_update.ask_marketing_consent,
1159        course_update.flagged_answers_threshold,
1160        course_update.flagged_answers_skip_manual_review_and_allow_retry,
1161        course_update.closed_at,
1162        course_update.closed_additional_message,
1163        course_update.closed_course_successor_id,
1164        course_update.chapter_locking_enabled,
1165        course_update.ai_policy as CourseAiPolicy,
1166        course_update.course_material_ai_instructions,
1167        course_id
1168    )
1169    .fetch_one(&mut *tx)
1170    .await?;
1171    let title = if old_course.name != course_update.name {
1172        Some(course_update.name.as_str())
1173    } else {
1174        None
1175    };
1176
1177    let description = if old_course.description != course_update.description {
1178        course_update.description.as_deref()
1179    } else {
1180        None
1181    };
1182
1183    update_course_embeddings(&mut tx, app_config, course_id, title, description).await?;
1184    tx.commit().await?;
1185    Ok(res)
1186}
1187
1188/// Enables or disables suspected-cheater detection for a single course. Used by the seed routine to
1189/// turn detection off for seeded courses (which are completed in seconds and would otherwise flag
1190/// every seeded user); production courses keep the on-by-default value set at creation.
1191pub async fn set_cheater_detection_enabled(
1192    conn: &mut PgConnection,
1193    course_id: Uuid,
1194    enabled: bool,
1195) -> ModelResult<()> {
1196    sqlx::query!(
1197        "
1198UPDATE courses
1199SET cheater_detection_enabled = $1
1200WHERE id = $2
1201  AND deleted_at IS NULL
1202        ",
1203        enabled,
1204        course_id,
1205    )
1206    .execute(conn)
1207    .await?;
1208    Ok(())
1209}
1210
1211pub async fn update_course_base_module_completion_count_requirement(
1212    conn: &mut PgConnection,
1213    id: Uuid,
1214    base_module_completion_requires_n_submodule_completions: i32,
1215) -> ModelResult<bool> {
1216    let res = sqlx::query!(
1217        "
1218UPDATE courses
1219SET base_module_completion_requires_n_submodule_completions = $1
1220WHERE id = $2
1221  AND deleted_at IS NULL
1222        ",
1223        base_module_completion_requires_n_submodule_completions,
1224        id,
1225    )
1226    .execute(conn)
1227    .await?;
1228    Ok(res.rows_affected() > 0)
1229}
1230
1231pub async fn delete_course(conn: &mut PgConnection, course_id: Uuid) -> ModelResult<Course> {
1232    let deleted = sqlx::query_as!(
1233        Course,
1234        r#"
1235UPDATE courses
1236SET deleted_at = now()
1237WHERE id = $1
1238AND deleted_at IS NULL
1239RETURNING *
1240    "#,
1241        course_id
1242    )
1243    .fetch_one(conn)
1244    .await?;
1245    Ok(deleted)
1246}
1247
1248pub async fn get_course_by_slug(conn: &mut PgConnection, course_slug: &str) -> ModelResult<Course> {
1249    let course = sqlx::query_as!(
1250        Course,
1251        r#"
1252SELECT *
1253FROM courses
1254WHERE slug = $1
1255  AND deleted_at IS NULL
1256"#,
1257        course_slug,
1258    )
1259    .fetch_one(conn)
1260    .await?;
1261    Ok(course)
1262}
1263
1264pub async fn get_cfgname_by_tag(
1265    conn: &mut PgConnection,
1266    ietf_language_tag: String,
1267) -> ModelResult<String> {
1268    let tag = ietf_language_tag
1269        .split('-')
1270        .next()
1271        .unwrap_or_else(|| &ietf_language_tag[..]);
1272
1273    let lang_name = LANGUAGE_TAG_TO_NAME.get(&tag);
1274
1275    let name = sqlx::query!(
1276        "SELECT cfgname::text FROM pg_ts_config WHERE cfgname = $1",
1277        lang_name
1278    )
1279    .fetch_optional(conn)
1280    .await?;
1281
1282    let res = name
1283        .and_then(|n| n.cfgname)
1284        .unwrap_or_else(|| "simple".to_string());
1285
1286    Ok(res)
1287}
1288
1289pub async fn is_draft(conn: &mut PgConnection, id: Uuid) -> ModelResult<bool> {
1290    let res = sqlx::query!(
1291        "
1292SELECT is_draft
1293FROM courses
1294WHERE id = $1
1295",
1296        id
1297    )
1298    .fetch_one(conn)
1299    .await?;
1300    Ok(res.is_draft)
1301}
1302
1303pub async fn is_joinable_by_code_only(conn: &mut PgConnection, id: Uuid) -> ModelResult<bool> {
1304    let res = sqlx::query!(
1305        "
1306SELECT is_joinable_by_code_only
1307FROM courses
1308WHERE id = $1
1309",
1310        id
1311    )
1312    .fetch_one(conn)
1313    .await?;
1314    Ok(res.is_joinable_by_code_only)
1315}
1316
1317pub async fn get_by_ids(conn: &mut PgConnection, course_ids: &[Uuid]) -> ModelResult<Vec<Course>> {
1318    let courses = sqlx::query_as!(
1319        Course,
1320        r#"
1321SELECT *
1322FROM courses
1323WHERE id IN (SELECT * FROM UNNEST($1::uuid[]))
1324  AND deleted_at IS NULL
1325        "#,
1326        course_ids
1327    )
1328    .fetch_all(conn)
1329    .await?;
1330    Ok(courses)
1331}
1332
1333/// Finds courses by slug or (fuzzy) name for the support chatbot's `find_course` tool. Exact
1334/// slug matches rank first, then substring name matches, then trigram similarity to `name`.
1335pub async fn search_courses_by_slug_or_name(
1336    conn: &mut PgConnection,
1337    query: &str,
1338    limit: i64,
1339) -> ModelResult<Vec<Course>> {
1340    let courses = sqlx::query_as!(
1341        Course,
1342        r#"
1343SELECT *
1344FROM courses
1345WHERE deleted_at IS NULL
1346  AND (LOWER(slug) = LOWER($1) OR name ILIKE '%' || $1 || '%' OR similarity(name, $1) > 0.1)
1347ORDER BY
1348  LOWER(slug) = LOWER($1) DESC,
1349  name ILIKE '%' || $1 || '%' DESC,
1350  similarity(name, $1) DESC
1351LIMIT $2
1352        "#,
1353        query,
1354        limit,
1355    )
1356    .fetch_all(conn)
1357    .await?;
1358    Ok(courses)
1359}
1360
1361pub async fn get_by_organization_id(
1362    conn: &mut PgConnection,
1363    organization_id: Uuid,
1364) -> ModelResult<Vec<Course>> {
1365    let courses = sqlx::query_as!(
1366        Course,
1367        r#"
1368SELECT *
1369FROM courses
1370WHERE organization_id = $1
1371  AND deleted_at IS NULL
1372ORDER BY name
1373        "#,
1374        organization_id
1375    )
1376    .fetch_all(conn)
1377    .await?;
1378    Ok(courses)
1379}
1380
1381pub async fn set_join_code_for_course(
1382    conn: &mut PgConnection,
1383    course_id: Uuid,
1384    join_code: String,
1385) -> ModelResult<()> {
1386    sqlx::query!(
1387        "
1388UPDATE courses
1389SET join_code = $2
1390WHERE id = $1
1391",
1392        course_id,
1393        join_code
1394    )
1395    .execute(conn)
1396    .await?;
1397    Ok(())
1398}
1399
1400pub async fn get_course_with_join_code(
1401    conn: &mut PgConnection,
1402    join_code: String,
1403) -> ModelResult<Course> {
1404    let course = sqlx::query_as!(
1405        Course,
1406        r#"
1407SELECT *
1408FROM courses
1409WHERE join_code = $1
1410  AND deleted_at IS NULL;
1411    "#,
1412        join_code,
1413    )
1414    .fetch_one(conn)
1415    .await?;
1416    Ok(course)
1417}
1418
1419pub async fn set_metadata(
1420    conn: &mut PgConnection,
1421    app_config: &ApplicationConfiguration,
1422    course_id: Uuid,
1423    course_metadata: CourseMetadataUpdate,
1424) -> ModelResult<CourseMetadata> {
1425    let new_prerequisite_ids: Vec<Uuid> = course_metadata
1426        .course_prerequisites
1427        .iter()
1428        .map(|p| p.id)
1429        .collect();
1430
1431    let old_prerequisites: Vec<CoursePrerequisite> =
1432        crate::course_prerequisites::get_by_course_id(conn, course_id).await?;
1433
1434    let prerequisites_to_delete: Vec<Uuid> = old_prerequisites
1435        .iter()
1436        .filter(|p| !new_prerequisite_ids.contains(&p.id))
1437        .map(|p| p.id.to_owned())
1438        .collect();
1439
1440    let old_prerequisites_hash: HashMap<Uuid, &String> = old_prerequisites
1441        .iter()
1442        .map(|p| (p.id, &p.prerequisite))
1443        .collect();
1444
1445    let changed_prerequisites: Vec<EditCoursePrerequisite> = course_metadata
1446        .course_prerequisites
1447        .into_iter()
1448        .filter(|p| match old_prerequisites_hash.get(&p.id) {
1449            Some(old_prerequisite) => **old_prerequisite != p.prerequisite,
1450            None => true,
1451        })
1452        .collect();
1453
1454    let (updated_prerequisite_ids, updated_prerequisites): (Vec<Uuid>, Vec<String>) =
1455        changed_prerequisites
1456            .iter()
1457            .map(|p| (p.id, p.prerequisite.to_owned()))
1458            .unzip();
1459
1460    let audience_ids: Vec<Uuid> = course_metadata
1461        .course_audiences
1462        .iter()
1463        .map(|a| a.id)
1464        .collect();
1465
1466    let old_audiences: Vec<CourseAudience> =
1467        crate::course_audiences::get_by_course_id(conn, course_id).await?;
1468
1469    let audiences_to_delete: Vec<Uuid> = old_audiences
1470        .iter()
1471        .filter(|a| !audience_ids.contains(&a.id))
1472        .map(|a| a.id.to_owned())
1473        .collect();
1474
1475    let old_audiences_hash: HashMap<Uuid, &String> =
1476        old_audiences.iter().map(|a| (a.id, &a.audience)).collect();
1477
1478    let changed_audiences: Vec<EditCourseAudience> = course_metadata
1479        .course_audiences
1480        .into_iter()
1481        .filter(|a| match old_audiences_hash.get(&a.id) {
1482            Some(old_audience) => **old_audience != a.audience,
1483            None => true,
1484        })
1485        .collect();
1486
1487    let (updated_audience_ids, updated_audiences): (Vec<Uuid>, Vec<String>) = changed_audiences
1488        .iter()
1489        .map(|a| (a.id, a.audience.to_owned()))
1490        .unzip();
1491
1492    let prerequisite_embeddings = if updated_prerequisites.is_empty() {
1493        None
1494    } else {
1495        Some(create_embeddings(app_config, updated_prerequisites.clone()).await?)
1496    };
1497
1498    let audience_embeddings = if updated_audiences.is_empty() {
1499        None
1500    } else {
1501        Some(create_embeddings(app_config, updated_audiences.clone()).await?)
1502    };
1503
1504    let mut tx = conn.begin().await?;
1505
1506    if let Some(embeddings) = prerequisite_embeddings {
1507        upsert_course_prerequisites(
1508            &mut tx,
1509            course_id,
1510            updated_prerequisite_ids,
1511            updated_prerequisites,
1512            embeddings,
1513        )
1514        .await?
1515    } else {
1516        vec![]
1517    };
1518
1519    if let Some(embeddings) = audience_embeddings {
1520        upsert_course_audiences(
1521            &mut tx,
1522            course_id,
1523            updated_audience_ids,
1524            updated_audiences,
1525            embeddings,
1526        )
1527        .await?
1528    } else {
1529        vec![]
1530    };
1531
1532    crate::course_prerequisites::delete_batch(&mut tx, prerequisites_to_delete).await?;
1533    crate::course_audiences::delete_batch(&mut tx, audiences_to_delete).await?;
1534
1535    let course = get_course(&mut tx, course_id).await?;
1536
1537    let update_payload = CourseUpdate {
1538        name: course.name,
1539        description: course_metadata.course_description,
1540        is_draft: course.is_draft,
1541        is_test_mode: course.is_test_mode,
1542        can_add_chatbot: course.can_add_chatbot,
1543        is_unlisted: course.is_unlisted,
1544        is_joinable_by_code_only: course.is_joinable_by_code_only,
1545        ask_marketing_consent: course.ask_marketing_consent,
1546        flagged_answers_threshold: course.flagged_answers_threshold.unwrap_or(0),
1547        flagged_answers_skip_manual_review_and_allow_retry: course
1548            .flagged_answers_skip_manual_review_and_allow_retry,
1549        closed_at: course.closed_at,
1550        closed_additional_message: course.closed_additional_message,
1551        closed_course_successor_id: course.closed_course_successor_id,
1552        chapter_locking_enabled: course.chapter_locking_enabled,
1553        ai_policy: course.ai_policy,
1554        course_material_ai_instructions: course.course_material_ai_instructions,
1555    };
1556    let updated_course = update_course(&mut tx, app_config, course_id, update_payload).await?;
1557
1558    tx.commit().await?;
1559
1560    let prerequisites = crate::course_prerequisites::get_by_course_id(conn, course_id).await?;
1561    let audiences = crate::course_audiences::get_by_course_id(conn, course_id).await?;
1562
1563    let res = CourseMetadata {
1564        course_description: updated_course.description,
1565        course_audiences: audiences,
1566        course_prerequisites: prerequisites,
1567        course_updated_at: updated_course.updated_at,
1568    };
1569    Ok(res)
1570}
1571
1572pub async fn get_metadata(
1573    conn: &mut PgConnection,
1574    course_id: Uuid,
1575) -> ModelResult<CompleteCourseMetadata> {
1576    let prerequisites: Vec<CoursePrerequisite> =
1577        crate::course_prerequisites::get_by_course_id(conn, course_id).await?;
1578    let audiences: Vec<CourseAudience> =
1579        crate::course_audiences::get_by_course_id(conn, course_id).await?;
1580    let course_data = get_course(conn, course_id).await?;
1581    let instances =
1582        crate::course_instances::get_course_instances_for_course(conn, course_id).await?;
1583    let module = crate::course_modules::get_default_by_course_id(conn, course_id).await?;
1584
1585    let organization =
1586        crate::organizations::get_organization(conn, course_data.organization_id).await?;
1587
1588    let metadata = CompleteCourseMetadata {
1589        course: course_data,
1590        course_instances: instances,
1591        default_module: module,
1592        course_prerequisites: prerequisites,
1593        course_audiences: audiences,
1594        course_organization: organization,
1595    };
1596    Ok(metadata)
1597}
1598
1599pub async fn update_course_embeddings(
1600    conn: &mut PgConnection,
1601    app_config: &ApplicationConfiguration,
1602    course_id: Uuid,
1603    title: Option<&str>,
1604    description: Option<&str>,
1605) -> ModelResult<()> {
1606    let title_embedding = if let Some(title) = title {
1607        Some(
1608            create_embeddings(app_config, vec![title.to_owned()])
1609                .await?
1610                .into_iter()
1611                .next()
1612                .ok_or_else(|| {
1613                    model_err!(Generic, "The embedding API returned no title embedding.")
1614                })
1615                .map(Vector::from)?,
1616        )
1617    } else {
1618        None
1619    };
1620
1621    let description_embedding = if let Some(description) = description {
1622        Some(
1623            create_embeddings(app_config, vec![description.to_owned()])
1624                .await?
1625                .into_iter()
1626                .next()
1627                .ok_or_else(|| {
1628                    model_err!(
1629                        Generic,
1630                        "The embedding API returned no description embedding."
1631                    )
1632                })
1633                .map(Vector::from)?,
1634        )
1635    } else {
1636        None
1637    };
1638
1639    sqlx::query!(
1640        r#"
1641INSERT INTO course_embeddings (
1642    course_id,
1643    title_embedding,
1644    description_embedding
1645)
1646VALUES ($1, $2, $3)
1647ON CONFLICT (course_id) WHERE deleted_at IS NULL
1648DO UPDATE SET
1649    title_embedding = COALESCE(
1650        $2,
1651        course_embeddings.title_embedding
1652    ),
1653    description_embedding = COALESCE(
1654        $3,
1655        course_embeddings.description_embedding
1656    )
1657"#,
1658        course_id,
1659        title_embedding,
1660        description_embedding,
1661    )
1662    .execute(conn)
1663    .await?;
1664
1665    Ok(())
1666}
1667
1668pub async fn get_by_description_vectors(
1669    conn: &mut PgConnection,
1670    query_vecs: Vec<Vec<f32>>,
1671    description_keywords: Vec<String>,
1672) -> ModelResult<Vec<Uuid>> {
1673    let vectors: Vec<Vector> = query_vecs.into_iter().map(Vector::from).collect();
1674    let res = sqlx::query_scalar!(
1675        r#"
1676SELECT id
1677FROM (
1678    SELECT
1679        c.id,
1680        LEAST(MIN(ce.title_embedding <#> v.embedding),
1681              MIN(ce.description_embedding <#> v.embedding)) AS distance
1682    FROM courses c, course_embeddings ce
1683    CROSS JOIN unnest($1::vector[]) AS v(embedding)
1684    WHERE c.deleted_at IS NULL AND ce.deleted_at IS NULL AND c.id = ce.course_id
1685    GROUP BY c.id
1686    ORDER BY distance ASC
1687    LIMIT 5
1688) t
1689UNION ALL
1690SELECT DISTINCT c.id
1691FROM courses c
1692CROSS JOIN unnest($2::text[]) AS k(keyword)
1693WHERE deleted_at IS NULL
1694AND to_tsvector(c.content_search_language::regconfig, c.description)
1695@@ websearch_to_tsquery(c.content_search_language::regconfig, k.keyword)
1696        "#,
1697        &vectors as _,
1698        &description_keywords
1699    )
1700    .fetch_all(conn)
1701    .await?;
1702    Ok(res.into_iter().flatten().collect())
1703}
1704
1705#[cfg(test)]
1706mod test {
1707    use super::*;
1708    use crate::{course_language_groups, courses, test_helper::*};
1709
1710    mod language_code_validation {
1711        use super::*;
1712
1713        #[tokio::test]
1714        async fn allows_valid_language_code() {
1715            let app_config =
1716                init_app_conf().expect("Application Configuration initialization failed");
1717            insert_data!(:tx, user: _user, :org);
1718            let course_language_group_id = course_language_groups::insert(
1719                tx.as_mut(),
1720                PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1721                "test-clg-allows-valid",
1722            )
1723            .await
1724            .unwrap();
1725            let new_course = create_new_course(org, "en-US");
1726            let res = courses::insert(
1727                tx.as_mut(),
1728                &app_config,
1729                PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1730                course_language_group_id,
1731                &new_course,
1732            )
1733            .await;
1734            assert!(res.is_ok());
1735        }
1736
1737        #[tokio::test]
1738        async fn disallows_empty_language_code() {
1739            let app_config =
1740                init_app_conf().expect("Application Configuration initialization failed");
1741            insert_data!(:tx, user: _user, :org);
1742            let course_language_group_id = course_language_groups::insert(
1743                tx.as_mut(),
1744                PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1745                "test-clg-disallows-empty",
1746            )
1747            .await
1748            .unwrap();
1749            let new_course = create_new_course(org, "");
1750            let res = courses::insert(
1751                tx.as_mut(),
1752                &app_config,
1753                PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1754                course_language_group_id,
1755                &new_course,
1756            )
1757            .await;
1758            assert!(res.is_err());
1759        }
1760
1761        #[tokio::test]
1762        async fn disallows_wrong_case_language_code() {
1763            let app_config =
1764                init_app_conf().expect("Application Configuration initialization failed");
1765            insert_data!(:tx, user: _user, :org);
1766            let course_language_group_id = course_language_groups::insert(
1767                tx.as_mut(),
1768                PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1769                "test-clg-disallows-wrong-case",
1770            )
1771            .await
1772            .unwrap();
1773            let new_course = create_new_course(org, "en-us");
1774            let res = courses::insert(
1775                tx.as_mut(),
1776                &app_config,
1777                PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1778                course_language_group_id,
1779                &new_course,
1780            )
1781            .await;
1782            assert!(res.is_err());
1783        }
1784
1785        #[tokio::test]
1786        async fn disallows_underscore_in_language_code() {
1787            let app_config =
1788                init_app_conf().expect("Application Configuration initialization failed");
1789            insert_data!(:tx, user: _user, :org);
1790            let course_language_group_id = course_language_groups::insert(
1791                tx.as_mut(),
1792                PKeyPolicy::Fixed(Uuid::parse_str("8e40c36c-835b-479c-8f07-863ad408f181").unwrap()),
1793                "test-clg-disallows-underscore",
1794            )
1795            .await
1796            .unwrap();
1797            let new_course = create_new_course(org, "en_US");
1798            let res = courses::insert(
1799                tx.as_mut(),
1800                &app_config,
1801                PKeyPolicy::Fixed(Uuid::parse_str("95d8ab4d-073c-4794-b8c5-f683f0856356").unwrap()),
1802                course_language_group_id,
1803                &new_course,
1804            )
1805            .await;
1806            assert!(res.is_err());
1807        }
1808
1809        fn create_new_course(organization_id: Uuid, language_code: &str) -> NewCourse {
1810            NewCourse {
1811                name: "".to_string(),
1812                slug: "".to_string(),
1813                organization_id,
1814                language_code: language_code.to_string(),
1815                teacher_in_charge_name: "teacher".to_string(),
1816                teacher_in_charge_email: "teacher@example.com".to_string(),
1817                description: "description".to_string(),
1818                is_draft: false,
1819                is_test_mode: false,
1820                is_unlisted: false,
1821                copy_user_permissions: false,
1822                is_joinable_by_code_only: false,
1823                join_code: None,
1824                ask_marketing_consent: false,
1825                flagged_answers_threshold: Some(3),
1826                can_add_chatbot: false,
1827            }
1828        }
1829    }
1830
1831    mod ai_policy {
1832        use super::*;
1833
1834        #[tokio::test]
1835        async fn update_course_round_trips_ai_policy_fields() {
1836            let app_config =
1837                init_app_conf().expect("Application Configuration initialization failed");
1838            insert_data!(:tx, user: _user, :org);
1839            let course_language_group_id = course_language_groups::insert(
1840                tx.as_mut(),
1841                PKeyPolicy::Fixed(Uuid::parse_str("a1b2c3d4-0000-0000-0000-000000000001").unwrap()),
1842                "test-clg-ai-policy",
1843            )
1844            .await
1845            .unwrap();
1846            let new_course = NewCourse {
1847                name: "AI policy course".to_string(),
1848                slug: "ai-policy-course".to_string(),
1849                organization_id: org,
1850                language_code: "en-US".to_string(),
1851                teacher_in_charge_name: "teacher".to_string(),
1852                teacher_in_charge_email: "teacher@example.com".to_string(),
1853                description: "description".to_string(),
1854                is_draft: false,
1855                is_test_mode: false,
1856                is_unlisted: false,
1857                copy_user_permissions: false,
1858                is_joinable_by_code_only: false,
1859                join_code: None,
1860                ask_marketing_consent: false,
1861                flagged_answers_threshold: Some(3),
1862                can_add_chatbot: false,
1863            };
1864            let course_id = courses::insert(
1865                tx.as_mut(),
1866                &app_config,
1867                PKeyPolicy::Fixed(Uuid::parse_str("a1b2c3d4-0000-0000-0000-000000000002").unwrap()),
1868                course_language_group_id,
1869                &new_course,
1870            )
1871            .await
1872            .unwrap();
1873
1874            // New courses default to the generic notice (NotSet / Unknown).
1875            let created = courses::get_course(tx.as_mut(), course_id).await.unwrap();
1876            assert_eq!(created.ai_policy, CourseAiPolicy::NotSet);
1877            assert_eq!(created.course_material_ai_instructions, None);
1878
1879            // A teacher selects a policy and indicates the material has its own AI instructions.
1880            let updated = courses::update_course(
1881                tx.as_mut(),
1882                &app_config,
1883                course_id,
1884                CourseUpdate {
1885                    name: created.name.clone(),
1886                    flagged_answers_threshold: 3,
1887                    ai_policy: CourseAiPolicy::Limited,
1888                    course_material_ai_instructions: Some(true),
1889                    ..Default::default()
1890                },
1891            )
1892            .await
1893            .unwrap();
1894            assert_eq!(updated.ai_policy, CourseAiPolicy::Limited);
1895            assert_eq!(updated.course_material_ai_instructions, Some(true));
1896
1897            // The change is persisted for subsequent reads (which feed the student dialog).
1898            let reread = courses::get_course(tx.as_mut(), course_id).await.unwrap();
1899            assert_eq!(reread.ai_policy, CourseAiPolicy::Limited);
1900            assert_eq!(reread.course_material_ai_instructions, Some(true));
1901        }
1902    }
1903}