api/routes/modules/assignments/tickets/ticket_messages/put.rs
1//! Ticket message edit handler.
2//!
3//! Provides an endpoint to edit an existing message on a ticket.
4//!
5//! Only the author of the message can update it. The endpoint validates
6//! that the `content` field is provided and not empty.
7
8use axum::{
9 Extension, Json,
10 extract::{Path, State},
11 http::StatusCode,
12 response::IntoResponse,
13};
14use db::models::ticket_messages::Model as TicketMessageModel;
15use util::state::AppState;
16
17use crate::{
18 auth::AuthUser,
19 response::ApiResponse,
20 routes::modules::assignments::tickets::ticket_messages::common::MessageResponse, ws::tickets::topics::ticket_chat_topic,
21};
22
23/// PUT /api/modules/{module_id}/assignments/{assignment_id}/tickets/{ticket_id}/messages/{message_id}
24///
25/// Update (edit) a **ticket message**. Only the **author** of the message may edit it.
26///
27/// ### Path Parameters
28/// - `module_id` (i64): Module ID (for scoping/authorization)
29/// - `assignment_id` (i64): Assignment ID (for scoping/authorization)
30/// - `ticket_id` (i64): Ticket ID (for scoping/authorization)
31/// - `message_id` (i64): The message to update
32///
33/// ### Authorization
34/// - Requires a valid bearer token
35/// - Caller must be the **author** of the message; otherwise `403 Forbidden` is returned
36///
37/// ### Request Body
38/// ```json
39/// { "content": "Updated message text" }
40/// ```
41/// - `content` (string, required): New message content (non-empty after trimming)
42///
43/// ### WebSocket Broadcast
44/// On success, the server broadcasts to:
45/// `ws/tickets/{ticket_id}`
46///
47/// Event payload:
48/// ```json
49/// {
50/// "event": "message_updated",
51/// "payload": {
52/// "id": 123,
53/// "ticket_id": 99,
54/// "content": "Updated message text",
55/// "created_at": "2025-02-01T12:00:00Z",
56/// "updated_at": "2025-02-01T12:05:00Z",
57/// "user": null
58/// }
59/// }
60/// ```
61///
62/// ### Responses
63///
64/// - `200 OK` — Message updated
65/// ```json
66/// {
67/// "success": true,
68/// "message": "Message updated successfully",
69/// "data": {
70/// "id": 123,
71/// "ticket_id": 99,
72/// "content": "Updated message text",
73/// "created_at": "2025-02-01T12:00:00Z",
74/// "updated_at": "2025-02-01T12:05:00Z",
75/// "user": null
76/// }
77/// }
78/// ```
79///
80/// - `400 Bad Request` — Missing/empty `content`
81/// ```json
82/// { "success": false, "message": "Content is required" }
83/// ```
84///
85/// - `403 Forbidden` — Caller is not the author
86/// ```json
87/// { "success": false, "message": "Forbidden" }
88/// ```
89///
90/// - `500 Internal Server Error` — Database error while updating
91/// ```json
92/// { "success": false, "message": "Failed to update message" }
93/// ```
94///
95/// ### Example Request
96/// ```http
97/// PUT /api/modules/42/assignments/7/tickets/99/messages/123
98/// Authorization: Bearer <token>
99/// Content-Type: application/json
100///
101/// { "content": "Updated message text" }
102/// ```
103pub async fn edit_ticket_message(
104 // NOTE: we now extract module_id, assignment_id, and ticket_id so we can build the WS topic
105 Path((_, _, ticket_id, message_id)): Path<(i64, i64, i64, i64)>,
106 State(app_state): State<AppState>,
107 Extension(AuthUser(claims)): Extension<AuthUser>,
108 Json(req): Json<serde_json::Value>,
109) -> impl IntoResponse {
110 let db = app_state.db();
111 let user_id = claims.sub;
112
113 // Only the author can edit
114 let is_author = TicketMessageModel::is_author(message_id, user_id, db).await;
115 if !is_author {
116 return (
117 StatusCode::FORBIDDEN,
118 Json(ApiResponse::<()>::error("Forbidden")),
119 )
120 .into_response();
121 }
122
123 // Validate content
124 let content = match req.get("content").and_then(|v| v.as_str()) {
125 Some(c) if !c.trim().is_empty() => c.trim().to_string(),
126 _ => {
127 return (
128 StatusCode::BAD_REQUEST,
129 Json(ApiResponse::<()>::error("Content is required")),
130 )
131 .into_response();
132 }
133 };
134
135 // Update in DB
136 let message = match TicketMessageModel::update(db, message_id, &content).await {
137 Ok(msg) => msg,
138 Err(_) => {
139 return (
140 StatusCode::INTERNAL_SERVER_ERROR,
141 Json(ApiResponse::<()>::error("Failed to update message")),
142 )
143 .into_response();
144 }
145 };
146
147 // Prepare REST response shape
148 let response = MessageResponse {
149 id: message.id,
150 ticket_id: message.ticket_id,
151 content: message.content.clone(),
152 created_at: message.created_at.to_rfc3339(),
153 updated_at: message.updated_at.to_rfc3339(),
154 user: None, // optional; clients preserve existing sender
155 };
156
157 // --- WebSocket broadcast: message_updated ---
158 let topic = ticket_chat_topic(ticket_id);
159 let payload = serde_json::json!({
160 "event": "message_updated",
161 "payload": {
162 "id": message.id,
163 "ticket_id": message.ticket_id,
164 "content": message.content,
165 "created_at": message.created_at.to_rfc3339(),
166 "updated_at": message.updated_at.to_rfc3339(),
167 "user": null
168 }
169 });
170 app_state.ws_clone().broadcast(&topic, payload.to_string()).await;
171
172 (
173 StatusCode::OK,
174 Json(ApiResponse::success(
175 response,
176 "Message updated successfully",
177 )),
178 )
179 .into_response()
180}