Skip to main content

headless_lms_models/
roles.rs

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