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