Skip to main content

koprogo_api/infrastructure/web/handlers/
ticket_handlers.rs

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
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    user: AuthenticatedUser,
177) -> impl Responder {
178    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
179    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
180    // un identifiant, sans demander d'identite.
181    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    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
312    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
313    // un identifiant, sans demander d'identite.
314    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// ==================== Ticket Field Update (Story 3.6 — INV-24) ====================
408
409/// PATCH `/tickets/{id}` — Story 3.6 (FR31) / INV-24.
410///
411/// Edits the editable fields of a ticket (title / description / category /
412/// priority / severity / incident_date / evidence_attachments / witnesses).
413/// Returns:
414/// - 200 OK on success;
415/// - 403 (`kind = ticket_immutable`) if the 5-minute editability window
416///   has elapsed;
417/// - 404 if the ticket does not exist;
418/// - 400 (`kind = validation`) on invariant violations;
419/// - 403 (multi-tenant isolation) if the ticket belongs to a different org.
420///
421/// Workflow transitions (assign/resolve/close/cancel/reopen) keep their
422/// own dedicated PUT endpoints — this PATCH is for content corrections
423/// inside the 5-minute window only.
424#[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    // Multi-tenant isolation — pre-check existence + org-membership before
457    // hitting the use-case (which only deals with the temporal invariant).
458    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            // Delegate to AppError's ResponseError impl — preserves typed
500            // status + JSON shape (`{error, kind}`) consistently across
501            // all 3.x stories.
502            err.error_response()
503        }
504    }
505}
506
507// ==================== Ticket Workflow Endpoints ====================
508
509#[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// ==================== Ticket Work Order Endpoint ====================
864
865#[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// ==================== Ticket Statistics Endpoints ====================
921
922#[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    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
1017    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
1018    // un identifiant, sans demander d'identite.
1019    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    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
1064    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
1065    // un identifiant, sans demander d'identite.
1066    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// ==================== Assignable Users ====================
1096
1097/// DTO for the assignable-users dropdown (minimal fields).
1098#[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/// List users that can be assigned to a ticket (syndic, board_member, contractor).
1108/// Sorted by role (contractors first) then last_name.
1109#[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    // Fetch all org users + filter roles eligible for ticket assignment
1134    match state.user_use_cases.list_all().await {
1135        Ok(users) => {
1136            let assignable: Vec<AssignableUserDto> = users
1137                .into_iter()
1138                .filter(|u| {
1139                    // Same org check
1140                    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, // TODO: join contractor_profiles when needed
1149                })
1150                .collect();
1151            HttpResponse::Ok().json(assignable)
1152        }
1153        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
1154    }
1155}