1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
7pub enum TicketCategory {
8 Plumbing, Electrical, Heating, CommonAreas, Elevator, Security, Cleaning, Landscaping, Other, }
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd, utoipa::ToSchema)]
21pub enum TicketPriority {
22 Low, Medium, High, Critical, }
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
30pub enum TicketStatus {
31 Open, InProgress, Resolved, Closed, Cancelled, }
37
38#[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>, 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 work_order_sent_at: Option<DateTime<Utc>>, 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 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 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 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 if self.status == TicketStatus::Open {
126 self.status = TicketStatus::InProgress;
127 }
128
129 Ok(())
130 }
131
132 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(()), _ => Err(format!(
142 "Cannot start work on ticket in status {:?}",
143 self.status
144 )),
145 }
146 }
147
148 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 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 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(()), _ => Err(format!(
187 "Cannot close ticket in status {:?}. Must be Resolved first.",
188 self.status
189 )),
190 }
191 }
192
193 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 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 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 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); }
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 ticket.created_at = Utc::now() - chrono::Duration::days(10);
459
460 assert!(ticket.is_overdue(5));
461 assert!(!ticket.is_overdue(15));
462
463 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 ticket.assign(Uuid::new_v4()).unwrap();
484 assert_eq!(ticket.status, TicketStatus::InProgress);
485
486 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 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 let result = ticket.send_work_order_to_contractor();
533 assert!(result.is_err());
534 assert!(result.unwrap_err().contains("InProgress status"));
535 }
536}