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