koprogo_api/domain/entities/
ticket.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Ticket Category - Types of maintenance requests
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub enum TicketCategory {
8    Plumbing,    // Plomberie
9    Electrical,  // Électricité
10    Heating,     // Chauffage
11    CommonAreas, // Parties communes
12    Elevator,    // Ascenseur
13    Security,    // Sécurité
14    Cleaning,    // Nettoyage
15    Landscaping, // Espaces verts
16    Other,       // Autre
17}
18
19/// Ticket Priority
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)]
21pub enum TicketPriority {
22    Low,      // Basse
23    Medium,   // Moyenne
24    High,     // Haute
25    Critical, // Critique/Urgente
26}
27
28/// Ticket Status - Workflow states
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
30pub enum TicketStatus {
31    Open,       // Ouvert (nouveau ticket)
32    InProgress, // En cours de traitement
33    Resolved,   // Résolu (intervention terminée)
34    Closed,     // Fermé (validé par le demandeur)
35    Cancelled,  // Annulé
36}
37
38/// Ticket Entity - Maintenance request from owners
39///
40/// Represents a maintenance request (ticket) submitted by a co-owner
41/// for issues in the building (plumbing, electrical, etc.).
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct Ticket {
44    pub id: Uuid,
45    pub organization_id: Uuid,
46    pub building_id: Uuid,
47    pub unit_id: Option<Uuid>, // If specific to a unit, None for common areas
48    pub created_by: Uuid,      // Owner who created the ticket
49    pub assigned_to: Option<Uuid>, // User (syndic, contractor) assigned
50    pub title: String,
51    pub description: String,
52    pub category: TicketCategory,
53    pub priority: TicketPriority,
54    pub status: TicketStatus,
55    pub resolution_notes: Option<String>, // Notes from resolver
56    pub created_at: DateTime<Utc>,
57    pub updated_at: DateTime<Utc>,
58    pub resolved_at: Option<DateTime<Utc>>,
59    pub closed_at: Option<DateTime<Utc>>,
60}
61
62impl Ticket {
63    /// Create a new ticket
64    pub fn new(
65        organization_id: Uuid,
66        building_id: Uuid,
67        unit_id: Option<Uuid>,
68        created_by: Uuid,
69        title: String,
70        description: String,
71        category: TicketCategory,
72        priority: TicketPriority,
73    ) -> Result<Self, String> {
74        // Validation
75        if title.trim().is_empty() {
76            return Err("Title cannot be empty".to_string());
77        }
78
79        if title.len() > 200 {
80            return Err("Title cannot exceed 200 characters".to_string());
81        }
82
83        if description.trim().is_empty() {
84            return Err("Description cannot be empty".to_string());
85        }
86
87        if description.len() > 5000 {
88            return Err("Description cannot exceed 5000 characters".to_string());
89        }
90
91        let now = Utc::now();
92
93        Ok(Self {
94            id: Uuid::new_v4(),
95            organization_id,
96            building_id,
97            unit_id,
98            created_by,
99            assigned_to: None,
100            title,
101            description,
102            category,
103            priority,
104            status: TicketStatus::Open,
105            resolution_notes: None,
106            created_at: now,
107            updated_at: now,
108            resolved_at: None,
109            closed_at: None,
110        })
111    }
112
113    /// Assign ticket to a user (syndic or contractor)
114    pub fn assign(&mut self, user_id: Uuid) -> Result<(), String> {
115        if self.status == TicketStatus::Closed || self.status == TicketStatus::Cancelled {
116            return Err("Cannot assign a closed or cancelled ticket".to_string());
117        }
118
119        self.assigned_to = Some(user_id);
120        self.updated_at = Utc::now();
121
122        // Auto-transition to InProgress if still Open
123        if self.status == TicketStatus::Open {
124            self.status = TicketStatus::InProgress;
125        }
126
127        Ok(())
128    }
129
130    /// Mark ticket as in progress
131    pub fn start_work(&mut self) -> Result<(), String> {
132        match self.status {
133            TicketStatus::Open => {
134                self.status = TicketStatus::InProgress;
135                self.updated_at = Utc::now();
136                Ok(())
137            }
138            TicketStatus::InProgress => Ok(()), // Already in progress
139            _ => Err(format!(
140                "Cannot start work on ticket in status {:?}",
141                self.status
142            )),
143        }
144    }
145
146    /// Resolve ticket (work completed)
147    pub fn resolve(&mut self, resolution_notes: String) -> Result<(), String> {
148        if resolution_notes.trim().is_empty() {
149            return Err("Resolution notes are required".to_string());
150        }
151
152        if resolution_notes.len() > 2000 {
153            return Err("Resolution notes cannot exceed 2000 characters".to_string());
154        }
155
156        match self.status {
157            TicketStatus::Open | TicketStatus::InProgress => {
158                self.status = TicketStatus::Resolved;
159                self.resolution_notes = Some(resolution_notes);
160                self.resolved_at = Some(Utc::now());
161                self.updated_at = Utc::now();
162                Ok(())
163            }
164            TicketStatus::Resolved => {
165                // Allow updating resolution notes
166                self.resolution_notes = Some(resolution_notes);
167                self.updated_at = Utc::now();
168                Ok(())
169            }
170            _ => Err(format!("Cannot resolve ticket in status {:?}", self.status)),
171        }
172    }
173
174    /// Close ticket (validation by requester or syndic)
175    pub fn close(&mut self) -> Result<(), String> {
176        match self.status {
177            TicketStatus::Resolved => {
178                self.status = TicketStatus::Closed;
179                self.closed_at = Some(Utc::now());
180                self.updated_at = Utc::now();
181                Ok(())
182            }
183            TicketStatus::Closed => Ok(()), // Already closed
184            _ => Err(format!(
185                "Cannot close ticket in status {:?}. Must be Resolved first.",
186                self.status
187            )),
188        }
189    }
190
191    /// Cancel ticket
192    pub fn cancel(&mut self, reason: String) -> Result<(), String> {
193        if self.status == TicketStatus::Closed {
194            return Err("Cannot cancel an already closed ticket".to_string());
195        }
196
197        if reason.trim().is_empty() {
198            return Err("Cancellation reason is required".to_string());
199        }
200
201        self.status = TicketStatus::Cancelled;
202        self.resolution_notes = Some(format!("CANCELLED: {}", reason));
203        self.updated_at = Utc::now();
204
205        Ok(())
206    }
207
208    /// Reopen ticket (if incorrectly resolved)
209    pub fn reopen(&mut self, reason: String) -> Result<(), String> {
210        if self.status != TicketStatus::Resolved && self.status != TicketStatus::Closed {
211            return Err("Can only reopen resolved or closed tickets".to_string());
212        }
213
214        if reason.trim().is_empty() {
215            return Err("Reopen reason is required".to_string());
216        }
217
218        self.status = TicketStatus::InProgress;
219        self.resolution_notes = Some(format!(
220            "{}\n\nREOPENED: {}",
221            self.resolution_notes.as_deref().unwrap_or(""),
222            reason
223        ));
224        self.resolved_at = None;
225        self.closed_at = None;
226        self.updated_at = Utc::now();
227
228        Ok(())
229    }
230
231    /// Check if ticket is overdue (open for more than X days)
232    pub fn is_overdue(&self, max_days: i64) -> bool {
233        if self.status == TicketStatus::Closed || self.status == TicketStatus::Cancelled {
234            return false;
235        }
236
237        let now = Utc::now();
238        let age = now - self.created_at;
239
240        age.num_days() > max_days
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn test_create_ticket_success() {
250        let ticket = Ticket::new(
251            Uuid::new_v4(),
252            Uuid::new_v4(),
253            Some(Uuid::new_v4()),
254            Uuid::new_v4(),
255            "Fuite d'eau salle de bain".to_string(),
256            "L'eau coule du plafond de la salle de bain".to_string(),
257            TicketCategory::Plumbing,
258            TicketPriority::High,
259        );
260
261        assert!(ticket.is_ok());
262        let ticket = ticket.unwrap();
263        assert_eq!(ticket.status, TicketStatus::Open);
264        assert!(ticket.assigned_to.is_none());
265    }
266
267    #[test]
268    fn test_create_ticket_empty_title() {
269        let result = Ticket::new(
270            Uuid::new_v4(),
271            Uuid::new_v4(),
272            None,
273            Uuid::new_v4(),
274            "   ".to_string(),
275            "Description".to_string(),
276            TicketCategory::Plumbing,
277            TicketPriority::Low,
278        );
279
280        assert!(result.is_err());
281        assert_eq!(result.unwrap_err(), "Title cannot be empty");
282    }
283
284    #[test]
285    fn test_assign_ticket() {
286        let mut ticket = Ticket::new(
287            Uuid::new_v4(),
288            Uuid::new_v4(),
289            None,
290            Uuid::new_v4(),
291            "Test".to_string(),
292            "Test description".to_string(),
293            TicketCategory::Electrical,
294            TicketPriority::Medium,
295        )
296        .unwrap();
297
298        let contractor_id = Uuid::new_v4();
299        let result = ticket.assign(contractor_id);
300
301        assert!(result.is_ok());
302        assert_eq!(ticket.assigned_to, Some(contractor_id));
303        assert_eq!(ticket.status, TicketStatus::InProgress); // Auto-transitioned
304    }
305
306    #[test]
307    fn test_resolve_ticket() {
308        let mut ticket = Ticket::new(
309            Uuid::new_v4(),
310            Uuid::new_v4(),
311            None,
312            Uuid::new_v4(),
313            "Test".to_string(),
314            "Test description".to_string(),
315            TicketCategory::Heating,
316            TicketPriority::Low,
317        )
318        .unwrap();
319
320        ticket.start_work().unwrap();
321
322        let result = ticket.resolve("Chaudière réparée, pièce remplacée".to_string());
323
324        assert!(result.is_ok());
325        assert_eq!(ticket.status, TicketStatus::Resolved);
326        assert!(ticket.resolved_at.is_some());
327        assert!(ticket.resolution_notes.is_some());
328    }
329
330    #[test]
331    fn test_close_ticket() {
332        let mut ticket = Ticket::new(
333            Uuid::new_v4(),
334            Uuid::new_v4(),
335            None,
336            Uuid::new_v4(),
337            "Test".to_string(),
338            "Test description".to_string(),
339            TicketCategory::CommonAreas,
340            TicketPriority::Medium,
341        )
342        .unwrap();
343
344        ticket.start_work().unwrap();
345        ticket.resolve("Fixed".to_string()).unwrap();
346
347        let result = ticket.close();
348
349        assert!(result.is_ok());
350        assert_eq!(ticket.status, TicketStatus::Closed);
351        assert!(ticket.closed_at.is_some());
352    }
353
354    #[test]
355    fn test_cannot_close_open_ticket() {
356        let mut ticket = Ticket::new(
357            Uuid::new_v4(),
358            Uuid::new_v4(),
359            None,
360            Uuid::new_v4(),
361            "Test".to_string(),
362            "Test description".to_string(),
363            TicketCategory::Elevator,
364            TicketPriority::Critical,
365        )
366        .unwrap();
367
368        let result = ticket.close();
369
370        assert!(result.is_err());
371        assert!(result.unwrap_err().contains("Must be Resolved first"));
372    }
373
374    #[test]
375    fn test_cancel_ticket() {
376        let mut ticket = Ticket::new(
377            Uuid::new_v4(),
378            Uuid::new_v4(),
379            None,
380            Uuid::new_v4(),
381            "Test".to_string(),
382            "Test description".to_string(),
383            TicketCategory::Other,
384            TicketPriority::Low,
385        )
386        .unwrap();
387
388        let result = ticket.cancel("Erreur de déclaration".to_string());
389
390        assert!(result.is_ok());
391        assert_eq!(ticket.status, TicketStatus::Cancelled);
392    }
393
394    #[test]
395    fn test_reopen_ticket() {
396        let mut ticket = Ticket::new(
397            Uuid::new_v4(),
398            Uuid::new_v4(),
399            None,
400            Uuid::new_v4(),
401            "Test".to_string(),
402            "Test description".to_string(),
403            TicketCategory::Plumbing,
404            TicketPriority::High,
405        )
406        .unwrap();
407
408        ticket.start_work().unwrap();
409        ticket.resolve("Fixed".to_string()).unwrap();
410        ticket.close().unwrap();
411
412        let result = ticket.reopen("Problème persiste".to_string());
413
414        assert!(result.is_ok());
415        assert_eq!(ticket.status, TicketStatus::InProgress);
416        assert!(ticket.closed_at.is_none());
417        assert!(ticket.resolution_notes.unwrap().contains("REOPENED"));
418    }
419
420    #[test]
421    fn test_is_overdue() {
422        let mut ticket = Ticket::new(
423            Uuid::new_v4(),
424            Uuid::new_v4(),
425            None,
426            Uuid::new_v4(),
427            "Test".to_string(),
428            "Test description".to_string(),
429            TicketCategory::Plumbing,
430            TicketPriority::High,
431        )
432        .unwrap();
433
434        // Simulate old ticket (10 days ago)
435        ticket.created_at = Utc::now() - chrono::Duration::days(10);
436
437        assert!(ticket.is_overdue(5));
438        assert!(!ticket.is_overdue(15));
439
440        // Closed tickets are never overdue
441        ticket.status = TicketStatus::Closed;
442        assert!(!ticket.is_overdue(5));
443    }
444}