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