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}