api/routes/modules/assignments/tickets/
get.rs

1//! Ticket retrieval handlers.
2//!
3//! Provides endpoints to fetch tickets for an assignment.
4//!
5//! Users can retrieve a single ticket or a list of tickets, with support for
6//! filtering, sorting, and pagination. The endpoints validate that the user
7//! has permission to view the ticket(s) before returning data.
8
9use crate::{
10    auth::AuthUser, response::ApiResponse,
11    routes::modules::assignments::tickets::common::{is_valid, TicketResponse, TicketWithUserResponse},
12};
13use axum::{
14    Extension,
15    extract::{Path, Query, State},
16    http::StatusCode,
17    response::{IntoResponse, Json},
18};
19use db::models::{tickets::{
20    Column as TicketColumn, Entity as TicketEntity, TicketStatus,
21}, user, user_module_role::{self, Role}};
22use db::models::user::{Entity as UserEntity};
23use migration::Expr;
24use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, JoinType, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait};
25use serde::{Deserialize, Serialize};
26use util::state::AppState;
27
28/// GET /api/modules/{module_id}/assignments/{assignment_id}/tickets/{ticket_id}
29///
30/// Retrieve a specific ticket along with information about the user who created it.
31/// Accessible to users assigned to the module (e.g., student, tutor, lecturer).
32///
33/// ### Path Parameters
34/// - `module_id` (i64): The ID of the module containing the assignment
35/// - `assignment_id` (i64): The ID of the assignment containing the ticket
36/// - `ticket_id` (i64): The ID of the ticket to retrieve
37///
38/// ### Responses
39///
40/// - `200 OK`
41/// ```json
42/// {
43///   "success": true,
44///   "message": "Ticket with user retrieved",
45///   "data": {
46///     "ticket": {
47///       "id": 101,
48///       "assignment_id": 456,
49///       "user_id": 789,
50///       "title": "Issue with question 2",
51///       "description": "I'm not sure what the question is asking.",
52///       "status": "open",
53///       "created_at": "2025-08-01T12:00:00Z",
54///       "updated_at": "2025-08-01T12:30:00Z"
55///     },
56///     "user": {
57///       "id": 789,
58///       "username": "u23571561",
59///       "email": "[email protected]",
60///       "profile_picture_path": "uploads/users/789/profile.png"
61///     }
62///   }
63/// }
64/// ```
65///
66/// - `403 Forbidden`
67/// ```json
68/// {
69///   "success": false,
70///   "message": "Forbidden"
71/// }
72/// ```
73///
74/// - `404 Not Found`
75/// ```json
76/// {
77///   "success": false,
78///   "message": "Ticket not found"
79/// }
80/// ```
81///
82/// - `500 Internal Server Error`
83/// ```json
84/// {
85///   "success": false,
86///   "message": "Failed to retrieve ticket"
87/// }
88/// ```
89pub async fn get_ticket(
90    State(app_state): State<AppState>,
91    Path((module_id, _, ticket_id)): Path<(i64, i64, i64)>,
92    Extension(AuthUser(claims)): Extension<AuthUser>,
93) -> impl IntoResponse {
94    let db = app_state.db();
95    let user_id = claims.sub;
96
97    if !is_valid(user_id, ticket_id, module_id, claims.admin, db).await {
98        return (
99            StatusCode::FORBIDDEN,
100            Json(ApiResponse::<()>::error("Forbidden")),
101        ).into_response();
102    }
103
104    // Fetch ticket and preload the user relation
105    match TicketEntity::find_by_id(ticket_id)
106        .find_also_related(UserEntity)
107        .one(db)
108        .await
109    {
110        Ok(Some((ticket, Some(user)))) => {
111            let response = TicketWithUserResponse {
112                ticket: ticket.into(),
113                user: user.into(),
114            };
115            (
116                StatusCode::OK,
117                Json(ApiResponse::success(response, "Ticket with user retrieved")),
118            ).into_response()
119        }
120        Ok(Some((_ticket, None))) => (
121            StatusCode::INTERNAL_SERVER_ERROR,
122            Json(ApiResponse::<()>::error("User not found")),
123        ).into_response(),
124        Ok(None) => (
125            StatusCode::NOT_FOUND,
126            Json(ApiResponse::<()>::error("Ticket not found")),
127        ).into_response(),
128        Err(_) => (
129            StatusCode::INTERNAL_SERVER_ERROR,
130            Json(ApiResponse::<()>::error("Failed to retrieve ticket")),
131        ).into_response(),
132    }
133}
134
135/// Query parameters for filtering, sorting, and pagination
136#[derive(Debug, Deserialize)]
137pub struct FilterReq {
138    /// Page number (default: 1)
139    pub page: Option<i32>,
140    /// Items per page (default: 20, max: 100)
141    pub per_page: Option<i32>,
142    /// Search query (matches title or description)
143    pub query: Option<String>,
144    /// Filter by ticket status
145    pub status: Option<String>,
146    /// Sort by fields (e.g., "created_at,-status")
147    pub sort: Option<String>,
148}
149
150/// Response for a paginated list of tickets
151#[derive(Serialize)]
152pub struct FilterResponse {
153    pub tickets: Vec<TicketResponse>,
154    pub page: i32,
155    pub per_page: i32,
156    pub total: i32,
157}
158
159impl FilterResponse {
160    fn new(tickets: Vec<TicketResponse>, page: i32, per_page: i32, total: i32) -> Self {
161        Self {
162            tickets,
163            page,
164            per_page,
165            total,
166        }
167    }
168}
169
170/// Helper to check if a user is a student in a module
171async fn is_student(module_id: i64, user_id: i64, db: &DatabaseConnection) -> bool {
172    user_module_role::Entity::find()
173        .filter(user_module_role::Column::UserId.eq(user_id))
174        .filter(user_module_role::Column::ModuleId.eq(module_id))
175        .filter(user_module_role::Column::Role.eq(Role::Student))
176        .join(JoinType::InnerJoin, user_module_role::Relation::User.def())
177        .filter(user::Column::Admin.eq(false))
178        .one(db)
179        .await
180        .map(|opt| opt.is_some())
181        .unwrap_or(false)
182}
183
184/// Retrieves tickets for an assignment with optional filtering, sorting, and pagination.
185///
186/// **Endpoint:** `GET /modules/{module_id}/assignments/{assignment_id}/tickets`  
187/// **Permissions:**  
188/// - Students can only see their own tickets  
189/// - Lecturers/assistants can see all tickets
190///
191/// ### Path parameters
192/// - `module_id`       → ID of the module (used for permission check)
193/// - `assignment_id`   → ID of the assignment
194///
195/// ### Query parameters
196/// - `page` → Page number (default: 1)
197/// - `per_page` → Number of items per page (default: 20, max: 100)
198/// - `query` → Search in ticket title or description
199/// - `status` → Filter by ticket status (`open`, `closed`)
200/// - `sort` → Comma-separated fields to sort by (prefix with `-` for descending)
201///
202/// ### Responses
203/// - `200 OK` → Tickets retrieved successfully
204/// ```json
205/// {
206///   "success": true,
207///   "data": {
208///     "tickets": [ /* Ticket objects */ ],
209///     "page": 1,
210///     "per_page": 20,
211///     "total": 42
212///   },
213///   "message": "Tickets retrieved successfully"
214/// }
215/// ```
216/// - `400 Bad Request` → Invalid query parameters (sort or status)
217/// ```json
218/// {
219///   "success": false,
220///   "data": null,
221///   "message": "Invalid field used"
222/// }
223/// ```
224/// - `500 Internal Server Error` → Failed to fetch tickets
225/// ```json
226/// {
227///   "success": false,
228///   "data": null,
229///   "message": "Failed to retrieve tickets"
230/// }
231/// ```
232pub async fn get_tickets(
233    Path((module_id, assignment_id)): Path<(i64, i64)>,
234    Extension(AuthUser(claims)): Extension<AuthUser>,
235    State(app_state): State<AppState>,
236    Query(params): Query<FilterReq>,
237) -> impl IntoResponse {
238    let db = app_state.db();
239    let user_id = claims.sub;
240
241    let page = params.page.unwrap_or(1).max(1);
242    let per_page = params.per_page.unwrap_or(20).min(100);
243
244    if let Some(sort_field) = &params.sort {
245        let valid_fields = ["created_at", "updated_at", "status"];
246        for field in sort_field.split(',') {
247            let field = field.trim().trim_start_matches('-');
248            if !valid_fields.contains(&field) {
249                return (
250                    StatusCode::BAD_REQUEST,
251                    Json(ApiResponse::<FilterResponse>::error("Invalid field used")),
252                )
253                    .into_response();
254            }
255        }
256    }
257
258    let mut condition = Condition::all().add(TicketColumn::AssignmentId.eq(assignment_id));
259
260    if is_student(module_id, user_id, db).await {
261        condition = condition.add(TicketColumn::UserId.eq(user_id));
262    }
263
264    if let Some(ref query) = params.query {
265        let pattern = format!("%{}%", query.to_lowercase());
266        condition = condition.add(
267            Condition::any()
268                .add(Expr::cust("LOWER(title)").like(&pattern))
269                .add(Expr::cust("LOWER(description)").like(&pattern)),
270        );
271    }
272
273    if let Some(ref status) = params.status {
274        match status.parse::<TicketStatus>() {
275            Ok(status_enum) => {
276                condition = condition.add(TicketColumn::Status.eq(status_enum));
277            }
278            Err(_) => {
279                return (
280                    StatusCode::BAD_REQUEST,
281                    Json(ApiResponse::<FilterResponse>::error("Invalid status value")),
282                )
283                    .into_response();
284            }
285        }
286    }
287
288    let mut query = TicketEntity::find().filter(condition);
289
290    if let Some(sort_param) = &params.sort {
291        for sort in sort_param.split(',') {
292            let (field, asc) = if sort.starts_with('-') {
293                (&sort[1..], false)
294            } else {
295                (sort, true)
296            };
297
298            query = match field {
299                "created_at" => {
300                    if asc {
301                        query.order_by_asc(TicketColumn::CreatedAt)
302                    } else {
303                        query.order_by_desc(TicketColumn::CreatedAt)
304                    }
305                }
306                "updated_at" => {
307                    if asc {
308                        query.order_by_asc(TicketColumn::UpdatedAt)
309                    } else {
310                        query.order_by_desc(TicketColumn::UpdatedAt)
311                    }
312                }
313                "status" => {
314                    if asc {
315                        query.order_by_asc(TicketColumn::Status)
316                    } else {
317                        query.order_by_desc(TicketColumn::Status)
318                    }
319                }
320                _ => query,
321            };
322        }
323    }
324
325    let paginator = query.clone().paginate(db, per_page as u64);
326    let total = match paginator.num_items().await {
327        Ok(n) => n as i32,
328        Err(e) => {
329            eprintln!("Error counting tickets: {:?}", e);
330            return (
331                StatusCode::INTERNAL_SERVER_ERROR,
332                Json(ApiResponse::<FilterResponse>::error(
333                    "Error counting tickets",
334                )),
335            )
336                .into_response();
337        }
338    };
339
340    match paginator.fetch_page((page - 1) as u64).await {
341        Ok(results) => {
342            let tickets: Vec<TicketResponse> =
343                results.into_iter().map(TicketResponse::from).collect();
344
345            let response = FilterResponse::new(tickets, page, per_page, total);
346            (
347                StatusCode::OK,
348                Json(ApiResponse::success(
349                    response,
350                    "Tickets retrieved successfully",
351                )),
352            )
353                .into_response()
354        }
355        Err(err) => {
356            eprintln!("Error fetching tickets: {:?}", err);
357            (
358                StatusCode::INTERNAL_SERVER_ERROR,
359                Json(ApiResponse::<FilterResponse>::error(
360                    "Failed to retrieve tickets",
361                )),
362            )
363                .into_response()
364        }
365    }
366}