api/routes/modules/
put.rs

1//! Module management routes.
2//!
3//! Provides endpoints for editing individual modules (`PUT /api/modules/{id}`)
4//! and bulk updating multiple modules (`PUT /api/modules/bulk`).  
5//! Only accessible by admin users. Responses follow the standard `ApiResponse` format.
6
7use axum::{
8    extract::{State, Path},
9    http::StatusCode,
10    response::IntoResponse,
11    Json,
12};
13use chrono::Utc;
14use util::state::AppState;
15use validator::Validate;
16use sea_orm::{
17    IntoActiveModel,
18    ActiveModelTrait,
19    ColumnTrait,
20    Condition,
21    EntityTrait,
22    QueryFilter,
23    Set,
24};
25use db::models::module::{
26    self,
27    ActiveModel as ModuleActiveModel,
28    Column as ModuleCol,
29    Entity as ModuleEntity,
30};
31use crate::response::ApiResponse;
32use crate::routes::modules::common::{ModuleRequest, ModuleResponse};
33use serde::{Deserialize, Serialize};
34use serde_json::Value;
35
36/// PUT /api/modules/{module_id}
37///
38/// Update the details of a specific module by its ID.  
39/// Only accessible by admin users.
40///
41/// ### Request Body
42/// ```json
43/// {
44///   "code": "CS101",
45///   "year": 2024,
46///   "description": "Introduction to Computer Science",
47///   "credits": 15
48/// }
49/// ```
50///
51/// ### Validation Rules
52/// - `code`: must be in format ABC123 (3 uppercase letters + 3 digits)
53/// - `year`: must be current year or later
54/// - `description`: must be at most 1000 characters
55/// - `credits`: must be a positive number
56///
57/// ### Responses
58///
59/// - `200 OK`  
60/// ```json
61/// {
62///   "success": true,
63///   "data": {
64///     "id": 1,
65///     "code": "CS101",
66///     "year": 2024,
67///     "description": "Introduction to Computer Science",
68///     "credits": 15,
69///     "created_at": "2024-01-01T00:00:00Z",
70///     "updated_at": "2024-01-01T00:00:00Z"
71///   },
72///   "message": "Module updated successfully"
73/// }
74/// ```
75///
76/// - `400 Bad Request`  
77/// ```json
78/// {
79///   "success": false,
80///   "data": null,
81///   "message": "Module code must be in format ABC123"
82/// }
83/// ```
84///
85/// - `403 Forbidden`  
86/// ```json
87/// {
88///   "success": false,
89///   "data": null,
90///   "message": "You do not have permission to perform this action"
91/// }
92/// ```
93///
94/// - `404 Not Found`  
95/// ```json
96/// {
97///   "success": false,
98///   "data": null,
99///   "message": "Module not found"
100/// }
101/// ```
102///
103/// - `409 Conflict`  
104/// ```json
105/// {
106///   "success": false,
107///   "data": null,
108///   "message": "Module code already exists"
109/// }
110/// ```
111pub async fn edit_module(
112    State(state): State<AppState>,
113    Path(module_id): Path<i64>,
114    Json(req): Json<ModuleRequest>,
115) -> impl IntoResponse {
116    let db = state.db();
117
118    if let Err(validation_errors) = req.validate() {
119        let error_message = common::format_validation_errors(&validation_errors);
120        return (
121            StatusCode::BAD_REQUEST,
122            Json(ApiResponse::<ModuleResponse>::error(error_message)),
123        );
124    }
125
126    let duplicate = ModuleEntity::find()
127        .filter(
128            Condition::all()
129                .add(ModuleCol::Code.eq(req.code.clone()))
130                .add(ModuleCol::Id.ne(module_id)),
131        )
132        .one(db)
133        .await;
134
135    if let Ok(Some(_)) = duplicate {
136        return (
137            StatusCode::CONFLICT,
138            Json(ApiResponse::<ModuleResponse>::error("Module code already exists")),
139        );
140    }
141
142    let updated_module = ModuleActiveModel {
143        id: Set(module_id),
144        code: Set(req.code.clone()),
145        year: Set(req.year),
146        description: Set(req.description.clone()),
147        credits: Set(req.credits),
148        updated_at: Set(Utc::now()),
149        ..Default::default()
150    };
151
152    match updated_module.update(db).await {
153        Ok(module) => (
154            StatusCode::OK,
155            Json(ApiResponse::success(ModuleResponse::from(module), "Module updated successfully")),
156        ),
157        Err(_) => (
158            StatusCode::INTERNAL_SERVER_ERROR,
159            Json(ApiResponse::<ModuleResponse>::error("Failed to update module")),
160        ),
161    }
162}
163
164
165#[derive(Debug, Deserialize, Validate)]
166pub struct BulkUpdateRequest {
167    #[validate(length(min = 1, message = "At least one module ID is required"))]
168    pub module_ids: Vec<i64>,
169    
170    #[validate(range(min = 2024, message = "Year must be at least 2024"))]
171    pub year: Option<i32>,
172    
173    #[validate(length(max = 1000, message = "Description must be at most 1000 characters"))]
174    pub description: Option<String>,
175    
176    #[validate(range(min = 1, message = "Credits must be positive"))]
177    pub credits: Option<i32>,
178}
179
180#[derive(Serialize)]
181pub struct BulkUpdateResult {
182    pub updated: usize,
183    pub failed: Vec<FailedUpdate>,
184}
185
186#[derive(Serialize)]
187pub struct FailedUpdate {
188    pub id: i64,
189    pub error: String,
190}
191
192/// PUT /api/modules/bulk
193///
194/// Bulk update multiple modules by their IDs.
195/// Only accessible by admin users.
196///
197/// ### Request Body
198/// ```json
199/// {
200///   "module_ids": [1, 2, 3],
201///   "year": 2025,
202///   "description": "Updated description",
203///   "credits": 20
204/// }
205/// ```
206///
207/// ### Rules
208/// - `code` cannot be modified via this route
209/// - All fields (`year`, `description`, `credits`) are optional
210/// - Empty/null fields are ignored (won't overwrite existing values)
211///
212/// ### Responses
213///
214/// - `200 OK`
215/// ```json
216/// {
217///   "success": true,
218///   "data": {
219///     "updated": 2,
220///     "failed": [
221///       { "id": 3, "error": "Module not found" }
222///     ]
223///   },
224///   "message": "Updated 2/3 modules"
225/// }
226/// ```
227///
228/// - `400 Bad Request`
229/// ```json
230/// {
231///   "success": false,
232///   "data": null,
233///   "message": "No module IDs provided"
234/// }
235/// ```
236pub async fn bulk_edit_modules(
237    State(app_state): State<AppState>,
238    Json(raw_value): Json<Value>,
239) -> impl IntoResponse {
240    let db = app_state.db();
241
242    // First check for forbidden 'code' field
243    if let Some(obj) = raw_value.as_object() {
244        if obj.keys().any(|k| k.to_lowercase() == "code") {
245            return (
246                StatusCode::BAD_REQUEST,
247                Json(ApiResponse::<BulkUpdateResult>::error("Bulk update cannot change module code")),
248            );
249        }
250    }
251
252    // Then parse and validate the request
253    let req: BulkUpdateRequest = match serde_json::from_value(raw_value) {
254        Ok(req) => req,
255        Err(e) => {
256            return (
257                StatusCode::BAD_REQUEST,
258                Json(ApiResponse::<BulkUpdateResult>::error(format!("Invalid request body: {}", e))),
259            );
260        }
261    };
262
263    // Validate the request
264    if let Err(validation_errors) = req.validate() {
265        let error_message = common::format_validation_errors(&validation_errors);
266        return (
267            StatusCode::BAD_REQUEST,
268            Json(ApiResponse::<BulkUpdateResult>::error(error_message)),
269        );
270    }
271
272    let mut updated = 0;
273    let mut failed = Vec::new();
274
275    for id in &req.module_ids {
276        let res = module::Entity::find()
277            .filter(module::Column::Id.eq(*id))
278            .one(db)
279            .await;
280
281        match res {
282            Ok(Some(model)) => {
283                let mut active = model.into_active_model();
284                let mut has_changes = false;
285
286                // Only update fields that are provided
287                if let Some(year) = req.year {
288                    active.year = Set(year);
289                    has_changes = true;
290                }
291
292                if let Some(ref description) = req.description {
293                    active.description = Set(Some(description.clone()));
294                    has_changes = true;
295                }
296
297                if let Some(credits) = req.credits {
298                    active.credits = Set(credits);
299                    has_changes = true;
300                }
301
302                if has_changes {
303                    active.updated_at = Set(Utc::now());
304
305                    if active.update(db).await.is_ok() {
306                        updated += 1;
307                    } else {
308                        failed.push(FailedUpdate {
309                            id: *id,
310                            error: "Failed to save updated module".into(),
311                        });
312                    }
313                } else {
314                    // No changes to apply, count as successful
315                    updated += 1;
316                }
317            }
318            Ok(None) => failed.push(FailedUpdate {
319                id: *id,
320                error: "Module not found".into(),
321            }),
322            Err(e) => failed.push(FailedUpdate {
323                id: *id,
324                error: e.to_string(),
325            }),
326        }
327    }
328
329    let result = BulkUpdateResult { updated, failed };
330    let message = format!("Updated {}/{} modules", updated, req.module_ids.len());
331
332    (
333        StatusCode::OK,
334        Json(ApiResponse::success(result, message)),
335    )
336}