Skip to main content

koprogo_api/infrastructure/web/handlers/
ticket_handlers.rs

1use crate::application::dto::{
2    AssignTicketRequest, CancelTicketRequest, CreateTicketRequest, ReopenTicketRequest,
3    ResolveTicketRequest, TicketResponse,
4};
5use crate::domain::entities::TicketStatus;
6use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
7use crate::infrastructure::web::{AppState, AuthenticatedUser};
8use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
9use std::collections::HashMap;
10use uuid::Uuid;
11
12/// Resolve display names for the requester and assignee of a single ticket.
13async 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
29/// Enrich a list of tickets in a single pass, deduplicating user lookups.
30async 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// ==================== Ticket CRUD Endpoints ====================
59
60#[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    // Use owner_id from AuthenticatedUser as created_by
88    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            // Multi-tenant isolation: verify ticket belongs to user's organization
145            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) -> impl Responder {
177    match state
178        .ticket_use_cases
179        .list_tickets_by_building(*building_id)
180        .await
181    {
182        Ok(tickets) => HttpResponse::Ok().json(enrich_tickets(&state, tickets).await),
183        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
184    }
185}
186
187#[utoipa::path(
188    get,
189    path = "/organizations/{organization_id}/tickets",
190    tag = "Tickets",
191    summary = "List all tickets for an organization",
192    params(
193        ("organization_id" = Uuid, Path, description = "Organization ID")
194    ),
195    responses(
196        (status = 200, description = "List of tickets"),
197        (status = 500, description = "Internal server error"),
198    ),
199    security(("bearer_auth" = []))
200)]
201#[get("/organizations/{organization_id}/tickets")]
202pub async fn list_organization_tickets(
203    state: web::Data<AppState>,
204    user: AuthenticatedUser,
205    organization_id: web::Path<Uuid>,
206) -> impl Responder {
207    if let Err(e) = user.verify_org_access(*organization_id) {
208        return HttpResponse::Forbidden().json(serde_json::json!({"error": e}));
209    }
210    match state
211        .ticket_use_cases
212        .list_tickets_by_organization(*organization_id)
213        .await
214    {
215        Ok(tickets) => HttpResponse::Ok().json(enrich_tickets(&state, tickets).await),
216        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
217    }
218}
219
220#[utoipa::path(
221    get,
222    path = "/tickets/my",
223    tag = "Tickets",
224    summary = "List tickets created by the authenticated user",
225    responses(
226        (status = 200, description = "List of tickets"),
227        (status = 500, description = "Internal server error"),
228    ),
229    security(("bearer_auth" = []))
230)]
231#[get("/tickets/my")]
232pub async fn list_my_tickets(
233    state: web::Data<AppState>,
234    user: AuthenticatedUser,
235) -> impl Responder {
236    let created_by = user.user_id;
237
238    match state.ticket_use_cases.list_my_tickets(created_by).await {
239        Ok(tickets) => HttpResponse::Ok().json(enrich_tickets(&state, tickets).await),
240        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
241    }
242}
243
244#[utoipa::path(
245    get,
246    path = "/tickets/assigned-to-me",
247    tag = "Tickets",
248    summary = "List tickets assigned to the authenticated user",
249    responses(
250        (status = 200, description = "List of assigned tickets"),
251        (status = 500, description = "Internal server error"),
252    ),
253    security(("bearer_auth" = []))
254)]
255#[get("/tickets/assigned-to-me")]
256pub async fn list_assigned_tickets(
257    state: web::Data<AppState>,
258    user: AuthenticatedUser,
259) -> impl Responder {
260    let assigned_to = user.user_id;
261
262    match state
263        .ticket_use_cases
264        .list_assigned_tickets(assigned_to)
265        .await
266    {
267        Ok(tickets) => HttpResponse::Ok().json(enrich_tickets(&state, tickets).await),
268        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
269    }
270}
271
272#[utoipa::path(
273    get,
274    path = "/buildings/{building_id}/tickets/status/{status}",
275    tag = "Tickets",
276    summary = "List tickets by status for a building",
277    params(
278        ("building_id" = Uuid, Path, description = "Building ID"),
279        ("status" = String, Path, description = "Ticket status (Open, InProgress, Resolved, Closed, Cancelled)")
280    ),
281    responses(
282        (status = 200, description = "List of tickets"),
283        (status = 400, description = "Invalid status"),
284        (status = 500, description = "Internal server error"),
285    ),
286    security(("bearer_auth" = []))
287)]
288#[get("/buildings/{building_id}/tickets/status/{status}")]
289pub async fn list_tickets_by_status(
290    state: web::Data<AppState>,
291    path: web::Path<(Uuid, String)>,
292) -> impl Responder {
293    let (building_id, status_str) = path.into_inner();
294
295    let status = match status_str.as_str() {
296        "Open" | "open" => TicketStatus::Open,
297        "InProgress" | "in_progress" => TicketStatus::InProgress,
298        "Resolved" | "resolved" => TicketStatus::Resolved,
299        "Closed" | "closed" => TicketStatus::Closed,
300        "Cancelled" | "cancelled" => TicketStatus::Cancelled,
301        _ => {
302            return HttpResponse::BadRequest().json(serde_json::json!({
303                "error": format!("Invalid status: {}", status_str)
304            }))
305        }
306    };
307
308    match state
309        .ticket_use_cases
310        .list_tickets_by_status(building_id, status)
311        .await
312    {
313        Ok(tickets) => HttpResponse::Ok().json(enrich_tickets(&state, tickets).await),
314        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
315    }
316}
317
318#[utoipa::path(
319    delete,
320    path = "/tickets/{id}",
321    tag = "Tickets",
322    summary = "Delete a ticket",
323    params(
324        ("id" = Uuid, Path, description = "Ticket ID")
325    ),
326    responses(
327        (status = 204, description = "Ticket deleted"),
328        (status = 400, description = "Bad request"),
329        (status = 401, description = "Unauthorized"),
330        (status = 404, description = "Ticket not found"),
331    ),
332    security(("bearer_auth" = []))
333)]
334#[delete("/tickets/{id}")]
335pub async fn delete_ticket(
336    state: web::Data<AppState>,
337    user: AuthenticatedUser,
338    id: web::Path<Uuid>,
339) -> impl Responder {
340    let organization_id = match user.require_organization() {
341        Ok(org_id) => org_id,
342        Err(e) => {
343            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
344        }
345    };
346
347    match state.ticket_use_cases.delete_ticket(*id).await {
348        Ok(true) => {
349            AuditLogEntry::new(
350                AuditEventType::TicketDeleted,
351                Some(user.user_id),
352                Some(organization_id),
353            )
354            .with_resource("Ticket", *id)
355            .log();
356
357            HttpResponse::NoContent().finish()
358        }
359        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
360            "error": "Ticket not found"
361        })),
362        Err(err) => {
363            AuditLogEntry::new(
364                AuditEventType::TicketDeleted,
365                Some(user.user_id),
366                Some(organization_id),
367            )
368            .with_error(err.clone())
369            .log();
370
371            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
372        }
373    }
374}
375
376// ==================== Ticket Workflow Endpoints ====================
377
378#[utoipa::path(
379    put,
380    path = "/tickets/{id}/assign",
381    tag = "Tickets",
382    summary = "Assign a ticket to a contractor",
383    params(
384        ("id" = Uuid, Path, description = "Ticket ID")
385    ),
386    request_body = AssignTicketRequest,
387    responses(
388        (status = 200, description = "Ticket assigned"),
389        (status = 400, description = "Bad request"),
390        (status = 401, description = "Unauthorized"),
391    ),
392    security(("bearer_auth" = []))
393)]
394#[put("/tickets/{id}/assign")]
395pub async fn assign_ticket(
396    state: web::Data<AppState>,
397    user: AuthenticatedUser,
398    id: web::Path<Uuid>,
399    request: web::Json<AssignTicketRequest>,
400) -> impl Responder {
401    let organization_id = match user.require_organization() {
402        Ok(org_id) => org_id,
403        Err(e) => {
404            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
405        }
406    };
407
408    match state
409        .ticket_use_cases
410        .assign_ticket(*id, request.into_inner())
411        .await
412    {
413        Ok(ticket) => {
414            AuditLogEntry::new(
415                AuditEventType::TicketAssigned,
416                Some(user.user_id),
417                Some(organization_id),
418            )
419            .with_resource("Ticket", ticket.id)
420            .log();
421
422            let enriched = enrich_ticket(&state, ticket).await;
423            HttpResponse::Ok().json(enriched)
424        }
425        Err(err) => {
426            AuditLogEntry::new(
427                AuditEventType::TicketAssigned,
428                Some(user.user_id),
429                Some(organization_id),
430            )
431            .with_error(err.clone())
432            .log();
433
434            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
435        }
436    }
437}
438
439#[utoipa::path(
440    put,
441    path = "/tickets/{id}/start-work",
442    tag = "Tickets",
443    summary = "Start work on an assigned ticket",
444    params(
445        ("id" = Uuid, Path, description = "Ticket ID")
446    ),
447    responses(
448        (status = 200, description = "Work started"),
449        (status = 400, description = "Bad request"),
450        (status = 401, description = "Unauthorized"),
451    ),
452    security(("bearer_auth" = []))
453)]
454#[put("/tickets/{id}/start-work")]
455pub async fn start_work(
456    state: web::Data<AppState>,
457    user: AuthenticatedUser,
458    id: web::Path<Uuid>,
459) -> impl Responder {
460    let organization_id = match user.require_organization() {
461        Ok(org_id) => org_id,
462        Err(e) => {
463            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
464        }
465    };
466
467    match state.ticket_use_cases.start_work(*id).await {
468        Ok(ticket) => {
469            AuditLogEntry::new(
470                AuditEventType::TicketStatusChanged,
471                Some(user.user_id),
472                Some(organization_id),
473            )
474            .with_resource("Ticket", ticket.id)
475            .log();
476
477            let enriched = enrich_ticket(&state, ticket).await;
478            HttpResponse::Ok().json(enriched)
479        }
480        Err(err) => {
481            AuditLogEntry::new(
482                AuditEventType::TicketStatusChanged,
483                Some(user.user_id),
484                Some(organization_id),
485            )
486            .with_error(err.clone())
487            .log();
488
489            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
490        }
491    }
492}
493
494#[utoipa::path(
495    put,
496    path = "/tickets/{id}/resolve",
497    tag = "Tickets",
498    summary = "Mark a ticket as resolved",
499    params(
500        ("id" = Uuid, Path, description = "Ticket ID")
501    ),
502    request_body = ResolveTicketRequest,
503    responses(
504        (status = 200, description = "Ticket resolved"),
505        (status = 400, description = "Bad request"),
506        (status = 401, description = "Unauthorized"),
507    ),
508    security(("bearer_auth" = []))
509)]
510#[put("/tickets/{id}/resolve")]
511pub async fn resolve_ticket(
512    state: web::Data<AppState>,
513    user: AuthenticatedUser,
514    id: web::Path<Uuid>,
515    request: web::Json<ResolveTicketRequest>,
516) -> impl Responder {
517    let organization_id = match user.require_organization() {
518        Ok(org_id) => org_id,
519        Err(e) => {
520            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
521        }
522    };
523
524    match state
525        .ticket_use_cases
526        .resolve_ticket(*id, request.into_inner())
527        .await
528    {
529        Ok(ticket) => {
530            AuditLogEntry::new(
531                AuditEventType::TicketResolved,
532                Some(user.user_id),
533                Some(organization_id),
534            )
535            .with_resource("Ticket", ticket.id)
536            .log();
537
538            let enriched = enrich_ticket(&state, ticket).await;
539            HttpResponse::Ok().json(enriched)
540        }
541        Err(err) => {
542            AuditLogEntry::new(
543                AuditEventType::TicketResolved,
544                Some(user.user_id),
545                Some(organization_id),
546            )
547            .with_error(err.clone())
548            .log();
549
550            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
551        }
552    }
553}
554
555#[utoipa::path(
556    put,
557    path = "/tickets/{id}/close",
558    tag = "Tickets",
559    summary = "Close a resolved ticket",
560    params(
561        ("id" = Uuid, Path, description = "Ticket ID")
562    ),
563    responses(
564        (status = 200, description = "Ticket closed"),
565        (status = 400, description = "Bad request"),
566        (status = 401, description = "Unauthorized"),
567    ),
568    security(("bearer_auth" = []))
569)]
570#[put("/tickets/{id}/close")]
571pub async fn close_ticket(
572    state: web::Data<AppState>,
573    user: AuthenticatedUser,
574    id: web::Path<Uuid>,
575) -> impl Responder {
576    let organization_id = match user.require_organization() {
577        Ok(org_id) => org_id,
578        Err(e) => {
579            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
580        }
581    };
582
583    match state.ticket_use_cases.close_ticket(*id).await {
584        Ok(ticket) => {
585            AuditLogEntry::new(
586                AuditEventType::TicketClosed,
587                Some(user.user_id),
588                Some(organization_id),
589            )
590            .with_resource("Ticket", ticket.id)
591            .log();
592
593            let enriched = enrich_ticket(&state, ticket).await;
594            HttpResponse::Ok().json(enriched)
595        }
596        Err(err) => {
597            AuditLogEntry::new(
598                AuditEventType::TicketClosed,
599                Some(user.user_id),
600                Some(organization_id),
601            )
602            .with_error(err.clone())
603            .log();
604
605            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
606        }
607    }
608}
609
610#[utoipa::path(
611    put,
612    path = "/tickets/{id}/cancel",
613    tag = "Tickets",
614    summary = "Cancel a ticket",
615    params(
616        ("id" = Uuid, Path, description = "Ticket ID")
617    ),
618    request_body = CancelTicketRequest,
619    responses(
620        (status = 200, description = "Ticket cancelled"),
621        (status = 400, description = "Bad request"),
622        (status = 401, description = "Unauthorized"),
623    ),
624    security(("bearer_auth" = []))
625)]
626#[put("/tickets/{id}/cancel")]
627pub async fn cancel_ticket(
628    state: web::Data<AppState>,
629    user: AuthenticatedUser,
630    id: web::Path<Uuid>,
631    request: web::Json<CancelTicketRequest>,
632) -> impl Responder {
633    let organization_id = match user.require_organization() {
634        Ok(org_id) => org_id,
635        Err(e) => {
636            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
637        }
638    };
639
640    match state
641        .ticket_use_cases
642        .cancel_ticket(*id, request.into_inner())
643        .await
644    {
645        Ok(ticket) => {
646            AuditLogEntry::new(
647                AuditEventType::TicketCancelled,
648                Some(user.user_id),
649                Some(organization_id),
650            )
651            .with_resource("Ticket", ticket.id)
652            .log();
653
654            let enriched = enrich_ticket(&state, ticket).await;
655            HttpResponse::Ok().json(enriched)
656        }
657        Err(err) => {
658            AuditLogEntry::new(
659                AuditEventType::TicketCancelled,
660                Some(user.user_id),
661                Some(organization_id),
662            )
663            .with_error(err.clone())
664            .log();
665
666            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
667        }
668    }
669}
670
671#[utoipa::path(
672    put,
673    path = "/tickets/{id}/reopen",
674    tag = "Tickets",
675    summary = "Reopen a closed or cancelled ticket",
676    params(
677        ("id" = Uuid, Path, description = "Ticket ID")
678    ),
679    request_body = ReopenTicketRequest,
680    responses(
681        (status = 200, description = "Ticket reopened"),
682        (status = 400, description = "Bad request"),
683        (status = 401, description = "Unauthorized"),
684    ),
685    security(("bearer_auth" = []))
686)]
687#[put("/tickets/{id}/reopen")]
688pub async fn reopen_ticket(
689    state: web::Data<AppState>,
690    user: AuthenticatedUser,
691    id: web::Path<Uuid>,
692    request: web::Json<ReopenTicketRequest>,
693) -> impl Responder {
694    let organization_id = match user.require_organization() {
695        Ok(org_id) => org_id,
696        Err(e) => {
697            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
698        }
699    };
700
701    match state
702        .ticket_use_cases
703        .reopen_ticket(*id, request.into_inner())
704        .await
705    {
706        Ok(ticket) => {
707            AuditLogEntry::new(
708                AuditEventType::TicketReopened,
709                Some(user.user_id),
710                Some(organization_id),
711            )
712            .with_resource("Ticket", ticket.id)
713            .log();
714
715            let enriched = enrich_ticket(&state, ticket).await;
716            HttpResponse::Ok().json(enriched)
717        }
718        Err(err) => {
719            AuditLogEntry::new(
720                AuditEventType::TicketReopened,
721                Some(user.user_id),
722                Some(organization_id),
723            )
724            .with_error(err.clone())
725            .log();
726
727            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
728        }
729    }
730}
731
732// ==================== Ticket Work Order Endpoint ====================
733
734#[utoipa::path(
735    put,
736    path = "/tickets/{id}/send-work-order",
737    tag = "Tickets",
738    summary = "Send work order to contractor (magic link PWA)",
739    params(
740        ("id" = Uuid, Path, description = "Ticket ID")
741    ),
742    responses(
743        (status = 200, description = "Work order sent"),
744        (status = 400, description = "Bad request"),
745        (status = 401, description = "Unauthorized"),
746    ),
747    security(("bearer_auth" = []))
748)]
749#[put("/tickets/{id}/send-work-order")]
750pub async fn send_work_order(
751    state: web::Data<AppState>,
752    user: AuthenticatedUser,
753    id: web::Path<Uuid>,
754) -> impl Responder {
755    let organization_id = match user.require_organization() {
756        Ok(org_id) => org_id,
757        Err(e) => {
758            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
759        }
760    };
761
762    match state.ticket_use_cases.send_work_order(*id).await {
763        Ok(ticket) => {
764            AuditLogEntry::new(
765                AuditEventType::TicketWorkOrderSent,
766                Some(user.user_id),
767                Some(organization_id),
768            )
769            .with_resource("Ticket", ticket.id)
770            .log();
771
772            let enriched = enrich_ticket(&state, ticket).await;
773            HttpResponse::Ok().json(enriched)
774        }
775        Err(err) => {
776            AuditLogEntry::new(
777                AuditEventType::TicketWorkOrderSent,
778                Some(user.user_id),
779                Some(organization_id),
780            )
781            .with_error(err.clone())
782            .log();
783
784            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
785        }
786    }
787}
788
789// ==================== Ticket Statistics Endpoints ====================
790
791#[utoipa::path(
792    get,
793    path = "/tickets/statistics",
794    tag = "Tickets",
795    summary = "Get ticket statistics for the organization",
796    responses(
797        (status = 200, description = "Ticket statistics"),
798        (status = 401, description = "Unauthorized"),
799        (status = 500, description = "Internal server error"),
800    ),
801    security(("bearer_auth" = []))
802)]
803#[get("/tickets/statistics")]
804pub async fn get_ticket_statistics_org(
805    state: web::Data<AppState>,
806    user: AuthenticatedUser,
807) -> impl Responder {
808    let organization_id = match user.require_organization() {
809        Ok(org_id) => org_id,
810        Err(e) => {
811            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
812        }
813    };
814
815    match state
816        .ticket_use_cases
817        .get_ticket_statistics_by_organization(organization_id)
818        .await
819    {
820        Ok(stats) => HttpResponse::Ok().json(stats),
821        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
822    }
823}
824
825#[utoipa::path(
826    get,
827    path = "/tickets/overdue",
828    tag = "Tickets",
829    summary = "List overdue tickets for the organization",
830    params(
831        ("max_days" = Option<i64>, Query, description = "Maximum overdue days filter (default: 7)")
832    ),
833    responses(
834        (status = 200, description = "List of overdue tickets"),
835        (status = 401, description = "Unauthorized"),
836        (status = 500, description = "Internal server error"),
837    ),
838    security(("bearer_auth" = []))
839)]
840#[get("/tickets/overdue")]
841pub async fn get_overdue_tickets_org(
842    state: web::Data<AppState>,
843    user: AuthenticatedUser,
844    query: web::Query<OverdueQuery>,
845) -> impl Responder {
846    let organization_id = match user.require_organization() {
847        Ok(org_id) => org_id,
848        Err(e) => {
849            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
850        }
851    };
852
853    let max_days = query.max_days.unwrap_or(7);
854
855    match state
856        .ticket_use_cases
857        .get_overdue_tickets_by_organization(organization_id, max_days)
858        .await
859    {
860        Ok(tickets) => HttpResponse::Ok().json(enrich_tickets(&state, tickets).await),
861        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
862    }
863}
864
865#[utoipa::path(
866    get,
867    path = "/buildings/{building_id}/tickets/statistics",
868    tag = "Tickets",
869    summary = "Get ticket statistics for a building",
870    params(
871        ("building_id" = Uuid, Path, description = "Building ID")
872    ),
873    responses(
874        (status = 200, description = "Ticket statistics"),
875        (status = 500, description = "Internal server error"),
876    ),
877    security(("bearer_auth" = []))
878)]
879#[get("/buildings/{building_id}/tickets/statistics")]
880pub async fn get_ticket_statistics(
881    state: web::Data<AppState>,
882    building_id: web::Path<Uuid>,
883) -> impl Responder {
884    match state
885        .ticket_use_cases
886        .get_ticket_statistics(*building_id)
887        .await
888    {
889        Ok(stats) => HttpResponse::Ok().json(stats),
890        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
891    }
892}
893
894#[utoipa::path(
895    get,
896    path = "/buildings/{building_id}/tickets/overdue",
897    tag = "Tickets",
898    summary = "List overdue tickets for a building",
899    params(
900        ("building_id" = Uuid, Path, description = "Building ID"),
901        ("max_days" = Option<i64>, Query, description = "Maximum overdue days filter (default: 7)")
902    ),
903    responses(
904        (status = 200, description = "List of overdue tickets"),
905        (status = 500, description = "Internal server error"),
906    ),
907    security(("bearer_auth" = []))
908)]
909#[get("/buildings/{building_id}/tickets/overdue")]
910pub async fn get_overdue_tickets(
911    state: web::Data<AppState>,
912    building_id: web::Path<Uuid>,
913    query: web::Query<OverdueQuery>,
914) -> impl Responder {
915    let max_days = query.max_days.unwrap_or(7);
916
917    match state
918        .ticket_use_cases
919        .get_overdue_tickets(*building_id, max_days)
920        .await
921    {
922        Ok(tickets) => HttpResponse::Ok().json(enrich_tickets(&state, tickets).await),
923        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
924    }
925}
926
927#[derive(serde::Deserialize)]
928pub struct OverdueQuery {
929    pub max_days: Option<i64>,
930}
931
932// ==================== Assignable Users ====================
933
934/// DTO for the assignable-users dropdown (minimal fields).
935#[derive(serde::Serialize, utoipa::ToSchema)]
936pub struct AssignableUserDto {
937    pub id: Uuid,
938    pub first_name: String,
939    pub last_name: String,
940    pub role: String,
941    pub profession: Option<String>,
942}
943
944/// List users that can be assigned to a ticket (syndic, board_member, contractor).
945/// Sorted by role (contractors first) then last_name.
946#[utoipa::path(
947    get,
948    path = "/tickets/assignable-users",
949    tag = "Tickets",
950    summary = "List users assignable to tickets",
951    responses(
952        (status = 200, description = "List of assignable users"),
953        (status = 401, description = "Unauthorized"),
954        (status = 403, description = "Forbidden — only syndic/superadmin"),
955    ),
956    security(("bearer_auth" = []))
957)]
958#[get("/tickets/assignable-users")]
959pub async fn list_assignable_users(
960    state: web::Data<AppState>,
961    user: AuthenticatedUser,
962) -> impl Responder {
963    let organization_id = match user.require_organization() {
964        Ok(org_id) => org_id,
965        Err(e) => {
966            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
967        }
968    };
969
970    // Fetch all org users + filter roles eligible for ticket assignment
971    match state.user_use_cases.list_all().await {
972        Ok(users) => {
973            let assignable: Vec<AssignableUserDto> = users
974                .into_iter()
975                .filter(|u| {
976                    // Same org check
977                    u.organization_id.as_deref() == Some(&organization_id.to_string())
978                })
979                .filter(|u| matches!(u.role.as_str(), "syndic" | "board_member" | "contractor"))
980                .map(|u| AssignableUserDto {
981                    id: u.id.parse().unwrap_or_default(),
982                    first_name: u.first_name.clone(),
983                    last_name: u.last_name.clone(),
984                    role: u.role.clone(),
985                    profession: None, // TODO: join contractor_profiles when needed
986                })
987                .collect();
988            HttpResponse::Ok().json(assignable)
989        }
990        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
991    }
992}