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::middleware::AuthenticatedUser;
5use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
6use chrono::{DateTime, Utc};
7use serde::Deserialize;
8use uuid::Uuid;
9
10/// Create a new resource booking
11///
12/// POST /resource-bookings
13///
14/// # Request Body
15/// - building_id: UUID
16/// - resource_type: ResourceType (MeetingRoom, LaundryRoom, Gym, etc.)
17/// - resource_name: String (e.g., "Meeting Room A")
18/// - start_time: DateTime<Utc>
19/// - end_time: DateTime<Utc>
20/// - notes: `Option<String>`
21/// - recurring_pattern: RecurringPattern (default: None)
22/// - recurrence_end_date: `Option<DateTime<Utc>>`
23/// - max_duration_hours: `Option<i64>` (default: 4)
24/// - max_advance_days: `Option<i64>` (default: 30)
25///
26/// # Responses
27/// - 201 Created: Booking created successfully
28/// - 400 Bad Request: Validation error or conflict
29/// - 404 Not Found: Building not found
30#[post("/resource-bookings")]
31pub async fn create_booking(
32    data: web::Data<AppState>,
33    auth: AuthenticatedUser,
34    request: web::Json<CreateResourceBookingDto>,
35) -> impl Responder {
36    let org_id = match auth.require_organization() {
37        Ok(id) => id,
38        Err(e) => {
39            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
40        }
41    };
42    match data
43        .resource_booking_use_cases
44        .create_booking(auth.user_id, org_id, request.into_inner())
45        .await
46    {
47        Ok(booking) => HttpResponse::Created().json(booking),
48        Err(e) => {
49            if e.contains("conflicts with") {
50                HttpResponse::Conflict().json(serde_json::json!({"error": e}))
51            } else if e.contains("not found") {
52                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
53            } else {
54                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
55            }
56        }
57    }
58}
59
60/// Get booking by ID
61///
62/// GET /resource-bookings/:id
63///
64/// # Responses
65/// - 200 OK: Booking details
66/// - 404 Not Found: Booking not found
67#[get("/resource-bookings/{id}")]
68pub async fn get_booking(
69    data: web::Data<AppState>,
70    _auth: AuthenticatedUser,
71    id: web::Path<Uuid>,
72) -> impl Responder {
73    match data
74        .resource_booking_use_cases
75        .get_booking(id.into_inner())
76        .await
77    {
78        Ok(booking) => HttpResponse::Ok().json(booking),
79        Err(e) => HttpResponse::NotFound().json(serde_json::json!({"error": e})),
80    }
81}
82
83/// List all bookings for a building
84///
85/// GET /buildings/:building_id/resource-bookings
86///
87/// # Responses
88/// - 200 OK: List of bookings
89#[get("/buildings/{building_id}/resource-bookings")]
90pub async fn list_building_bookings(
91    data: web::Data<AppState>,
92    _auth: AuthenticatedUser,
93    building_id: web::Path<Uuid>,
94) -> impl Responder {
95    match data
96        .resource_booking_use_cases
97        .list_building_bookings(building_id.into_inner())
98        .await
99    {
100        Ok(bookings) => HttpResponse::Ok().json(bookings),
101        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
102    }
103}
104
105/// List bookings by resource type
106///
107/// GET /buildings/:building_id/resource-bookings/type/:resource_type
108///
109/// # Responses
110/// - 200 OK: List of bookings for resource type
111#[get("/buildings/{building_id}/resource-bookings/type/{resource_type}")]
112pub async fn list_by_resource_type(
113    data: web::Data<AppState>,
114    _auth: AuthenticatedUser,
115    path: web::Path<(Uuid, String)>,
116) -> impl Responder {
117    let (building_id, resource_type_str) = path.into_inner();
118
119    // Parse resource_type from string
120    let resource_type: ResourceType =
121        match serde_json::from_str(&format!("\"{}\"", resource_type_str)) {
122            Ok(rt) => rt,
123            Err(_) => {
124                return HttpResponse::BadRequest().json(serde_json::json!({
125                    "error": format!("Invalid resource type: {}", resource_type_str)
126                }))
127            }
128        };
129
130    match data
131        .resource_booking_use_cases
132        .list_by_resource_type(building_id, resource_type)
133        .await
134    {
135        Ok(bookings) => HttpResponse::Ok().json(bookings),
136        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
137    }
138}
139
140/// List bookings for a specific resource
141///
142/// GET /buildings/:building_id/resource-bookings/resource/:resource_type/:resource_name
143///
144/// # Responses
145/// - 200 OK: List of bookings for specific resource
146#[get("/buildings/{building_id}/resource-bookings/resource/{resource_type}/{resource_name}")]
147pub async fn list_by_resource(
148    data: web::Data<AppState>,
149    _auth: AuthenticatedUser,
150    path: web::Path<(Uuid, String, String)>,
151) -> impl Responder {
152    let (building_id, resource_type_str, resource_name) = path.into_inner();
153
154    // Parse resource_type from string
155    let resource_type: ResourceType =
156        match serde_json::from_str(&format!("\"{}\"", resource_type_str)) {
157            Ok(rt) => rt,
158            Err(_) => {
159                return HttpResponse::BadRequest().json(serde_json::json!({
160                    "error": format!("Invalid resource type: {}", resource_type_str)
161                }))
162            }
163        };
164
165    match data
166        .resource_booking_use_cases
167        .list_by_resource(building_id, resource_type, resource_name)
168        .await
169    {
170        Ok(bookings) => HttpResponse::Ok().json(bookings),
171        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
172    }
173}
174
175/// List user's bookings
176///
177/// GET /resource-bookings/my
178///
179/// # Responses
180/// - 200 OK: List of user's bookings
181#[get("/resource-bookings/my")]
182pub async fn list_my_bookings(
183    data: web::Data<AppState>,
184    auth: AuthenticatedUser,
185) -> impl Responder {
186    let org_id = match auth.require_organization() {
187        Ok(id) => id,
188        Err(e) => {
189            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
190        }
191    };
192    match data
193        .resource_booking_use_cases
194        .list_user_bookings(auth.user_id, org_id)
195        .await
196    {
197        Ok(bookings) => HttpResponse::Ok().json(bookings),
198        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
199    }
200}
201
202/// List user's bookings by status
203///
204/// GET /resource-bookings/my/status/:status
205///
206/// # Responses
207/// - 200 OK: List of user's bookings with status
208#[get("/resource-bookings/my/status/{status}")]
209pub async fn list_my_bookings_by_status(
210    data: web::Data<AppState>,
211    auth: AuthenticatedUser,
212    status: web::Path<String>,
213) -> impl Responder {
214    // Parse status from string
215    let booking_status: BookingStatus =
216        match serde_json::from_str(&format!("\"{}\"", status.into_inner())) {
217            Ok(s) => s,
218            Err(_) => {
219                return HttpResponse::BadRequest()
220                    .json(serde_json::json!({"error": "Invalid status"}))
221            }
222        };
223
224    let org_id = match auth.require_organization() {
225        Ok(id) => id,
226        Err(e) => {
227            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
228        }
229    };
230    match data
231        .resource_booking_use_cases
232        .list_user_bookings_by_status(auth.user_id, org_id, booking_status)
233        .await
234    {
235        Ok(bookings) => HttpResponse::Ok().json(bookings),
236        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
237    }
238}
239
240/// List building bookings by status
241///
242/// GET /buildings/:building_id/resource-bookings/status/:status
243///
244/// # Responses
245/// - 200 OK: List of bookings with status
246#[get("/buildings/{building_id}/resource-bookings/status/{status}")]
247pub async fn list_building_bookings_by_status(
248    data: web::Data<AppState>,
249    _auth: AuthenticatedUser,
250    path: web::Path<(Uuid, String)>,
251) -> impl Responder {
252    let (building_id, status_str) = path.into_inner();
253
254    // Parse status from string
255    let booking_status: BookingStatus = match serde_json::from_str(&format!("\"{}\"", status_str)) {
256        Ok(s) => s,
257        Err(_) => {
258            return HttpResponse::BadRequest().json(serde_json::json!({
259                "error": format!("Invalid status: {}", status_str)
260            }))
261        }
262    };
263
264    match data
265        .resource_booking_use_cases
266        .list_building_bookings_by_status(building_id, booking_status)
267        .await
268    {
269        Ok(bookings) => HttpResponse::Ok().json(bookings),
270        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
271    }
272}
273
274#[derive(Deserialize)]
275pub struct UpcomingQuery {
276    limit: Option<i64>,
277}
278
279/// List upcoming bookings (future, confirmed or pending)
280///
281/// GET /buildings/:building_id/resource-bookings/upcoming?limit=50
282///
283/// # Responses
284/// - 200 OK: List of upcoming bookings
285#[get("/buildings/{building_id}/resource-bookings/upcoming")]
286pub async fn list_upcoming_bookings(
287    data: web::Data<AppState>,
288    _auth: AuthenticatedUser,
289    building_id: web::Path<Uuid>,
290    query: web::Query<UpcomingQuery>,
291) -> impl Responder {
292    match data
293        .resource_booking_use_cases
294        .list_upcoming_bookings(building_id.into_inner(), query.limit)
295        .await
296    {
297        Ok(bookings) => HttpResponse::Ok().json(bookings),
298        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
299    }
300}
301
302/// List active bookings (currently in progress)
303///
304/// GET /buildings/:building_id/resource-bookings/active
305///
306/// # Responses
307/// - 200 OK: List of active bookings
308#[get("/buildings/{building_id}/resource-bookings/active")]
309pub async fn list_active_bookings(
310    data: web::Data<AppState>,
311    _auth: AuthenticatedUser,
312    building_id: web::Path<Uuid>,
313) -> impl Responder {
314    match data
315        .resource_booking_use_cases
316        .list_active_bookings(building_id.into_inner())
317        .await
318    {
319        Ok(bookings) => HttpResponse::Ok().json(bookings),
320        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
321    }
322}
323
324#[derive(Deserialize)]
325pub struct PastQuery {
326    limit: Option<i64>,
327}
328
329/// List past bookings
330///
331/// GET /buildings/:building_id/resource-bookings/past?limit=50
332///
333/// # Responses
334/// - 200 OK: List of past bookings
335#[get("/buildings/{building_id}/resource-bookings/past")]
336pub async fn list_past_bookings(
337    data: web::Data<AppState>,
338    _auth: AuthenticatedUser,
339    building_id: web::Path<Uuid>,
340    query: web::Query<PastQuery>,
341) -> impl Responder {
342    match data
343        .resource_booking_use_cases
344        .list_past_bookings(building_id.into_inner(), query.limit)
345        .await
346    {
347        Ok(bookings) => HttpResponse::Ok().json(bookings),
348        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
349    }
350}
351
352/// Update booking details (resource_name, notes)
353///
354/// PUT /resource-bookings/:id
355///
356/// # Request Body
357/// - resource_name: `Option<String>`
358/// - notes: `Option<String>`
359///
360/// # Responses
361/// - 200 OK: Booking updated
362/// - 400 Bad Request: Validation error
363/// - 403 Forbidden: Not booking owner
364/// - 404 Not Found: Booking not found
365#[put("/resource-bookings/{id}")]
366pub async fn update_booking(
367    data: web::Data<AppState>,
368    auth: AuthenticatedUser,
369    id: web::Path<Uuid>,
370    request: web::Json<UpdateResourceBookingDto>,
371) -> impl Responder {
372    let org_id = match auth.require_organization() {
373        Ok(id) => id,
374        Err(e) => {
375            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
376        }
377    };
378    match data
379        .resource_booking_use_cases
380        .update_booking(id.into_inner(), auth.user_id, org_id, request.into_inner())
381        .await
382    {
383        Ok(booking) => HttpResponse::Ok().json(booking),
384        Err(e) => {
385            if e.contains("Only the booking owner") {
386                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
387            } else if e.contains("not found") {
388                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
389            } else {
390                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
391            }
392        }
393    }
394}
395
396/// Cancel a booking
397///
398/// POST /resource-bookings/:id/cancel
399///
400/// # Responses
401/// - 200 OK: Booking cancelled
402/// - 400 Bad Request: Cannot cancel (invalid state)
403/// - 403 Forbidden: Not booking owner
404/// - 404 Not Found: Booking not found
405#[post("/resource-bookings/{id}/cancel")]
406pub async fn cancel_booking(
407    data: web::Data<AppState>,
408    auth: AuthenticatedUser,
409    id: web::Path<Uuid>,
410) -> impl Responder {
411    let org_id = match auth.require_organization() {
412        Ok(id) => id,
413        Err(e) => {
414            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
415        }
416    };
417    match data
418        .resource_booking_use_cases
419        .cancel_booking(id.into_inner(), auth.user_id, org_id)
420        .await
421    {
422        Ok(booking) => HttpResponse::Ok().json(booking),
423        Err(e) => {
424            if e.contains("Only the booking owner") {
425                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
426            } else if e.contains("not found") {
427                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
428            } else {
429                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
430            }
431        }
432    }
433}
434
435/// Complete a booking (admin only)
436///
437/// POST /resource-bookings/:id/complete
438///
439/// # Responses
440/// - 200 OK: Booking completed
441/// - 400 Bad Request: Cannot complete
442/// - 404 Not Found: Booking not found
443#[post("/resource-bookings/{id}/complete")]
444pub async fn complete_booking(
445    data: web::Data<AppState>,
446    _auth: AuthenticatedUser,
447    id: web::Path<Uuid>,
448) -> impl Responder {
449    match data
450        .resource_booking_use_cases
451        .complete_booking(id.into_inner())
452        .await
453    {
454        Ok(booking) => HttpResponse::Ok().json(booking),
455        Err(e) => {
456            if e.contains("not found") {
457                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
458            } else {
459                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
460            }
461        }
462    }
463}
464
465/// Mark booking as no-show (admin only)
466///
467/// POST /resource-bookings/:id/no-show
468///
469/// # Responses
470/// - 200 OK: Booking marked as no-show
471/// - 400 Bad Request: Cannot mark as no-show
472/// - 404 Not Found: Booking not found
473#[post("/resource-bookings/{id}/no-show")]
474pub async fn mark_no_show(
475    data: web::Data<AppState>,
476    _auth: AuthenticatedUser,
477    id: web::Path<Uuid>,
478) -> impl Responder {
479    match data
480        .resource_booking_use_cases
481        .mark_no_show(id.into_inner())
482        .await
483    {
484        Ok(booking) => HttpResponse::Ok().json(booking),
485        Err(e) => {
486            if e.contains("not found") {
487                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
488            } else {
489                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
490            }
491        }
492    }
493}
494
495/// Confirm a pending booking (admin only)
496///
497/// POST /resource-bookings/:id/confirm
498///
499/// # Responses
500/// - 200 OK: Booking confirmed
501/// - 400 Bad Request: Cannot confirm
502/// - 404 Not Found: Booking not found
503#[post("/resource-bookings/{id}/confirm")]
504pub async fn confirm_booking(
505    data: web::Data<AppState>,
506    _auth: AuthenticatedUser,
507    id: web::Path<Uuid>,
508) -> impl Responder {
509    match data
510        .resource_booking_use_cases
511        .confirm_booking(id.into_inner())
512        .await
513    {
514        Ok(booking) => HttpResponse::Ok().json(booking),
515        Err(e) => {
516            if e.contains("not found") {
517                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
518            } else {
519                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
520            }
521        }
522    }
523}
524
525/// Delete a booking
526///
527/// DELETE /resource-bookings/:id
528///
529/// # Responses
530/// - 204 No Content: Booking deleted
531/// - 403 Forbidden: Not booking owner
532/// - 404 Not Found: Booking not found
533#[delete("/resource-bookings/{id}")]
534pub async fn delete_booking(
535    data: web::Data<AppState>,
536    auth: AuthenticatedUser,
537    id: web::Path<Uuid>,
538) -> impl Responder {
539    let org_id = match auth.require_organization() {
540        Ok(id) => id,
541        Err(e) => {
542            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
543        }
544    };
545    match data
546        .resource_booking_use_cases
547        .delete_booking(id.into_inner(), auth.user_id, org_id)
548        .await
549    {
550        Ok(()) => HttpResponse::NoContent().finish(),
551        Err(e) => {
552            if e.contains("Only the booking owner") {
553                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
554            } else if e.contains("not found") {
555                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
556            } else {
557                HttpResponse::InternalServerError().json(serde_json::json!({"error": e}))
558            }
559        }
560    }
561}
562
563#[derive(Deserialize)]
564pub struct CheckConflictsQuery {
565    pub building_id: Uuid,
566    pub resource_type: String,
567    pub resource_name: String,
568    pub start_time: DateTime<Utc>,
569    pub end_time: DateTime<Utc>,
570    pub exclude_booking_id: Option<Uuid>,
571}
572
573/// Check for booking conflicts (preview before creating)
574///
575/// GET /resource-bookings/check-conflicts?building_id=...&resource_type=...&resource_name=...&start_time=...&end_time=...
576///
577/// # Query Parameters
578/// - building_id: UUID
579/// - resource_type: String
580/// - resource_name: String
581/// - start_time: ISO 8601 DateTime
582/// - end_time: ISO 8601 DateTime
583/// - exclude_booking_id: `Option<UUID>`
584///
585/// # Responses
586/// - 200 OK: List of conflicting bookings (empty if no conflicts)
587#[get("/resource-bookings/check-conflicts")]
588pub async fn check_conflicts(
589    data: web::Data<AppState>,
590    _auth: AuthenticatedUser,
591    query: web::Query<CheckConflictsQuery>,
592) -> impl Responder {
593    // Parse resource_type
594    let resource_type: ResourceType =
595        match serde_json::from_str(&format!("\"{}\"", query.resource_type)) {
596            Ok(rt) => rt,
597            Err(_) => {
598                return HttpResponse::BadRequest().json(serde_json::json!({
599                    "error": format!("Invalid resource type: {}", query.resource_type)
600                }))
601            }
602        };
603
604    match data
605        .resource_booking_use_cases
606        .check_conflicts(
607            query.building_id,
608            resource_type,
609            query.resource_name.clone(),
610            query.start_time,
611            query.end_time,
612            query.exclude_booking_id,
613        )
614        .await
615    {
616        Ok(conflicts) => HttpResponse::Ok().json(conflicts),
617        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
618    }
619}
620
621/// Get booking statistics for a building
622///
623/// GET /buildings/:building_id/resource-bookings/statistics
624///
625/// # Responses
626/// - 200 OK: Booking statistics
627#[get("/buildings/{building_id}/resource-bookings/statistics")]
628pub async fn get_booking_statistics(
629    data: web::Data<AppState>,
630    _auth: AuthenticatedUser,
631    building_id: web::Path<Uuid>,
632) -> impl Responder {
633    match data
634        .resource_booking_use_cases
635        .get_statistics(building_id.into_inner())
636        .await
637    {
638        Ok(stats) => HttpResponse::Ok().json(stats),
639        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
640    }
641}