backend/handlers/
ticket.rs

1use std::sync::Arc;
2
3use axum::{
4    Extension, Json,
5    extract::{Path, State},
6    http::StatusCode,
7    response::IntoResponse,
8};
9use serde_json::json;
10use sqlx::{Row, query};
11
12use crate::{
13    AppState,
14    models::{FilteredUser, TicketCreateScheme, TicketResponse, TicketUpdateScheme},
15};
16
17/// Creates a new support ticket.
18///
19/// Associates the ticket with the authenticated [`FilteredUser`] and sets the current timestamp.
20/// Converts the [`TicketCreateScheme`] request into a database record.
21/// Tickets are automatically created with "open" status.
22///
23/// # Arguments
24/// - `Extension(user)`: Authenticated [`FilteredUser`] (extracted from JWT token via middleware)
25/// - `State(data)`: Application state containing [`AppState`] for database access
26/// - `Json(body)`: [`TicketCreateScheme`] containing ticket details (category, subject, description, room)
27///
28/// # Returns
29/// - `200 OK` on successful creation
30/// - `500 Internal Server Error` if database insertion fails
31///
32/// # Database Fields Set Automatically
33/// - `user_id`: From authenticated user
34/// - `status`: Defaults to "open"
35/// - `date`: Current UTC timestamp
36pub async fn create_ticket(
37    Extension(user): Extension<FilteredUser>,
38    State(data): State<Arc<AppState>>,
39    Json(body): Json<TicketCreateScheme>,
40) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
41    let query = query(
42        r#"INSERT INTO tickets (category, description, betreff, room, user_id) VALUES ($1, $2, $3, $4, $5)"#,
43    )
44    .bind(body.category.to_string())
45    .bind(body.description.to_string())
46    .bind(body.betreff.to_string())
47    .bind(body.room)
48    .bind(user.id)
49    .execute(&data.db)
50    .await;
51
52    if let Err(err) = query {
53        return Err((
54            StatusCode::INTERNAL_SERVER_ERROR,
55            Json(json!({"status": "error", "message": format!("{:?}", err),})),
56        ));
57    }
58
59    let response_status = serde_json::json!({"status": "success"});
60    Ok(Json(response_status))
61}
62
63/// Deletes a ticket by ID.
64///
65/// Only admins can delete tickets (enforced by middleware). Removes the [`TicketResponse`] and associated data from the database.
66///
67/// # Arguments
68/// - `Path(id)`: Ticket ID to delete, extracted from URL path
69/// - `State(data)`: Application state containing [`AppState`] for database access
70///
71/// # Returns
72/// - `204 No Content` on successful deletion
73/// - `404 Not Found` if ticket doesn't exist
74/// - `500 Internal Server Error` if database error occurs
75pub async fn delete_ticket(
76    Path(id): Path<i32>,
77    State(data): State<Arc<AppState>>,
78) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
79    let query = sqlx::query(r#"DELETE FROM tickets WHERE id = $1"#)
80        .bind(id)
81        .execute(&data.db)
82        .await
83        .map_err(|e| {
84            (
85                StatusCode::INTERNAL_SERVER_ERROR,
86                Json(json!({"status": "error", "message": format!("{:?}", e)})),
87            )
88        })?;
89
90    if query.rows_affected() == 0 {
91        let error_response = serde_json::json!({
92            "status": "error",
93            "message": format!("Ticket with ID {} not found", id)
94        });
95        return Err((StatusCode::NOT_FOUND, Json(error_response)));
96    }
97
98    Ok(StatusCode::NO_CONTENT)
99}
100
101/// Retrieves all non-archived tickets.
102///
103/// Returns a list of all active [`TicketResponse`] objects with user information denormalized for easier rendering.
104/// Tickets are ordered by creation date (newest first). Joins with [`User`](crate::models::User) table to include creator information.
105///
106/// # Arguments
107/// - `State(data)`: Application state containing [`AppState`] for database access
108///
109/// # Filtering
110/// - Excludes tickets with status "Archived"
111/// - Uses LEFT JOIN to include creator information from [`User`](crate::models::User)
112///
113/// # Returns
114/// - `200 OK` with array of [`TicketResponse`] objects
115/// - `500 Internal Server Error` if database query fails
116///
117/// # Example Response
118/// ```json
119/// [
120///   {
121///     "id": 1,
122///     "category": "maintenance",
123///     "betreff": "Broken light",
124///     "description": "Ceiling light not working",
125///     "room": 101,
126///     "status": "open",
127///     "date": "2024-01-15T10:30:00Z",
128///     "user_id": 5,
129///     "user_first_name": "John",
130///     "user_last_name": "Doe"
131///   }
132/// ]
133/// ```
134pub async fn get_tickets(
135    State(data): State<Arc<AppState>>,
136) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
137    // Query tickets with denormalized user info, excluding archived tickets
138    let tickets = sqlx::query(
139        r#"SELECT t.id, t.category, t.betreff, t.description, t.room, t.status, t.date, t.user_id, u.first_name, u.last_name
140           FROM tickets t
141           LEFT JOIN users u ON t.user_id = u.id
142           ORDER BY t.date DESC"#,
143    )
144    .fetch_all(&data.db)
145    .await
146    .map_err(|e| {
147        let error_response = serde_json::json!({
148            "status": "error",
149            "message": format!("Database error: {}", e),
150        });
151        (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
152    })?;
153
154    // Transform raw database rows into TicketResponse structs
155    let ticket_response: Vec<TicketResponse> = tickets
156        .iter()
157        .map(|row| TicketResponse {
158            id: row.get("id"),
159            category: row.get("category"),
160            betreff: row.get("betreff"),
161            description: row.get("description"),
162            room: row.get("room"),
163            status: row.get("status"),
164            date: row.get("date"),
165            user_id: row.get("user_id"),
166            user_first_name: row.get("first_name"),
167            user_last_name: row.get("last_name"),
168        })
169        .collect();
170
171    let json_response = serde_json::json!(ticket_response);
172    Ok(Json(json_response))
173}
174
175/// Retrieves a specific ticket by ID.
176///
177/// Includes full ticket details and denormalized user information (creator name).
178/// Returns a [`TicketResponse`] with all metadata by joining with [`User`](crate::models::User) table.
179///
180/// # Arguments
181/// - `Path(id)`: Ticket ID to retrieve, extracted from URL path
182/// - `State(data)`: Application state containing [`AppState`] for database access
183///
184/// # Returns
185/// - `200 OK` with [`TicketResponse`] object
186/// - `404 Not Found` if ticket doesn't exist
187/// - `500 Internal Server Error` if database error occurs
188///
189/// # Example Response
190/// ```json
191/// {
192///   "id": 1,
193///   "category": "maintenance",
194///   "betreff": "Broken light in room 101",
195///   "description": "The ceiling light is not working",
196///   "room": 101,
197///   "status": "open",
198///   "date": "2024-01-15T10:30:00Z",
199///   "user_id": 5,
200///   "user_first_name": "John",
201///   "user_last_name": "Doe"
202/// }
203/// ```
204pub async fn get_ticket_by_id(
205    Path(id): Path<i32>,
206    State(data): State<Arc<AppState>>,
207) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
208    let query = sqlx::query(
209        r#"SELECT t.id, t.category, t.betreff, t.description, t.room, t.status, t.date, t.user_id, u.first_name, u.last_name
210           FROM tickets t
211           LEFT JOIN users u ON t.user_id = u.id
212           WHERE t.id = $1"#,
213    )
214    .bind(id)
215    .fetch_one(&data.db)
216    .await;
217
218    match query {
219        Ok(row) => {
220            let ticket_response = TicketResponse {
221                id: row.get("id"),
222                category: row.get("category"),
223                betreff: row.get("betreff"),
224                description: row.get("description"),
225                room: row.get("room"),
226                status: row.get("status"),
227                date: row.get("date"),
228                user_id: row.get("user_id"),
229                user_first_name: row.get("first_name"),
230                user_last_name: row.get("last_name"),
231            };
232            let response = serde_json::json!(ticket_response);
233            return Ok(Json(response));
234        }
235        Err(sqlx::Error::RowNotFound) => {
236            let error_response = serde_json::json!({
237                "status": "fail",
238                "message": format!("Ticket with ID {} not found", id)
239            });
240            return Err((StatusCode::NOT_FOUND, Json(error_response)));
241        }
242        Err(e) => {
243            return Err((
244                StatusCode::INTERNAL_SERVER_ERROR,
245                Json(json!({"status": "error", "message": format!("{:?}", e)})),
246            ));
247        }
248    };
249}
250
251/// Updates a ticket's status.
252///
253/// Only admins can update ticket status (enforced by middleware). Applies [`TicketUpdateScheme`] to modify the [`TicketResponse`].
254/// This is typically used to transition tickets through their lifecycle (open → in_progress → resolved → archived).
255///
256/// # Arguments
257/// - `Path(id)`: Ticket ID to update, extracted from URL path
258/// - `State(data)`: Application state containing [`AppState`] for database access
259/// - `Json(body)`: [`TicketUpdateScheme`] update payload containing new status
260///
261/// # Returns
262/// - `200 OK` with updated [`TicketResponse`]
263/// - `500 Internal Server Error` if ticket not found or database error
264///
265/// # Typical Status Flow
266/// - `open`: Initial state, waiting for action
267/// - `in_progress`: Currently being worked on
268/// - `resolved`: Issue fixed
269/// - `archived`: Closed/hidden from normal view
270pub async fn edit_ticket(
271    Path(id): Path<i32>,
272    State(data): State<Arc<AppState>>,
273    Json(body): Json<TicketUpdateScheme>,
274) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
275    // Update the ticket status
276    let update_result = sqlx::query(r#"UPDATE tickets SET status = $1 WHERE id = $2"#)
277        .bind(body.status.to_owned())
278        .bind(id)
279        .execute(&data.db)
280        .await
281        .map_err(|e| {
282            (
283                StatusCode::INTERNAL_SERVER_ERROR,
284                Json(json!({"status": "error", "message": format!("{:?}", e)})),
285            )
286        })?;
287
288    if update_result.rows_affected() == 0 {
289        let error_response = serde_json::json!({
290            "status": "error",
291            "message": format!("Ticket with ID {} not found", id)
292        });
293        return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)));
294    }
295
296    // Fetch and return the updated ticket
297    let updated_ticket = sqlx::query(
298        r#"SELECT t.id, t.category, t.betreff, t.description, t.room, t.status, t.date, t.user_id, u.first_name, u.last_name
299           FROM tickets t
300           LEFT JOIN users u ON t.user_id = u.id
301           WHERE t.id = $1"#,
302    )
303    .bind(id)
304    .fetch_one(&data.db)
305    .await
306    .map_err(|e| {
307        (
308            StatusCode::INTERNAL_SERVER_ERROR,
309            Json(json!({"status": "error", "message": format!("{:?}", e)})),
310        )
311    })?;
312
313    let ticket_response = TicketResponse {
314        id: updated_ticket.get("id"),
315        category: updated_ticket.get("category"),
316        betreff: updated_ticket.get("betreff"),
317        description: updated_ticket.get("description"),
318        room: updated_ticket.get("room"),
319        status: updated_ticket.get("status"),
320        date: updated_ticket.get("date"),
321        user_id: updated_ticket.get("user_id"),
322        user_first_name: updated_ticket.get("first_name"),
323        user_last_name: updated_ticket.get("last_name"),
324    };
325
326    let response = serde_json::json!({
327        "ticket": ticket_response,
328        "status": "success"
329    });
330
331    Ok(Json(response))
332}