koprogo_api/infrastructure/database/repositories/
ticket_repository_impl.rs

1use crate::application::ports::TicketRepository;
2use crate::domain::entities::{Ticket, TicketCategory, TicketPriority, TicketStatus};
3use async_trait::async_trait;
4use sqlx::PgPool;
5use uuid::Uuid;
6
7/// PostgreSQL implementation of TicketRepository
8pub struct PostgresTicketRepository {
9    pool: PgPool,
10}
11
12impl PostgresTicketRepository {
13    pub fn new(pool: PgPool) -> Self {
14        Self { pool }
15    }
16
17    /// Convert TicketCategory enum to database string
18    fn category_to_db(category: &TicketCategory) -> &'static str {
19        match category {
20            TicketCategory::Plumbing => "Plumbing",
21            TicketCategory::Electrical => "Electrical",
22            TicketCategory::Heating => "Heating",
23            TicketCategory::CommonAreas => "CommonAreas",
24            TicketCategory::Elevator => "Elevator",
25            TicketCategory::Security => "Security",
26            TicketCategory::Cleaning => "Cleaning",
27            TicketCategory::Landscaping => "Landscaping",
28            TicketCategory::Other => "Other",
29        }
30    }
31
32    /// Convert database string to TicketCategory enum
33    fn category_from_db(s: &str) -> Result<TicketCategory, String> {
34        match s {
35            "Plumbing" => Ok(TicketCategory::Plumbing),
36            "Electrical" => Ok(TicketCategory::Electrical),
37            "Heating" => Ok(TicketCategory::Heating),
38            "CommonAreas" => Ok(TicketCategory::CommonAreas),
39            "Elevator" => Ok(TicketCategory::Elevator),
40            "Security" => Ok(TicketCategory::Security),
41            "Cleaning" => Ok(TicketCategory::Cleaning),
42            "Landscaping" => Ok(TicketCategory::Landscaping),
43            "Other" => Ok(TicketCategory::Other),
44            _ => Err(format!("Invalid ticket category: {}", s)),
45        }
46    }
47
48    /// Convert TicketPriority enum to database string
49    fn priority_to_db(priority: &TicketPriority) -> &'static str {
50        match priority {
51            TicketPriority::Low => "Low",
52            TicketPriority::Medium => "Medium",
53            TicketPriority::High => "High",
54            TicketPriority::Critical => "Critical",
55        }
56    }
57
58    /// Convert database string to TicketPriority enum
59    fn priority_from_db(s: &str) -> Result<TicketPriority, String> {
60        match s {
61            "Low" => Ok(TicketPriority::Low),
62            "Medium" => Ok(TicketPriority::Medium),
63            "High" => Ok(TicketPriority::High),
64            "Critical" => Ok(TicketPriority::Critical),
65            _ => Err(format!("Invalid ticket priority: {}", s)),
66        }
67    }
68
69    /// Convert TicketStatus enum to database string
70    fn status_to_db(status: &TicketStatus) -> &'static str {
71        match status {
72            TicketStatus::Open => "Open",
73            TicketStatus::InProgress => "InProgress",
74            TicketStatus::Resolved => "Resolved",
75            TicketStatus::Closed => "Closed",
76            TicketStatus::Cancelled => "Cancelled",
77        }
78    }
79
80    /// Convert database string to TicketStatus enum
81    fn status_from_db(s: &str) -> Result<TicketStatus, String> {
82        match s {
83            "Open" => Ok(TicketStatus::Open),
84            "InProgress" => Ok(TicketStatus::InProgress),
85            "Resolved" => Ok(TicketStatus::Resolved),
86            "Closed" => Ok(TicketStatus::Closed),
87            "Cancelled" => Ok(TicketStatus::Cancelled),
88            _ => Err(format!("Invalid ticket status: {}", s)),
89        }
90    }
91}
92
93#[async_trait]
94impl TicketRepository for PostgresTicketRepository {
95    async fn create(&self, ticket: &Ticket) -> Result<Ticket, String> {
96        let category_str = Self::category_to_db(&ticket.category);
97        let priority_str = Self::priority_to_db(&ticket.priority);
98        let status_str = Self::status_to_db(&ticket.status);
99
100        let row = sqlx::query!(
101            r#"
102            INSERT INTO tickets (
103                id, organization_id, building_id, unit_id, created_by, assigned_to,
104                title, description, category, priority, status, resolution_notes,
105                created_at, updated_at, resolved_at, closed_at
106            )
107            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
108            RETURNING id, organization_id, building_id, unit_id, created_by, assigned_to,
109                      title, description, category, priority, status, resolution_notes,
110                      created_at, updated_at, resolved_at, closed_at
111            "#,
112            ticket.id,
113            ticket.organization_id,
114            ticket.building_id,
115            ticket.unit_id,
116            ticket.created_by,
117            ticket.assigned_to,
118            ticket.title,
119            ticket.description,
120            category_str,
121            priority_str,
122            status_str,
123            ticket.resolution_notes,
124            ticket.created_at,
125            ticket.updated_at,
126            ticket.resolved_at,
127            ticket.closed_at
128        )
129        .fetch_one(&self.pool)
130        .await
131        .map_err(|e| format!("Database error creating ticket: {}", e))?;
132
133        Ok(Ticket {
134            id: row.id,
135            organization_id: row.organization_id,
136            building_id: row.building_id,
137            unit_id: row.unit_id,
138            created_by: row.created_by,
139            assigned_to: row.assigned_to,
140            title: row.title,
141            description: row.description,
142            category: Self::category_from_db(&row.category)?,
143            priority: Self::priority_from_db(&row.priority)?,
144            status: Self::status_from_db(&row.status)?,
145            resolution_notes: row.resolution_notes,
146            created_at: row.created_at,
147            updated_at: row.updated_at,
148            resolved_at: row.resolved_at,
149            closed_at: row.closed_at,
150            work_order_sent_at: None,
151        })
152    }
153
154    async fn find_by_id(&self, id: Uuid) -> Result<Option<Ticket>, String> {
155        let row = sqlx::query!(
156            r#"
157            SELECT id, organization_id, building_id, unit_id, created_by, assigned_to,
158                   title, description, category, priority, status, resolution_notes,
159                   created_at, updated_at, resolved_at, closed_at
160            FROM tickets
161            WHERE id = $1
162            "#,
163            id
164        )
165        .fetch_optional(&self.pool)
166        .await
167        .map_err(|e| format!("Database error finding ticket: {}", e))?;
168
169        match row {
170            Some(r) => Ok(Some(Ticket {
171                id: r.id,
172                organization_id: r.organization_id,
173                building_id: r.building_id,
174                unit_id: r.unit_id,
175                created_by: r.created_by,
176                assigned_to: r.assigned_to,
177                title: r.title,
178                description: r.description,
179                category: Self::category_from_db(&r.category)?,
180                priority: Self::priority_from_db(&r.priority)?,
181                status: Self::status_from_db(&r.status)?,
182                resolution_notes: r.resolution_notes,
183                created_at: r.created_at,
184                updated_at: r.updated_at,
185                resolved_at: r.resolved_at,
186                closed_at: r.closed_at,
187                work_order_sent_at: None,
188            })),
189            None => Ok(None),
190        }
191    }
192
193    async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Ticket>, String> {
194        let rows = sqlx::query!(
195            r#"
196            SELECT id, organization_id, building_id, unit_id, created_by, assigned_to,
197                   title, description, category, priority, status, resolution_notes,
198                   created_at, updated_at, resolved_at, closed_at
199            FROM tickets
200            WHERE building_id = $1
201            ORDER BY created_at DESC
202            "#,
203            building_id
204        )
205        .fetch_all(&self.pool)
206        .await
207        .map_err(|e| format!("Database error finding tickets by building: {}", e))?;
208
209        rows.into_iter()
210            .map(|r| {
211                Ok(Ticket {
212                    id: r.id,
213                    organization_id: r.organization_id,
214                    building_id: r.building_id,
215                    unit_id: r.unit_id,
216                    created_by: r.created_by,
217                    assigned_to: r.assigned_to,
218                    title: r.title,
219                    description: r.description,
220                    category: Self::category_from_db(&r.category)?,
221                    priority: Self::priority_from_db(&r.priority)?,
222                    status: Self::status_from_db(&r.status)?,
223                    resolution_notes: r.resolution_notes,
224                    created_at: r.created_at,
225                    updated_at: r.updated_at,
226                    resolved_at: r.resolved_at,
227                    closed_at: r.closed_at,
228                    work_order_sent_at: None,
229                })
230            })
231            .collect()
232    }
233
234    async fn find_by_organization(&self, organization_id: Uuid) -> Result<Vec<Ticket>, String> {
235        let rows = sqlx::query!(
236            r#"
237            SELECT id, organization_id, building_id, unit_id, created_by, assigned_to,
238                   title, description, category, priority, status, resolution_notes,
239                   created_at, updated_at, resolved_at, closed_at
240            FROM tickets
241            WHERE organization_id = $1
242            ORDER BY created_at DESC
243            "#,
244            organization_id
245        )
246        .fetch_all(&self.pool)
247        .await
248        .map_err(|e| format!("Database error finding tickets by organization: {}", e))?;
249
250        rows.into_iter()
251            .map(|r| {
252                Ok(Ticket {
253                    id: r.id,
254                    organization_id: r.organization_id,
255                    building_id: r.building_id,
256                    unit_id: r.unit_id,
257                    created_by: r.created_by,
258                    assigned_to: r.assigned_to,
259                    title: r.title,
260                    description: r.description,
261                    category: Self::category_from_db(&r.category)?,
262                    priority: Self::priority_from_db(&r.priority)?,
263                    status: Self::status_from_db(&r.status)?,
264                    resolution_notes: r.resolution_notes,
265                    created_at: r.created_at,
266                    updated_at: r.updated_at,
267                    resolved_at: r.resolved_at,
268                    closed_at: r.closed_at,
269                    work_order_sent_at: None,
270                })
271            })
272            .collect()
273    }
274
275    async fn find_by_created_by(&self, created_by: Uuid) -> Result<Vec<Ticket>, String> {
276        let rows = sqlx::query!(
277            r#"
278            SELECT id, organization_id, building_id, unit_id, created_by, assigned_to,
279                   title, description, category, priority, status, resolution_notes,
280                   created_at, updated_at, resolved_at, closed_at
281            FROM tickets
282            WHERE created_by = $1
283            ORDER BY created_at DESC
284            "#,
285            created_by
286        )
287        .fetch_all(&self.pool)
288        .await
289        .map_err(|e| format!("Database error finding tickets by creator: {}", e))?;
290
291        rows.into_iter()
292            .map(|r| {
293                Ok(Ticket {
294                    id: r.id,
295                    organization_id: r.organization_id,
296                    building_id: r.building_id,
297                    unit_id: r.unit_id,
298                    created_by: r.created_by,
299                    assigned_to: r.assigned_to,
300                    title: r.title,
301                    description: r.description,
302                    category: Self::category_from_db(&r.category)?,
303                    priority: Self::priority_from_db(&r.priority)?,
304                    status: Self::status_from_db(&r.status)?,
305                    resolution_notes: r.resolution_notes,
306                    created_at: r.created_at,
307                    updated_at: r.updated_at,
308                    resolved_at: r.resolved_at,
309                    closed_at: r.closed_at,
310                    work_order_sent_at: None,
311                })
312            })
313            .collect()
314    }
315
316    async fn find_by_assigned_to(&self, assigned_to: Uuid) -> Result<Vec<Ticket>, String> {
317        let rows = sqlx::query!(
318            r#"
319            SELECT id, organization_id, building_id, unit_id, created_by, assigned_to,
320                   title, description, category, priority, status, resolution_notes,
321                   created_at, updated_at, resolved_at, closed_at
322            FROM tickets
323            WHERE assigned_to = $1
324            ORDER BY created_at DESC
325            "#,
326            assigned_to
327        )
328        .fetch_all(&self.pool)
329        .await
330        .map_err(|e| format!("Database error finding tickets by assignee: {}", e))?;
331
332        rows.into_iter()
333            .map(|r| {
334                Ok(Ticket {
335                    id: r.id,
336                    organization_id: r.organization_id,
337                    building_id: r.building_id,
338                    unit_id: r.unit_id,
339                    created_by: r.created_by,
340                    assigned_to: r.assigned_to,
341                    title: r.title,
342                    description: r.description,
343                    category: Self::category_from_db(&r.category)?,
344                    priority: Self::priority_from_db(&r.priority)?,
345                    status: Self::status_from_db(&r.status)?,
346                    resolution_notes: r.resolution_notes,
347                    created_at: r.created_at,
348                    updated_at: r.updated_at,
349                    resolved_at: r.resolved_at,
350                    closed_at: r.closed_at,
351                    work_order_sent_at: None,
352                })
353            })
354            .collect()
355    }
356
357    async fn find_by_status(
358        &self,
359        building_id: Uuid,
360        status: TicketStatus,
361    ) -> Result<Vec<Ticket>, String> {
362        let status_str = Self::status_to_db(&status);
363
364        let rows = sqlx::query!(
365            r#"
366            SELECT id, organization_id, building_id, unit_id, created_by, assigned_to,
367                   title, description, category, priority, status, resolution_notes,
368                   created_at, updated_at, resolved_at, closed_at
369            FROM tickets
370            WHERE building_id = $1 AND status = $2
371            ORDER BY created_at DESC
372            "#,
373            building_id,
374            status_str
375        )
376        .fetch_all(&self.pool)
377        .await
378        .map_err(|e| format!("Database error finding tickets by status: {}", e))?;
379
380        rows.into_iter()
381            .map(|r| {
382                Ok(Ticket {
383                    id: r.id,
384                    organization_id: r.organization_id,
385                    building_id: r.building_id,
386                    unit_id: r.unit_id,
387                    created_by: r.created_by,
388                    assigned_to: r.assigned_to,
389                    title: r.title,
390                    description: r.description,
391                    category: Self::category_from_db(&r.category)?,
392                    priority: Self::priority_from_db(&r.priority)?,
393                    status: Self::status_from_db(&r.status)?,
394                    resolution_notes: r.resolution_notes,
395                    created_at: r.created_at,
396                    updated_at: r.updated_at,
397                    resolved_at: r.resolved_at,
398                    closed_at: r.closed_at,
399                    work_order_sent_at: None,
400                })
401            })
402            .collect()
403    }
404
405    async fn update(&self, ticket: &Ticket) -> Result<Ticket, String> {
406        let category_str = Self::category_to_db(&ticket.category);
407        let priority_str = Self::priority_to_db(&ticket.priority);
408        let status_str = Self::status_to_db(&ticket.status);
409
410        let row = sqlx::query!(
411            r#"
412            UPDATE tickets
413            SET organization_id = $2,
414                building_id = $3,
415                unit_id = $4,
416                created_by = $5,
417                assigned_to = $6,
418                title = $7,
419                description = $8,
420                category = $9,
421                priority = $10,
422                status = $11,
423                resolution_notes = $12,
424                updated_at = $13,
425                resolved_at = $14,
426                closed_at = $15
427            WHERE id = $1
428            RETURNING id, organization_id, building_id, unit_id, created_by, assigned_to,
429                      title, description, category, priority, status, resolution_notes,
430                      created_at, updated_at, resolved_at, closed_at
431            "#,
432            ticket.id,
433            ticket.organization_id,
434            ticket.building_id,
435            ticket.unit_id,
436            ticket.created_by,
437            ticket.assigned_to,
438            ticket.title,
439            ticket.description,
440            category_str,
441            priority_str,
442            status_str,
443            ticket.resolution_notes,
444            ticket.updated_at,
445            ticket.resolved_at,
446            ticket.closed_at
447        )
448        .fetch_one(&self.pool)
449        .await
450        .map_err(|e| format!("Database error updating ticket: {}", e))?;
451
452        Ok(Ticket {
453            id: row.id,
454            organization_id: row.organization_id,
455            building_id: row.building_id,
456            unit_id: row.unit_id,
457            created_by: row.created_by,
458            assigned_to: row.assigned_to,
459            title: row.title,
460            description: row.description,
461            category: Self::category_from_db(&row.category)?,
462            priority: Self::priority_from_db(&row.priority)?,
463            status: Self::status_from_db(&row.status)?,
464            resolution_notes: row.resolution_notes,
465            created_at: row.created_at,
466            updated_at: row.updated_at,
467            resolved_at: row.resolved_at,
468            closed_at: row.closed_at,
469            work_order_sent_at: None,
470        })
471    }
472
473    async fn delete(&self, id: Uuid) -> Result<bool, String> {
474        let result = sqlx::query!(
475            r#"
476            DELETE FROM tickets
477            WHERE id = $1
478            "#,
479            id
480        )
481        .execute(&self.pool)
482        .await
483        .map_err(|e| format!("Database error deleting ticket: {}", e))?;
484
485        Ok(result.rows_affected() > 0)
486    }
487
488    async fn count_by_building(&self, building_id: Uuid) -> Result<i64, String> {
489        let row = sqlx::query!(
490            r#"
491            SELECT COUNT(*) as count
492            FROM tickets
493            WHERE building_id = $1
494            "#,
495            building_id
496        )
497        .fetch_one(&self.pool)
498        .await
499        .map_err(|e| format!("Database error counting tickets: {}", e))?;
500
501        Ok(row.count.unwrap_or(0))
502    }
503
504    async fn count_by_status(
505        &self,
506        building_id: Uuid,
507        status: TicketStatus,
508    ) -> Result<i64, String> {
509        let status_str = Self::status_to_db(&status);
510
511        let row = sqlx::query!(
512            r#"
513            SELECT COUNT(*) as count
514            FROM tickets
515            WHERE building_id = $1 AND status = $2
516            "#,
517            building_id,
518            status_str
519        )
520        .fetch_one(&self.pool)
521        .await
522        .map_err(|e| format!("Database error counting tickets by status: {}", e))?;
523
524        Ok(row.count.unwrap_or(0))
525    }
526
527    async fn count_by_organization(&self, organization_id: Uuid) -> Result<i64, String> {
528        let count: (i64,) =
529            sqlx::query_as("SELECT COUNT(*) FROM tickets WHERE organization_id = $1")
530                .bind(organization_id)
531                .fetch_one(&self.pool)
532                .await
533                .map_err(|e| format!("Database error counting tickets by organization: {}", e))?;
534
535        Ok(count.0)
536    }
537
538    async fn count_by_organization_and_status(
539        &self,
540        organization_id: Uuid,
541        status: TicketStatus,
542    ) -> Result<i64, String> {
543        let status_str = Self::status_to_db(&status);
544
545        let count: (i64,) = sqlx::query_as(
546            "SELECT COUNT(*) FROM tickets WHERE organization_id = $1 AND status = $2",
547        )
548        .bind(organization_id)
549        .bind(status_str)
550        .fetch_one(&self.pool)
551        .await
552        .map_err(|e| format!("Database error counting tickets by org and status: {}", e))?;
553
554        Ok(count.0)
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    #[test]
563    fn test_category_conversion() {
564        assert_eq!(
565            PostgresTicketRepository::category_to_db(&TicketCategory::Plumbing),
566            "Plumbing"
567        );
568        assert_eq!(
569            PostgresTicketRepository::category_from_db("Electrical").unwrap(),
570            TicketCategory::Electrical
571        );
572    }
573
574    #[test]
575    fn test_priority_conversion() {
576        assert_eq!(
577            PostgresTicketRepository::priority_to_db(&TicketPriority::Critical),
578            "Critical"
579        );
580        assert_eq!(
581            PostgresTicketRepository::priority_from_db("Low").unwrap(),
582            TicketPriority::Low
583        );
584    }
585
586    #[test]
587    fn test_status_conversion() {
588        assert_eq!(
589            PostgresTicketRepository::status_to_db(&TicketStatus::InProgress),
590            "InProgress"
591        );
592        assert_eq!(
593            PostgresTicketRepository::status_from_db("Resolved").unwrap(),
594            TicketStatus::Resolved
595        );
596    }
597
598    #[test]
599    fn test_invalid_category() {
600        assert!(PostgresTicketRepository::category_from_db("Invalid").is_err());
601    }
602
603    #[test]
604    fn test_invalid_priority() {
605        assert!(PostgresTicketRepository::priority_from_db("Invalid").is_err());
606    }
607
608    #[test]
609    fn test_invalid_status() {
610        assert!(PostgresTicketRepository::status_from_db("Invalid").is_err());
611    }
612}