Skip to main content

koprogo_api/infrastructure/web/handlers/
resource_booking_handlers.rs

1use crate::application::dto::{CreateResourceBookingDto, UpdateResourceBookingDto};
2use crate::domain::entities::{BookingStatus, ResourceType};
3use crate::infrastructure::web::app_state::AppState;
4use crate::infrastructure::web::classification_erreurs;
5use crate::infrastructure::web::middleware::scope_guard::{
6    verify_booking_org_access, verify_building_org_access,
7};
8use crate::infrastructure::web::middleware::AuthenticatedUser;
9use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
10use chrono::{DateTime, Utc};
11use serde::Deserialize;
12use uuid::Uuid;
13
14/// Create a new resource booking
15///
16/// POST /resource-bookings
17///
18/// # Request Body
19/// - building_id: UUID
20/// - resource_type: ResourceType (MeetingRoom, LaundryRoom, Gym, etc.)
21/// - resource_name: String (e.g., "Meeting Room A")
22/// - start_time: `DateTime<Utc>`
23/// - end_time: `DateTime<Utc>`
24/// - notes: `Option<String>`
25/// - recurring_pattern: RecurringPattern (default: None)
26/// - recurrence_end_date: `Option<DateTime<Utc>>`
27/// - max_duration_hours: `Option<i64>` (default: 4)
28/// - max_advance_days: `Option<i64>` (default: 30)
29///
30/// # Responses
31/// - 201 Created: Booking created successfully
32/// - 400 Bad Request: Validation error or conflict
33/// - 404 Not Found: Building not found
34#[post("/resource-bookings")]
35pub async fn create_booking(
36    data: web::Data<AppState>,
37    auth: AuthenticatedUser,
38    request: web::Json<CreateResourceBookingDto>,
39) -> impl Responder {
40    let org_id = match auth.require_organization() {
41        Ok(id) => id,
42        Err(e) => {
43            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
44        }
45    };
46    // Story #588 (INV-5/FR27) — même idiome RBAC que le reste des handlers
47    // (cf. iot_grid_handlers, stats_handlers) : "syndic" ou superadmin.
48    let is_syndic = auth.is_superadmin() || auth.role == "syndic";
49    match data
50        .resource_booking_use_cases
51        .create_booking(auth.user_id, org_id, is_syndic, request.into_inner())
52        .await
53    {
54        Ok(booking) => HttpResponse::Created().json(booking),
55        Err(e) => {
56            // Issue #781 — le refus "pas de fiche de copropriétaire" est un
57            // 403 (règle métier : réserver engage une personne, pas encore
58            // la copropriété). Depuis la story #588, un syndic dispose d'une
59            // échappatoire tracée : `on_behalf_of_acp` + motif. Le `kind`
60            // laisse le frontend router vers un message traduit dans les
61            // quatre locales sans dépendre du libellé français.
62            if e.contains("conflicts with") {
63                HttpResponse::Conflict().json(serde_json::json!({"error": e}))
64            } else if classification_erreurs::est_motif_acp_manquant(&e) {
65                HttpResponse::UnprocessableEntity().json(serde_json::json!({
66                    "error": e,
67                    "kind": "reservation_motif_required",
68                }))
69            } else if classification_erreurs::est_refus_owner_requis(&e) {
70                HttpResponse::Forbidden().json(serde_json::json!({
71                    "error": e,
72                    "kind": "owner_profile_required",
73                }))
74            } else if classification_erreurs::est_interdit(&e) {
75                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
76            } else if classification_erreurs::est_introuvable(&e) {
77                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
78            } else {
79                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
80            }
81        }
82    }
83}
84
85/// Get booking by ID
86///
87/// GET /resource-bookings/:id
88///
89/// # Responses
90/// - 200 OK: Booking details
91/// - 404 Not Found: Booking not found
92#[get("/resource-bookings/{id}")]
93pub async fn get_booking(
94    data: web::Data<AppState>,
95    auth: AuthenticatedUser,
96    id: web::Path<Uuid>,
97) -> impl Responder {
98    // Cloisonnement : la réservation visée doit relever d'une ACP que cet
99    // utilisateur a le droit de voir. Cette route n'ayant pas d'immeuble en
100    // chemin, la chaîne réservation → immeuble → ACP est remontée par le
101    // garde. L'identité était prise puis ignorée — `_auth` (#772).
102    if let Err(err) = verify_booking_org_access(
103        &auth,
104        *id,
105        &data.resource_booking_use_cases,
106        &data.building_use_cases,
107        &data.acp_use_cases,
108    )
109    .await
110    {
111        return err.error_response();
112    }
113
114    match data
115        .resource_booking_use_cases
116        .get_booking(id.into_inner())
117        .await
118    {
119        Ok(booking) => HttpResponse::Ok().json(booking),
120        Err(e) => HttpResponse::NotFound().json(serde_json::json!({"error": e})),
121    }
122}
123
124/// List all bookings for a building
125///
126/// GET /buildings/:building_id/resource-bookings
127///
128/// # Responses
129/// - 200 OK: List of bookings
130#[get("/buildings/{building_id}/resource-bookings")]
131pub async fn list_building_bookings(
132    data: web::Data<AppState>,
133    auth: AuthenticatedUser,
134    building_id: web::Path<Uuid>,
135) -> impl Responder {
136    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
137    // utilisateur a le droit de voir. L'identité était prise puis ignorée —
138    // `_auth` — et n'importe quel utilisateur authentifié lisait les
139    // réservations de n'importe quel immeuble (#772).
140    if let Err(err) = verify_building_org_access(
141        &auth,
142        *building_id,
143        &data.building_use_cases,
144        &data.acp_use_cases,
145    )
146    .await
147    {
148        return err.error_response();
149    }
150
151    match data
152        .resource_booking_use_cases
153        .list_building_bookings(building_id.into_inner())
154        .await
155    {
156        Ok(bookings) => HttpResponse::Ok().json(bookings),
157        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
158    }
159}
160
161/// List bookings by resource type
162///
163/// GET /buildings/:building_id/resource-bookings/type/:resource_type
164///
165/// # Responses
166/// - 200 OK: List of bookings for resource type
167#[get("/buildings/{building_id}/resource-bookings/type/{resource_type}")]
168pub async fn list_by_resource_type(
169    data: web::Data<AppState>,
170    auth: AuthenticatedUser,
171    path: web::Path<(Uuid, String)>,
172) -> impl Responder {
173    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
174    // utilisateur a le droit de voir. L'identité était prise puis ignorée —
175    // `_auth` — et n'importe quel utilisateur authentifié lisait les
176    // réservations de n'importe quel immeuble (#772).
177    if let Err(err) =
178        verify_building_org_access(&auth, path.0, &data.building_use_cases, &data.acp_use_cases)
179            .await
180    {
181        return err.error_response();
182    }
183
184    let (building_id, resource_type_str) = path.into_inner();
185
186    // Parse resource_type from string
187    let resource_type: ResourceType =
188        match serde_json::from_str(&format!("\"{}\"", resource_type_str)) {
189            Ok(rt) => rt,
190            Err(_) => {
191                return HttpResponse::BadRequest().json(serde_json::json!({
192                    "error": format!("Invalid resource type: {}", resource_type_str)
193                }))
194            }
195        };
196
197    match data
198        .resource_booking_use_cases
199        .list_by_resource_type(building_id, resource_type)
200        .await
201    {
202        Ok(bookings) => HttpResponse::Ok().json(bookings),
203        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
204    }
205}
206
207/// List bookings for a specific resource
208///
209/// GET /buildings/:building_id/resource-bookings/resource/:resource_type/:resource_name
210///
211/// # Responses
212/// - 200 OK: List of bookings for specific resource
213#[get("/buildings/{building_id}/resource-bookings/resource/{resource_type}/{resource_name}")]
214pub async fn list_by_resource(
215    data: web::Data<AppState>,
216    auth: AuthenticatedUser,
217    path: web::Path<(Uuid, String, String)>,
218) -> impl Responder {
219    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
220    // utilisateur a le droit de voir. L'identité était prise puis ignorée —
221    // `_auth` — et n'importe quel utilisateur authentifié lisait les
222    // réservations de n'importe quel immeuble (#772).
223    if let Err(err) =
224        verify_building_org_access(&auth, path.0, &data.building_use_cases, &data.acp_use_cases)
225            .await
226    {
227        return err.error_response();
228    }
229
230    let (building_id, resource_type_str, resource_name) = path.into_inner();
231
232    // Parse resource_type from string
233    let resource_type: ResourceType =
234        match serde_json::from_str(&format!("\"{}\"", resource_type_str)) {
235            Ok(rt) => rt,
236            Err(_) => {
237                return HttpResponse::BadRequest().json(serde_json::json!({
238                    "error": format!("Invalid resource type: {}", resource_type_str)
239                }))
240            }
241        };
242
243    match data
244        .resource_booking_use_cases
245        .list_by_resource(building_id, resource_type, resource_name)
246        .await
247    {
248        Ok(bookings) => HttpResponse::Ok().json(bookings),
249        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
250    }
251}
252
253/// List user's bookings
254///
255/// GET /resource-bookings/my
256///
257/// # Responses
258/// - 200 OK: List of user's bookings
259#[get("/resource-bookings/my")]
260pub async fn list_my_bookings(
261    data: web::Data<AppState>,
262    auth: AuthenticatedUser,
263) -> impl Responder {
264    let org_id = match auth.require_organization() {
265        Ok(id) => id,
266        Err(e) => {
267            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
268        }
269    };
270    match data
271        .resource_booking_use_cases
272        .list_user_bookings(auth.user_id, org_id)
273        .await
274    {
275        Ok(bookings) => HttpResponse::Ok().json(bookings),
276        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
277    }
278}
279
280/// List user's bookings by status
281///
282/// GET /resource-bookings/my/status/:status
283///
284/// # Responses
285/// - 200 OK: List of user's bookings with status
286#[get("/resource-bookings/my/status/{status}")]
287pub async fn list_my_bookings_by_status(
288    data: web::Data<AppState>,
289    auth: AuthenticatedUser,
290    status: web::Path<String>,
291) -> impl Responder {
292    // Parse status from string
293    let booking_status: BookingStatus =
294        match serde_json::from_str(&format!("\"{}\"", status.into_inner())) {
295            Ok(s) => s,
296            Err(_) => {
297                return HttpResponse::BadRequest()
298                    .json(serde_json::json!({"error": "Invalid status"}))
299            }
300        };
301
302    let org_id = match auth.require_organization() {
303        Ok(id) => id,
304        Err(e) => {
305            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
306        }
307    };
308    match data
309        .resource_booking_use_cases
310        .list_user_bookings_by_status(auth.user_id, org_id, booking_status)
311        .await
312    {
313        Ok(bookings) => HttpResponse::Ok().json(bookings),
314        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
315    }
316}
317
318/// List building bookings by status
319///
320/// GET /buildings/:building_id/resource-bookings/status/:status
321///
322/// # Responses
323/// - 200 OK: List of bookings with status
324#[get("/buildings/{building_id}/resource-bookings/status/{status}")]
325pub async fn list_building_bookings_by_status(
326    data: web::Data<AppState>,
327    auth: AuthenticatedUser,
328    path: web::Path<(Uuid, String)>,
329) -> impl Responder {
330    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
331    // utilisateur a le droit de voir. L'identité était prise puis ignorée —
332    // `_auth` — et n'importe quel utilisateur authentifié lisait les
333    // réservations de n'importe quel immeuble (#772).
334    if let Err(err) =
335        verify_building_org_access(&auth, path.0, &data.building_use_cases, &data.acp_use_cases)
336            .await
337    {
338        return err.error_response();
339    }
340
341    let (building_id, status_str) = path.into_inner();
342
343    // Parse status from string
344    let booking_status: BookingStatus = match serde_json::from_str(&format!("\"{}\"", status_str)) {
345        Ok(s) => s,
346        Err(_) => {
347            return HttpResponse::BadRequest().json(serde_json::json!({
348                "error": format!("Invalid status: {}", status_str)
349            }))
350        }
351    };
352
353    match data
354        .resource_booking_use_cases
355        .list_building_bookings_by_status(building_id, booking_status)
356        .await
357    {
358        Ok(bookings) => HttpResponse::Ok().json(bookings),
359        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
360    }
361}
362
363#[derive(Deserialize)]
364pub struct UpcomingQuery {
365    limit: Option<i64>,
366}
367
368/// List upcoming bookings (future, confirmed or pending)
369///
370/// GET /buildings/:building_id/resource-bookings/upcoming?limit=50
371///
372/// # Responses
373/// - 200 OK: List of upcoming bookings
374#[get("/buildings/{building_id}/resource-bookings/upcoming")]
375pub async fn list_upcoming_bookings(
376    data: web::Data<AppState>,
377    auth: AuthenticatedUser,
378    building_id: web::Path<Uuid>,
379    query: web::Query<UpcomingQuery>,
380) -> impl Responder {
381    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
382    // utilisateur a le droit de voir. L'identité était prise puis ignorée —
383    // `_auth` — et n'importe quel utilisateur authentifié lisait les
384    // réservations de n'importe quel immeuble (#772).
385    if let Err(err) = verify_building_org_access(
386        &auth,
387        *building_id,
388        &data.building_use_cases,
389        &data.acp_use_cases,
390    )
391    .await
392    {
393        return err.error_response();
394    }
395
396    match data
397        .resource_booking_use_cases
398        .list_upcoming_bookings(building_id.into_inner(), query.limit)
399        .await
400    {
401        Ok(bookings) => HttpResponse::Ok().json(bookings),
402        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
403    }
404}
405
406/// List active bookings (currently in progress)
407///
408/// GET /buildings/:building_id/resource-bookings/active
409///
410/// # Responses
411/// - 200 OK: List of active bookings
412#[get("/buildings/{building_id}/resource-bookings/active")]
413pub async fn list_active_bookings(
414    data: web::Data<AppState>,
415    auth: AuthenticatedUser,
416    building_id: web::Path<Uuid>,
417) -> impl Responder {
418    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
419    // utilisateur a le droit de voir. L'identité était prise puis ignorée —
420    // `_auth` — et n'importe quel utilisateur authentifié lisait les
421    // réservations de n'importe quel immeuble (#772).
422    if let Err(err) = verify_building_org_access(
423        &auth,
424        *building_id,
425        &data.building_use_cases,
426        &data.acp_use_cases,
427    )
428    .await
429    {
430        return err.error_response();
431    }
432
433    match data
434        .resource_booking_use_cases
435        .list_active_bookings(building_id.into_inner())
436        .await
437    {
438        Ok(bookings) => HttpResponse::Ok().json(bookings),
439        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
440    }
441}
442
443#[derive(Deserialize)]
444pub struct PastQuery {
445    limit: Option<i64>,
446}
447
448/// List past bookings
449///
450/// GET /buildings/:building_id/resource-bookings/past?limit=50
451///
452/// # Responses
453/// - 200 OK: List of past bookings
454#[get("/buildings/{building_id}/resource-bookings/past")]
455pub async fn list_past_bookings(
456    data: web::Data<AppState>,
457    auth: AuthenticatedUser,
458    building_id: web::Path<Uuid>,
459    query: web::Query<PastQuery>,
460) -> impl Responder {
461    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
462    // utilisateur a le droit de voir. L'identité était prise puis ignorée —
463    // `_auth` — et n'importe quel utilisateur authentifié lisait les
464    // réservations de n'importe quel immeuble (#772).
465    if let Err(err) = verify_building_org_access(
466        &auth,
467        *building_id,
468        &data.building_use_cases,
469        &data.acp_use_cases,
470    )
471    .await
472    {
473        return err.error_response();
474    }
475
476    match data
477        .resource_booking_use_cases
478        .list_past_bookings(building_id.into_inner(), query.limit)
479        .await
480    {
481        Ok(bookings) => HttpResponse::Ok().json(bookings),
482        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
483    }
484}
485
486/// Update booking details (resource_name, notes)
487///
488/// PUT /resource-bookings/:id
489///
490/// # Request Body
491/// - resource_name: `Option<String>`
492/// - notes: `Option<String>`
493///
494/// # Responses
495/// - 200 OK: Booking updated
496/// - 400 Bad Request: Validation error
497/// - 403 Forbidden: Not booking owner
498/// - 404 Not Found: Booking not found
499#[put("/resource-bookings/{id}")]
500pub async fn update_booking(
501    data: web::Data<AppState>,
502    auth: AuthenticatedUser,
503    id: web::Path<Uuid>,
504    request: web::Json<UpdateResourceBookingDto>,
505) -> impl Responder {
506    let org_id = match auth.require_organization() {
507        Ok(id) => id,
508        Err(e) => {
509            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
510        }
511    };
512    match data
513        .resource_booking_use_cases
514        .update_booking(id.into_inner(), auth.user_id, org_id, request.into_inner())
515        .await
516    {
517        Ok(booking) => HttpResponse::Ok().json(booking),
518        Err(e) => {
519            if e.contains("Only the booking owner") {
520                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
521            } else if classification_erreurs::est_introuvable(&e) {
522                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
523            } else {
524                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
525            }
526        }
527    }
528}
529
530/// Cancel a booking
531///
532/// POST /resource-bookings/:id/cancel
533///
534/// # Responses
535/// - 200 OK: Booking cancelled
536/// - 400 Bad Request: Cannot cancel (invalid state)
537/// - 403 Forbidden: Not booking owner
538/// - 404 Not Found: Booking not found
539#[post("/resource-bookings/{id}/cancel")]
540pub async fn cancel_booking(
541    data: web::Data<AppState>,
542    auth: AuthenticatedUser,
543    id: web::Path<Uuid>,
544) -> impl Responder {
545    let org_id = match auth.require_organization() {
546        Ok(id) => id,
547        Err(e) => {
548            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
549        }
550    };
551    match data
552        .resource_booking_use_cases
553        .cancel_booking(id.into_inner(), auth.user_id, org_id)
554        .await
555    {
556        Ok(booking) => HttpResponse::Ok().json(booking),
557        Err(e) => {
558            if e.contains("Only the booking owner") {
559                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
560            } else if classification_erreurs::est_introuvable(&e) {
561                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
562            } else {
563                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
564            }
565        }
566    }
567}
568
569/// Complete a booking (admin only)
570///
571/// POST /resource-bookings/:id/complete
572///
573/// # Responses
574/// - 200 OK: Booking completed
575/// - 400 Bad Request: Cannot complete
576/// - 404 Not Found: Booking not found
577#[post("/resource-bookings/{id}/complete")]
578pub async fn complete_booking(
579    data: web::Data<AppState>,
580    auth: AuthenticatedUser,
581    id: web::Path<Uuid>,
582) -> impl Responder {
583    // Cloisonnement : la réservation visée doit relever d'une ACP que cet
584    // utilisateur a le droit de voir. Cette route n'ayant pas d'immeuble en
585    // chemin, la chaîne réservation → immeuble → ACP est remontée par le
586    // garde. L'identité était prise puis ignorée — `_auth` (#772).
587    if let Err(err) = verify_booking_org_access(
588        &auth,
589        *id,
590        &data.resource_booking_use_cases,
591        &data.building_use_cases,
592        &data.acp_use_cases,
593    )
594    .await
595    {
596        return err.error_response();
597    }
598
599    match data
600        .resource_booking_use_cases
601        .complete_booking(id.into_inner())
602        .await
603    {
604        Ok(booking) => HttpResponse::Ok().json(booking),
605        Err(e) => {
606            if classification_erreurs::est_introuvable(&e) {
607                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
608            } else {
609                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
610            }
611        }
612    }
613}
614
615/// Mark booking as no-show (admin only)
616///
617/// POST /resource-bookings/:id/no-show
618///
619/// # Responses
620/// - 200 OK: Booking marked as no-show
621/// - 400 Bad Request: Cannot mark as no-show
622/// - 404 Not Found: Booking not found
623#[post("/resource-bookings/{id}/no-show")]
624pub async fn mark_no_show(
625    data: web::Data<AppState>,
626    auth: AuthenticatedUser,
627    id: web::Path<Uuid>,
628) -> impl Responder {
629    // Cloisonnement : la réservation visée doit relever d'une ACP que cet
630    // utilisateur a le droit de voir. Cette route n'ayant pas d'immeuble en
631    // chemin, la chaîne réservation → immeuble → ACP est remontée par le
632    // garde. L'identité était prise puis ignorée — `_auth` (#772).
633    if let Err(err) = verify_booking_org_access(
634        &auth,
635        *id,
636        &data.resource_booking_use_cases,
637        &data.building_use_cases,
638        &data.acp_use_cases,
639    )
640    .await
641    {
642        return err.error_response();
643    }
644
645    match data
646        .resource_booking_use_cases
647        .mark_no_show(id.into_inner())
648        .await
649    {
650        Ok(booking) => HttpResponse::Ok().json(booking),
651        Err(e) => {
652            if classification_erreurs::est_introuvable(&e) {
653                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
654            } else {
655                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
656            }
657        }
658    }
659}
660
661/// Confirm a pending booking (admin only)
662///
663/// POST /resource-bookings/:id/confirm
664///
665/// # Responses
666/// - 200 OK: Booking confirmed
667/// - 400 Bad Request: Cannot confirm
668/// - 404 Not Found: Booking not found
669#[post("/resource-bookings/{id}/confirm")]
670pub async fn confirm_booking(
671    data: web::Data<AppState>,
672    auth: AuthenticatedUser,
673    id: web::Path<Uuid>,
674) -> impl Responder {
675    // Cloisonnement : la réservation visée doit relever d'une ACP que cet
676    // utilisateur a le droit de voir. Cette route n'ayant pas d'immeuble en
677    // chemin, la chaîne réservation → immeuble → ACP est remontée par le
678    // garde. L'identité était prise puis ignorée — `_auth` (#772).
679    if let Err(err) = verify_booking_org_access(
680        &auth,
681        *id,
682        &data.resource_booking_use_cases,
683        &data.building_use_cases,
684        &data.acp_use_cases,
685    )
686    .await
687    {
688        return err.error_response();
689    }
690
691    match data
692        .resource_booking_use_cases
693        .confirm_booking(id.into_inner())
694        .await
695    {
696        Ok(booking) => HttpResponse::Ok().json(booking),
697        Err(e) => {
698            if classification_erreurs::est_introuvable(&e) {
699                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
700            } else {
701                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
702            }
703        }
704    }
705}
706
707/// Delete a booking
708///
709/// DELETE /resource-bookings/:id
710///
711/// # Responses
712/// - 204 No Content: Booking deleted
713/// - 403 Forbidden: Not booking owner
714/// - 404 Not Found: Booking not found
715#[delete("/resource-bookings/{id}")]
716pub async fn delete_booking(
717    data: web::Data<AppState>,
718    auth: AuthenticatedUser,
719    id: web::Path<Uuid>,
720) -> impl Responder {
721    let org_id = match auth.require_organization() {
722        Ok(id) => id,
723        Err(e) => {
724            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
725        }
726    };
727    match data
728        .resource_booking_use_cases
729        .delete_booking(id.into_inner(), auth.user_id, org_id)
730        .await
731    {
732        Ok(()) => HttpResponse::NoContent().finish(),
733        Err(e) => {
734            if e.contains("Only the booking owner") {
735                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
736            } else if classification_erreurs::est_introuvable(&e) {
737                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
738            } else {
739                HttpResponse::InternalServerError().json(serde_json::json!({"error": e}))
740            }
741        }
742    }
743}
744
745#[derive(Deserialize)]
746pub struct CheckConflictsQuery {
747    pub building_id: Uuid,
748    pub resource_type: String,
749    pub resource_name: String,
750    pub start_time: DateTime<Utc>,
751    pub end_time: DateTime<Utc>,
752    pub exclude_booking_id: Option<Uuid>,
753}
754
755/// Check for booking conflicts (preview before creating)
756///
757/// GET /resource-bookings/check-conflicts?building_id=...&resource_type=...&resource_name=...&start_time=...&end_time=...
758///
759/// # Query Parameters
760/// - building_id: UUID
761/// - resource_type: String
762/// - resource_name: String
763/// - start_time: ISO 8601 DateTime
764/// - end_time: ISO 8601 DateTime
765/// - exclude_booking_id: `Option<UUID>`
766///
767/// # Responses
768/// - 200 OK: List of conflicting bookings (empty if no conflicts)
769#[get("/resource-bookings/check-conflicts")]
770pub async fn check_conflicts(
771    data: web::Data<AppState>,
772    auth: AuthenticatedUser,
773    query: web::Query<CheckConflictsQuery>,
774) -> impl Responder {
775    // Cloisonnement (#882) : `building_id` arrive en paramètre de requête sans
776    // aucune vérification. Une réservation dit qui a réservé la salle ou le
777    // parking visiteur, et QUAND — la même donnée que `verify_booking_org_access`
778    // protège déjà pour les réservations existantes de ce fichier.
779    if let Err(err) = verify_building_org_access(
780        &auth,
781        query.building_id,
782        &data.building_use_cases,
783        &data.acp_use_cases,
784    )
785    .await
786    {
787        return err.error_response();
788    }
789
790    // Parse resource_type
791    let resource_type: ResourceType =
792        match serde_json::from_str(&format!("\"{}\"", query.resource_type)) {
793            Ok(rt) => rt,
794            Err(_) => {
795                return HttpResponse::BadRequest().json(serde_json::json!({
796                    "error": format!("Invalid resource type: {}", query.resource_type)
797                }))
798            }
799        };
800
801    match data
802        .resource_booking_use_cases
803        .check_conflicts(
804            query.building_id,
805            resource_type,
806            query.resource_name.clone(),
807            query.start_time,
808            query.end_time,
809            query.exclude_booking_id,
810        )
811        .await
812    {
813        Ok(conflicts) => HttpResponse::Ok().json(conflicts),
814        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
815    }
816}
817
818/// Get booking statistics for a building
819///
820/// GET /buildings/:building_id/resource-bookings/statistics
821///
822/// # Responses
823/// - 200 OK: Booking statistics
824#[get("/buildings/{building_id}/resource-bookings/statistics")]
825pub async fn get_booking_statistics(
826    data: web::Data<AppState>,
827    auth: AuthenticatedUser,
828    building_id: web::Path<Uuid>,
829) -> impl Responder {
830    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
831    // utilisateur a le droit de voir. L'identité était prise puis ignorée —
832    // `_auth` — et n'importe quel utilisateur authentifié lisait les
833    // réservations de n'importe quel immeuble (#772).
834    if let Err(err) = verify_building_org_access(
835        &auth,
836        *building_id,
837        &data.building_use_cases,
838        &data.acp_use_cases,
839    )
840    .await
841    {
842        return err.error_response();
843    }
844
845    match data
846        .resource_booking_use_cases
847        .get_statistics(building_id.into_inner())
848        .await
849    {
850        Ok(stats) => HttpResponse::Ok().json(stats),
851        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
852    }
853}