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        })
151    }
152
153    async fn find_by_id(&self, id: Uuid) -> Result<Option<Ticket>, String> {
154        let row = sqlx::query!(
155            r#"
156            SELECT id, organization_id, building_id, unit_id, created_by, assigned_to,
157                   title, description, category, priority, status, resolution_notes,
158                   created_at, updated_at, resolved_at, closed_at
159            FROM tickets
160            WHERE id = $1
161            "#,
162            id
163        )
164        .fetch_optional(&self.pool)
165        .await
166        .map_err(|e| format!("Database error finding ticket: {}", e))?;
167
168        match row {
169            Some(r) => Ok(Some(Ticket {
170                id: r.id,
171                organization_id: r.organization_id,
172                building_id: r.building_id,
173                unit_id: r.unit_id,
174                created_by: r.created_by,
175                assigned_to: r.assigned_to,
176                title: r.title,
177                description: r.description,
178                category: Self::category_from_db(&r.category)?,
179                priority: Self::priority_from_db(&r.priority)?,
180                status: Self::status_from_db(&r.status)?,
181                resolution_notes: r.resolution_notes,
182                created_at: r.created_at,
183                updated_at: r.updated_at,
184                resolved_at: r.resolved_at,
185                closed_at: r.closed_at,
186            })),
187            None => Ok(None),
188        }
189    }
190
191    async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Ticket>, String> {
192        let rows = sqlx::query!(
193            r#"
194            SELECT id, organization_id, building_id, unit_id, created_by, assigned_to,
195                   title, description, category, priority, status, resolution_notes,
196                   created_at, updated_at, resolved_at, closed_at
197            FROM tickets
198            WHERE building_id = $1
199            ORDER BY created_at DESC
200            "#,
201            building_id
202        )
203        .fetch_all(&self.pool)
204        .await
205        .map_err(|e| format!("Database error finding tickets by building: {}", e))?;
206
207        rows.into_iter()
208            .map(|r| {
209                Ok(Ticket {
210                    id: r.id,
211                    organization_id: r.organization_id,
212                    building_id: r.building_id,
213                    unit_id: r.unit_id,
214                    created_by: r.created_by,
215                    assigned_to: r.assigned_to,
216                    title: r.title,
217                    description: r.description,
218                    category: Self::category_from_db(&r.category)?,
219                    priority: Self::priority_from_db(&r.priority)?,
220                    status: Self::status_from_db(&r.status)?,
221                    resolution_notes: r.resolution_notes,
222                    created_at: r.created_at,
223                    updated_at: r.updated_at,
224                    resolved_at: r.resolved_at,
225                    closed_at: r.closed_at,
226                })
227            })
228            .collect()
229    }
230
231    async fn find_by_organization(&self, organization_id: Uuid) -> Result<Vec<Ticket>, String> {
232        let rows = sqlx::query!(
233            r#"
234            SELECT id, organization_id, building_id, unit_id, created_by, assigned_to,
235                   title, description, category, priority, status, resolution_notes,
236                   created_at, updated_at, resolved_at, closed_at
237            FROM tickets
238            WHERE organization_id = $1
239            ORDER BY created_at DESC
240            "#,
241            organization_id
242        )
243        .fetch_all(&self.pool)
244        .await
245        .map_err(|e| format!("Database error finding tickets by organization: {}", e))?;
246
247        rows.into_iter()
248            .map(|r| {
249                Ok(Ticket {
250                    id: r.id,
251                    organization_id: r.organization_id,
252                    building_id: r.building_id,
253                    unit_id: r.unit_id,
254                    created_by: r.created_by,
255                    assigned_to: r.assigned_to,
256                    title: r.title,
257                    description: r.description,
258                    category: Self::category_from_db(&r.category)?,
259                    priority: Self::priority_from_db(&r.priority)?,
260                    status: Self::status_from_db(&r.status)?,
261                    resolution_notes: r.resolution_notes,
262                    created_at: r.created_at,
263                    updated_at: r.updated_at,
264                    resolved_at: r.resolved_at,
265                    closed_at: r.closed_at,
266                })
267            })
268            .collect()
269    }
270
271    async fn find_by_created_by(&self, created_by: Uuid) -> Result<Vec<Ticket>, String> {
272        let rows = sqlx::query!(
273            r#"
274            SELECT id, organization_id, building_id, unit_id, created_by, assigned_to,
275                   title, description, category, priority, status, resolution_notes,
276                   created_at, updated_at, resolved_at, closed_at
277            FROM tickets
278            WHERE created_by = $1
279            ORDER BY created_at DESC
280            "#,
281            created_by
282        )
283        .fetch_all(&self.pool)
284        .await
285        .map_err(|e| format!("Database error finding tickets by creator: {}", e))?;
286
287        rows.into_iter()
288            .map(|r| {
289                Ok(Ticket {
290                    id: r.id,
291                    organization_id: r.organization_id,
292                    building_id: r.building_id,
293                    unit_id: r.unit_id,
294                    created_by: r.created_by,
295                    assigned_to: r.assigned_to,
296                    title: r.title,
297                    description: r.description,
298                    category: Self::category_from_db(&r.category)?,
299                    priority: Self::priority_from_db(&r.priority)?,
300                    status: Self::status_from_db(&r.status)?,
301                    resolution_notes: r.resolution_notes,
302                    created_at: r.created_at,
303                    updated_at: r.updated_at,
304                    resolved_at: r.resolved_at,
305                    closed_at: r.closed_at,
306                })
307            })
308            .collect()
309    }
310
311    async fn find_by_assigned_to(&self, assigned_to: Uuid) -> Result<Vec<Ticket>, String> {
312        let rows = sqlx::query!(
313            r#"
314            SELECT id, organization_id, building_id, unit_id, created_by, assigned_to,
315                   title, description, category, priority, status, resolution_notes,
316                   created_at, updated_at, resolved_at, closed_at
317            FROM tickets
318            WHERE assigned_to = $1
319            ORDER BY created_at DESC
320            "#,
321            assigned_to
322        )
323        .fetch_all(&self.pool)
324        .await
325        .map_err(|e| format!("Database error finding tickets by assignee: {}", e))?;
326
327        rows.into_iter()
328            .map(|r| {
329                Ok(Ticket {
330                    id: r.id,
331                    organization_id: r.organization_id,
332                    building_id: r.building_id,
333                    unit_id: r.unit_id,
334                    created_by: r.created_by,
335                    assigned_to: r.assigned_to,
336                    title: r.title,
337                    description: r.description,
338                    category: Self::category_from_db(&r.category)?,
339                    priority: Self::priority_from_db(&r.priority)?,
340                    status: Self::status_from_db(&r.status)?,
341                    resolution_notes: r.resolution_notes,
342                    created_at: r.created_at,
343                    updated_at: r.updated_at,
344                    resolved_at: r.resolved_at,
345                    closed_at: r.closed_at,
346                })
347            })
348            .collect()
349    }
350
351    async fn find_by_status(
352        &self,
353        building_id: Uuid,
354        status: TicketStatus,
355    ) -> Result<Vec<Ticket>, String> {
356        let status_str = Self::status_to_db(&status);
357
358        let rows = sqlx::query!(
359            r#"
360            SELECT id, organization_id, building_id, unit_id, created_by, assigned_to,
361                   title, description, category, priority, status, resolution_notes,
362                   created_at, updated_at, resolved_at, closed_at
363            FROM tickets
364            WHERE building_id = $1 AND status = $2
365            ORDER BY created_at DESC
366            "#,
367            building_id,
368            status_str
369        )
370        .fetch_all(&self.pool)
371        .await
372        .map_err(|e| format!("Database error finding tickets by status: {}", e))?;
373
374        rows.into_iter()
375            .map(|r| {
376                Ok(Ticket {
377                    id: r.id,
378                    organization_id: r.organization_id,
379                    building_id: r.building_id,
380                    unit_id: r.unit_id,
381                    created_by: r.created_by,
382                    assigned_to: r.assigned_to,
383                    title: r.title,
384                    description: r.description,
385                    category: Self::category_from_db(&r.category)?,
386                    priority: Self::priority_from_db(&r.priority)?,
387                    status: Self::status_from_db(&r.status)?,
388                    resolution_notes: r.resolution_notes,
389                    created_at: r.created_at,
390                    updated_at: r.updated_at,
391                    resolved_at: r.resolved_at,
392                    closed_at: r.closed_at,
393                })
394            })
395            .collect()
396    }
397
398    async fn update(&self, ticket: &Ticket) -> Result<Ticket, String> {
399        let category_str = Self::category_to_db(&ticket.category);
400        let priority_str = Self::priority_to_db(&ticket.priority);
401        let status_str = Self::status_to_db(&ticket.status);
402
403        let row = sqlx::query!(
404            r#"
405            UPDATE tickets
406            SET organization_id = $2,
407                building_id = $3,
408                unit_id = $4,
409                created_by = $5,
410                assigned_to = $6,
411                title = $7,
412                description = $8,
413                category = $9,
414                priority = $10,
415                status = $11,
416                resolution_notes = $12,
417                updated_at = $13,
418                resolved_at = $14,
419                closed_at = $15
420            WHERE id = $1
421            RETURNING id, organization_id, building_id, unit_id, created_by, assigned_to,
422                      title, description, category, priority, status, resolution_notes,
423                      created_at, updated_at, resolved_at, closed_at
424            "#,
425            ticket.id,
426            ticket.organization_id,
427            ticket.building_id,
428            ticket.unit_id,
429            ticket.created_by,
430            ticket.assigned_to,
431            ticket.title,
432            ticket.description,
433            category_str,
434            priority_str,
435            status_str,
436            ticket.resolution_notes,
437            ticket.updated_at,
438            ticket.resolved_at,
439            ticket.closed_at
440        )
441        .fetch_one(&self.pool)
442        .await
443        .map_err(|e| format!("Database error updating ticket: {}", e))?;
444
445        Ok(Ticket {
446            id: row.id,
447            organization_id: row.organization_id,
448            building_id: row.building_id,
449            unit_id: row.unit_id,
450            created_by: row.created_by,
451            assigned_to: row.assigned_to,
452            title: row.title,
453            description: row.description,
454            category: Self::category_from_db(&row.category)?,
455            priority: Self::priority_from_db(&row.priority)?,
456            status: Self::status_from_db(&row.status)?,
457            resolution_notes: row.resolution_notes,
458            created_at: row.created_at,
459            updated_at: row.updated_at,
460            resolved_at: row.resolved_at,
461            closed_at: row.closed_at,
462        })
463    }
464
465    async fn delete(&self, id: Uuid) -> Result<bool, String> {
466        let result = sqlx::query!(
467            r#"
468            DELETE FROM tickets
469            WHERE id = $1
470            "#,
471            id
472        )
473        .execute(&self.pool)
474        .await
475        .map_err(|e| format!("Database error deleting ticket: {}", e))?;
476
477        Ok(result.rows_affected() > 0)
478    }
479
480    async fn count_by_building(&self, building_id: Uuid) -> Result<i64, String> {
481        let row = sqlx::query!(
482            r#"
483            SELECT COUNT(*) as count
484            FROM tickets
485            WHERE building_id = $1
486            "#,
487            building_id
488        )
489        .fetch_one(&self.pool)
490        .await
491        .map_err(|e| format!("Database error counting tickets: {}", e))?;
492
493        Ok(row.count.unwrap_or(0))
494    }
495
496    async fn count_by_status(
497        &self,
498        building_id: Uuid,
499        status: TicketStatus,
500    ) -> Result<i64, String> {
501        let status_str = Self::status_to_db(&status);
502
503        let row = sqlx::query!(
504            r#"
505            SELECT COUNT(*) as count
506            FROM tickets
507            WHERE building_id = $1 AND status = $2
508            "#,
509            building_id,
510            status_str
511        )
512        .fetch_one(&self.pool)
513        .await
514        .map_err(|e| format!("Database error counting tickets by status: {}", e))?;
515
516        Ok(row.count.unwrap_or(0))
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn test_category_conversion() {
526        assert_eq!(
527            PostgresTicketRepository::category_to_db(&TicketCategory::Plumbing),
528            "Plumbing"
529        );
530        assert_eq!(
531            PostgresTicketRepository::category_from_db("Electrical").unwrap(),
532            TicketCategory::Electrical
533        );
534    }
535
536    #[test]
537    fn test_priority_conversion() {
538        assert_eq!(
539            PostgresTicketRepository::priority_to_db(&TicketPriority::Critical),
540            "Critical"
541        );
542        assert_eq!(
543            PostgresTicketRepository::priority_from_db("Low").unwrap(),
544            TicketPriority::Low
545        );
546    }
547
548    #[test]
549    fn test_status_conversion() {
550        assert_eq!(
551            PostgresTicketRepository::status_to_db(&TicketStatus::InProgress),
552            "InProgress"
553        );
554        assert_eq!(
555            PostgresTicketRepository::status_from_db("Resolved").unwrap(),
556            TicketStatus::Resolved
557        );
558    }
559
560    #[test]
561    fn test_invalid_category() {
562        assert!(PostgresTicketRepository::category_from_db("Invalid").is_err());
563    }
564
565    #[test]
566    fn test_invalid_priority() {
567        assert!(PostgresTicketRepository::priority_from_db("Invalid").is_err());
568    }
569
570    #[test]
571    fn test_invalid_status() {
572        assert!(PostgresTicketRepository::status_from_db("Invalid").is_err());
573    }
574}