Skip to main content

headless_lms_models/
course_instance_enrollments.rs

1use crate::{
2    course_instances::CourseInstance, course_module_completions::CourseModuleCompletion,
3    courses::Course, prelude::*, user_course_settings::UserCourseSettings,
4};
5use utoipa::ToSchema;
6
7#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
8
9pub struct CourseInstanceEnrollment {
10    pub user_id: Uuid,
11    pub course_id: Uuid,
12    pub course_instance_id: Uuid,
13    pub created_at: DateTime<Utc>,
14    pub updated_at: DateTime<Utc>,
15    pub deleted_at: Option<DateTime<Utc>>,
16}
17
18#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
19
20pub struct CourseInstanceEnrollmentsInfo {
21    pub course_instance_enrollments: Vec<CourseInstanceEnrollment>,
22    pub course_instances: Vec<CourseInstance>,
23    pub courses: Vec<Course>,
24    pub user_course_settings: Vec<UserCourseSettings>,
25    pub course_module_completions: Vec<CourseModuleCompletion>,
26}
27
28#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
29
30pub struct CourseEnrollmentInfo {
31    pub course_id: Uuid,
32    pub course: Course,
33    pub course_instances: Vec<CourseInstance>,
34    pub user_course_settings: Option<UserCourseSettings>,
35    pub course_module_completions: Vec<CourseModuleCompletion>,
36    pub course_module_completions_needing_review: i32,
37    pub first_enrolled_at: DateTime<Utc>,
38    pub is_current: bool,
39}
40
41#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
42
43pub struct CourseEnrollmentsInfo {
44    pub course_enrollments: Vec<CourseEnrollmentInfo>,
45}
46
47pub async fn insert(
48    conn: &mut PgConnection,
49    user_id: Uuid,
50    course_id: Uuid,
51    course_instance_id: Uuid,
52) -> ModelResult<()> {
53    sqlx::query!(
54        "
55INSERT INTO course_instance_enrollments (user_id, course_id, course_instance_id)
56VALUES ($1, $2, $3)
57",
58        user_id,
59        course_id,
60        course_instance_id,
61    )
62    .execute(conn)
63    .await?;
64    Ok(())
65}
66
67#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
68pub struct NewCourseInstanceEnrollment {
69    pub user_id: Uuid,
70    pub course_id: Uuid,
71    pub course_instance_id: Uuid,
72}
73
74/**
75Inserts enrollment if it doesn't exist yet; on conflict updates deleted_at to NULL (upsert).
76
77Handles duplicate submissions (e.g. multiple tabs or parallel requests) by conflicting on (user_id, course_id, course_instance_id).
78*/
79pub async fn insert_enrollment_if_it_doesnt_exist(
80    conn: &mut PgConnection,
81    enrollment: NewCourseInstanceEnrollment,
82) -> ModelResult<CourseInstanceEnrollment> {
83    let enrollment = sqlx::query_as!(
84        CourseInstanceEnrollment,
85        "
86INSERT INTO course_instance_enrollments (user_id, course_id, course_instance_id)
87VALUES ($1, $2, $3)
88ON CONFLICT (user_id, course_id, course_instance_id)
89DO UPDATE SET deleted_at = NULL
90RETURNING *;
91",
92        enrollment.user_id,
93        enrollment.course_id,
94        enrollment.course_instance_id,
95    )
96    .fetch_one(conn)
97    .await?;
98    Ok(enrollment)
99}
100
101pub async fn insert_enrollment_and_set_as_current(
102    conn: &mut PgConnection,
103    new_enrollment: NewCourseInstanceEnrollment,
104) -> ModelResult<CourseInstanceEnrollment> {
105    let mut tx = conn.begin().await?;
106
107    let enrollment = insert_enrollment_if_it_doesnt_exist(&mut tx, new_enrollment).await?;
108    crate::user_course_settings::upsert_user_course_settings_for_enrollment(&mut tx, &enrollment)
109        .await?;
110    tx.commit().await?;
111
112    Ok(enrollment)
113}
114
115pub async fn get_by_user_and_course_instance_id(
116    conn: &mut PgConnection,
117    user_id: Uuid,
118    course_instance_id: Uuid,
119) -> ModelResult<CourseInstanceEnrollment> {
120    let res = sqlx::query_as!(
121        CourseInstanceEnrollment,
122        "
123SELECT *
124FROM course_instance_enrollments
125WHERE user_id = $1
126  AND course_instance_id = $2
127  AND deleted_at IS NULL
128        ",
129        user_id,
130        course_instance_id
131    )
132    .fetch_one(conn)
133    .await?;
134    Ok(res)
135}
136
137pub async fn get_by_user_id(
138    conn: &mut PgConnection,
139    user_id: Uuid,
140) -> ModelResult<Vec<CourseInstanceEnrollment>> {
141    let res = sqlx::query_as!(
142        CourseInstanceEnrollment,
143        "
144SELECT *
145FROM course_instance_enrollments
146WHERE user_id = $1
147  AND deleted_at IS NULL
148        ",
149        user_id
150    )
151    .fetch_all(conn)
152    .await?;
153    Ok(res)
154}
155
156pub async fn get_by_user_id_and_course_ids(
157    conn: &mut PgConnection,
158    user_id: Uuid,
159    course_ids: &[Uuid],
160) -> ModelResult<Vec<CourseInstanceEnrollment>> {
161    let res = sqlx::query_as!(
162        CourseInstanceEnrollment,
163        "
164SELECT *
165FROM course_instance_enrollments
166WHERE user_id = $1
167  AND course_id = ANY($2)
168  AND deleted_at IS NULL
169        ",
170        user_id,
171        course_ids
172    )
173    .fetch_all(conn)
174    .await?;
175    Ok(res)
176}
177
178pub async fn get_course_instance_enrollments_info_for_user(
179    conn: &mut PgConnection,
180    user_id: Uuid,
181) -> ModelResult<CourseInstanceEnrollmentsInfo> {
182    let course_instance_enrollments = get_by_user_id(conn, user_id).await?;
183
184    let course_instance_ids: Vec<Uuid> = course_instance_enrollments
185        .iter()
186        .map(|e| e.course_instance_id)
187        .collect();
188
189    let course_instances = crate::course_instances::get_by_ids(conn, &course_instance_ids).await?;
190
191    let course_ids: Vec<Uuid> = course_instances.iter().map(|e| e.course_id).collect();
192
193    let courses = crate::courses::get_by_ids(conn, &course_ids).await?;
194
195    let course_module_completions =
196        crate::course_module_completions::get_all_by_user_id(conn, user_id).await?;
197
198    // 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
199    let user_course_settings =
200        crate::user_course_settings::get_all_by_user_and_multiple_current_courses(
201            conn,
202            &course_ids,
203            user_id,
204        )
205        .await?;
206
207    Ok(CourseInstanceEnrollmentsInfo {
208        course_instance_enrollments,
209        course_instances,
210        courses,
211        user_course_settings,
212        course_module_completions,
213    })
214}
215
216struct CourseEnrollmentRow {
217    course_id: Uuid,
218    first_enrolled_at: Option<DateTime<Utc>>,
219}
220
221/// Returns one entry per course the user is enrolled in, with aggregated data.
222pub async fn get_course_enrollments_info_for_user(
223    conn: &mut PgConnection,
224    user_id: Uuid,
225) -> ModelResult<CourseEnrollmentsInfo> {
226    let rows = sqlx::query_as!(
227        CourseEnrollmentRow,
228        "
229SELECT course_id, MIN(created_at) AS first_enrolled_at
230FROM course_instance_enrollments
231WHERE user_id = $1 AND deleted_at IS NULL
232GROUP BY course_id
233ORDER BY first_enrolled_at
234        ",
235        user_id
236    )
237    .fetch_all(&mut *conn)
238    .await?;
239
240    let course_ids: Vec<Uuid> = rows.iter().map(|r| r.course_id).collect();
241
242    let course_instance_enrollments = get_by_user_id(&mut *conn, user_id).await?;
243    let all_course_module_completions =
244        crate::course_module_completions::get_all_by_user_id(conn, user_id).await?;
245    let user_course_settings =
246        crate::user_course_settings::get_all_by_user_id(conn, user_id).await?;
247    let courses = crate::courses::get_by_ids(conn, &course_ids).await?;
248    let course_instance_ids: Vec<Uuid> = course_instance_enrollments
249        .iter()
250        .map(|e| e.course_instance_id)
251        .collect();
252    let all_course_instances =
253        crate::course_instances::get_by_ids(conn, &course_instance_ids).await?;
254
255    let mut course_enrollments = Vec::with_capacity(rows.len());
256    for row in rows {
257        let course = courses
258            .iter()
259            .find(|c| c.id == row.course_id)
260            .cloned()
261            .ok_or_else(|| {
262                crate::ModelError::new(
263                    crate::error::ModelErrorType::NotFound,
264                    "Course not found for enrollment".to_string(),
265                    None,
266                )
267            })?;
268        let course_instances: Vec<_> = all_course_instances
269            .iter()
270            .filter(|ci| ci.course_id == row.course_id)
271            .cloned()
272            .collect();
273        let user_course_settings_for_course = user_course_settings
274            .iter()
275            .find(|ucs| ucs.course_language_group_id == course.course_language_group_id)
276            .cloned();
277        let course_module_completions = all_course_module_completions
278            .iter()
279            .filter(|cmc| cmc.course_id == row.course_id)
280            .cloned()
281            .collect();
282        let course_module_completions_needing_review = all_course_module_completions
283            .iter()
284            .filter(|cmc| cmc.course_id == row.course_id && cmc.needs_to_be_reviewed)
285            .count() as i32;
286        let is_current = user_course_settings_for_course
287            .as_ref()
288            .map(|ucs| ucs.current_course_id == row.course_id)
289            .unwrap_or(false);
290
291        let first_enrolled_at = row.first_enrolled_at.ok_or_else(|| {
292            crate::ModelError::new(
293                crate::error::ModelErrorType::Generic,
294                "first_enrolled_at missing for grouped enrollment row".to_string(),
295                None,
296            )
297        })?;
298
299        course_enrollments.push(CourseEnrollmentInfo {
300            course_id: row.course_id,
301            course,
302            course_instances,
303            user_course_settings: user_course_settings_for_course,
304            course_module_completions,
305            course_module_completions_needing_review,
306            first_enrolled_at,
307            is_current,
308        });
309    }
310
311    Ok(CourseEnrollmentsInfo { course_enrollments })
312}