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
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}