api/routes/me/
tickets.rs

1//! # My Tickets Handlers
2//!
3//! Provides endpoints to fetch tickets for assignments associated with the currently authenticated user.
4//!
5//! Users can retrieve a paginated list of tickets, filtered by role, year, status, and search query.  
6//! The results include assignment, module, and user details.  
7//! Students only see their own tickets, while lecturers and assistants can view other users' tickets.
8
9use axum::{
10    Extension, Json,
11    extract::{Query, State},
12    http::StatusCode,
13    response::IntoResponse,
14};
15use db::models::{
16    assignment, module, tickets, user,
17    user_module_role::{self},
18};
19use migration::Expr;
20use sea_orm::{
21    ColumnTrait, Condition, EntityTrait, JoinType, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait
22};
23use serde::{Deserialize, Serialize};
24use util::state::AppState;
25use crate::{auth::AuthUser, response::ApiResponse};
26
27/// Query parameters for filtering, sorting, and pagination of tickets
28#[derive(Debug, Deserialize)]
29pub struct FilterReq {
30    /// Page number (default: 1)
31    pub page: Option<i32>,
32    /// Items per page (default: 20)
33    pub per_page: Option<i32>,
34    /// Search query (matches ticket title, module code, assignment name, and username for staff)
35    pub query: Option<String>,
36    /// Filter tickets by role (student, lecturer, assistant, etc.)
37    pub role: Option<String>,
38    /// Filter tickets by module year
39    pub year: Option<i32>,
40    /// Filter tickets by ticket status
41    pub status: Option<String>,
42    /// Sort fields (comma-separated, prefix with `-` for descending)
43    pub sort: Option<String>,
44}
45
46/// Response for a single ticket
47#[derive(Serialize)]
48pub struct TicketsResponse {
49    pub id: i64,
50    pub title: String,
51    pub status: String,
52    pub created_at: String,
53    pub updated_at: String,
54    pub module: ModuleResponse,
55    pub assignment: AssignmentResponse,
56    pub user: UserResponse,
57}
58
59/// Response object for a user
60#[derive(Serialize)]
61pub struct UserResponse {
62    pub id: i64,
63    pub username: String,
64}
65
66/// Response object for an assignment
67#[derive(Serialize)]
68pub struct AssignmentResponse {
69    pub id: i64,
70    pub name: String,
71}
72
73/// Response object for a module
74#[derive(Serialize)]
75pub struct ModuleResponse {
76    pub id: i64,
77    pub code: String,
78}
79
80/// Response for a paginated list of tickets
81#[derive(Serialize)]
82pub struct FilterResponse {
83    pub tickets: Vec<TicketsResponse>,
84    pub page: i32,
85    pub per_page: i32,
86    pub total: i32,
87}
88
89impl FilterResponse {
90    fn new(tickets: Vec<TicketsResponse>, page: i32, per_page: i32, total: i32) -> Self {
91        Self { tickets, page, per_page, total }
92    }
93}
94
95/// Retrieves tickets for the currently authenticated user.
96///
97/// **Endpoint:** `GET /my/tickets`  
98/// **Permissions:**  
99/// - Students see only their own tickets  
100/// - Lecturers and assistants see tickets from other users in modules they are assigned to
101///
102/// ### Query parameters
103/// - `page` → Page number (default: 1)
104/// - `per_page` → Number of items per page (default: 20, max: 100)
105/// - `query` → Search tickets by title, module code, assignment name, and username (staff only)
106/// - `role` → Filter tickets by user role
107/// - `year` → Filter tickets by module year
108/// - `status` → Filter tickets by ticket status
109/// - `sort` → Sort tickets by fields (e.g., `created_at,-updated_at`)
110///
111/// ### Responses
112/// - `200 OK` → Tickets retrieved successfully
113/// ```json
114/// {
115///   "success": true,
116///   "data": {
117///     "tickets": [ /* Ticket objects */ ],
118///     "page": 1,
119///     "per_page": 20,
120///     "total": 42
121///   },
122///   "message": "Tickets retrieved successfully"
123/// }
124/// ```
125/// - `400 Bad Request` → Invalid status value
126/// ```json
127/// {
128///   "success": false,
129///   "data": null,
130///   "message": "Invalid status value"
131/// }
132/// ```
133/// - `500 Internal Server Error` → Failed to retrieve tickets
134/// ```json
135/// {
136///   "success": false,
137///   "data": null,
138///   "message": "Failed to retrieve tickets"
139/// }
140/// ```
141pub async fn get_my_tickets(
142    State(state): State<AppState>,
143    Extension(AuthUser(claims)): Extension<AuthUser>,
144    Query(params): Query<FilterReq>,
145) -> impl IntoResponse {
146    let db = state.db();
147    let user_id = claims.sub;
148
149    let page = params.page.unwrap_or(1).max(1);
150    let per_page = params.per_page.unwrap_or(20).min(100);
151
152    let requested_role = params.role.clone().unwrap_or_else(|| "student".to_string());
153
154    let memberships = user_module_role::Entity::find()
155        .filter(user_module_role::Column::UserId.eq(user_id))
156        .filter(user_module_role::Column::Role.eq(requested_role.clone()))
157        .all(db)
158        .await
159        .unwrap_or_default();
160
161    if memberships.is_empty() {
162        let response = FilterResponse::new(vec![], page, per_page, 0);
163        return (
164            StatusCode::OK,
165            Json(ApiResponse::success(response, "Tickets retrieved successfully")),
166        )
167            .into_response();
168    }
169
170    let module_ids: Vec<i64> = memberships.iter().map(|m| m.module_id).collect();
171
172    let assignments = assignment::Entity::find()
173        .filter(assignment::Column::ModuleId.is_in(module_ids.clone()))
174        .all(db)
175        .await
176        .unwrap_or_default();
177
178    if assignments.is_empty() {
179        let response = FilterResponse::new(vec![], page, per_page, 0);
180        return (
181            StatusCode::OK,
182            Json(ApiResponse::success(response, "Tickets retrieved successfully")),
183        )
184            .into_response();
185    }
186
187    let assignment_ids: Vec<i64> = assignments.iter().map(|a| a.id).collect();
188
189    let mut condition = Condition::all()
190        .add(tickets::Column::AssignmentId.is_in(assignment_ids.clone()));
191
192    if requested_role == "student" {
193        condition = condition.add(tickets::Column::UserId.eq(user_id));
194    } else {
195        condition = condition.add(tickets::Column::UserId.ne(user_id));
196    }
197
198    if let Some(year) = params.year {
199        condition = condition.add(Expr::col((module::Entity, module::Column::Year)).eq(year));
200    }
201
202    if let Some(ref q) = params.query {
203        let pattern = format!("%{}%", q.to_lowercase());
204        condition = condition.add(
205            Condition::any()
206                .add(Expr::cust("LOWER(tickets.title)").like(&pattern))
207                .add(Expr::cust("LOWER(module.code)").like(&pattern))
208                .add(Expr::cust("LOWER(assignment.name)").like(&pattern)),
209        );
210        if requested_role != "student" {
211            condition = condition.add(Expr::cust("LOWER(user.username)").like(&pattern));
212        }
213    }
214
215    if let Some(ref s) = params.status {
216        match s.parse::<tickets::TicketStatus>() {
217            Ok(st) => condition = condition.add(tickets::Column::Status.eq(st)),
218            Err(_) => {
219                return (
220                    StatusCode::BAD_REQUEST,
221                    Json(ApiResponse::<FilterResponse>::error("Invalid status value")),
222                )
223                    .into_response();
224            }
225        }
226    }
227
228    let mut query = tickets::Entity::find()
229        .join(JoinType::InnerJoin, tickets::Relation::Assignment.def())
230        .join(JoinType::InnerJoin, assignment::Relation::Module.def())
231        .filter(condition);
232
233    if requested_role != "student" {
234        query = query.join(JoinType::InnerJoin, tickets::Relation::User.def());
235    }
236
237    if let Some(sort_param) = &params.sort {
238        for sort in sort_param.split(',') {
239            let (field, asc) = if sort.starts_with('-') { (&sort[1..], false) } else { (sort, true) };
240            query = match field {
241                "created_at" => if asc { query.order_by_asc(tickets::Column::CreatedAt) } else { query.order_by_desc(tickets::Column::CreatedAt) },
242                _ => query,
243            };
244        }
245    } else {
246        query = query
247            .order_by_desc(tickets::Column::CreatedAt)
248            .order_by_asc(tickets::Column::Id);
249    }
250
251    let paginator = query.clone().paginate(db, per_page as u64);
252    let total = match paginator.num_items().await {
253        Ok(n) => n as i32,
254        Err(_) => {
255            return (
256                StatusCode::INTERNAL_SERVER_ERROR,
257                Json(ApiResponse::<FilterResponse>::error("Error counting tickets")),
258            )
259                .into_response();
260        }
261    };
262
263    match paginator.fetch_page((page - 1) as u64).await {
264        Ok(results) => {
265            let mut tickets_vec = Vec::new();
266            for t in results {
267                let a = assignment::Entity::find_by_id(t.assignment_id)
268                    .one(db)
269                    .await
270                    .unwrap_or(None);
271                if a.is_none() { continue; }
272                let a = a.unwrap();
273
274                let m = module::Entity::find_by_id(a.module_id)
275                    .one(db)
276                    .await
277                    .unwrap_or(None);
278                if m.is_none() { continue; }
279                let m = m.unwrap();
280
281                let u = user::Entity::find_by_id(t.user_id)
282                    .one(db)
283                    .await
284                    .unwrap_or(None);
285
286                tickets_vec.push(TicketsResponse {
287                    id: t.id,
288                    title: t.title,
289                    status: t.status.to_string(),
290                    created_at: t.created_at.to_string(),
291                    updated_at: t.updated_at.to_string(),
292                    module: ModuleResponse { id: m.id, code: m.code },
293                    assignment: AssignmentResponse { id: a.id, name: a.name },
294                    user: UserResponse {
295                        id: t.user_id,
296                        username: u.map(|uu| uu.username).unwrap_or_default(),
297                    },
298                });
299            }
300
301            let response = FilterResponse::new(tickets_vec, page, per_page, total);
302            (
303                StatusCode::OK,
304                Json(ApiResponse::success(response, "Tickets retrieved successfully")),
305            )
306                .into_response()
307        }
308        Err(_) => (
309            StatusCode::INTERNAL_SERVER_ERROR,
310            Json(ApiResponse::<FilterResponse>::error("Failed to retrieve tickets")),
311        )
312            .into_response(),
313    }
314}