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