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) = ¶ms.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}