api/routes/users/
get.rs

1use axum::{
2    extract::{State, Path, Query},
3    http::{StatusCode, Response},
4    response::IntoResponse,
5    Json,
6};
7use sea_orm::{ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder};
8use serde::{Deserialize, Serialize};
9use util::state::AppState;
10use validator::Validate;
11use crate::response::ApiResponse;
12use crate::routes::common::UserModule;
13use db::models::user::{Entity as UserEntity, Model as UserModel, Column as UserColumn};
14use std::{path::PathBuf};
15use tokio::fs::File;
16use tokio_util::io::ReaderStream;
17use axum::body::Body;
18use mime_guess::from_path;
19
20#[derive(Debug, Deserialize, Validate)]
21pub struct ListUsersQuery {
22    #[validate(range(min = 1))]
23    pub page: Option<u64>,
24    #[validate(range(min = 1, max = 100))]
25    pub per_page: Option<u64>,
26    pub sort: Option<String>,
27    pub query: Option<String>,
28    pub email: Option<String>,
29    pub username: Option<String>,
30    pub admin: Option<bool>,
31}
32
33#[derive(Debug, Serialize)]
34pub struct UserListItem {
35    pub id: String,
36    pub email: String,
37    pub username: String,
38    pub admin: bool,
39    pub created_at: String,
40    pub updated_at: String,
41}
42
43#[derive(Debug, Serialize)]
44pub struct UsersListResponse {
45    pub users: Vec<UserListItem>,
46    pub page: u64,
47    pub per_page: u64,
48    pub total: u64,
49}
50
51impl From<UserModel> for UserListItem {
52    fn from(user: UserModel) -> Self {
53        Self {
54            id: user.id.to_string(),
55            email: user.email,
56            username: user.username,
57            admin: user.admin,
58            created_at: user.created_at.to_string(),
59            updated_at: user.updated_at.to_string(),
60        }
61    }
62}
63
64/// GET /api/users
65///
66/// Retrieve a paginated list of users with optional filtering and sorting.
67/// Requires admin privileges.
68///
69/// ### Query Parameters
70/// - `page` (optional): Page number (default: 1, min: 1)
71/// - `per_page` (optional): Items per page (default: 20, min: 1, max: 100)
72/// - `query` (optional): Case-insensitive partial match against email OR username
73/// - `email` (optional): Case-insensitive partial match on email
74/// - `username` (optional): Case-insensitive partial match on student number
75/// - `admin` (optional): Filter by admin status (true/false)
76/// - `sort` (optional): Comma-separated sort fields. Use `-` prefix for descending
77///
78/// ### Examples
79/// ```http
80/// GET /api/users?page=2&per_page=10
81/// GET /api/users?query=u1234
82/// GET /api/[email protected]
83/// GET /api/users?username=u1234
84/// GET /api/users?admin=true
85/// GET /api/users?sort=email,-created_at
86/// GET /api/users?page=1&per_page=10&admin=false&query=jacques&sort=-email
87/// ```
88///
89/// ### Responses
90///
91/// - `200 OK`
92/// ```json
93/// {
94///   "success": true,
95///   "data": {
96///     "users": [
97///       {
98///         "id": "uuid",
99///         "email": "[email protected]",
100///         "username": "u12345678",
101///         "admin": false,
102///         "created_at": "2025-05-23T18:00:00Z",
103///         "updated_at": "2025-05-23T18:00:00Z"
104///       }
105///     ],
106///     "page": 1,
107///     "per_page": 10,
108///     "total": 135
109///   },
110///   "message": "Users retrieved successfully"
111/// }
112/// ```
113///
114/// - `400 Bad Request` - Invalid query parameters
115/// - `401 Unauthorized` - Missing or invalid JWT
116/// - `403 Forbidden` - Authenticated but not admin user
117/// - `500 Internal Server Error` - Database error
118pub async fn list_users(
119    State(app_state): State<AppState>,
120    Query(query): Query<ListUsersQuery>
121) -> impl IntoResponse {
122    let db = app_state.db();
123    
124    if let Err(e) = query.validate() {
125        return (
126            StatusCode::BAD_REQUEST,
127            Json(ApiResponse::<UsersListResponse>::error(
128                common::format_validation_errors(&e),
129            )),
130        );
131    }
132
133    let page = query.page.unwrap_or(1);
134    let per_page = query.per_page.unwrap_or(20);
135
136    let mut condition = Condition::all();
137
138    if let Some(q) = &query.query {
139        let pattern = format!("%{}%", q.to_lowercase());
140        condition = condition.add(
141            Condition::any()
142                .add(UserColumn::Email.contains(&pattern))
143                .add(UserColumn::Username.contains(&pattern)),
144        );
145    }
146
147    if let Some(email) = &query.email {
148        condition = condition.add(UserColumn::Email.contains(&format!("%{}%", email)));
149    }
150
151    if let Some(sn) = &query.username {
152        condition = condition.add(UserColumn::Username.contains(&format!("%{}%", sn)));
153    }
154
155    if let Some(admin) = query.admin {
156        condition = condition.add(UserColumn::Admin.eq(admin));
157    }
158
159    let mut query_builder = UserEntity::find().filter(condition);
160
161    if let Some(sort_param) = &query.sort {
162        for sort_field in sort_param.split(',') {
163            let (field, desc) = if sort_field.starts_with('-') {
164                (&sort_field[1..], true)
165            } else {
166                (sort_field, false)
167            };
168
169            match field {
170                "email" => {
171                    query_builder = if desc {
172                        query_builder.order_by_desc(UserColumn::Email)
173                    } else {
174                        query_builder.order_by_asc(UserColumn::Email)
175                    };
176                }
177                "username" => {
178                    query_builder = if desc {
179                        query_builder.order_by_desc(UserColumn::Username)
180                    } else {
181                        query_builder.order_by_asc(UserColumn::Username)
182                    };
183                }
184                "created_at" => {
185                    query_builder = if desc {
186                        query_builder.order_by_desc(UserColumn::CreatedAt)
187                    } else {
188                        query_builder.order_by_asc(UserColumn::CreatedAt)
189                    };
190                }
191                "admin" => {
192                    query_builder = if desc {
193                        query_builder.order_by_desc(UserColumn::Admin)
194                    } else {
195                        query_builder.order_by_asc(UserColumn::Admin)
196                    };
197                }
198                _ => {}
199            }
200        }
201    } else {
202        query_builder = query_builder.order_by_asc(UserColumn::Id);
203    }
204
205    let paginator = query_builder.paginate(db, per_page);
206    let total = paginator.num_items().await.unwrap_or(0);
207    let users = paginator.fetch_page(page - 1).await.unwrap_or_default();
208    let users = users.into_iter().map(UserListItem::from).collect();
209
210    (
211        StatusCode::OK,
212        Json(ApiResponse::success(
213            UsersListResponse {
214                users,
215                page,
216                per_page,
217                total,
218            },
219            "Users retrieved successfully",
220        )),
221    )
222}
223
224/// GET /api/users/{user_id}
225///
226/// Fetch a single user by ID. Requires admin privileges.
227///
228/// ### Path Parameters
229/// - `id`: The user ID (integer)
230///
231/// ### Responses
232/// - `200 OK`: User found
233/// - `400 Bad Request`: Invalid ID format
234/// - `404 Not Found`: User does not exist
235/// - `500 Internal Server Error`: DB error
236pub async fn get_user(
237    State(app_state): State<AppState>,
238    Path(user_id): Path<i64>
239) -> impl IntoResponse {
240    let db = app_state.db();
241
242    match UserEntity::find_by_id(user_id).one(db).await {
243        Ok(Some(user)) => {
244            let user_item = UserListItem::from(user);
245            (
246                StatusCode::OK,
247                Json(ApiResponse::success(user_item, "User retrieved successfully")),
248            )
249        }
250        Ok(None) => (
251            StatusCode::NOT_FOUND,
252            Json(ApiResponse::<UserListItem>::error("User not found")),
253        ),
254        Err(err) => (
255            StatusCode::INTERNAL_SERVER_ERROR,
256            Json(ApiResponse::<UserListItem>::error(format!("Database error: {}", err))),
257        ),
258    }
259}
260
261/// GET /api/users/{user_id}/modules
262///
263/// Retrieve all modules that a specific user is involved in, including their role in each module.
264/// Requires admin privileges.
265///
266/// ### Path Parameters
267/// - `id`: The ID of the user to fetch modules for
268///
269/// ### Responses
270///
271/// - `200 OK`
272/// ```json
273/// {
274///   "success": true,
275///   "data": [
276///     {
277///       "id": 1,
278///       "code": "COS301",
279///       "year": 2025,
280///       "description": "Advanced Software Engineering",
281///       "credits": 16,
282///       "role": "Lecturer",
283///       "created_at": "2025-05-01T08:00:00Z",
284///       "updated_at": "2025-05-01T08:00:00Z"
285///     }
286///   ],
287///   "message": "Modules for user retrieved successfully"
288/// }
289/// ```
290///
291/// - `400 Bad Request` (invalid ID format)
292/// ```json
293/// {
294///   "success": false,
295///   "message": "Invalid user ID format"
296/// }
297/// ```
298///
299/// - `403 Forbidden` - Not an admin user
300/// - `404 Not Found` - User not found
301/// ```json
302/// {
303///   "success": false,
304///   "message": "User not found"
305/// }
306/// ```
307///
308/// - `500 Internal Server Error` - Database error
309/// ```json
310/// {
311///   "success": false,
312///   "message": "Database error: detailed error here"
313/// }
314/// ```
315pub async fn get_user_modules(
316    State(app_state): State<AppState>,
317    Path(user_id): Path<i64>
318) -> impl IntoResponse {
319    let db = app_state.db();
320
321    let roles = match UserModel::get_module_roles(db, user_id).await {
322        Ok(r) => r,
323        Err(e) => {
324            return (
325                StatusCode::INTERNAL_SERVER_ERROR,
326                Json(ApiResponse::<Vec<UserModule>>::error(format!("Database error: {}", e))),
327            );
328        }
329    };
330
331    let modules = roles
332        .into_iter()
333        .map(|r| UserModule {
334            id: r.module_id,
335            code: r.module_code,
336            year: r.module_year,
337            description: r.module_description.unwrap_or_default(),
338            credits: r.module_credits,
339            role: r.role,
340            created_at: r.module_created_at,
341            updated_at: r.module_updated_at,
342        })
343        .collect::<Vec<_>>();
344
345    (
346        StatusCode::OK,
347        Json(ApiResponse::success(
348            modules,
349            "Modules for user retrieved successfully",
350        )),
351    )
352}
353
354/// GET /api/users/{user_id}/avatar
355///
356/// Returns the avatar image file for a user if it exists.
357pub async fn get_avatar(
358    State(_state): State<AppState>,
359    Path(user_id): Path<i64>,
360) -> impl IntoResponse {
361    let root = std::env::var("USER_PROFILE_STORAGE_ROOT")
362        .unwrap_or_else(|_| "data/user_profile_pictures".to_string());
363
364    let avatar_path = PathBuf::from(&root).join(format!("user_{}/avatar", user_id));
365
366    // Try common extensions
367    for ext in ["jpg", "png", "gif"] {
368        let try_path = avatar_path.with_extension(ext);
369        if try_path.exists() {
370            let file = match File::open(&try_path).await {
371                Ok(f) => f,
372                Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
373            };
374
375            let mime = from_path(&try_path).first_or_octet_stream();
376            let stream = ReaderStream::new(file);
377            let body = Body::from_stream(stream);
378
379            return Response::builder()
380                .header("Content-Type", mime.as_ref())
381                .body(body)
382                .unwrap();
383        }
384    }
385
386    StatusCode::NOT_FOUND.into_response()
387}