1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub enum TicketCategory {
8 Plumbing, Electrical, Heating, CommonAreas, Elevator, Security, Cleaning, Landscaping, Other, }
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)]
21pub enum TicketPriority {
22 Low, Medium, High, Critical, }
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
30pub enum TicketStatus {
31 Open, InProgress, Resolved, Closed, Cancelled, }
37
38#[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>, pub created_by: Uuid, pub assigned_to: Option<Uuid>, 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>, 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 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 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 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 if self.status == TicketStatus::Open {
124 self.status = TicketStatus::InProgress;
125 }
126
127 Ok(())
128 }
129
130 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(()), _ => Err(format!(
140 "Cannot start work on ticket in status {:?}",
141 self.status
142 )),
143 }
144 }
145
146 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 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 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(()), _ => Err(format!(
185 "Cannot close ticket in status {:?}. Must be Resolved first.",
186 self.status
187 )),
188 }
189 }
190
191 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 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 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); }
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 ticket.created_at = Utc::now() - chrono::Duration::days(10);
436
437 assert!(ticket.is_overdue(5));
438 assert!(!ticket.is_overdue(15));
439
440 ticket.status = TicketStatus::Closed;
442 assert!(!ticket.is_overdue(5));
443 }
444}