Skip to main content

koprogo_api/infrastructure/database/repositories/
resource_booking_repository_impl.rs

1use crate::application::dto::BookingStatisticsDto;
2use crate::application::ports::ResourceBookingRepository;
3use crate::domain::entities::{BookingStatus, RecurringPattern, ResourceBooking, ResourceType};
4use crate::infrastructure::pool::DbPool;
5use async_trait::async_trait;
6use chrono::{DateTime, Utc};
7use sqlx::Row;
8use uuid::Uuid;
9
10pub struct PostgresResourceBookingRepository {
11    pool: DbPool,
12}
13
14impl PostgresResourceBookingRepository {
15    pub fn new(pool: DbPool) -> Self {
16        Self { pool }
17    }
18
19    /// Helper to convert database row to ResourceBooking entity
20    fn row_to_entity(row: &sqlx::postgres::PgRow) -> Result<ResourceBooking, String> {
21        // Parse ENUMs from database strings
22        let resource_type_str: String = row
23            .try_get("res_type")
24            .map_err(|e| format!("Failed to get resource_type: {}", e))?;
25        let resource_type: ResourceType =
26            serde_json::from_str(&format!("\"{}\"", resource_type_str))
27                .map_err(|e| format!("Failed to parse resource_type: {}", e))?;
28
29        let status_str: String = row
30            .try_get("status")
31            .map_err(|e| format!("Failed to get status: {}", e))?;
32        let status: BookingStatus = serde_json::from_str(&format!("\"{}\"", status_str))
33            .map_err(|e| format!("Failed to parse status: {}", e))?;
34
35        let recurring_pattern_str: String = row
36            .try_get("recurring_pattern")
37            .map_err(|e| format!("Failed to get recurring_pattern: {}", e))?;
38        let recurring_pattern: RecurringPattern =
39            serde_json::from_str(&format!("\"{}\"", recurring_pattern_str))
40                .map_err(|e| format!("Failed to parse recurring_pattern: {}", e))?;
41
42        Ok(ResourceBooking {
43            id: row
44                .try_get("id")
45                .map_err(|e| format!("Failed to get id: {}", e))?,
46            building_id: row
47                .try_get("building_id")
48                .map_err(|e| format!("Failed to get building_id: {}", e))?,
49            resource_type,
50            resource_name: row
51                .try_get("resource_name")
52                .map_err(|e| format!("Failed to get resource_name: {}", e))?,
53            booked_by: row
54                .try_get("booked_by")
55                .map_err(|e| format!("Failed to get booked_by: {}", e))?,
56            // Story #588 — colonnes nullables (syndic on_behalf_of_acp).
57            booked_by_user_id: row
58                .try_get("booked_by_user_id")
59                .map_err(|e| format!("Failed to get booked_by_user_id: {}", e))?,
60            on_behalf_of_acp: row
61                .try_get("on_behalf_of_acp")
62                .map_err(|e| format!("Failed to get on_behalf_of_acp: {}", e))?,
63            motif: row
64                .try_get("motif")
65                .map_err(|e| format!("Failed to get motif: {}", e))?,
66            start_time: row
67                .try_get("start_time")
68                .map_err(|e| format!("Failed to get start_time: {}", e))?,
69            end_time: row
70                .try_get("end_time")
71                .map_err(|e| format!("Failed to get end_time: {}", e))?,
72            status,
73            notes: row
74                .try_get("notes")
75                .map_err(|e| format!("Failed to get notes: {}", e))?,
76            recurring_pattern,
77            recurrence_end_date: row
78                .try_get("recurrence_end_date")
79                .map_err(|e| format!("Failed to get recurrence_end_date: {}", e))?,
80            created_at: row
81                .try_get("created_at")
82                .map_err(|e| format!("Failed to get created_at: {}", e))?,
83            updated_at: row
84                .try_get("updated_at")
85                .map_err(|e| format!("Failed to get updated_at: {}", e))?,
86        })
87    }
88}
89
90#[async_trait]
91impl ResourceBookingRepository for PostgresResourceBookingRepository {
92    async fn create(&self, booking: &ResourceBooking) -> Result<ResourceBooking, String> {
93        // Serialize ENUMs to strings for database
94        let resource_type_str = serde_json::to_string(&booking.resource_type)
95            .map_err(|e| format!("Failed to serialize resource_type: {}", e))?
96            .trim_matches('"')
97            .to_string();
98
99        let status_str = serde_json::to_string(&booking.status)
100            .map_err(|e| format!("Failed to serialize status: {}", e))?
101            .trim_matches('"')
102            .to_string();
103
104        let recurring_pattern_str = serde_json::to_string(&booking.recurring_pattern)
105            .map_err(|e| format!("Failed to serialize recurring_pattern: {}", e))?
106            .trim_matches('"')
107            .to_string();
108
109        sqlx::query(
110            r#"
111            INSERT INTO resource_bookings (
112                id, building_id, resource_type, resource_name, booked_by,
113                start_time, end_time, status, notes, recurring_pattern,
114                recurrence_end_date, created_at, updated_at, booked_by_user_id, on_behalf_of_acp, motif
115            )
116            VALUES ($1, $2, $3::resource_type, $4, $5, $6, $7, $8::booking_status, $9,
117                    $10::recurring_pattern, $11, $12, $13, $14, $15, $16)
118            "#,
119        )
120        .bind(booking.id)
121        .bind(booking.building_id)
122        .bind(&resource_type_str)
123        .bind(&booking.resource_name)
124        .bind(booking.booked_by)
125        .bind(booking.start_time)
126        .bind(booking.end_time)
127        .bind(&status_str)
128        .bind(&booking.notes)
129        .bind(&recurring_pattern_str)
130        .bind(booking.recurrence_end_date)
131        .bind(booking.created_at)
132        .bind(booking.updated_at)
133        .bind(booking.booked_by_user_id)
134        .bind(booking.on_behalf_of_acp)
135        .bind(&booking.motif)
136        .execute(&self.pool)
137        .await
138        .map_err(|e| format!("Failed to create resource booking: {}", e))?;
139
140        Ok(booking.clone())
141    }
142
143    async fn find_by_id(&self, id: Uuid) -> Result<Option<ResourceBooking>, String> {
144        let row = sqlx::query(
145            r#"
146            SELECT id, building_id, resource_type::text AS res_type, resource_name, booked_by,
147                   start_time, end_time, status::text AS status, notes, recurring_pattern::text AS recurring_pattern,
148                   recurrence_end_date, created_at, updated_at, booked_by_user_id, on_behalf_of_acp, motif
149            FROM resource_bookings
150            WHERE id = $1
151            "#,
152        )
153        .bind(id)
154        .fetch_optional(&self.pool)
155        .await
156        .map_err(|e| format!("Failed to find resource booking: {}", e))?;
157
158        match row {
159            Some(r) => Ok(Some(Self::row_to_entity(&r)?)),
160            None => Ok(None),
161        }
162    }
163
164    async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<ResourceBooking>, String> {
165        let rows = sqlx::query(
166            r#"
167            SELECT id, building_id, resource_type::text AS res_type, resource_name, booked_by,
168                   start_time, end_time, status::text AS status, notes, recurring_pattern::text AS recurring_pattern,
169                   recurrence_end_date, created_at, updated_at, booked_by_user_id, on_behalf_of_acp, motif
170            FROM resource_bookings
171            WHERE building_id = $1
172            ORDER BY start_time ASC
173            "#,
174        )
175        .bind(building_id)
176        .fetch_all(&self.pool)
177        .await
178        .map_err(|e| format!("Failed to find bookings by building: {}", e))?;
179
180        rows.iter().map(Self::row_to_entity).collect()
181    }
182
183    async fn find_by_building_and_resource_type(
184        &self,
185        building_id: Uuid,
186        resource_type: ResourceType,
187    ) -> Result<Vec<ResourceBooking>, String> {
188        let resource_type_str = serde_json::to_string(&resource_type)
189            .map_err(|e| format!("Failed to serialize resource_type: {}", e))?
190            .trim_matches('"')
191            .to_string();
192
193        let rows = sqlx::query(
194            r#"
195            SELECT id, building_id, resource_type::text AS res_type, resource_name, booked_by,
196                   start_time, end_time, status::text AS status, notes, recurring_pattern::text AS recurring_pattern,
197                   recurrence_end_date, created_at, updated_at, booked_by_user_id, on_behalf_of_acp, motif
198            FROM resource_bookings
199            WHERE building_id = $1 AND resource_type = $2::resource_type
200            ORDER BY start_time ASC
201            "#,
202        )
203        .bind(building_id)
204        .bind(&resource_type_str)
205        .fetch_all(&self.pool)
206        .await
207        .map_err(|e| {
208            format!(
209                "Failed to find bookings by building and resource type: {}",
210                e
211            )
212        })?;
213
214        rows.iter().map(Self::row_to_entity).collect()
215    }
216
217    async fn find_by_resource(
218        &self,
219        building_id: Uuid,
220        resource_type: ResourceType,
221        resource_name: &str,
222    ) -> Result<Vec<ResourceBooking>, String> {
223        let resource_type_str = serde_json::to_string(&resource_type)
224            .map_err(|e| format!("Failed to serialize resource_type: {}", e))?
225            .trim_matches('"')
226            .to_string();
227
228        let rows = sqlx::query(
229            r#"
230            SELECT id, building_id, resource_type::text AS res_type, resource_name, booked_by,
231                   start_time, end_time, status::text AS status, notes, recurring_pattern::text AS recurring_pattern,
232                   recurrence_end_date, created_at, updated_at, booked_by_user_id, on_behalf_of_acp, motif
233            FROM resource_bookings
234            WHERE building_id = $1 AND resource_type = $2::resource_type AND resource_name = $3
235            ORDER BY start_time ASC
236            "#,
237        )
238        .bind(building_id)
239        .bind(&resource_type_str)
240        .bind(resource_name)
241        .fetch_all(&self.pool)
242        .await
243        .map_err(|e| format!("Failed to find bookings by resource: {}", e))?;
244
245        rows.iter().map(Self::row_to_entity).collect()
246    }
247
248    async fn find_by_user(&self, user_id: Uuid) -> Result<Vec<ResourceBooking>, String> {
249        let rows = sqlx::query(
250            r#"
251            SELECT id, building_id, resource_type::text AS res_type, resource_name, booked_by,
252                   start_time, end_time, status::text AS status, notes, recurring_pattern::text AS recurring_pattern,
253                   recurrence_end_date, created_at, updated_at, booked_by_user_id, on_behalf_of_acp, motif
254            FROM resource_bookings
255            WHERE booked_by = $1
256            ORDER BY start_time DESC
257            "#,
258        )
259        .bind(user_id)
260        .fetch_all(&self.pool)
261        .await
262        .map_err(|e| format!("Failed to find bookings by user: {}", e))?;
263
264        rows.iter().map(Self::row_to_entity).collect()
265    }
266
267    async fn find_by_user_and_status(
268        &self,
269        user_id: Uuid,
270        status: BookingStatus,
271    ) -> Result<Vec<ResourceBooking>, String> {
272        let status_str = serde_json::to_string(&status)
273            .map_err(|e| format!("Failed to serialize status: {}", e))?
274            .trim_matches('"')
275            .to_string();
276
277        let rows = sqlx::query(
278            r#"
279            SELECT id, building_id, resource_type::text AS res_type, resource_name, booked_by,
280                   start_time, end_time, status::text AS status, notes, recurring_pattern::text AS recurring_pattern,
281                   recurrence_end_date, created_at, updated_at, booked_by_user_id, on_behalf_of_acp, motif
282            FROM resource_bookings
283            WHERE booked_by = $1 AND status = $2::booking_status
284            ORDER BY start_time DESC
285            "#,
286        )
287        .bind(user_id)
288        .bind(&status_str)
289        .fetch_all(&self.pool)
290        .await
291        .map_err(|e| format!("Failed to find bookings by user and status: {}", e))?;
292
293        rows.iter().map(Self::row_to_entity).collect()
294    }
295
296    async fn find_by_building_and_status(
297        &self,
298        building_id: Uuid,
299        status: BookingStatus,
300    ) -> Result<Vec<ResourceBooking>, String> {
301        let status_str = serde_json::to_string(&status)
302            .map_err(|e| format!("Failed to serialize status: {}", e))?
303            .trim_matches('"')
304            .to_string();
305
306        let rows = sqlx::query(
307            r#"
308            SELECT id, building_id, resource_type::text AS res_type, resource_name, booked_by,
309                   start_time, end_time, status::text AS status, notes, recurring_pattern::text AS recurring_pattern,
310                   recurrence_end_date, created_at, updated_at, booked_by_user_id, on_behalf_of_acp, motif
311            FROM resource_bookings
312            WHERE building_id = $1 AND status = $2::booking_status
313            ORDER BY start_time ASC
314            "#,
315        )
316        .bind(building_id)
317        .bind(&status_str)
318        .fetch_all(&self.pool)
319        .await
320        .map_err(|e| format!("Failed to find bookings by building and status: {}", e))?;
321
322        rows.iter().map(Self::row_to_entity).collect()
323    }
324
325    async fn find_upcoming(
326        &self,
327        building_id: Uuid,
328        limit: Option<i64>,
329    ) -> Result<Vec<ResourceBooking>, String> {
330        let limit_val = limit.unwrap_or(50);
331
332        let rows = sqlx::query(
333            r#"
334            SELECT id, building_id, resource_type::text AS res_type, resource_name, booked_by,
335                   start_time, end_time, status::text AS status, notes, recurring_pattern::text AS recurring_pattern,
336                   recurrence_end_date, created_at, updated_at, booked_by_user_id, on_behalf_of_acp, motif
337            FROM resource_bookings
338            WHERE building_id = $1
339              AND start_time > NOW()
340              AND status IN ('Confirmed', 'Pending')
341            ORDER BY start_time ASC
342            LIMIT $2
343            "#,
344        )
345        .bind(building_id)
346        .bind(limit_val)
347        .fetch_all(&self.pool)
348        .await
349        .map_err(|e| format!("Failed to find upcoming bookings: {}", e))?;
350
351        rows.iter().map(Self::row_to_entity).collect()
352    }
353
354    async fn find_active(&self, building_id: Uuid) -> Result<Vec<ResourceBooking>, String> {
355        let rows = sqlx::query(
356            r#"
357            SELECT id, building_id, resource_type::text AS res_type, resource_name, booked_by,
358                   start_time, end_time, status::text AS status, notes, recurring_pattern::text AS recurring_pattern,
359                   recurrence_end_date, created_at, updated_at, booked_by_user_id, on_behalf_of_acp, motif
360            FROM resource_bookings
361            WHERE building_id = $1
362              AND status = 'Confirmed'
363              AND start_time <= NOW()
364              AND end_time > NOW()
365            ORDER BY start_time ASC
366            "#,
367        )
368        .bind(building_id)
369        .fetch_all(&self.pool)
370        .await
371        .map_err(|e| format!("Failed to find active bookings: {}", e))?;
372
373        rows.iter().map(Self::row_to_entity).collect()
374    }
375
376    async fn find_past(
377        &self,
378        building_id: Uuid,
379        limit: Option<i64>,
380    ) -> Result<Vec<ResourceBooking>, String> {
381        let limit_val = limit.unwrap_or(50);
382
383        let rows = sqlx::query(
384            r#"
385            SELECT id, building_id, resource_type::text AS res_type, resource_name, booked_by,
386                   start_time, end_time, status::text AS status, notes, recurring_pattern::text AS recurring_pattern,
387                   recurrence_end_date, created_at, updated_at, booked_by_user_id, on_behalf_of_acp, motif
388            FROM resource_bookings
389            WHERE building_id = $1
390              AND end_time < NOW()
391            ORDER BY start_time DESC
392            LIMIT $2
393            "#,
394        )
395        .bind(building_id)
396        .bind(limit_val)
397        .fetch_all(&self.pool)
398        .await
399        .map_err(|e| format!("Failed to find past bookings: {}", e))?;
400
401        rows.iter().map(Self::row_to_entity).collect()
402    }
403
404    async fn find_conflicts(
405        &self,
406        building_id: Uuid,
407        resource_type: ResourceType,
408        resource_name: &str,
409        start_time: DateTime<Utc>,
410        end_time: DateTime<Utc>,
411        exclude_booking_id: Option<Uuid>,
412    ) -> Result<Vec<ResourceBooking>, String> {
413        let resource_type_str = serde_json::to_string(&resource_type)
414            .map_err(|e| format!("Failed to serialize resource_type: {}", e))?
415            .trim_matches('"')
416            .to_string();
417
418        // Conflict detection: start1 < end2 AND start2 < end1
419        // Exclude cancelled, completed, and no-show bookings
420        let rows = if let Some(exclude_id) = exclude_booking_id {
421            sqlx::query(
422                r#"
423                SELECT id, building_id, resource_type::text AS res_type, resource_name, booked_by,
424                       start_time, end_time, status::text AS status, notes, recurring_pattern::text AS recurring_pattern,
425                       recurrence_end_date, created_at, updated_at, booked_by_user_id, on_behalf_of_acp, motif
426                FROM resource_bookings
427                WHERE building_id = $1
428                  AND resource_type = $2::resource_type
429                  AND resource_name = $3
430                  AND status IN ('Pending', 'Confirmed')
431                  AND start_time < $5
432                  AND end_time > $4
433                  AND id != $6
434                ORDER BY start_time ASC
435                "#,
436            )
437            .bind(building_id)
438            .bind(&resource_type_str)
439            .bind(resource_name)
440            .bind(start_time)
441            .bind(end_time)
442            .bind(exclude_id)
443            .fetch_all(&self.pool)
444            .await
445        } else {
446            sqlx::query(
447                r#"
448                SELECT id, building_id, resource_type::text AS res_type, resource_name, booked_by,
449                       start_time, end_time, status::text AS status, notes, recurring_pattern::text AS recurring_pattern,
450                       recurrence_end_date, created_at, updated_at, booked_by_user_id, on_behalf_of_acp, motif
451                FROM resource_bookings
452                WHERE building_id = $1
453                  AND resource_type = $2::resource_type
454                  AND resource_name = $3
455                  AND status IN ('Pending', 'Confirmed')
456                  AND start_time < $5
457                  AND end_time > $4
458                ORDER BY start_time ASC
459                "#,
460            )
461            .bind(building_id)
462            .bind(&resource_type_str)
463            .bind(resource_name)
464            .bind(start_time)
465            .bind(end_time)
466            .fetch_all(&self.pool)
467            .await
468        }
469        .map_err(|e| format!("Failed to find conflicting bookings: {}", e))?;
470
471        rows.iter().map(Self::row_to_entity).collect()
472    }
473
474    async fn update(&self, booking: &ResourceBooking) -> Result<ResourceBooking, String> {
475        let resource_type_str = serde_json::to_string(&booking.resource_type)
476            .map_err(|e| format!("Failed to serialize resource_type: {}", e))?
477            .trim_matches('"')
478            .to_string();
479
480        let status_str = serde_json::to_string(&booking.status)
481            .map_err(|e| format!("Failed to serialize status: {}", e))?
482            .trim_matches('"')
483            .to_string();
484
485        let recurring_pattern_str = serde_json::to_string(&booking.recurring_pattern)
486            .map_err(|e| format!("Failed to serialize recurring_pattern: {}", e))?
487            .trim_matches('"')
488            .to_string();
489
490        let result = sqlx::query(
491            r#"
492            UPDATE resource_bookings
493            SET resource_type = $2::resource_type,
494                resource_name = $3,
495                start_time = $4,
496                end_time = $5,
497                status = $6::booking_status,
498                notes = $7,
499                recurring_pattern = $8::recurring_pattern,
500                recurrence_end_date = $9,
501                updated_at = $10
502            WHERE id = $1
503            "#,
504        )
505        .bind(booking.id)
506        .bind(&resource_type_str)
507        .bind(&booking.resource_name)
508        .bind(booking.start_time)
509        .bind(booking.end_time)
510        .bind(&status_str)
511        .bind(&booking.notes)
512        .bind(&recurring_pattern_str)
513        .bind(booking.recurrence_end_date)
514        .bind(booking.updated_at)
515        .execute(&self.pool)
516        .await
517        .map_err(|e| format!("Failed to update resource booking: {}", e))?;
518
519        if result.rows_affected() == 0 {
520            return Err("Resource booking not found".to_string());
521        }
522
523        Ok(booking.clone())
524    }
525
526    async fn delete(&self, id: Uuid) -> Result<(), String> {
527        let result = sqlx::query(
528            r#"
529            DELETE FROM resource_bookings
530            WHERE id = $1
531            "#,
532        )
533        .bind(id)
534        .execute(&self.pool)
535        .await
536        .map_err(|e| format!("Failed to delete resource booking: {}", e))?;
537
538        if result.rows_affected() == 0 {
539            return Err("Resource booking not found".to_string());
540        }
541
542        Ok(())
543    }
544
545    async fn count_by_building(&self, building_id: Uuid) -> Result<i64, String> {
546        let row = sqlx::query(
547            r#"
548            SELECT COUNT(*) as count
549            FROM resource_bookings
550            WHERE building_id = $1
551            "#,
552        )
553        .bind(building_id)
554        .fetch_one(&self.pool)
555        .await
556        .map_err(|e| format!("Failed to count bookings by building: {}", e))?;
557
558        let count: i64 = row
559            .try_get("count")
560            .map_err(|e| format!("Failed to get count: {}", e))?;
561        Ok(count)
562    }
563
564    async fn count_by_building_and_status(
565        &self,
566        building_id: Uuid,
567        status: BookingStatus,
568    ) -> Result<i64, String> {
569        let status_str = serde_json::to_string(&status)
570            .map_err(|e| format!("Failed to serialize status: {}", e))?
571            .trim_matches('"')
572            .to_string();
573
574        let row = sqlx::query(
575            r#"
576            SELECT COUNT(*) as count
577            FROM resource_bookings
578            WHERE building_id = $1 AND status = $2::booking_status
579            "#,
580        )
581        .bind(building_id)
582        .bind(&status_str)
583        .fetch_one(&self.pool)
584        .await
585        .map_err(|e| format!("Failed to count bookings by building and status: {}", e))?;
586
587        let count: i64 = row
588            .try_get("count")
589            .map_err(|e| format!("Failed to get count: {}", e))?;
590        Ok(count)
591    }
592
593    async fn count_by_resource(
594        &self,
595        building_id: Uuid,
596        resource_type: ResourceType,
597        resource_name: &str,
598    ) -> Result<i64, String> {
599        let resource_type_str = serde_json::to_string(&resource_type)
600            .map_err(|e| format!("Failed to serialize resource_type: {}", e))?
601            .trim_matches('"')
602            .to_string();
603
604        let row = sqlx::query(
605            r#"
606            SELECT COUNT(*) as count
607            FROM resource_bookings
608            WHERE building_id = $1 AND resource_type = $2::resource_type AND resource_name = $3
609            "#,
610        )
611        .bind(building_id)
612        .bind(&resource_type_str)
613        .bind(resource_name)
614        .fetch_one(&self.pool)
615        .await
616        .map_err(|e| format!("Failed to count bookings by resource: {}", e))?;
617
618        let count: i64 = row
619            .try_get("count")
620            .map_err(|e| format!("Failed to get count: {}", e))?;
621        Ok(count)
622    }
623
624    async fn get_statistics(&self, building_id: Uuid) -> Result<BookingStatisticsDto, String> {
625        // Get counts by status
626        let total = self.count_by_building(building_id).await?;
627        let confirmed = self
628            .count_by_building_and_status(building_id, BookingStatus::Confirmed)
629            .await?;
630        let pending = self
631            .count_by_building_and_status(building_id, BookingStatus::Pending)
632            .await?;
633        let completed = self
634            .count_by_building_and_status(building_id, BookingStatus::Completed)
635            .await?;
636        let cancelled = self
637            .count_by_building_and_status(building_id, BookingStatus::Cancelled)
638            .await?;
639        let no_show = self
640            .count_by_building_and_status(building_id, BookingStatus::NoShow)
641            .await?;
642
643        // Get active bookings count (currently in progress)
644        let active_bookings = self.find_active(building_id).await?.len() as i64;
645
646        // Get upcoming bookings count (future)
647        let upcoming_row = sqlx::query(
648            r#"
649            SELECT COUNT(*) as count
650            FROM resource_bookings
651            WHERE building_id = $1
652              AND start_time > NOW()
653              AND status IN ('Confirmed', 'Pending')
654            "#,
655        )
656        .bind(building_id)
657        .fetch_one(&self.pool)
658        .await
659        .map_err(|e| format!("Failed to count upcoming bookings: {}", e))?;
660
661        let upcoming_bookings: i64 = upcoming_row
662            .try_get("count")
663            .map_err(|e| format!("Failed to get upcoming count: {}", e))?;
664
665        // Calculate total hours booked
666        let hours_row = sqlx::query(
667            r#"
668            SELECT COALESCE(SUM(EXTRACT(EPOCH FROM (end_time - start_time)) / 3600), 0) as total_hours
669            FROM resource_bookings
670            WHERE building_id = $1
671              AND status IN ('Confirmed', 'Completed')
672            "#,
673        )
674        .bind(building_id)
675        .fetch_one(&self.pool)
676        .await
677        .map_err(|e| format!("Failed to calculate total hours booked: {}", e))?;
678
679        let total_hours_booked: f64 = hours_row
680            .try_get("total_hours")
681            .map_err(|e| format!("Failed to get total_hours: {}", e))?;
682
683        // Find most popular resource
684        let popular_row = sqlx::query(
685            r#"
686            SELECT resource_name, COUNT(*) as booking_count
687            FROM resource_bookings
688            WHERE building_id = $1
689            GROUP BY resource_name
690            ORDER BY booking_count DESC
691            LIMIT 1
692            "#,
693        )
694        .bind(building_id)
695        .fetch_optional(&self.pool)
696        .await
697        .map_err(|e| format!("Failed to find most popular resource: {}", e))?;
698
699        let most_popular_resource = popular_row.map(|row| {
700            row.try_get::<String, _>("resource_name")
701                .unwrap_or_default()
702        });
703
704        Ok(BookingStatisticsDto {
705            building_id,
706            total_bookings: total,
707            confirmed_bookings: confirmed,
708            pending_bookings: pending,
709            completed_bookings: completed,
710            cancelled_bookings: cancelled,
711            no_show_bookings: no_show,
712            active_bookings,
713            upcoming_bookings,
714            total_hours_booked,
715            most_popular_resource,
716        })
717    }
718}