headless_lms_models/
roles.rs

1use crate::prelude::*;
2
3#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type)]
4#[cfg_attr(feature = "ts_rs", derive(TS))]
5#[sqlx(type_name = "user_role", rename_all = "snake_case")]
6pub enum UserRole {
7    Reviewer,
8    Assistant,
9    Teacher,
10    Admin,
11    CourseOrExamCreator,
12    MaterialViewer,
13    TeachingAndLearningServices,
14    StatsViewer,
15}
16
17#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
18pub struct Role {
19    pub is_global: bool,
20    pub organization_id: Option<Uuid>,
21    pub course_id: Option<Uuid>,
22    pub course_instance_id: Option<Uuid>,
23    pub exam_id: Option<Uuid>,
24    pub role: UserRole,
25    pub user_id: Uuid,
26}
27
28impl Role {
29    pub fn is_global(&self) -> bool {
30        self.is_global
31    }
32
33    pub fn is_role_for_organization(&self, organization_id: Uuid) -> bool {
34        self.organization_id
35            .map(|id| id == organization_id)
36            .unwrap_or_default()
37    }
38
39    pub fn is_role_for_course(&self, course_id: Uuid) -> bool {
40        self.course_id.map(|id| id == course_id).unwrap_or_default()
41    }
42
43    pub fn is_role_for_course_instance(&self, course_instance_id: Uuid) -> bool {
44        self.course_instance_id
45            .map(|id| id == course_instance_id)
46            .unwrap_or_default()
47    }
48
49    pub fn is_role_for_exam(&self, exam_id: Uuid) -> bool {
50        self.exam_id.map(|id| id == exam_id).unwrap_or_default()
51    }
52
53    /// Returns a human-readable description of the domain this role applies to
54    pub fn domain_description(&self) -> String {
55        if self.is_global {
56            "Global".to_string()
57        } else if let Some(id) = self.organization_id {
58            return format!("Organization {}", id);
59        } else if let Some(id) = self.course_id {
60            return format!("Course {}", id);
61        } else if let Some(id) = self.course_instance_id {
62            return format!("CourseInstance {}", id);
63        } else if let Some(id) = self.exam_id {
64            return format!("Exam {}", id);
65        } else {
66            return "Unknown domain".to_string();
67        }
68    }
69}
70
71#[derive(Debug, Clone, Copy, Deserialize)]
72#[cfg_attr(feature = "ts_rs", derive(TS))]
73#[serde(tag = "tag", content = "id")]
74pub enum RoleDomain {
75    Global,
76    Organization(Uuid),
77    Course(Uuid),
78    CourseInstance(Uuid),
79    Exam(Uuid),
80}
81
82#[derive(Debug, Deserialize)]
83#[cfg_attr(feature = "ts_rs", derive(TS))]
84pub struct RoleInfo {
85    pub email: String,
86    pub role: UserRole,
87    pub domain: RoleDomain,
88}
89
90#[derive(Debug, Serialize)]
91#[cfg_attr(feature = "ts_rs", derive(TS))]
92pub struct RoleUser {
93    pub id: Uuid,
94    pub first_name: Option<String>,
95    pub last_name: Option<String>,
96    pub email: String,
97    pub role: UserRole,
98}
99
100pub async fn get(conn: &mut PgConnection, domain: RoleDomain) -> ModelResult<Vec<RoleUser>> {
101    let users = match domain {
102        RoleDomain::Global => {
103            sqlx::query_as!(
104                RoleUser,
105                r#"
106SELECT users.id AS "id!",
107  user_details.first_name,
108  user_details.last_name,
109  user_details.email,
110  role AS "role!: UserRole"
111FROM users
112  JOIN roles ON users.id = roles.user_id
113  JOIN user_details ON users.id = user_details.user_id
114WHERE is_global = TRUE
115AND roles.deleted_at IS NULL
116"#,
117            )
118            .fetch_all(conn)
119            .await?
120        }
121        RoleDomain::Organization(id) => {
122            sqlx::query_as!(
123                RoleUser,
124                r#"
125SELECT users.id,
126  user_details.first_name,
127  user_details.last_name,
128  user_details.email,
129  role AS "role: UserRole"
130FROM users
131  JOIN roles ON users.id = roles.user_id
132  JOIN user_details ON users.id = user_details.user_id
133WHERE roles.organization_id = $1
134AND roles.deleted_at IS NULL
135"#,
136                id
137            )
138            .fetch_all(conn)
139            .await?
140        }
141        RoleDomain::Course(id) => {
142            sqlx::query_as!(
143                RoleUser,
144                r#"
145SELECT users.id,
146  user_details.first_name,
147  user_details.last_name,
148  user_details.email,
149  role AS "role: UserRole"
150FROM users
151  JOIN roles ON users.id = roles.user_id
152  JOIN user_details ON users.id = user_details.user_id
153WHERE roles.course_id = $1
154AND roles.deleted_at IS NULL
155"#,
156                id
157            )
158            .fetch_all(conn)
159            .await?
160        }
161        RoleDomain::CourseInstance(id) => {
162            sqlx::query_as!(
163                RoleUser,
164                r#"
165SELECT users.id,
166  user_details.first_name,
167  user_details.last_name,
168  user_details.email,
169  role AS "role: UserRole"
170FROM users
171  JOIN roles ON users.id = roles.user_id
172  JOIN user_details ON users.id = user_details.user_id
173WHERE roles.course_instance_id = $1
174AND roles.deleted_at IS NULL
175"#,
176                id
177            )
178            .fetch_all(conn)
179            .await?
180        }
181        RoleDomain::Exam(id) => {
182            sqlx::query_as!(
183                RoleUser,
184                r#"
185SELECT users.id,
186  user_details.first_name,
187  user_details.last_name,
188  user_details.email,
189  role AS "role: UserRole"
190FROM users
191  JOIN roles ON users.id = roles.user_id
192  JOIN user_details ON users.id = user_details.user_id
193WHERE roles.exam_id = $1
194AND roles.deleted_at IS NULL
195"#,
196                id
197            )
198            .fetch_all(conn)
199            .await?
200        }
201    };
202    Ok(users)
203}
204
205pub async fn insert(
206    conn: &mut PgConnection,
207    user_id: Uuid,
208    role: UserRole,
209    domain: RoleDomain,
210) -> ModelResult<Uuid> {
211    let id = match domain {
212        RoleDomain::Global => {
213            sqlx::query!(
214                "
215INSERT INTO roles (user_id, role, is_global)
216VALUES ($1, $2, True)
217RETURNING id
218",
219                user_id,
220                role as UserRole
221            )
222            .fetch_one(conn)
223            .await?
224            .id
225        }
226        RoleDomain::Organization(id) => {
227            sqlx::query!(
228                "
229INSERT INTO roles (user_id, role, organization_id)
230VALUES ($1, $2, $3)
231RETURNING id
232",
233                user_id,
234                role as UserRole,
235                id
236            )
237            .fetch_one(conn)
238            .await?
239            .id
240        }
241        RoleDomain::Course(id) => {
242            sqlx::query!(
243                "
244INSERT INTO roles (user_id, role, course_id)
245VALUES ($1, $2, $3)
246RETURNING id
247",
248                user_id,
249                role as UserRole,
250                id
251            )
252            .fetch_one(conn)
253            .await?
254            .id
255        }
256        RoleDomain::CourseInstance(id) => {
257            sqlx::query!(
258                "
259INSERT INTO roles (user_id, role, course_instance_id)
260VALUES ($1, $2, $3)
261RETURNING id
262",
263                user_id,
264                role as UserRole,
265                id
266            )
267            .fetch_one(conn)
268            .await?
269            .id
270        }
271        RoleDomain::Exam(id) => {
272            sqlx::query!(
273                "
274INSERT INTO roles (user_id, role, exam_id)
275VALUES ($1, $2, $3)
276RETURNING id
277",
278                user_id,
279                role as UserRole,
280                id
281            )
282            .fetch_one(conn)
283            .await?
284            .id
285        }
286    };
287    Ok(id)
288}
289
290pub async fn remove(
291    conn: &mut PgConnection,
292    user_id: Uuid,
293    role: UserRole,
294    domain: RoleDomain,
295) -> ModelResult<()> {
296    match domain {
297        RoleDomain::Global => {
298            sqlx::query!(
299                "
300UPDATE roles
301SET deleted_at = NOW()
302WHERE user_id = $1
303  AND role = $2
304  AND deleted_at IS NULL
305",
306                user_id,
307                role as UserRole
308            )
309            .execute(conn)
310            .await?;
311        }
312        RoleDomain::Organization(id) => {
313            sqlx::query!(
314                "
315UPDATE roles
316SET deleted_at = NOW()
317WHERE user_id = $1
318  AND role = $2
319  AND organization_id = $3
320  AND deleted_at IS NULL
321",
322                user_id,
323                role as UserRole,
324                id
325            )
326            .execute(conn)
327            .await?;
328        }
329        RoleDomain::Course(id) => {
330            sqlx::query!(
331                "
332UPDATE roles
333SET deleted_at = NOW()
334WHERE user_id = $1
335  AND role = $2
336  AND course_id = $3
337  AND deleted_at IS NULL
338",
339                user_id,
340                role as UserRole,
341                id
342            )
343            .execute(conn)
344            .await?;
345        }
346        RoleDomain::CourseInstance(id) => {
347            sqlx::query!(
348                "
349UPDATE roles
350SET deleted_at = NOW()
351WHERE user_id = $1
352  AND role = $2
353  AND course_instance_id = $3
354  AND deleted_at IS NULL
355",
356                user_id,
357                role as UserRole,
358                id
359            )
360            .execute(conn)
361            .await?;
362        }
363        RoleDomain::Exam(id) => {
364            sqlx::query!(
365                "
366UPDATE roles
367SET deleted_at = NOW()
368WHERE user_id = $1
369  AND role = $2
370  AND exam_id = $3
371  AND deleted_at IS NULL
372",
373                user_id,
374                role as UserRole,
375                id
376            )
377            .execute(conn)
378            .await?;
379        }
380    }
381    Ok(())
382}
383
384pub async fn get_roles(conn: &mut PgConnection, user_id: Uuid) -> ModelResult<Vec<Role>> {
385    let roles = sqlx::query_as!(
386        Role,
387        r#"
388SELECT is_global,
389  organization_id,
390  course_id,
391  course_instance_id,
392  exam_id,
393  role AS "role: UserRole",
394  user_id
395FROM roles
396WHERE user_id = $1
397AND roles.deleted_at IS NULL
398"#,
399        user_id
400    )
401    .fetch_all(conn)
402    .await?;
403    Ok(roles)
404}
405
406/// Gets all roles related to a specific course.
407/// This includes:
408/// - Global roles
409/// - Organization roles for the organization that owns the course
410/// - Course roles for this specific course
411/// - Course instance roles for any instance of this course
412pub async fn get_course_related_roles(
413    conn: &mut PgConnection,
414    course_id: Uuid,
415) -> ModelResult<Vec<Role>> {
416    let roles = sqlx::query_as!(
417        Role,
418        r#"
419WITH course_org AS (
420  SELECT organization_id
421  FROM courses
422  WHERE id = $1
423    AND deleted_at IS NULL
424)
425SELECT is_global,
426  organization_id,
427  course_id,
428  course_instance_id,
429  exam_id,
430  role AS "role: UserRole",
431  user_id
432FROM roles
433WHERE (
434    is_global = TRUE
435    OR organization_id = (
436      SELECT organization_id
437      FROM course_org
438    )
439    OR course_id = $1
440    OR course_instance_id IN (
441      SELECT id
442      FROM course_instances
443      WHERE course_id = $1
444        AND deleted_at IS NULL
445    )
446  )
447  AND deleted_at IS NULL
448"#,
449        course_id
450    )
451    .fetch_all(conn)
452    .await?;
453
454    Ok(roles)
455}
456
457/// Gets all roles related to any course in a language group.
458/// This includes global roles, organization roles for any organization that has a course in the group,
459/// course roles for any course in the group, and course instance roles for any instance of those courses.
460pub async fn get_course_language_group_related_roles(
461    conn: &mut PgConnection,
462    course_language_group_id: Uuid,
463) -> ModelResult<Vec<Role>> {
464    let roles = sqlx::query_as!(
465        Role,
466        r#"
467WITH course_org AS (
468  SELECT DISTINCT organization_id
469  FROM courses
470  WHERE course_language_group_id = $1
471    AND deleted_at IS NULL
472)
473SELECT is_global,
474  organization_id,
475  course_id,
476  course_instance_id,
477  exam_id,
478  role AS "role: UserRole",
479  user_id
480FROM roles
481WHERE (
482    is_global = TRUE
483    OR organization_id IN (
484      SELECT organization_id
485      FROM course_org
486    )
487    OR course_id IN (
488      SELECT id
489      FROM courses
490      WHERE course_language_group_id = $1
491        AND deleted_at IS NULL
492    )
493    OR course_instance_id IN (
494      SELECT ci.id
495      FROM course_instances ci
496        JOIN courses c ON ci.course_id = c.id
497      WHERE c.course_language_group_id = $1
498        AND ci.deleted_at IS NULL
499    )
500  )
501  AND deleted_at IS NULL
502"#,
503        course_language_group_id
504    )
505    .fetch_all(conn)
506    .await?;
507
508    Ok(roles)
509}