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}