Skip to main content

headless_lms_models/
course_instance_enrollments.rs

1use std::collections::{HashMap, HashSet};
2
3use crate::{
4    course_instances::CourseInstance, course_module_completions::CourseModuleCompletion,
5    courses::Course, prelude::*, user_course_settings::UserCourseSettings,
6};
7use utoipa::ToSchema;
8
9#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
10
11pub struct CourseInstanceEnrollment {
12    pub user_id: Uuid,
13    pub course_id: Uuid,
14    pub course_instance_id: Uuid,
15    pub created_at: DateTime<Utc>,
16    pub updated_at: DateTime<Utc>,
17    pub deleted_at: Option<DateTime<Utc>>,
18}
19
20#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
21
22pub struct CourseInstanceEnrollmentsInfo {
23    pub course_instance_enrollments: Vec<CourseInstanceEnrollment>,
24    pub course_instances: Vec<CourseInstance>,
25    pub courses: Vec<Course>,
26    pub user_course_settings: Vec<UserCourseSettings>,
27    pub course_module_completions: Vec<CourseModuleCompletion>,
28}
29
30/// One UTC day's exercise-submission count for a module, used for the activity-density violins on the
31/// cross-course timeline. `day` is midnight of the day the submissions fall in.
32#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
33pub struct DailySubmissionCount {
34    pub day: DateTime<Utc>,
35    pub count: i32,
36}
37
38/// Slim module descriptor so the frontend can label per-module completions and show "X of Y modules"
39/// without a separate course-structure fetch. Default (base) module has `name = None`.
40#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
41pub struct CourseModuleInfo {
42    pub id: Uuid,
43    pub name: Option<String>,
44    pub order_number: i32,
45    /// Earliest exercise submission by this user in this module. No module "start" is stored, so the
46    /// frontend uses this to infer when an additional module was first worked on. `None` if untouched.
47    pub first_submission_at: Option<DateTime<Utc>>,
48    /// Number of non-deleted exercises in this module. Submission density is divided by this so courses
49    /// of very different size stay comparable. `0` if the module has no chapter-bound exercises.
50    pub exercise_count: i32,
51    /// This user's exercise submissions in this module bucketed by UTC day, ascending. Empty if none.
52    pub daily_submissions: Vec<DailySubmissionCount>,
53    /// ECTS credits the module is worth. `None` when the module grants no credits.
54    pub ects_credits: Option<f32>,
55    /// The module's course code in the university's registry, e.g. `BSCS1001`.
56    pub uh_course_code: Option<String>,
57    pub enable_credit_registration_via_suotar: bool,
58}
59
60#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
61
62pub struct CourseEnrollmentInfo {
63    pub course_id: Uuid,
64    pub course: Course,
65    pub course_instances: Vec<CourseInstance>,
66    pub user_course_settings: Option<UserCourseSettings>,
67    /// All non-deleted modules of the course, ordered by `order_number`.
68    pub course_modules: Vec<CourseModuleInfo>,
69    pub course_module_completions: Vec<CourseModuleCompletion>,
70    pub course_module_completions_needing_review: i32,
71    pub first_enrolled_at: DateTime<Utc>,
72    pub is_current: bool,
73}
74
75#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
76
77pub struct CourseEnrollmentsInfo {
78    pub course_enrollments: Vec<CourseEnrollmentInfo>,
79}
80
81pub async fn insert(
82    conn: &mut PgConnection,
83    user_id: Uuid,
84    course_id: Uuid,
85    course_instance_id: Uuid,
86) -> ModelResult<()> {
87    sqlx::query!(
88        "
89INSERT INTO course_instance_enrollments (user_id, course_id, course_instance_id)
90VALUES ($1, $2, $3)
91",
92        user_id,
93        course_id,
94        course_instance_id,
95    )
96    .execute(conn)
97    .await?;
98    Ok(())
99}
100
101#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
102pub struct NewCourseInstanceEnrollment {
103    pub user_id: Uuid,
104    pub course_id: Uuid,
105    pub course_instance_id: Uuid,
106}
107
108/**
109Inserts enrollment if it doesn't exist yet; on conflict updates deleted_at to NULL (upsert).
110
111Handles duplicate submissions (e.g. multiple tabs or parallel requests) by conflicting on (user_id, course_id, course_instance_id).
112*/
113pub async fn insert_enrollment_if_it_doesnt_exist(
114    conn: &mut PgConnection,
115    enrollment: NewCourseInstanceEnrollment,
116) -> ModelResult<CourseInstanceEnrollment> {
117    let enrollment = sqlx::query_as!(
118        CourseInstanceEnrollment,
119        "
120INSERT INTO course_instance_enrollments (user_id, course_id, course_instance_id)
121VALUES ($1, $2, $3)
122ON CONFLICT (user_id, course_id, course_instance_id)
123DO UPDATE SET deleted_at = NULL
124RETURNING *;
125",
126        enrollment.user_id,
127        enrollment.course_id,
128        enrollment.course_instance_id,
129    )
130    .fetch_one(conn)
131    .await?;
132    Ok(enrollment)
133}
134
135pub async fn insert_enrollment_and_set_as_current(
136    conn: &mut PgConnection,
137    new_enrollment: NewCourseInstanceEnrollment,
138) -> ModelResult<CourseInstanceEnrollment> {
139    let mut tx = conn.begin().await?;
140
141    let enrollment = insert_enrollment_if_it_doesnt_exist(&mut tx, new_enrollment).await?;
142    crate::user_course_settings::upsert_user_course_settings_for_enrollment(&mut tx, &enrollment)
143        .await?;
144    tx.commit().await?;
145
146    Ok(enrollment)
147}
148
149pub async fn get_by_user_and_course_instance_id(
150    conn: &mut PgConnection,
151    user_id: Uuid,
152    course_instance_id: Uuid,
153) -> ModelResult<CourseInstanceEnrollment> {
154    let res = sqlx::query_as!(
155        CourseInstanceEnrollment,
156        "
157SELECT *
158FROM course_instance_enrollments
159WHERE user_id = $1
160  AND course_instance_id = $2
161  AND deleted_at IS NULL
162        ",
163        user_id,
164        course_instance_id
165    )
166    .fetch_one(conn)
167    .await?;
168    Ok(res)
169}
170
171pub async fn get_by_user_id(
172    conn: &mut PgConnection,
173    user_id: Uuid,
174) -> ModelResult<Vec<CourseInstanceEnrollment>> {
175    let res = sqlx::query_as!(
176        CourseInstanceEnrollment,
177        "
178SELECT *
179FROM course_instance_enrollments
180WHERE user_id = $1
181  AND deleted_at IS NULL
182        ",
183        user_id
184    )
185    .fetch_all(conn)
186    .await?;
187    Ok(res)
188}
189
190pub async fn get_by_user_id_and_course_ids(
191    conn: &mut PgConnection,
192    user_id: Uuid,
193    course_ids: &[Uuid],
194) -> ModelResult<Vec<CourseInstanceEnrollment>> {
195    let res = sqlx::query_as!(
196        CourseInstanceEnrollment,
197        "
198SELECT *
199FROM course_instance_enrollments
200WHERE user_id = $1
201  AND course_id = ANY($2)
202  AND deleted_at IS NULL
203        ",
204        user_id,
205        course_ids
206    )
207    .fetch_all(conn)
208    .await?;
209    Ok(res)
210}
211
212pub async fn get_course_instance_enrollments_info_for_user(
213    conn: &mut PgConnection,
214    user_id: Uuid,
215) -> ModelResult<CourseInstanceEnrollmentsInfo> {
216    let course_instance_enrollments = get_by_user_id(conn, user_id).await?;
217
218    let course_instance_ids: Vec<Uuid> = course_instance_enrollments
219        .iter()
220        .map(|e| e.course_instance_id)
221        .collect();
222
223    let course_instances = crate::course_instances::get_by_ids(conn, &course_instance_ids).await?;
224
225    let course_ids: Vec<Uuid> = course_instances.iter().map(|e| e.course_id).collect();
226
227    let courses = crate::courses::get_by_ids(conn, &course_ids).await?;
228
229    let course_module_completions =
230        crate::course_module_completions::get_all_by_user_id(conn, user_id).await?;
231
232    // Returns all user course settings because there is always an enrollment for a current course instance (enforced by a database constraint), and all of those are in the course_ids list
233    let user_course_settings =
234        crate::user_course_settings::get_all_by_user_and_multiple_current_courses(
235            conn,
236            &course_ids,
237            user_id,
238        )
239        .await?;
240
241    Ok(CourseInstanceEnrollmentsInfo {
242        course_instance_enrollments,
243        course_instances,
244        courses,
245        user_course_settings,
246        course_module_completions,
247    })
248}
249
250struct CourseEnrollmentRow {
251    course_id: Uuid,
252    first_enrolled_at: Option<DateTime<Utc>>,
253}
254
255/// Returns one entry per course the user is enrolled in, with aggregated data.
256///
257/// Enrollments whose course has been soft-deleted are left out.
258pub async fn get_course_enrollments_info_for_user(
259    conn: &mut PgConnection,
260    user_id: Uuid,
261) -> ModelResult<CourseEnrollmentsInfo> {
262    let rows = sqlx::query_as!(
263        CourseEnrollmentRow,
264        "
265SELECT course_id, MIN(created_at) AS first_enrolled_at
266FROM course_instance_enrollments
267WHERE user_id = $1 AND deleted_at IS NULL
268GROUP BY course_id
269ORDER BY first_enrolled_at
270        ",
271        user_id
272    )
273    .fetch_all(&mut *conn)
274    .await?;
275
276    let course_ids: Vec<Uuid> = rows.iter().map(|r| r.course_id).collect();
277
278    // Earliest submission per module for this user; module ids are globally unique, so grouping by
279    // module alone is enough. Used to infer when an additional (non-base) module was first worked on.
280    // Restricts to non-deleted exercises/chapters like the exercise-count and daily-submission queries
281    // below, so all three agree on which content is in scope (soft-deleted content stays hidden).
282    let module_first_submission_rows = sqlx::query!(
283        r#"
284SELECT c.course_module_id AS "course_module_id!",
285       MIN(ess.created_at) AS "first_submission_at!"
286FROM exercise_slide_submissions ess
287JOIN exercises e ON e.id = ess.exercise_id AND e.deleted_at IS NULL
288JOIN chapters c ON c.id = e.chapter_id AND c.deleted_at IS NULL
289WHERE ess.user_id = $1 AND ess.course_id = ANY($2) AND ess.deleted_at IS NULL
290GROUP BY c.course_module_id
291        "#,
292        user_id,
293        &course_ids
294    )
295    .fetch_all(&mut *conn)
296    .await?;
297    let first_submission_by_module: HashMap<Uuid, DateTime<Utc>> = module_first_submission_rows
298        .into_iter()
299        .map(|r| (r.course_module_id, r.first_submission_at))
300        .collect();
301
302    // Exercise count per module, to normalize submission density (submissions per exercise) so courses
303    // of very different size are comparable. Exercises map to a module via their chapter; exercises with
304    // no chapter (e.g. exams) are not counted.
305    let module_exercise_count_rows = sqlx::query!(
306        r#"
307SELECT c.course_module_id AS "course_module_id!",
308       COUNT(*) AS "count!"
309FROM exercises e
310JOIN chapters c ON c.id = e.chapter_id AND c.deleted_at IS NULL
311WHERE e.course_id = ANY($1) AND e.deleted_at IS NULL
312GROUP BY c.course_module_id
313        "#,
314        &course_ids
315    )
316    .fetch_all(&mut *conn)
317    .await?;
318    let exercise_count_by_module: HashMap<Uuid, i64> = module_exercise_count_rows
319        .into_iter()
320        .map(|r| (r.course_module_id, r.count))
321        .collect();
322
323    // This user's submissions per module bucketed by UTC day, for the activity-density violins. Restricts
324    // to the same non-deleted exercises/chapters as the exercise-count query above so the density
325    // numerator and denominator agree; DATE_TRUNC is anchored to UTC (not the session timezone) so the
326    // buckets line up with the frontend's fixed 24h grid.
327    let module_daily_submission_rows = sqlx::query!(
328        r#"
329SELECT c.course_module_id AS "course_module_id!",
330       DATE_TRUNC('day', ess.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS "day!",
331       COUNT(*) AS "count!"
332FROM exercise_slide_submissions ess
333JOIN exercises e ON e.id = ess.exercise_id AND e.deleted_at IS NULL
334JOIN chapters c ON c.id = e.chapter_id AND c.deleted_at IS NULL
335WHERE ess.user_id = $1 AND ess.course_id = ANY($2) AND ess.deleted_at IS NULL
336GROUP BY c.course_module_id, DATE_TRUNC('day', ess.created_at AT TIME ZONE 'UTC')
337ORDER BY c.course_module_id, "day!"
338        "#,
339        user_id,
340        &course_ids
341    )
342    .fetch_all(&mut *conn)
343    .await?;
344    let mut daily_submissions_by_module: HashMap<Uuid, Vec<DailySubmissionCount>> = HashMap::new();
345    for r in module_daily_submission_rows {
346        daily_submissions_by_module
347            .entry(r.course_module_id)
348            .or_default()
349            .push(DailySubmissionCount {
350                day: r.day,
351                count: r.count as i32,
352            });
353    }
354
355    // The flag is not on the `CourseModule` DTO, so `get_by_course_ids` below cannot supply it.
356    let credit_registration_enabled_module_ids: HashSet<Uuid> = sqlx::query_scalar!(
357        "
358SELECT id
359FROM course_modules
360WHERE course_id = ANY($1)
361  AND enable_credit_registration_via_suotar
362  AND deleted_at IS NULL
363        ",
364        &course_ids
365    )
366    .fetch_all(&mut *conn)
367    .await?
368    .into_iter()
369    .collect();
370
371    let course_instance_enrollments = get_by_user_id(&mut *conn, user_id).await?;
372    let all_course_module_completions =
373        crate::course_module_completions::get_all_by_user_id(conn, user_id).await?;
374    let user_course_settings =
375        crate::user_course_settings::get_all_by_user_id(conn, user_id).await?;
376    let courses = crate::courses::get_by_ids(conn, &course_ids).await?;
377    let course_instance_ids: Vec<Uuid> = course_instance_enrollments
378        .iter()
379        .map(|e| e.course_instance_id)
380        .collect();
381    let all_course_instances =
382        crate::course_instances::get_by_ids(conn, &course_instance_ids).await?;
383    let all_course_modules = crate::course_modules::get_by_course_ids(conn, &course_ids).await?;
384
385    let mut course_enrollments = Vec::with_capacity(rows.len());
386    for row in rows {
387        // A missing course row means the course was soft-deleted; erroring here would take the
388        // user's other courses down with it.
389        let Some(course) = courses.iter().find(|c| c.id == row.course_id).cloned() else {
390            warn!(
391                user_id = %user_id,
392                course_id = %row.course_id,
393                "Skipping enrollment because its course has been deleted"
394            );
395            continue;
396        };
397        let course_instances: Vec<_> = all_course_instances
398            .iter()
399            .filter(|ci| ci.course_id == row.course_id)
400            .cloned()
401            .collect();
402        let user_course_settings_for_course = user_course_settings
403            .iter()
404            .find(|ucs| ucs.course_language_group_id == course.course_language_group_id)
405            .cloned();
406        let course_modules = all_course_modules
407            .iter()
408            .filter(|m| m.course_id == row.course_id)
409            .map(|m| CourseModuleInfo {
410                id: m.id,
411                name: m.name.clone(),
412                order_number: m.order_number,
413                first_submission_at: first_submission_by_module.get(&m.id).copied(),
414                exercise_count: exercise_count_by_module.get(&m.id).copied().unwrap_or(0) as i32,
415                daily_submissions: daily_submissions_by_module
416                    .get(&m.id)
417                    .cloned()
418                    .unwrap_or_default(),
419                ects_credits: m.ects_credits,
420                uh_course_code: m.uh_course_code.clone(),
421                enable_credit_registration_via_suotar: credit_registration_enabled_module_ids
422                    .contains(&m.id),
423            })
424            .collect();
425        let course_module_completions = all_course_module_completions
426            .iter()
427            .filter(|cmc| cmc.course_id == row.course_id)
428            .cloned()
429            .collect();
430        let course_module_completions_needing_review = all_course_module_completions
431            .iter()
432            .filter(|cmc| cmc.course_id == row.course_id && cmc.needs_to_be_reviewed)
433            .count() as i32;
434        let is_current = user_course_settings_for_course
435            .as_ref()
436            .map(|ucs| ucs.current_course_id == row.course_id)
437            .unwrap_or(false);
438
439        let first_enrolled_at = row.first_enrolled_at.ok_or_else(|| {
440            crate::ModelError::new(
441                crate::error::ModelErrorType::Generic,
442                "first_enrolled_at missing for grouped enrollment row".to_string(),
443                None,
444            )
445        })?;
446
447        course_enrollments.push(CourseEnrollmentInfo {
448            course_id: row.course_id,
449            course,
450            course_instances,
451            user_course_settings: user_course_settings_for_course,
452            course_modules,
453            course_module_completions,
454            course_module_completions_needing_review,
455            first_enrolled_at,
456            is_current,
457        });
458    }
459
460    Ok(CourseEnrollmentsInfo { course_enrollments })
461}