api/routes/modules/
get.rs

1//! Module query routes.
2//!
3//! Provides endpoints to retrieve modules:
4//! - `GET /api/modules/{id}` → Get details of a single module with assigned users.
5//! - `GET /api/modules` → Paginated and optionally filtered list of modules.
6//! - `GET /api/modules/me` → Retrieve modules for the authenticated user, grouped by role.
7//!
8//! All responses follow the standard `ApiResponse` format.
9
10use crate::routes::common::UserResponse;
11use crate::{auth::AuthUser, response::ApiResponse};
12use axum::{
13    Extension, Json,
14    extract::{Path, Query, State},
15    http::StatusCode,
16    response::{IntoResponse, Response},
17};
18use db::models::user_module_role;
19use db::models::{
20    module::{Column as ModuleCol, Entity as ModuleEntity, Model as Module},
21    user::{Column as UserCol, Entity as UserEntity, Model as UserModel},
22    user_module_role::{Column as RoleCol, Entity as RoleEntity, Role},
23};
24use sea_orm::{
25    ColumnTrait, Condition, DatabaseConnection, EntityTrait, JoinType, PaginatorTrait, QueryFilter,
26    QueryOrder, QuerySelect,
27};
28use serde::{Deserialize, Serialize};
29use util::state::AppState;
30
31#[derive(Debug, Serialize, Deserialize)]
32pub struct ModuleResponse {
33    pub id: i64,
34    pub code: String,
35    pub year: i32,
36    pub description: Option<String>,
37    pub credits: i32,
38    pub created_at: String,
39    pub updated_at: String,
40    pub lecturers: Vec<UserResponse>,
41    pub tutors: Vec<UserResponse>,
42    pub students: Vec<UserResponse>,
43}
44
45impl From<db::models::module::Model> for ModuleResponse {
46    fn from(m: db::models::module::Model) -> Self {
47        Self {
48            id: m.id,
49            code: m.code,
50            year: m.year,
51            description: m.description,
52            credits: m.credits,
53            created_at: m.created_at.to_rfc3339(),
54            updated_at: m.updated_at.to_rfc3339(),
55            lecturers: vec![],
56            tutors: vec![],
57            students: vec![],
58        }
59    }
60}
61
62/// GET /api/modules/{module_id}
63///
64/// Retrieves detailed information about a specific module, including assigned lecturers, tutors, and students.
65///
66/// # Arguments
67///
68/// The argument is extracted automatically from the HTTP route:
69/// - Path parameter `module_id`: The ID of the module to retrieve.
70///
71/// # Returns
72///
73/// Returns an HTTP response indicating the result:
74/// - `200 OK` with the full module details (including associated lecturers, tutors, and students) if successful.
75/// - `404 NOT FOUND` if no module is found with the given `module_id`.
76/// - `500 INTERNAL SERVER ERROR` if a database error occurs or if related personnel data (lecturers, tutors, or students) fails to load.
77///
78/// The response body is a JSON object using a standardized API response format, containing:
79/// - Module information.
80/// - Lists of users for each role (lecturers, tutors, students), each mapped to `UserResponse`.
81///
82/// # Example Response
83///
84/// - `200 OK`  
85/// ```json
86/// {
87///   "success": true,
88///   "data": {
89///     "id": 1,
90///     "code": "CS101",
91///     "year": 2024,
92///     "description": "Introduction to Computer Science",
93///     "credits": 15,
94///     "created_at": "2024-01-15T10:00:00Z",
95///     "updated_at": "2024-01-15T10:00:00Z",
96///     "lecturers": [
97///       {
98///         "id": 1,
99///         "username": "lecturer1",
100///         "email": "[email protected]",
101///         "admin": false,
102///         "created_at": "2024-01-01T00:00:00Z",
103///         "updated_at": "2024-01-01T00:00:00Z"
104///       }
105///     ],
106///     "tutors": [
107///       {
108///         "id": 2,
109///         "username": "tutor1",
110///         "email": "[email protected]",
111///         "admin": false,
112///         "created_at": "2024-01-01T00:00:00Z",
113///         "updated_at": "2024-01-01T00:00:00Z"
114///       }
115///     ],
116///     "students": [
117///       {
118///         "id": 3,
119///         "username": "student1",
120///         "email": "[email protected]",
121///         "admin": false,
122///         "created_at": "2024-01-01T00:00:00Z",
123///         "updated_at": "2024-01-01T00:00:00Z"
124///       }
125///     ]
126///   },
127///   "message": "Module retrieved successfully"
128/// }
129/// ```
130///
131/// - `404 Not Found`  
132/// ```json
133/// {
134///   "success": false,
135///   "message": "Module not found"
136/// }
137/// ```
138///
139/// - `500 Internal Server Error`  
140/// ```json
141/// {
142///   "success": false,
143///   "message": "Database error retrieving module"
144/// }
145/// ```
146pub async fn get_module(State(state): State<AppState>, Path(module_id): Path<i64>) -> Response {
147    let db = state.db();
148
149    let module = ModuleEntity::find_by_id(module_id)
150        .one(db)
151        .await
152        .unwrap()
153        .unwrap();
154
155    let (lecturers, tutors, students) = tokio::join!(
156        get_users_by_role(db, module_id, Role::Lecturer),
157        get_users_by_role(db, module_id, Role::Tutor),
158        get_users_by_role(db, module_id, Role::Student),
159    );
160
161    if lecturers.is_err() || tutors.is_err() || students.is_err() {
162        return (
163            StatusCode::INTERNAL_SERVER_ERROR,
164            Json(ApiResponse::<()>::error(
165                "Failed to retrieve assigned personnel",
166            )),
167        )
168            .into_response();
169    }
170
171    let mut response = ModuleResponse::from(module);
172    response.lecturers = lecturers
173        .unwrap()
174        .into_iter()
175        .map(UserResponse::from)
176        .collect();
177    response.tutors = tutors
178        .unwrap()
179        .into_iter()
180        .map(UserResponse::from)
181        .collect();
182    response.students = students
183        .unwrap()
184        .into_iter()
185        .map(UserResponse::from)
186        .collect();
187
188    (
189        StatusCode::OK,
190        Json(ApiResponse::success(
191            response,
192            "Module retrieved successfully",
193        )),
194    )
195        .into_response()
196}
197
198async fn get_users_by_role(
199    db: &DatabaseConnection,
200    module_id: i64,
201    role: Role,
202) -> Result<Vec<UserModel>, sea_orm::DbErr> {
203    UserEntity::find()
204        .join(
205            JoinType::InnerJoin,
206            UserEntity::belongs_to(RoleEntity)
207                .from(UserCol::Id)
208                .to(RoleCol::UserId)
209                .into(),
210        )
211        .filter(
212            Condition::all()
213                .add(RoleCol::ModuleId.eq(module_id))
214                .add(RoleCol::Role.eq(role)),
215        )
216        .all(db)
217        .await
218}
219
220#[derive(Debug, Deserialize)]
221pub struct FilterReq {
222    pub page: Option<i32>,
223    pub per_page: Option<i32>,
224    pub query: Option<String>,
225    pub code: Option<String>,
226    pub year: Option<i32>,
227    pub sort: Option<String>,
228}
229#[derive(Debug, Deserialize, Serialize)]
230pub struct ModuleDetailsResponse {
231    pub id: i64,
232    pub code: String,
233    pub year: i32,
234    pub description: Option<String>,
235    pub credits: i32,
236    pub created_at: String,
237    pub updated_at: String,
238}
239
240impl From<Module> for ModuleDetailsResponse {
241    fn from(m: Module) -> Self {
242        Self {
243            id: m.id,
244            code: m.code,
245            year: m.year,
246            description: m.description,
247            credits: m.credits,
248            created_at: m.created_at.to_rfc3339(),
249            updated_at: m.updated_at.to_rfc3339(),
250        }
251    }
252}
253
254#[derive(Serialize)]
255pub struct FilterResponse {
256    pub modules: Vec<ModuleDetailsResponse>,
257    pub page: i32,
258    pub per_page: i32,
259    pub total: i32,
260}
261
262impl From<(Vec<Module>, i32, i32, i32)> for FilterResponse {
263    fn from(data: (Vec<Module>, i32, i32, i32)) -> Self {
264        let (modules, page, per_page, total) = data;
265        Self {
266            modules: modules
267                .into_iter()
268                .map(ModuleDetailsResponse::from)
269                .collect(),
270            page,
271            per_page,
272            total,
273        }
274    }
275}
276
277/// GET /api/modules
278///
279/// Retrieves a paginated and optionally filtered list of modules.
280///
281/// # Arguments
282///
283/// The arguments are automatically extracted from query parameters via the `FilterReq` struct:
284/// - `page`: (Optional) The page number for pagination. Defaults to 1 if not provided. Minimum value is 1.
285/// - `per_page`: (Optional) The number of items per page. Defaults to 20. Maximum is 100. Minimum is 1.
286/// - `query`: (Optional) A general search string that filters modules by `code` or `description`.
287/// - `code`: (Optional) A filter to match specific module codes.
288/// - `year`: (Optional) A filter to match modules by academic year.
289/// - `sort`: (Optional) A comma-separated list of fields to sort by. Prefix with `-` for descending order (e.g., `-year`).
290///
291/// Allowed sort fields: `"code"`, `"created_at"`, `"year"`, `"credits"`, `"description"`.
292///
293/// # Returns
294///
295/// Returns an HTTP response indicating the result:
296/// - `200 OK` with a list of matching modules, paginated and wrapped in a standardized response format.
297/// - `400 BAD REQUEST` if an invalid field is used for sorting.
298/// - `500 INTERNAL SERVER ERROR` if a database error occurs while retrieving the modules.
299///
300/// The response body contains:
301/// - A paginated list of modules.
302/// - Metadata: current page, items per page, and total items.
303///
304/// # Example Response
305///
306/// - `200 OK`  
307/// ```json
308/// {
309///   "success": true,
310///   "data": {
311///     "modules": [
312///       {
313///         "id": 1,
314///         "code": "CS101",
315///         "year": 2024,
316///         "description": "Introduction to Computer Science",
317///         "credits": 15,
318///         "created_at": "2024-01-15T10:00:00Z",
319///         "updated_at": "2024-01-15T10:00:00Z"
320///       }
321///     ],
322///     "page": 1,
323///     "per_page": 20,
324///     "total": 57
325///   },
326///   "message": "Modules retrieved successfully"
327/// }
328/// ```
329///
330/// - `400 Bad Request`  
331/// ```json
332/// {
333///   "success": false,
334///   "message": "Invalid field used for sorting"
335/// }
336/// ```
337///
338/// - `500 Internal Server Error`  
339/// ```json
340/// {
341///   "success": false,
342///   "message": "An internal server error occurred"
343/// }
344/// ```
345pub async fn get_modules(
346    State(state): State<AppState>,
347    Extension(AuthUser(claims)): Extension<AuthUser>,
348    Query(params): Query<FilterReq>,
349) -> impl IntoResponse {
350    let db = state.db();
351    let user_id = claims.sub;
352    let page = params.page.unwrap_or(1).max(1);
353    let per_page = params.per_page.unwrap_or(20).min(100);
354
355    let build_query = |query: sea_orm::Select<ModuleEntity>| -> sea_orm::Select<ModuleEntity> {
356        let mut query = query;
357
358        if let Some(ref q) = params.query {
359            let q = q.to_lowercase();
360            query = query.filter(
361                ModuleCol::Code
362                    .contains(&q)
363                    .or(ModuleCol::Description.contains(&q)),
364            );
365        }
366        if let Some(ref code) = params.code {
367            query = query.filter(ModuleCol::Code.contains(&code.to_lowercase()));
368        }
369        if let Some(year) = params.year {
370            query = query.filter(ModuleCol::Year.eq(year));
371        }
372        if let Some(sort_str) = &params.sort {
373            for field in sort_str.split(',') {
374                let trimmed = field.trim();
375                if trimmed.is_empty() {
376                    continue;
377                }
378                let (column, descending) = if trimmed.starts_with('-') {
379                    (&trimmed[1..], true)
380                } else {
381                    (trimmed, false)
382                };
383                query = match column {
384                    "code" => {
385                        if descending {
386                            query.order_by_desc(ModuleCol::Code)
387                        } else {
388                            query.order_by_asc(ModuleCol::Code)
389                        }
390                    }
391                    "created_at" => {
392                        if descending {
393                            query.order_by_desc(ModuleCol::CreatedAt)
394                        } else {
395                            query.order_by_asc(ModuleCol::CreatedAt)
396                        }
397                    }
398                    "year" => {
399                        if descending {
400                            query.order_by_desc(ModuleCol::Year)
401                        } else {
402                            query.order_by_asc(ModuleCol::Year)
403                        }
404                    }
405                    "credits" => {
406                        if descending {
407                            query.order_by_desc(ModuleCol::Credits)
408                        } else {
409                            query.order_by_asc(ModuleCol::Credits)
410                        }
411                    }
412                    "description" => {
413                        if descending {
414                            query.order_by_desc(ModuleCol::Description)
415                        } else {
416                            query.order_by_asc(ModuleCol::Description)
417                        }
418                    }
419                    _ => query,
420                };
421            }
422        }
423
424        query
425    };
426
427    // If admin, fetch all modules
428    let query = if claims.admin {
429        build_query(ModuleEntity::find())
430    } else {
431        // Otherwise, filter by membership
432        let memberships = user_module_role::Entity::find()
433            .filter(user_module_role::Column::UserId.eq(user_id))
434            .all(db)
435            .await
436            .unwrap_or_default();
437
438        if memberships.is_empty() {
439            let response = FilterResponse::from((Vec::<Module>::new(), page, per_page, 0));
440            return (
441                StatusCode::OK,
442                Json(ApiResponse::success(
443                    response,
444                    "Modules retrieved successfully",
445                )),
446            );
447        }
448
449        let module_ids: Vec<i64> = memberships.iter().map(|m| m.module_id).collect();
450        build_query(ModuleEntity::find().filter(ModuleCol::Id.is_in(module_ids)))
451    };
452
453    let paginator = query.paginate(db, per_page as u64);
454    let total = paginator.num_items().await.unwrap_or(0) as i32;
455    let modules: Vec<Module> = paginator
456        .fetch_page((page - 1) as u64)
457        .await
458        .unwrap_or_default();
459
460    let response = FilterResponse::from((modules, page, per_page, total));
461    (
462        StatusCode::OK,
463        Json(ApiResponse::success(
464            response,
465            "Modules retrieved successfully",
466        )),
467    )
468}
469
470#[derive(Debug, Deserialize, Serialize)]
471pub struct MyDetailsResponse {
472    pub as_student: Vec<ModuleDetailsResponse>,
473    pub as_tutor: Vec<ModuleDetailsResponse>,
474    pub as_lecturer: Vec<ModuleDetailsResponse>,
475    pub as_assistant_lecturer: Vec<ModuleDetailsResponse>,
476}
477
478impl From<(Vec<Module>, Vec<Module>, Vec<Module>, Vec<Module>)> for MyDetailsResponse {
479    fn from(
480        (as_student, as_tutor, as_lecturer, as_assistant_lecturer): (
481            Vec<Module>,
482            Vec<Module>,
483            Vec<Module>,
484            Vec<Module>,
485        ),
486    ) -> Self {
487        MyDetailsResponse {
488            as_student: as_student
489                .into_iter()
490                .map(ModuleDetailsResponse::from)
491                .collect(),
492            as_tutor: as_tutor
493                .into_iter()
494                .map(ModuleDetailsResponse::from)
495                .collect(),
496            as_lecturer: as_lecturer
497                .into_iter()
498                .map(ModuleDetailsResponse::from)
499                .collect(),
500            as_assistant_lecturer: as_assistant_lecturer
501                .into_iter()
502                .map(ModuleDetailsResponse::from)
503                .collect(),
504        }
505    }
506}
507
508/// GET /api/modules/me
509///
510/// Retrieves detailed information about the modules the authenticated user is assigned to.
511///
512/// # Arguments
513///
514/// This endpoint requires authentication. The user ID is automatically extracted from the JWT token.
515///
516/// # Returns
517///
518/// Returns an HTTP response indicating the result:
519/// - `200 OK` with the user's module assignments organized by role if successful.
520/// - `500 INTERNAL SERVER ERROR` if a database error occurs while retrieving the module details.
521///
522/// The response body contains:
523/// - `as_student`: List of modules where the user is assigned as a student.
524/// - `as_tutor`: List of modules where the user is assigned as a tutor.
525/// - `as_lecturer`: List of modules where the user is assigned as a lecturer.
526/// - `as_assistant_lecturer`: List of modules where the user is assigned as an assistant lecturer.
527///
528/// # Example Response
529///
530/// - `200 OK`  
531/// ```json
532/// {
533///   "success": true,
534///   "data": {
535///     "as_student": [
536///       { "id": 1, "code": "CS101", "year": 2024, "description": "...", "credits": 15, "created_at": "...", "updated_at": "..." }
537///     ],
538///     "as_tutor": [
539///       { "id": 2, "code": "CS201", "year": 2024, "description": "...", "credits": 20, "created_at": "...", "updated_at": "..." }
540///     ],
541///     "as_lecturer": [],
542///     "as_assistant_lecturer": []
543///   },
544///   "message": "My module details retrieved successfully"
545/// }
546/// ```
547///
548/// - `500 Internal Server Error`  
549/// ```json
550/// {
551///   "success": false,
552///   "message": "An error occurred while retrieving module details"
553/// }
554/// ```
555pub async fn get_my_details(
556    State(state): State<AppState>,
557    Extension(AuthUser(claims)): Extension<AuthUser>,
558) -> impl IntoResponse {
559    let db = state.db();
560
561    let user_id = claims.sub;
562
563    let (as_student, as_tutor, as_lecturer, as_assistant_lecturer) = tokio::join!(
564        get_modules_by_user_and_role(db, user_id, Role::Student),
565        get_modules_by_user_and_role(db, user_id, Role::Tutor),
566        get_modules_by_user_and_role(db, user_id, Role::Lecturer),
567        get_modules_by_user_and_role(db, user_id, Role::AssistantLecturer),
568    );
569
570    match (as_student, as_tutor, as_lecturer, as_assistant_lecturer) {
571        (Ok(students), Ok(tutors), Ok(lecturers), Ok(assistants)) => {
572            let response = MyDetailsResponse::from((students, tutors, lecturers, assistants));
573            (
574                StatusCode::OK,
575                Json(ApiResponse::success(
576                    response,
577                    "My module details retrieved successfully",
578                )),
579            )
580        }
581        _ => (
582            StatusCode::INTERNAL_SERVER_ERROR,
583            Json(ApiResponse::<MyDetailsResponse>::error(
584                "An error occurred while retrieving module details",
585            )),
586        ),
587    }
588}
589
590/// Helper to fetch modules by user_id and role using SeaORM relations
591async fn get_modules_by_user_and_role(
592    db: &DatabaseConnection,
593    user_id: i64,
594    role: Role,
595) -> Result<Vec<Module>, sea_orm::DbErr> {
596    RoleEntity::find()
597        .filter(
598            Condition::all()
599                .add(RoleCol::UserId.eq(user_id))
600                .add(RoleCol::Role.eq(role)),
601        )
602        .find_also_related(ModuleEntity)
603        .all(db)
604        .await
605        .map(|results| {
606            results
607                .into_iter()
608                .filter_map(|(_, module)| module)
609                .collect()
610        })
611}