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, utoipa::ToSchema)]
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, utoipa::ToSchema)]
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, utoipa::ToSchema)]
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, utoipa::ToSchema)]
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 work_order_sent_at: Option<DateTime<Utc>>, // When magic link PWA was sent to contractor (Issue #309)
57    pub created_at: DateTime<Utc>,
58    pub updated_at: DateTime<Utc>,
59    pub resolved_at: Option<DateTime<Utc>>,
60    pub closed_at: Option<DateTime<Utc>>,
61}
62
63impl Ticket {
64    /// Create a new ticket
65    pub fn new(
66        organization_id: Uuid,
67        building_id: Uuid,
68        unit_id: Option<Uuid>,
69        created_by: Uuid,
70        title: String,
71        description: String,
72        category: TicketCategory,
73        priority: TicketPriority,
74    ) -> Result<Self, String> {
75        // Validation
76        if title.trim().is_empty() {
77            return Err("Title cannot be empty".to_string());
78        }
79
80        if title.len() > 200 {
81            return Err("Title cannot exceed 200 characters".to_string());
82        }
83
84        if description.trim().is_empty() {
85            return Err("Description cannot be empty".to_string());
86        }
87
88        if description.len() > 5000 {
89            return Err("Description cannot exceed 5000 characters".to_string());
90        }
91
92        let now = Utc::now();
93
94        Ok(Self {
95            id: Uuid::new_v4(),
96            organization_id,
97            building_id,
98            unit_id,
99            created_by,
100            assigned_to: None,
101            title,
102            description,
103            category,
104            priority,
105            status: TicketStatus::Open,
106            resolution_notes: None,
107            work_order_sent_at: None,
108            created_at: now,
109            updated_at: now,
110            resolved_at: None,
111            closed_at: None,
112        })
113    }
114
115    /// Assign ticket to a user (syndic or contractor)
116    pub fn assign(&mut self, user_id: Uuid) -> Result<(), String> {
117        if self.status == TicketStatus::Closed || self.status == TicketStatus::Cancelled {
118            return Err("Cannot assign a closed or cancelled ticket".to_string());
119        }
120
121        self.assigned_to = Some(user_id);
122        self.updated_at = Utc::now();
123
124        // Auto-transition to InProgress if still Open
125        if self.status == TicketStatus::Open {
126            self.status = TicketStatus::InProgress;
127        }
128
129        Ok(())
130    }
131
132    /// Mark ticket as in progress
133    pub fn start_work(&mut self) -> Result<(), String> {
134        match self.status {
135            TicketStatus::Open => {
136                self.status = TicketStatus::InProgress;
137                self.updated_at = Utc::now();
138                Ok(())
139            }
140            TicketStatus::InProgress => Ok(()), // Already in progress
141            _ => Err(format!(
142                "Cannot start work on ticket in status {:?}",
143                self.status
144            )),
145        }
146    }
147
148    /// Resolve ticket (work completed)
149    pub fn resolve(&mut self, resolution_notes: String) -> Result<(), String> {
150        if resolution_notes.trim().is_empty() {
151            return Err("Resolution notes are required".to_string());
152        }
153
154        if resolution_notes.len() > 2000 {
155            return Err("Resolution notes cannot exceed 2000 characters".to_string());
156        }
157
158        match self.status {
159            TicketStatus::Open | TicketStatus::InProgress => {
160                self.status = TicketStatus::Resolved;
161                self.resolution_notes = Some(resolution_notes);
162                self.resolved_at = Some(Utc::now());
163                self.updated_at = Utc::now();
164                Ok(())
165            }
166            TicketStatus::Resolved => {
167                // Allow updating resolution notes
168                self.resolution_notes = Some(resolution_notes);
169                self.updated_at = Utc::now();
170                Ok(())
171            }
172            _ => Err(format!("Cannot resolve ticket in status {:?}", self.status)),
173        }
174    }
175
176    /// Close ticket (validation by requester or syndic)
177    pub fn close(&mut self) -> Result<(), String> {
178        match self.status {
179            TicketStatus::Resolved => {
180                self.status = TicketStatus::Closed;
181                self.closed_at = Some(Utc::now());
182                self.updated_at = Utc::now();
183                Ok(())
184            }
185            TicketStatus::Closed => Ok(()), // Already closed
186            _ => Err(format!(
187                "Cannot close ticket in status {:?}. Must be Resolved first.",
188                self.status
189            )),
190        }
191    }
192
193    /// Cancel ticket
194    pub fn cancel(&mut self, reason: String) -> Result<(), String> {
195        if self.status == TicketStatus::Closed {
196            return Err("Cannot cancel an already closed ticket".to_string());
197        }
198
199        if reason.trim().is_empty() {
200            return Err("Cancellation reason is required".to_string());
201        }
202
203        self.status = TicketStatus::Cancelled;
204        self.resolution_notes = Some(format!("CANCELLED: {}", reason));
205        self.updated_at = Utc::now();
206
207        Ok(())
208    }
209
210    /// Reopen ticket (if incorrectly resolved)
211    pub fn reopen(&mut self, reason: String) -> Result<(), String> {
212        if self.status != TicketStatus::Resolved && self.status != TicketStatus::Closed {
213            return Err("Can only reopen resolved or closed tickets".to_string());
214        }
215
216        if reason.trim().is_empty() {
217            return Err("Reopen reason is required".to_string());
218        }
219
220        self.status = TicketStatus::InProgress;
221        self.resolution_notes = Some(format!(
222            "{}\n\nREOPENED: {}",
223            self.resolution_notes.as_deref().unwrap_or(""),
224            reason
225        ));
226        self.resolved_at = None;
227        self.closed_at = None;
228        self.updated_at = Utc::now();
229
230        Ok(())
231    }
232
233    /// Send work order to contractor (magic link PWA) - Issue #309
234    /// Validates ticket is in InProgress status (must be assigned)
235    pub fn send_work_order_to_contractor(&mut self) -> Result<(), String> {
236        if self.status != TicketStatus::InProgress {
237            return Err(
238                "Can only send work order to contractor for tickets in InProgress status"
239                    .to_string(),
240            );
241        }
242
243        if self.assigned_to.is_none() {
244            return Err(
245                "Ticket must be assigned to a contractor before sending work order".to_string(),
246            );
247        }
248
249        self.work_order_sent_at = Some(Utc::now());
250        self.updated_at = Utc::now();
251        Ok(())
252    }
253
254    /// Check if ticket is overdue (open for more than X days)
255    pub fn is_overdue(&self, max_days: i64) -> bool {
256        if self.status == TicketStatus::Closed || self.status == TicketStatus::Cancelled {
257            return false;
258        }
259
260        let now = Utc::now();
261        let age = now - self.created_at;
262
263        age.num_days() > max_days
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_create_ticket_success() {
273        let ticket = Ticket::new(
274            Uuid::new_v4(),
275            Uuid::new_v4(),
276            Some(Uuid::new_v4()),
277            Uuid::new_v4(),
278            "Fuite d'eau salle de bain".to_string(),
279            "L'eau coule du plafond de la salle de bain".to_string(),
280            TicketCategory::Plumbing,
281            TicketPriority::High,
282        );
283
284        assert!(ticket.is_ok());
285        let ticket = ticket.unwrap();
286        assert_eq!(ticket.status, TicketStatus::Open);
287        assert!(ticket.assigned_to.is_none());
288    }
289
290    #[test]
291    fn test_create_ticket_empty_title() {
292        let result = Ticket::new(
293            Uuid::new_v4(),
294            Uuid::new_v4(),
295            None,
296            Uuid::new_v4(),
297            "   ".to_string(),
298            "Description".to_string(),
299            TicketCategory::Plumbing,
300            TicketPriority::Low,
301        );
302
303        assert!(result.is_err());
304        assert_eq!(result.unwrap_err(), "Title cannot be empty");
305    }
306
307    #[test]
308    fn test_assign_ticket() {
309        let mut ticket = Ticket::new(
310            Uuid::new_v4(),
311            Uuid::new_v4(),
312            None,
313            Uuid::new_v4(),
314            "Test".to_string(),
315            "Test description".to_string(),
316            TicketCategory::Electrical,
317            TicketPriority::Medium,
318        )
319        .unwrap();
320
321        let contractor_id = Uuid::new_v4();
322        let result = ticket.assign(contractor_id);
323
324        assert!(result.is_ok());
325        assert_eq!(ticket.assigned_to, Some(contractor_id));
326        assert_eq!(ticket.status, TicketStatus::InProgress); // Auto-transitioned
327    }
328
329    #[test]
330    fn test_resolve_ticket() {
331        let mut ticket = Ticket::new(
332            Uuid::new_v4(),
333            Uuid::new_v4(),
334            None,
335            Uuid::new_v4(),
336            "Test".to_string(),
337            "Test description".to_string(),
338            TicketCategory::Heating,
339            TicketPriority::Low,
340        )
341        .unwrap();
342
343        ticket.start_work().unwrap();
344
345        let result = ticket.resolve("Chaudière réparée, pièce remplacée".to_string());
346
347        assert!(result.is_ok());
348        assert_eq!(ticket.status, TicketStatus::Resolved);
349        assert!(ticket.resolved_at.is_some());
350        assert!(ticket.resolution_notes.is_some());
351    }
352
353    #[test]
354    fn test_close_ticket() {
355        let mut ticket = Ticket::new(
356            Uuid::new_v4(),
357            Uuid::new_v4(),
358            None,
359            Uuid::new_v4(),
360            "Test".to_string(),
361            "Test description".to_string(),
362            TicketCategory::CommonAreas,
363            TicketPriority::Medium,
364        )
365        .unwrap();
366
367        ticket.start_work().unwrap();
368        ticket.resolve("Fixed".to_string()).unwrap();
369
370        let result = ticket.close();
371
372        assert!(result.is_ok());
373        assert_eq!(ticket.status, TicketStatus::Closed);
374        assert!(ticket.closed_at.is_some());
375    }
376
377    #[test]
378    fn test_cannot_close_open_ticket() {
379        let mut ticket = Ticket::new(
380            Uuid::new_v4(),
381            Uuid::new_v4(),
382            None,
383            Uuid::new_v4(),
384            "Test".to_string(),
385            "Test description".to_string(),
386            TicketCategory::Elevator,
387            TicketPriority::Critical,
388        )
389        .unwrap();
390
391        let result = ticket.close();
392
393        assert!(result.is_err());
394        assert!(result.unwrap_err().contains("Must be Resolved first"));
395    }
396
397    #[test]
398    fn test_cancel_ticket() {
399        let mut ticket = Ticket::new(
400            Uuid::new_v4(),
401            Uuid::new_v4(),
402            None,
403            Uuid::new_v4(),
404            "Test".to_string(),
405            "Test description".to_string(),
406            TicketCategory::Other,
407            TicketPriority::Low,
408        )
409        .unwrap();
410
411        let result = ticket.cancel("Erreur de déclaration".to_string());
412
413        assert!(result.is_ok());
414        assert_eq!(ticket.status, TicketStatus::Cancelled);
415    }
416
417    #[test]
418    fn test_reopen_ticket() {
419        let mut ticket = Ticket::new(
420            Uuid::new_v4(),
421            Uuid::new_v4(),
422            None,
423            Uuid::new_v4(),
424            "Test".to_string(),
425            "Test description".to_string(),
426            TicketCategory::Plumbing,
427            TicketPriority::High,
428        )
429        .unwrap();
430
431        ticket.start_work().unwrap();
432        ticket.resolve("Fixed".to_string()).unwrap();
433        ticket.close().unwrap();
434
435        let result = ticket.reopen("Problème persiste".to_string());
436
437        assert!(result.is_ok());
438        assert_eq!(ticket.status, TicketStatus::InProgress);
439        assert!(ticket.closed_at.is_none());
440        assert!(ticket.resolution_notes.unwrap().contains("REOPENED"));
441    }
442
443    #[test]
444    fn test_is_overdue() {
445        let mut ticket = Ticket::new(
446            Uuid::new_v4(),
447            Uuid::new_v4(),
448            None,
449            Uuid::new_v4(),
450            "Test".to_string(),
451            "Test description".to_string(),
452            TicketCategory::Plumbing,
453            TicketPriority::High,
454        )
455        .unwrap();
456
457        // Simulate old ticket (10 days ago)
458        ticket.created_at = Utc::now() - chrono::Duration::days(10);
459
460        assert!(ticket.is_overdue(5));
461        assert!(!ticket.is_overdue(15));
462
463        // Closed tickets are never overdue
464        ticket.status = TicketStatus::Closed;
465        assert!(!ticket.is_overdue(5));
466    }
467
468    #[test]
469    fn test_send_work_order_success() {
470        let mut ticket = Ticket::new(
471            Uuid::new_v4(),
472            Uuid::new_v4(),
473            None,
474            Uuid::new_v4(),
475            "Test".to_string(),
476            "Test description".to_string(),
477            TicketCategory::Plumbing,
478            TicketPriority::High,
479        )
480        .unwrap();
481
482        // Assign contractor
483        ticket.assign(Uuid::new_v4()).unwrap();
484        assert_eq!(ticket.status, TicketStatus::InProgress);
485
486        // Send work order
487        let result = ticket.send_work_order_to_contractor();
488        assert!(result.is_ok());
489        assert!(ticket.work_order_sent_at.is_some());
490    }
491
492    #[test]
493    fn test_send_work_order_requires_assignment() {
494        let mut ticket = Ticket::new(
495            Uuid::new_v4(),
496            Uuid::new_v4(),
497            None,
498            Uuid::new_v4(),
499            "Test".to_string(),
500            "Test description".to_string(),
501            TicketCategory::Electrical,
502            TicketPriority::Medium,
503        )
504        .unwrap();
505
506        // Try to send without assigning
507        let result = ticket.send_work_order_to_contractor();
508        assert!(result.is_err());
509        assert!(result.unwrap_err().contains("InProgress"));
510    }
511
512    #[test]
513    fn test_send_work_order_requires_in_progress() {
514        let mut ticket = Ticket::new(
515            Uuid::new_v4(),
516            Uuid::new_v4(),
517            None,
518            Uuid::new_v4(),
519            "Test".to_string(),
520            "Test description".to_string(),
521            TicketCategory::Heating,
522            TicketPriority::Low,
523        )
524        .unwrap();
525
526        let contractor_id = Uuid::new_v4();
527        ticket.assign(contractor_id).unwrap();
528        ticket.start_work().unwrap();
529        ticket.resolve("Fixed".to_string()).unwrap();
530
531        // Try to send work order on resolved ticket
532        let result = ticket.send_work_order_to_contractor();
533        assert!(result.is_err());
534        assert!(result.unwrap_err().contains("InProgress status"));
535    }
536}