1use crate::application::dto::{
2 AssignTicketRequest, CancelTicketRequest, CreateTicketRequest, ReopenTicketRequest,
3 ResolveTicketRequest, TicketResponse, UpdateTicketRequest,
4};
5use crate::domain::entities::TicketStatus;
6use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
7use crate::infrastructure::web::{AppState, AuthenticatedUser};
8use actix_web::{delete, get, patch, post, put, web, HttpResponse, Responder, ResponseError};
9use std::collections::HashMap;
10use uuid::Uuid;
11
12async fn enrich_ticket(state: &AppState, mut ticket: TicketResponse) -> TicketResponse {
14 if let Ok(name) = state
15 .user_use_cases
16 .find_display_name(ticket.created_by)
17 .await
18 {
19 ticket.requester_name = name;
20 }
21 if let Some(assignee_id) = ticket.assigned_to {
22 if let Ok(name) = state.user_use_cases.find_display_name(assignee_id).await {
23 ticket.assigned_to_name = name;
24 }
25 }
26 ticket
27}
28
29async fn enrich_tickets(state: &AppState, tickets: Vec<TicketResponse>) -> Vec<TicketResponse> {
31 let mut user_ids: Vec<Uuid> = Vec::new();
32 for t in &tickets {
33 user_ids.push(t.created_by);
34 if let Some(a) = t.assigned_to {
35 user_ids.push(a);
36 }
37 }
38 user_ids.sort();
39 user_ids.dedup();
40
41 let mut names: HashMap<Uuid, String> = HashMap::new();
42 for id in user_ids {
43 if let Ok(Some(name)) = state.user_use_cases.find_display_name(id).await {
44 names.insert(id, name);
45 }
46 }
47
48 tickets
49 .into_iter()
50 .map(|mut t| {
51 t.requester_name = names.get(&t.created_by).cloned();
52 t.assigned_to_name = t.assigned_to.and_then(|a| names.get(&a).cloned());
53 t
54 })
55 .collect()
56}
57
58#[utoipa::path(
61 post,
62 path = "/tickets",
63 tag = "Tickets",
64 summary = "Create a new maintenance ticket",
65 request_body = CreateTicketRequest,
66 responses(
67 (status = 201, description = "Ticket created"),
68 (status = 400, description = "Bad request"),
69 (status = 401, description = "Unauthorized"),
70 (status = 500, description = "Internal server error"),
71 ),
72 security(("bearer_auth" = []))
73)]
74#[post("/tickets")]
75pub async fn create_ticket(
76 state: web::Data<AppState>,
77 user: AuthenticatedUser,
78 request: web::Json<CreateTicketRequest>,
79) -> impl Responder {
80 let organization_id = match user.require_organization() {
81 Ok(org_id) => org_id,
82 Err(e) => {
83 return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
84 }
85 };
86
87 let created_by = user.user_id;
89
90 match state
91 .ticket_use_cases
92 .create_ticket(organization_id, created_by, request.into_inner())
93 .await
94 {
95 Ok(ticket) => {
96 AuditLogEntry::new(
97 AuditEventType::TicketCreated,
98 Some(user.user_id),
99 Some(organization_id),
100 )
101 .with_resource("Ticket", ticket.id)
102 .log();
103
104 let enriched = enrich_ticket(&state, ticket).await;
105 HttpResponse::Created().json(enriched)
106 }
107 Err(err) => {
108 AuditLogEntry::new(
109 AuditEventType::TicketCreated,
110 Some(user.user_id),
111 Some(organization_id),
112 )
113 .with_error(err.clone())
114 .log();
115
116 HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
117 }
118 }
119}
120
121#[utoipa::path(
122 get,
123 path = "/tickets/{id}",
124 tag = "Tickets",
125 summary = "Get a ticket by ID",
126 params(
127 ("id" = Uuid, Path, description = "Ticket ID")
128 ),
129 responses(
130 (status = 200, description = "Ticket found"),
131 (status = 404, description = "Ticket not found"),
132 (status = 500, description = "Internal server error"),
133 ),
134 security(("bearer_auth" = []))
135)]
136#[get("/tickets/{id}")]
137pub async fn get_ticket(
138 state: web::Data<AppState>,
139 user: AuthenticatedUser,
140 id: web::Path<Uuid>,
141) -> impl Responder {
142 match state.ticket_use_cases.get_ticket(*id).await {
143 Ok(Some(ticket)) => {
144 if let Err(e) = user.verify_org_access(ticket.organization_id) {
146 return HttpResponse::Forbidden().json(serde_json::json!({ "error": e }));
147 }
148 let enriched = enrich_ticket(&state, ticket).await;
149 HttpResponse::Ok().json(enriched)
150 }
151 Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
152 "error": "Ticket not found"
153 })),
154 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
155 }
156}
157
158#[utoipa::path(
159 get,
160 path = "/buildings/{building_id}/tickets",
161 tag = "Tickets",
162 summary = "List all tickets for a building",
163 params(
164 ("building_id" = Uuid, Path, description = "Building ID")
165 ),
166 responses(
167 (status = 200, description = "List of tickets"),
168 (status = 500, description = "Internal server error"),
169 ),
170 security(("bearer_auth" = []))
171)]
172#[get("/buildings/{building_id}/tickets")]
173pub async fn list_building_tickets(
174 state: web::Data<AppState>,
175 building_id: web::Path<Uuid>,
176 user: AuthenticatedUser,
177) -> impl Responder {
178 if let Err(err) =
182 crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
183 &user,
184 *building_id,
185 &state.building_use_cases,
186 &state.acp_use_cases,
187 )
188 .await
189 {
190 return err.error_response();
191 }
192
193 match state
194 .ticket_use_cases
195 .list_tickets_by_building(*building_id)
196 .await
197 {
198 Ok(tickets) => HttpResponse::Ok().json(enrich_tickets(&state, tickets).await),
199 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
200 }
201}
202
203#[utoipa::path(
204 get,
205 path = "/organizations/{organization_id}/tickets",
206 tag = "Tickets",
207 summary = "List all tickets for an organization",
208 params(
209 ("organization_id" = Uuid, Path, description = "Organization ID")
210 ),
211 responses(
212 (status = 200, description = "List of tickets"),
213 (status = 500, description = "Internal server error"),
214 ),
215 security(("bearer_auth" = []))
216)]
217#[get("/organizations/{organization_id}/tickets")]
218pub async fn list_organization_tickets(
219 state: web::Data<AppState>,
220 user: AuthenticatedUser,
221 organization_id: web::Path<Uuid>,
222) -> impl Responder {
223 if let Err(e) = user.verify_org_access(*organization_id) {
224 return HttpResponse::Forbidden().json(serde_json::json!({"error": e}));
225 }
226 match state
227 .ticket_use_cases
228 .list_tickets_by_organization(*organization_id)
229 .await
230 {
231 Ok(tickets) => HttpResponse::Ok().json(enrich_tickets(&state, tickets).await),
232 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
233 }
234}
235
236#[utoipa::path(
237 get,
238 path = "/tickets/my",
239 tag = "Tickets",
240 summary = "List tickets created by the authenticated user",
241 responses(
242 (status = 200, description = "List of tickets"),
243 (status = 500, description = "Internal server error"),
244 ),
245 security(("bearer_auth" = []))
246)]
247#[get("/tickets/my")]
248pub async fn list_my_tickets(
249 state: web::Data<AppState>,
250 user: AuthenticatedUser,
251) -> impl Responder {
252 let created_by = user.user_id;
253
254 match state.ticket_use_cases.list_my_tickets(created_by).await {
255 Ok(tickets) => HttpResponse::Ok().json(enrich_tickets(&state, tickets).await),
256 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
257 }
258}
259
260#[utoipa::path(
261 get,
262 path = "/tickets/assigned-to-me",
263 tag = "Tickets",
264 summary = "List tickets assigned to the authenticated user",
265 responses(
266 (status = 200, description = "List of assigned tickets"),
267 (status = 500, description = "Internal server error"),
268 ),
269 security(("bearer_auth" = []))
270)]
271#[get("/tickets/assigned-to-me")]
272pub async fn list_assigned_tickets(
273 state: web::Data<AppState>,
274 user: AuthenticatedUser,
275) -> impl Responder {
276 let assigned_to = user.user_id;
277
278 match state
279 .ticket_use_cases
280 .list_assigned_tickets(assigned_to)
281 .await
282 {
283 Ok(tickets) => HttpResponse::Ok().json(enrich_tickets(&state, tickets).await),
284 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
285 }
286}
287
288#[utoipa::path(
289 get,
290 path = "/buildings/{building_id}/tickets/status/{status}",
291 tag = "Tickets",
292 summary = "List tickets by status for a building",
293 params(
294 ("building_id" = Uuid, Path, description = "Building ID"),
295 ("status" = String, Path, description = "Ticket status (Open, InProgress, Resolved, Closed, Cancelled)")
296 ),
297 responses(
298 (status = 200, description = "List of tickets"),
299 (status = 400, description = "Invalid status"),
300 (status = 500, description = "Internal server error"),
301 ),
302 security(("bearer_auth" = []))
303)]
304#[get("/buildings/{building_id}/tickets/status/{status}")]
305pub async fn list_tickets_by_status(
306 state: web::Data<AppState>,
307 path: web::Path<(Uuid, String)>,
308 user: AuthenticatedUser,
309) -> impl Responder {
310 let (building_id, status_str) = path.into_inner();
311 if let Err(err) =
315 crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
316 &user,
317 building_id,
318 &state.building_use_cases,
319 &state.acp_use_cases,
320 )
321 .await
322 {
323 return err.error_response();
324 }
325
326 let status = match status_str.as_str() {
327 "Open" | "open" => TicketStatus::Open,
328 "InProgress" | "in_progress" => TicketStatus::InProgress,
329 "Resolved" | "resolved" => TicketStatus::Resolved,
330 "Closed" | "closed" => TicketStatus::Closed,
331 "Cancelled" | "cancelled" => TicketStatus::Cancelled,
332 _ => {
333 return HttpResponse::BadRequest().json(serde_json::json!({
334 "error": format!("Invalid status: {}", status_str)
335 }))
336 }
337 };
338
339 match state
340 .ticket_use_cases
341 .list_tickets_by_status(building_id, status)
342 .await
343 {
344 Ok(tickets) => HttpResponse::Ok().json(enrich_tickets(&state, tickets).await),
345 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
346 }
347}
348
349#[utoipa::path(
350 delete,
351 path = "/tickets/{id}",
352 tag = "Tickets",
353 summary = "Delete a ticket",
354 params(
355 ("id" = Uuid, Path, description = "Ticket ID")
356 ),
357 responses(
358 (status = 204, description = "Ticket deleted"),
359 (status = 400, description = "Bad request"),
360 (status = 401, description = "Unauthorized"),
361 (status = 404, description = "Ticket not found"),
362 ),
363 security(("bearer_auth" = []))
364)]
365#[delete("/tickets/{id}")]
366pub async fn delete_ticket(
367 state: web::Data<AppState>,
368 user: AuthenticatedUser,
369 id: web::Path<Uuid>,
370) -> impl Responder {
371 let organization_id = match user.require_organization() {
372 Ok(org_id) => org_id,
373 Err(e) => {
374 return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
375 }
376 };
377
378 match state.ticket_use_cases.delete_ticket(*id).await {
379 Ok(true) => {
380 AuditLogEntry::new(
381 AuditEventType::TicketDeleted,
382 Some(user.user_id),
383 Some(organization_id),
384 )
385 .with_resource("Ticket", *id)
386 .log();
387
388 HttpResponse::NoContent().finish()
389 }
390 Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
391 "error": "Ticket not found"
392 })),
393 Err(err) => {
394 AuditLogEntry::new(
395 AuditEventType::TicketDeleted,
396 Some(user.user_id),
397 Some(organization_id),
398 )
399 .with_error(err.clone())
400 .log();
401
402 HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
403 }
404 }
405}
406
407#[utoipa::path(
425 patch,
426 path = "/tickets/{id}",
427 tag = "Tickets",
428 summary = "Update editable fields of a ticket (Story 3.6 INV-24, within 5-min window)",
429 params(
430 ("id" = Uuid, Path, description = "Ticket ID")
431 ),
432 request_body = UpdateTicketRequest,
433 responses(
434 (status = 200, description = "Ticket updated"),
435 (status = 400, description = "Validation error (e.g. complaint without severity)"),
436 (status = 401, description = "Unauthorized"),
437 (status = 403, description = "Ticket immutable (INV-24) or out of scope"),
438 (status = 404, description = "Ticket not found"),
439 ),
440 security(("bearer_auth" = []))
441)]
442#[patch("/tickets/{id}")]
443pub async fn update_ticket_fields(
444 state: web::Data<AppState>,
445 user: AuthenticatedUser,
446 id: web::Path<Uuid>,
447 request: web::Json<UpdateTicketRequest>,
448) -> impl Responder {
449 let organization_id = match user.require_organization() {
450 Ok(org_id) => org_id,
451 Err(e) => {
452 return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
453 }
454 };
455
456 match state.ticket_use_cases.get_ticket(*id).await {
459 Ok(Some(existing)) => {
460 if let Err(e) = user.verify_org_access(existing.organization_id) {
461 return HttpResponse::Forbidden().json(serde_json::json!({ "error": e }));
462 }
463 }
464 Ok(None) => {
465 return HttpResponse::NotFound()
466 .json(serde_json::json!({ "error": "Ticket not found" }));
467 }
468 Err(err) => {
469 return HttpResponse::InternalServerError().json(serde_json::json!({ "error": err }));
470 }
471 }
472
473 match state
474 .ticket_use_cases
475 .update_ticket_fields(*id, request.into_inner())
476 .await
477 {
478 Ok(ticket) => {
479 AuditLogEntry::new(
480 AuditEventType::TicketUpdated,
481 Some(user.user_id),
482 Some(organization_id),
483 )
484 .with_resource("Ticket", ticket.id)
485 .log();
486
487 let enriched = enrich_ticket(&state, ticket).await;
488 HttpResponse::Ok().json(enriched)
489 }
490 Err(err) => {
491 AuditLogEntry::new(
492 AuditEventType::TicketUpdated,
493 Some(user.user_id),
494 Some(organization_id),
495 )
496 .with_error(err.to_string())
497 .log();
498
499 err.error_response()
503 }
504 }
505}
506
507#[utoipa::path(
510 put,
511 path = "/tickets/{id}/assign",
512 tag = "Tickets",
513 summary = "Assign a ticket to a contractor",
514 params(
515 ("id" = Uuid, Path, description = "Ticket ID")
516 ),
517 request_body = AssignTicketRequest,
518 responses(
519 (status = 200, description = "Ticket assigned"),
520 (status = 400, description = "Bad request"),
521 (status = 401, description = "Unauthorized"),
522 ),
523 security(("bearer_auth" = []))
524)]
525#[put("/tickets/{id}/assign")]
526pub async fn assign_ticket(
527 state: web::Data<AppState>,
528 user: AuthenticatedUser,
529 id: web::Path<Uuid>,
530 request: web::Json<AssignTicketRequest>,
531) -> impl Responder {
532 let organization_id = match user.require_organization() {
533 Ok(org_id) => org_id,
534 Err(e) => {
535 return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
536 }
537 };
538
539 match state
540 .ticket_use_cases
541 .assign_ticket(*id, request.into_inner())
542 .await
543 {
544 Ok(ticket) => {
545 AuditLogEntry::new(
546 AuditEventType::TicketAssigned,
547 Some(user.user_id),
548 Some(organization_id),
549 )
550 .with_resource("Ticket", ticket.id)
551 .log();
552
553 let enriched = enrich_ticket(&state, ticket).await;
554 HttpResponse::Ok().json(enriched)
555 }
556 Err(err) => {
557 AuditLogEntry::new(
558 AuditEventType::TicketAssigned,
559 Some(user.user_id),
560 Some(organization_id),
561 )
562 .with_error(err.clone())
563 .log();
564
565 HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
566 }
567 }
568}
569
570#[utoipa::path(
571 put,
572 path = "/tickets/{id}/start-work",
573 tag = "Tickets",
574 summary = "Start work on an assigned ticket",
575 params(
576 ("id" = Uuid, Path, description = "Ticket ID")
577 ),
578 responses(
579 (status = 200, description = "Work started"),
580 (status = 400, description = "Bad request"),
581 (status = 401, description = "Unauthorized"),
582 ),
583 security(("bearer_auth" = []))
584)]
585#[put("/tickets/{id}/start-work")]
586pub async fn start_work(
587 state: web::Data<AppState>,
588 user: AuthenticatedUser,
589 id: web::Path<Uuid>,
590) -> impl Responder {
591 let organization_id = match user.require_organization() {
592 Ok(org_id) => org_id,
593 Err(e) => {
594 return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
595 }
596 };
597
598 match state.ticket_use_cases.start_work(*id).await {
599 Ok(ticket) => {
600 AuditLogEntry::new(
601 AuditEventType::TicketStatusChanged,
602 Some(user.user_id),
603 Some(organization_id),
604 )
605 .with_resource("Ticket", ticket.id)
606 .log();
607
608 let enriched = enrich_ticket(&state, ticket).await;
609 HttpResponse::Ok().json(enriched)
610 }
611 Err(err) => {
612 AuditLogEntry::new(
613 AuditEventType::TicketStatusChanged,
614 Some(user.user_id),
615 Some(organization_id),
616 )
617 .with_error(err.clone())
618 .log();
619
620 HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
621 }
622 }
623}
624
625#[utoipa::path(
626 put,
627 path = "/tickets/{id}/resolve",
628 tag = "Tickets",
629 summary = "Mark a ticket as resolved",
630 params(
631 ("id" = Uuid, Path, description = "Ticket ID")
632 ),
633 request_body = ResolveTicketRequest,
634 responses(
635 (status = 200, description = "Ticket resolved"),
636 (status = 400, description = "Bad request"),
637 (status = 401, description = "Unauthorized"),
638 ),
639 security(("bearer_auth" = []))
640)]
641#[put("/tickets/{id}/resolve")]
642pub async fn resolve_ticket(
643 state: web::Data<AppState>,
644 user: AuthenticatedUser,
645 id: web::Path<Uuid>,
646 request: web::Json<ResolveTicketRequest>,
647) -> impl Responder {
648 let organization_id = match user.require_organization() {
649 Ok(org_id) => org_id,
650 Err(e) => {
651 return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
652 }
653 };
654
655 match state
656 .ticket_use_cases
657 .resolve_ticket(*id, request.into_inner())
658 .await
659 {
660 Ok(ticket) => {
661 AuditLogEntry::new(
662 AuditEventType::TicketResolved,
663 Some(user.user_id),
664 Some(organization_id),
665 )
666 .with_resource("Ticket", ticket.id)
667 .log();
668
669 let enriched = enrich_ticket(&state, ticket).await;
670 HttpResponse::Ok().json(enriched)
671 }
672 Err(err) => {
673 AuditLogEntry::new(
674 AuditEventType::TicketResolved,
675 Some(user.user_id),
676 Some(organization_id),
677 )
678 .with_error(err.clone())
679 .log();
680
681 HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
682 }
683 }
684}
685
686#[utoipa::path(
687 put,
688 path = "/tickets/{id}/close",
689 tag = "Tickets",
690 summary = "Close a resolved ticket",
691 params(
692 ("id" = Uuid, Path, description = "Ticket ID")
693 ),
694 responses(
695 (status = 200, description = "Ticket closed"),
696 (status = 400, description = "Bad request"),
697 (status = 401, description = "Unauthorized"),
698 ),
699 security(("bearer_auth" = []))
700)]
701#[put("/tickets/{id}/close")]
702pub async fn close_ticket(
703 state: web::Data<AppState>,
704 user: AuthenticatedUser,
705 id: web::Path<Uuid>,
706) -> impl Responder {
707 let organization_id = match user.require_organization() {
708 Ok(org_id) => org_id,
709 Err(e) => {
710 return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
711 }
712 };
713
714 match state.ticket_use_cases.close_ticket(*id).await {
715 Ok(ticket) => {
716 AuditLogEntry::new(
717 AuditEventType::TicketClosed,
718 Some(user.user_id),
719 Some(organization_id),
720 )
721 .with_resource("Ticket", ticket.id)
722 .log();
723
724 let enriched = enrich_ticket(&state, ticket).await;
725 HttpResponse::Ok().json(enriched)
726 }
727 Err(err) => {
728 AuditLogEntry::new(
729 AuditEventType::TicketClosed,
730 Some(user.user_id),
731 Some(organization_id),
732 )
733 .with_error(err.clone())
734 .log();
735
736 HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
737 }
738 }
739}
740
741#[utoipa::path(
742 put,
743 path = "/tickets/{id}/cancel",
744 tag = "Tickets",
745 summary = "Cancel a ticket",
746 params(
747 ("id" = Uuid, Path, description = "Ticket ID")
748 ),
749 request_body = CancelTicketRequest,
750 responses(
751 (status = 200, description = "Ticket cancelled"),
752 (status = 400, description = "Bad request"),
753 (status = 401, description = "Unauthorized"),
754 ),
755 security(("bearer_auth" = []))
756)]
757#[put("/tickets/{id}/cancel")]
758pub async fn cancel_ticket(
759 state: web::Data<AppState>,
760 user: AuthenticatedUser,
761 id: web::Path<Uuid>,
762 request: web::Json<CancelTicketRequest>,
763) -> impl Responder {
764 let organization_id = match user.require_organization() {
765 Ok(org_id) => org_id,
766 Err(e) => {
767 return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
768 }
769 };
770
771 match state
772 .ticket_use_cases
773 .cancel_ticket(*id, request.into_inner())
774 .await
775 {
776 Ok(ticket) => {
777 AuditLogEntry::new(
778 AuditEventType::TicketCancelled,
779 Some(user.user_id),
780 Some(organization_id),
781 )
782 .with_resource("Ticket", ticket.id)
783 .log();
784
785 let enriched = enrich_ticket(&state, ticket).await;
786 HttpResponse::Ok().json(enriched)
787 }
788 Err(err) => {
789 AuditLogEntry::new(
790 AuditEventType::TicketCancelled,
791 Some(user.user_id),
792 Some(organization_id),
793 )
794 .with_error(err.clone())
795 .log();
796
797 HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
798 }
799 }
800}
801
802#[utoipa::path(
803 put,
804 path = "/tickets/{id}/reopen",
805 tag = "Tickets",
806 summary = "Reopen a closed or cancelled ticket",
807 params(
808 ("id" = Uuid, Path, description = "Ticket ID")
809 ),
810 request_body = ReopenTicketRequest,
811 responses(
812 (status = 200, description = "Ticket reopened"),
813 (status = 400, description = "Bad request"),
814 (status = 401, description = "Unauthorized"),
815 ),
816 security(("bearer_auth" = []))
817)]
818#[put("/tickets/{id}/reopen")]
819pub async fn reopen_ticket(
820 state: web::Data<AppState>,
821 user: AuthenticatedUser,
822 id: web::Path<Uuid>,
823 request: web::Json<ReopenTicketRequest>,
824) -> impl Responder {
825 let organization_id = match user.require_organization() {
826 Ok(org_id) => org_id,
827 Err(e) => {
828 return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
829 }
830 };
831
832 match state
833 .ticket_use_cases
834 .reopen_ticket(*id, request.into_inner())
835 .await
836 {
837 Ok(ticket) => {
838 AuditLogEntry::new(
839 AuditEventType::TicketReopened,
840 Some(user.user_id),
841 Some(organization_id),
842 )
843 .with_resource("Ticket", ticket.id)
844 .log();
845
846 let enriched = enrich_ticket(&state, ticket).await;
847 HttpResponse::Ok().json(enriched)
848 }
849 Err(err) => {
850 AuditLogEntry::new(
851 AuditEventType::TicketReopened,
852 Some(user.user_id),
853 Some(organization_id),
854 )
855 .with_error(err.clone())
856 .log();
857
858 HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
859 }
860 }
861}
862
863#[utoipa::path(
866 put,
867 path = "/tickets/{id}/send-work-order",
868 tag = "Tickets",
869 summary = "Send work order to contractor (magic link PWA)",
870 params(
871 ("id" = Uuid, Path, description = "Ticket ID")
872 ),
873 responses(
874 (status = 200, description = "Work order sent"),
875 (status = 400, description = "Bad request"),
876 (status = 401, description = "Unauthorized"),
877 ),
878 security(("bearer_auth" = []))
879)]
880#[put("/tickets/{id}/send-work-order")]
881pub async fn send_work_order(
882 state: web::Data<AppState>,
883 user: AuthenticatedUser,
884 id: web::Path<Uuid>,
885) -> impl Responder {
886 let organization_id = match user.require_organization() {
887 Ok(org_id) => org_id,
888 Err(e) => {
889 return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
890 }
891 };
892
893 match state.ticket_use_cases.send_work_order(*id).await {
894 Ok(ticket) => {
895 AuditLogEntry::new(
896 AuditEventType::TicketWorkOrderSent,
897 Some(user.user_id),
898 Some(organization_id),
899 )
900 .with_resource("Ticket", ticket.id)
901 .log();
902
903 let enriched = enrich_ticket(&state, ticket).await;
904 HttpResponse::Ok().json(enriched)
905 }
906 Err(err) => {
907 AuditLogEntry::new(
908 AuditEventType::TicketWorkOrderSent,
909 Some(user.user_id),
910 Some(organization_id),
911 )
912 .with_error(err.clone())
913 .log();
914
915 HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
916 }
917 }
918}
919
920#[utoipa::path(
923 get,
924 path = "/tickets/statistics",
925 tag = "Tickets",
926 summary = "Get ticket statistics for the organization",
927 responses(
928 (status = 200, description = "Ticket statistics"),
929 (status = 401, description = "Unauthorized"),
930 (status = 500, description = "Internal server error"),
931 ),
932 security(("bearer_auth" = []))
933)]
934#[get("/tickets/statistics")]
935pub async fn get_ticket_statistics_org(
936 state: web::Data<AppState>,
937 user: AuthenticatedUser,
938) -> impl Responder {
939 let organization_id = match user.require_organization() {
940 Ok(org_id) => org_id,
941 Err(e) => {
942 return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
943 }
944 };
945
946 match state
947 .ticket_use_cases
948 .get_ticket_statistics_by_organization(organization_id)
949 .await
950 {
951 Ok(stats) => HttpResponse::Ok().json(stats),
952 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
953 }
954}
955
956#[utoipa::path(
957 get,
958 path = "/tickets/overdue",
959 tag = "Tickets",
960 summary = "List overdue tickets for the organization",
961 params(
962 ("max_days" = Option<i64>, Query, description = "Maximum overdue days filter (default: 7)")
963 ),
964 responses(
965 (status = 200, description = "List of overdue tickets"),
966 (status = 401, description = "Unauthorized"),
967 (status = 500, description = "Internal server error"),
968 ),
969 security(("bearer_auth" = []))
970)]
971#[get("/tickets/overdue")]
972pub async fn get_overdue_tickets_org(
973 state: web::Data<AppState>,
974 user: AuthenticatedUser,
975 query: web::Query<OverdueQuery>,
976) -> impl Responder {
977 let organization_id = match user.require_organization() {
978 Ok(org_id) => org_id,
979 Err(e) => {
980 return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
981 }
982 };
983
984 let max_days = query.max_days.unwrap_or(7);
985
986 match state
987 .ticket_use_cases
988 .get_overdue_tickets_by_organization(organization_id, max_days)
989 .await
990 {
991 Ok(tickets) => HttpResponse::Ok().json(enrich_tickets(&state, tickets).await),
992 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
993 }
994}
995
996#[utoipa::path(
997 get,
998 path = "/buildings/{building_id}/tickets/statistics",
999 tag = "Tickets",
1000 summary = "Get ticket statistics for a building",
1001 params(
1002 ("building_id" = Uuid, Path, description = "Building ID")
1003 ),
1004 responses(
1005 (status = 200, description = "Ticket statistics"),
1006 (status = 500, description = "Internal server error"),
1007 ),
1008 security(("bearer_auth" = []))
1009)]
1010#[get("/buildings/{building_id}/tickets/statistics")]
1011pub async fn get_ticket_statistics(
1012 state: web::Data<AppState>,
1013 building_id: web::Path<Uuid>,
1014 user: AuthenticatedUser,
1015) -> impl Responder {
1016 if let Err(err) =
1020 crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
1021 &user,
1022 *building_id,
1023 &state.building_use_cases,
1024 &state.acp_use_cases,
1025 )
1026 .await
1027 {
1028 return err.error_response();
1029 }
1030
1031 match state
1032 .ticket_use_cases
1033 .get_ticket_statistics(*building_id)
1034 .await
1035 {
1036 Ok(stats) => HttpResponse::Ok().json(stats),
1037 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
1038 }
1039}
1040
1041#[utoipa::path(
1042 get,
1043 path = "/buildings/{building_id}/tickets/overdue",
1044 tag = "Tickets",
1045 summary = "List overdue tickets for a building",
1046 params(
1047 ("building_id" = Uuid, Path, description = "Building ID"),
1048 ("max_days" = Option<i64>, Query, description = "Maximum overdue days filter (default: 7)")
1049 ),
1050 responses(
1051 (status = 200, description = "List of overdue tickets"),
1052 (status = 500, description = "Internal server error"),
1053 ),
1054 security(("bearer_auth" = []))
1055)]
1056#[get("/buildings/{building_id}/tickets/overdue")]
1057pub async fn get_overdue_tickets(
1058 state: web::Data<AppState>,
1059 building_id: web::Path<Uuid>,
1060 query: web::Query<OverdueQuery>,
1061 user: AuthenticatedUser,
1062) -> impl Responder {
1063 if let Err(err) =
1067 crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
1068 &user,
1069 *building_id,
1070 &state.building_use_cases,
1071 &state.acp_use_cases,
1072 )
1073 .await
1074 {
1075 return err.error_response();
1076 }
1077
1078 let max_days = query.max_days.unwrap_or(7);
1079
1080 match state
1081 .ticket_use_cases
1082 .get_overdue_tickets(*building_id, max_days)
1083 .await
1084 {
1085 Ok(tickets) => HttpResponse::Ok().json(enrich_tickets(&state, tickets).await),
1086 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
1087 }
1088}
1089
1090#[derive(serde::Deserialize)]
1091pub struct OverdueQuery {
1092 pub max_days: Option<i64>,
1093}
1094
1095#[derive(serde::Serialize, utoipa::ToSchema)]
1099pub struct AssignableUserDto {
1100 pub id: Uuid,
1101 pub first_name: String,
1102 pub last_name: String,
1103 pub role: String,
1104 pub profession: Option<String>,
1105}
1106
1107#[utoipa::path(
1110 get,
1111 path = "/tickets/assignable-users",
1112 tag = "Tickets",
1113 summary = "List users assignable to tickets",
1114 responses(
1115 (status = 200, description = "List of assignable users"),
1116 (status = 401, description = "Unauthorized"),
1117 (status = 403, description = "Forbidden — only syndic/superadmin"),
1118 ),
1119 security(("bearer_auth" = []))
1120)]
1121#[get("/tickets/assignable-users")]
1122pub async fn list_assignable_users(
1123 state: web::Data<AppState>,
1124 user: AuthenticatedUser,
1125) -> impl Responder {
1126 let organization_id = match user.require_organization() {
1127 Ok(org_id) => org_id,
1128 Err(e) => {
1129 return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
1130 }
1131 };
1132
1133 match state.user_use_cases.list_all().await {
1135 Ok(users) => {
1136 let assignable: Vec<AssignableUserDto> = users
1137 .into_iter()
1138 .filter(|u| {
1139 u.organization_id.as_deref() == Some(&organization_id.to_string())
1141 })
1142 .filter(|u| matches!(u.role.as_str(), "syndic" | "board_member" | "contractor"))
1143 .map(|u| AssignableUserDto {
1144 id: u.id.parse().unwrap_or_default(),
1145 first_name: u.first_name.clone(),
1146 last_name: u.last_name.clone(),
1147 role: u.role.clone(),
1148 profession: None, })
1150 .collect();
1151 HttpResponse::Ok().json(assignable)
1152 }
1153 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
1154 }
1155}