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

1//! Ticket messages retrieval handler.
2//!
3//! Provides an endpoint to retrieve messages for a specific ticket within an assignment.
4//!
5//! Only the user who has access to the ticket can view its messages. Supports pagination
6//! and optional search query for filtering messages by content.
7
8use crate::{
9    auth::AuthUser,
10    response::ApiResponse,
11    routes::modules::assignments::tickets::{
12        common::is_valid, ticket_messages::common::{MessageResponse, UserResponse},
13    },
14};
15use axum::{
16    Extension, Json,
17    extract::{Path, Query, State},
18    http::StatusCode,
19    response::IntoResponse,
20};
21
22use db::models::{
23    ticket_messages::{Column as TicketMessageColumn, Entity as TicketMessageEntity},
24    user::Entity as UserEntity,
25};
26use migration::Expr;
27use sea_orm::{ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter};
28use serde::{Deserialize, Serialize};
29use util::state::AppState;
30
31#[derive(Debug, Deserialize)]
32pub struct FilterReq {
33    pub page: Option<i32>,
34    pub per_page: Option<i32>,
35    pub query: Option<String>,
36}
37
38#[derive(Serialize)]
39pub struct FilterResponse {
40    pub tickets: Vec<MessageResponse>,
41    pub page: i32,
42    pub per_page: i32,
43    pub total: i32,
44}
45
46/// GET /api/modules/{module_id}/assignments/{assignment_id}/tickets/{ticket_id}/messages
47///
48/// Retrieve a paginated list of **ticket messages** for a specific ticket.  
49/// Requires authentication and that the caller is allowed to view the ticket
50/// (validated by `is_valid`, e.g., ticket participant/assigned staff for the module).
51///
52/// ### Path Parameters
53/// - `module_id` (i64): ID of the module that owns the assignment/ticket
54/// - `assignment_id` (i64): ID of the assignment (present in the route; not used in filtering here)
55/// - `ticket_id` (i64): ID of the ticket whose messages are being fetched
56///
57/// ### Query Parameters
58/// - `page` (optional, i32): Page number. Defaults to **1**. Minimum **1**
59/// - `per_page` (optional, i32): Items per page. Defaults to **50**. Maximum **100**
60/// - `query` (optional, string): Case-insensitive substring filter applied to message `content`
61///
62/// > **Note:** Sorting is not supported on this endpoint. Results are returned in the
63/// database's default order for the query.
64///
65/// ### Responses
66///
67/// - `200 OK`
68/// ```json
69/// {
70///   "success": true,
71///   "message": "Messages retrieved successfully",
72///   "data": {
73///     "tickets": [
74///       {
75///         "id": 101,
76///         "ticket_id": 99,
77///         "content": "Hey, I'm blocked on step 3.",
78///         "created_at": "2025-02-18T09:12:33Z",
79///         "updated_at": "2025-02-18T09:12:33Z",
80///         "user": {
81///           "id": 12,
82///           "username": "alice"
83///         }
84///       },
85///       {
86///         "id": 102,
87///         "ticket_id": 99,
88///         "content": "Try re-running with the latest config.",
89///         "created_at": "2025-02-18T09:14:10Z",
90///         "updated_at": "2025-02-18T09:14:10Z",
91///         "user": {
92///           "id": 8,
93///           "username": "tutor_bob"
94///         }
95///       }
96///     ],
97///     "page": 1,
98///     "per_page": 50,
99///     "total": 2
100///   }
101/// }
102/// ```
103///
104/// - `403 Forbidden`
105/// ```json
106/// {
107///   "success": false,
108///   "message": "Forbidden"
109/// }
110/// ```
111///
112/// - `500 Internal Server Error`
113/// ```json
114/// {
115///   "success": false,
116///   "message": "Error counting tickets"
117/// }
118/// ```
119/// or
120/// ```json
121/// {
122///   "success": false,
123///   "message": "Failed to retrieve tickets"
124/// }
125/// ```
126///
127/// ### Example Request
128/// ```http
129/// GET /api/modules/42/assignments/7/tickets/99/messages?page=1&per_page=50&query=blocked
130/// Authorization: Bearer <token>
131/// ```
132///
133/// ### Example Success (200)
134/// See the `200 OK` example above.
135pub async fn get_ticket_messages(
136    Path((module_id, _, ticket_id)): Path<(i64, i64, i64)>,
137    State(app_state): State<AppState>,
138    Extension(AuthUser(claims)): Extension<AuthUser>,
139    Query(params): Query<FilterReq>,
140) -> impl IntoResponse {
141    let user_id: i64 = claims.sub;
142    let db = app_state.db();
143
144    if !is_valid(user_id, ticket_id, module_id, claims.admin, db).await {
145        return (
146            StatusCode::FORBIDDEN,
147            Json(ApiResponse::<()>::error("Forbidden")),
148        )
149            .into_response();
150    }
151
152    let page = params.page.unwrap_or(1).max(1);
153    let per_page = params.per_page.unwrap_or(50).min(100);
154    let mut condition = Condition::all().add(TicketMessageColumn::TicketId.eq(ticket_id));
155
156    if let Some(ref q) = params.query {
157        let pattern = format!("%{}%", q.to_lowercase());
158        condition =
159            condition.add(Condition::any().add(Expr::cust("LOWER(content)").like(&pattern)));
160    }
161
162    let total = match TicketMessageEntity::find()
163        .filter(condition.clone())
164        .count(db)
165        .await
166    {
167        Ok(n) => n as i32,
168        Err(e) => {
169            eprintln!("Error counting tickets: {:?}", e);
170            return (
171                StatusCode::INTERNAL_SERVER_ERROR,
172                Json(ApiResponse::<FilterResponse>::error(
173                    "Error counting tickets",
174                )),
175            )
176                .into_response();
177        }
178    };
179
180    let paginator = TicketMessageEntity::find()
181        .filter(condition)
182        .find_also_related(UserEntity)
183        .paginate(db, per_page as u64);
184    match paginator.fetch_page((page - 1) as u64).await {
185        Ok(results) => {
186            let messages: Vec<MessageResponse> = results
187                .into_iter()
188                .map(|(message, user)| MessageResponse {
189                    id: message.id,
190                    ticket_id: message.ticket_id,
191                    content: message.content,
192                    created_at: message.created_at.to_rfc3339(),
193                    updated_at: message.updated_at.to_rfc3339(),
194                    user: user.map(|u| {
195                        UserResponse {
196                            id: u.id,
197                            username: u.username,
198                        }
199                    }),
200                })
201                .collect();
202
203            let response = FilterResponse {
204                tickets: messages,
205                page,
206                per_page,
207                total,
208            };
209
210            (
211                StatusCode::OK,
212                Json(ApiResponse::success(
213                    response,
214                    "Messages retrieved successfully",
215                )),
216            )
217                .into_response()
218        }
219        Err(err) => {
220            eprintln!("Error fetching tickets: {:?}", err);
221            (
222                StatusCode::INTERNAL_SERVER_ERROR,
223                Json(ApiResponse::<FilterResponse>::error(
224                    "Failed to retrieve tickets",
225                )),
226            )
227                .into_response()
228        }
229    }
230}