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