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
7pub struct PostgresTicketRepository {
9 pool: PgPool,
10}
11
12impl PostgresTicketRepository {
13 pub fn new(pool: PgPool) -> Self {
14 Self { pool }
15 }
16
17 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 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 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 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 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 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, work_order_sent_at
106 )
107 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
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, work_order_sent_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 ticket.work_order_sent_at
129 )
130 .fetch_one(&self.pool)
131 .await
132 .map_err(|e| format!("Database error creating ticket: {}", e))?;
133
134 Ok(Ticket {
135 id: row.id,
136 organization_id: row.organization_id,
137 building_id: row.building_id,
138 unit_id: row.unit_id,
139 created_by: row.created_by,
140 assigned_to: row.assigned_to,
141 title: row.title,
142 description: row.description,
143 category: Self::category_from_db(&row.category)?,
144 priority: Self::priority_from_db(&row.priority)?,
145 status: Self::status_from_db(&row.status)?,
146 resolution_notes: row.resolution_notes,
147 created_at: row.created_at,
148 updated_at: row.updated_at,
149 resolved_at: row.resolved_at,
150 closed_at: row.closed_at,
151 work_order_sent_at: row.work_order_sent_at,
152 kind: crate::domain::entities::TicketKind::Request,
159 severity: None,
160 incident_date: None,
161 evidence_attachments: Vec::new(),
162 witnesses: Vec::new(),
163 sla_due_at: None,
166 sla_escalated_at: None,
167 })
168 }
169
170 async fn find_by_id(&self, id: Uuid) -> Result<Option<Ticket>, String> {
171 let row = sqlx::query!(
172 r#"
173 SELECT id, organization_id, building_id, unit_id, created_by, assigned_to,
174 title, description, category, priority, status, resolution_notes,
175 created_at, updated_at, resolved_at, closed_at, work_order_sent_at
176 FROM tickets
177 WHERE id = $1
178 "#,
179 id
180 )
181 .fetch_optional(&self.pool)
182 .await
183 .map_err(|e| format!("Database error finding ticket: {}", e))?;
184
185 match row {
186 Some(r) => Ok(Some(Ticket {
187 id: r.id,
188 organization_id: r.organization_id,
189 building_id: r.building_id,
190 unit_id: r.unit_id,
191 created_by: r.created_by,
192 assigned_to: r.assigned_to,
193 title: r.title,
194 description: r.description,
195 category: Self::category_from_db(&r.category)?,
196 priority: Self::priority_from_db(&r.priority)?,
197 status: Self::status_from_db(&r.status)?,
198 resolution_notes: r.resolution_notes,
199 created_at: r.created_at,
200 updated_at: r.updated_at,
201 resolved_at: r.resolved_at,
202 closed_at: r.closed_at,
203 work_order_sent_at: r.work_order_sent_at,
204 kind: crate::domain::entities::TicketKind::Request,
209 severity: None,
210 incident_date: None,
211 evidence_attachments: Vec::new(),
212 witnesses: Vec::new(),
213 sla_due_at: None,
215 sla_escalated_at: None,
216 })),
217 None => Ok(None),
218 }
219 }
220
221 async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Ticket>, String> {
222 let rows = sqlx::query!(
223 r#"
224 SELECT id, organization_id, building_id, unit_id, created_by, assigned_to,
225 title, description, category, priority, status, resolution_notes,
226 created_at, updated_at, resolved_at, closed_at, work_order_sent_at
227 FROM tickets
228 WHERE building_id = $1
229 ORDER BY created_at DESC
230 "#,
231 building_id
232 )
233 .fetch_all(&self.pool)
234 .await
235 .map_err(|e| format!("Database error finding tickets by building: {}", e))?;
236
237 rows.into_iter()
238 .map(|r| {
239 Ok(Ticket {
240 id: r.id,
241 organization_id: r.organization_id,
242 building_id: r.building_id,
243 unit_id: r.unit_id,
244 created_by: r.created_by,
245 assigned_to: r.assigned_to,
246 title: r.title,
247 description: r.description,
248 category: Self::category_from_db(&r.category)?,
249 priority: Self::priority_from_db(&r.priority)?,
250 status: Self::status_from_db(&r.status)?,
251 resolution_notes: r.resolution_notes,
252 created_at: r.created_at,
253 updated_at: r.updated_at,
254 resolved_at: r.resolved_at,
255 closed_at: r.closed_at,
256 work_order_sent_at: r.work_order_sent_at,
257 kind: crate::domain::entities::TicketKind::Request,
259 severity: None,
260 incident_date: None,
261 evidence_attachments: Vec::new(),
262 witnesses: Vec::new(),
263 sla_due_at: None,
265 sla_escalated_at: None,
266 })
267 })
268 .collect()
269 }
270
271 async fn find_by_organization(&self, organization_id: 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, work_order_sent_at
277 FROM tickets
278 WHERE organization_id = $1
279 ORDER BY created_at DESC
280 "#,
281 organization_id
282 )
283 .fetch_all(&self.pool)
284 .await
285 .map_err(|e| format!("Database error finding tickets by organization: {}", 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 work_order_sent_at: r.work_order_sent_at,
307 kind: crate::domain::entities::TicketKind::Request,
309 severity: None,
310 incident_date: None,
311 evidence_attachments: Vec::new(),
312 witnesses: Vec::new(),
313 sla_due_at: None,
315 sla_escalated_at: None,
316 })
317 })
318 .collect()
319 }
320
321 async fn find_by_created_by(&self, created_by: Uuid) -> Result<Vec<Ticket>, String> {
322 let rows = sqlx::query!(
323 r#"
324 SELECT id, organization_id, building_id, unit_id, created_by, assigned_to,
325 title, description, category, priority, status, resolution_notes,
326 created_at, updated_at, resolved_at, closed_at, work_order_sent_at
327 FROM tickets
328 WHERE created_by = $1
329 ORDER BY created_at DESC
330 "#,
331 created_by
332 )
333 .fetch_all(&self.pool)
334 .await
335 .map_err(|e| format!("Database error finding tickets by creator: {}", e))?;
336
337 rows.into_iter()
338 .map(|r| {
339 Ok(Ticket {
340 id: r.id,
341 organization_id: r.organization_id,
342 building_id: r.building_id,
343 unit_id: r.unit_id,
344 created_by: r.created_by,
345 assigned_to: r.assigned_to,
346 title: r.title,
347 description: r.description,
348 category: Self::category_from_db(&r.category)?,
349 priority: Self::priority_from_db(&r.priority)?,
350 status: Self::status_from_db(&r.status)?,
351 resolution_notes: r.resolution_notes,
352 created_at: r.created_at,
353 updated_at: r.updated_at,
354 resolved_at: r.resolved_at,
355 closed_at: r.closed_at,
356 work_order_sent_at: r.work_order_sent_at,
357 kind: crate::domain::entities::TicketKind::Request,
359 severity: None,
360 incident_date: None,
361 evidence_attachments: Vec::new(),
362 witnesses: Vec::new(),
363 sla_due_at: None,
365 sla_escalated_at: None,
366 })
367 })
368 .collect()
369 }
370
371 async fn find_by_assigned_to(&self, assigned_to: Uuid) -> Result<Vec<Ticket>, String> {
372 let rows = sqlx::query!(
373 r#"
374 SELECT id, organization_id, building_id, unit_id, created_by, assigned_to,
375 title, description, category, priority, status, resolution_notes,
376 created_at, updated_at, resolved_at, closed_at, work_order_sent_at
377 FROM tickets
378 WHERE assigned_to = $1
379 ORDER BY created_at DESC
380 "#,
381 assigned_to
382 )
383 .fetch_all(&self.pool)
384 .await
385 .map_err(|e| format!("Database error finding tickets by assignee: {}", e))?;
386
387 rows.into_iter()
388 .map(|r| {
389 Ok(Ticket {
390 id: r.id,
391 organization_id: r.organization_id,
392 building_id: r.building_id,
393 unit_id: r.unit_id,
394 created_by: r.created_by,
395 assigned_to: r.assigned_to,
396 title: r.title,
397 description: r.description,
398 category: Self::category_from_db(&r.category)?,
399 priority: Self::priority_from_db(&r.priority)?,
400 status: Self::status_from_db(&r.status)?,
401 resolution_notes: r.resolution_notes,
402 created_at: r.created_at,
403 updated_at: r.updated_at,
404 resolved_at: r.resolved_at,
405 closed_at: r.closed_at,
406 work_order_sent_at: r.work_order_sent_at,
407 kind: crate::domain::entities::TicketKind::Request,
409 severity: None,
410 incident_date: None,
411 evidence_attachments: Vec::new(),
412 witnesses: Vec::new(),
413 sla_due_at: None,
415 sla_escalated_at: None,
416 })
417 })
418 .collect()
419 }
420
421 async fn find_by_status(
422 &self,
423 building_id: Uuid,
424 status: TicketStatus,
425 ) -> Result<Vec<Ticket>, String> {
426 let status_str = Self::status_to_db(&status);
427
428 let rows = sqlx::query!(
429 r#"
430 SELECT id, organization_id, building_id, unit_id, created_by, assigned_to,
431 title, description, category, priority, status, resolution_notes,
432 created_at, updated_at, resolved_at, closed_at, work_order_sent_at
433 FROM tickets
434 WHERE building_id = $1 AND status = $2
435 ORDER BY created_at DESC
436 "#,
437 building_id,
438 status_str
439 )
440 .fetch_all(&self.pool)
441 .await
442 .map_err(|e| format!("Database error finding tickets by status: {}", e))?;
443
444 rows.into_iter()
445 .map(|r| {
446 Ok(Ticket {
447 id: r.id,
448 organization_id: r.organization_id,
449 building_id: r.building_id,
450 unit_id: r.unit_id,
451 created_by: r.created_by,
452 assigned_to: r.assigned_to,
453 title: r.title,
454 description: r.description,
455 category: Self::category_from_db(&r.category)?,
456 priority: Self::priority_from_db(&r.priority)?,
457 status: Self::status_from_db(&r.status)?,
458 resolution_notes: r.resolution_notes,
459 created_at: r.created_at,
460 updated_at: r.updated_at,
461 resolved_at: r.resolved_at,
462 closed_at: r.closed_at,
463 work_order_sent_at: r.work_order_sent_at,
464 kind: crate::domain::entities::TicketKind::Request,
466 severity: None,
467 incident_date: None,
468 evidence_attachments: Vec::new(),
469 witnesses: Vec::new(),
470 sla_due_at: None,
472 sla_escalated_at: None,
473 })
474 })
475 .collect()
476 }
477
478 async fn update(&self, ticket: &Ticket) -> Result<Ticket, String> {
479 let category_str = Self::category_to_db(&ticket.category);
480 let priority_str = Self::priority_to_db(&ticket.priority);
481 let status_str = Self::status_to_db(&ticket.status);
482
483 let row = sqlx::query!(
484 r#"
485 UPDATE tickets
486 SET organization_id = $2,
487 building_id = $3,
488 unit_id = $4,
489 created_by = $5,
490 assigned_to = $6,
491 title = $7,
492 description = $8,
493 category = $9,
494 priority = $10,
495 status = $11,
496 resolution_notes = $12,
497 updated_at = $13,
498 resolved_at = $14,
499 closed_at = $15,
500 work_order_sent_at = $16
501 WHERE id = $1
502 RETURNING id, organization_id, building_id, unit_id, created_by, assigned_to,
503 title, description, category, priority, status, resolution_notes,
504 created_at, updated_at, resolved_at, closed_at, work_order_sent_at
505 "#,
506 ticket.id,
507 ticket.organization_id,
508 ticket.building_id,
509 ticket.unit_id,
510 ticket.created_by,
511 ticket.assigned_to,
512 ticket.title,
513 ticket.description,
514 category_str,
515 priority_str,
516 status_str,
517 ticket.resolution_notes,
518 ticket.updated_at,
519 ticket.resolved_at,
520 ticket.closed_at,
521 ticket.work_order_sent_at
522 )
523 .fetch_one(&self.pool)
524 .await
525 .map_err(|e| format!("Database error updating ticket: {}", e))?;
526
527 Ok(Ticket {
528 id: row.id,
529 organization_id: row.organization_id,
530 building_id: row.building_id,
531 unit_id: row.unit_id,
532 created_by: row.created_by,
533 assigned_to: row.assigned_to,
534 title: row.title,
535 description: row.description,
536 category: Self::category_from_db(&row.category)?,
537 priority: Self::priority_from_db(&row.priority)?,
538 status: Self::status_from_db(&row.status)?,
539 resolution_notes: row.resolution_notes,
540 created_at: row.created_at,
541 updated_at: row.updated_at,
542 resolved_at: row.resolved_at,
543 closed_at: row.closed_at,
544 work_order_sent_at: row.work_order_sent_at,
545 kind: crate::domain::entities::TicketKind::Request,
552 severity: None,
553 incident_date: None,
554 evidence_attachments: Vec::new(),
555 witnesses: Vec::new(),
556 sla_due_at: None,
559 sla_escalated_at: None,
560 })
561 }
562
563 async fn delete(&self, id: Uuid) -> Result<bool, String> {
564 let result = sqlx::query!(
565 r#"
566 DELETE FROM tickets
567 WHERE id = $1
568 "#,
569 id
570 )
571 .execute(&self.pool)
572 .await
573 .map_err(|e| format!("Database error deleting ticket: {}", e))?;
574
575 Ok(result.rows_affected() > 0)
576 }
577
578 async fn count_by_building(&self, building_id: Uuid) -> Result<i64, String> {
579 let row = sqlx::query!(
580 r#"
581 SELECT COUNT(*) as count
582 FROM tickets
583 WHERE building_id = $1
584 "#,
585 building_id
586 )
587 .fetch_one(&self.pool)
588 .await
589 .map_err(|e| format!("Database error counting tickets: {}", e))?;
590
591 Ok(row.count.unwrap_or(0))
592 }
593
594 async fn count_by_status(
595 &self,
596 building_id: Uuid,
597 status: TicketStatus,
598 ) -> Result<i64, String> {
599 let status_str = Self::status_to_db(&status);
600
601 let row = sqlx::query!(
602 r#"
603 SELECT COUNT(*) as count
604 FROM tickets
605 WHERE building_id = $1 AND status = $2
606 "#,
607 building_id,
608 status_str
609 )
610 .fetch_one(&self.pool)
611 .await
612 .map_err(|e| format!("Database error counting tickets by status: {}", e))?;
613
614 Ok(row.count.unwrap_or(0))
615 }
616
617 async fn count_by_organization(&self, organization_id: Uuid) -> Result<i64, String> {
618 let count: (i64,) =
619 sqlx::query_as("SELECT COUNT(*) FROM tickets WHERE organization_id = $1")
620 .bind(organization_id)
621 .fetch_one(&self.pool)
622 .await
623 .map_err(|e| format!("Database error counting tickets by organization: {}", e))?;
624
625 Ok(count.0)
626 }
627
628 async fn count_by_organization_and_status(
629 &self,
630 organization_id: Uuid,
631 status: TicketStatus,
632 ) -> Result<i64, String> {
633 let status_str = Self::status_to_db(&status);
634
635 let count: (i64,) = sqlx::query_as(
636 "SELECT COUNT(*) FROM tickets WHERE organization_id = $1 AND status = $2",
637 )
638 .bind(organization_id)
639 .bind(status_str)
640 .fetch_one(&self.pool)
641 .await
642 .map_err(|e| format!("Database error counting tickets by org and status: {}", e))?;
643
644 Ok(count.0)
645 }
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651
652 #[test]
653 fn test_category_conversion() {
654 assert_eq!(
655 PostgresTicketRepository::category_to_db(&TicketCategory::Plumbing),
656 "Plumbing"
657 );
658 assert_eq!(
659 PostgresTicketRepository::category_from_db("Electrical").unwrap(),
660 TicketCategory::Electrical
661 );
662 }
663
664 #[test]
665 fn test_priority_conversion() {
666 assert_eq!(
667 PostgresTicketRepository::priority_to_db(&TicketPriority::Critical),
668 "Critical"
669 );
670 assert_eq!(
671 PostgresTicketRepository::priority_from_db("Low").unwrap(),
672 TicketPriority::Low
673 );
674 }
675
676 #[test]
677 fn test_status_conversion() {
678 assert_eq!(
679 PostgresTicketRepository::status_to_db(&TicketStatus::InProgress),
680 "InProgress"
681 );
682 assert_eq!(
683 PostgresTicketRepository::status_from_db("Resolved").unwrap(),
684 TicketStatus::Resolved
685 );
686 }
687
688 #[test]
689 fn test_invalid_category() {
690 assert!(PostgresTicketRepository::category_from_db("Invalid").is_err());
691 }
692
693 #[test]
694 fn test_invalid_priority() {
695 assert!(PostgresTicketRepository::priority_from_db("Invalid").is_err());
696 }
697
698 #[test]
699 fn test_invalid_status() {
700 assert!(PostgresTicketRepository::status_from_db("Invalid").is_err());
701 }
702}