1use chrono::{DateTime, Duration, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub enum ConvocationType {
8 Ordinary,
10 Extraordinary,
12 SecondConvocation,
14}
15
16impl ConvocationType {
17 pub fn minimum_notice_days(&self) -> i64 {
19 match self {
20 ConvocationType::Ordinary => 15,
21 ConvocationType::Extraordinary | ConvocationType::SecondConvocation => 8,
22 }
23 }
24
25 pub fn to_db_string(&self) -> &'static str {
27 match self {
28 ConvocationType::Ordinary => "ordinary",
29 ConvocationType::Extraordinary => "extraordinary",
30 ConvocationType::SecondConvocation => "second_convocation",
31 }
32 }
33
34 pub fn from_db_string(s: &str) -> Result<Self, String> {
36 match s {
37 "ordinary" => Ok(ConvocationType::Ordinary),
38 "extraordinary" => Ok(ConvocationType::Extraordinary),
39 "second_convocation" => Ok(ConvocationType::SecondConvocation),
40 _ => Err(format!("Invalid meeting type: {}", s)),
41 }
42 }
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub enum ConvocationStatus {
48 Draft,
50 Scheduled,
52 Sent,
54 Cancelled,
56}
57
58impl ConvocationStatus {
59 pub fn to_db_string(&self) -> &'static str {
60 match self {
61 ConvocationStatus::Draft => "draft",
62 ConvocationStatus::Scheduled => "scheduled",
63 ConvocationStatus::Sent => "sent",
64 ConvocationStatus::Cancelled => "cancelled",
65 }
66 }
67
68 pub fn from_db_string(s: &str) -> Result<Self, String> {
69 match s {
70 "draft" => Ok(ConvocationStatus::Draft),
71 "scheduled" => Ok(ConvocationStatus::Scheduled),
72 "sent" => Ok(ConvocationStatus::Sent),
73 "cancelled" => Ok(ConvocationStatus::Cancelled),
74 _ => Err(format!("Invalid convocation status: {}", s)),
75 }
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct Convocation {
87 pub id: Uuid,
88 pub organization_id: Uuid,
89 pub building_id: Uuid,
90 pub meeting_id: Uuid,
91 pub meeting_type: ConvocationType,
92 pub meeting_date: DateTime<Utc>,
93 pub status: ConvocationStatus,
94
95 pub minimum_send_date: DateTime<Utc>, pub actual_send_date: Option<DateTime<Utc>>, pub scheduled_send_date: Option<DateTime<Utc>>, pub pdf_file_path: Option<String>, pub language: String, pub total_recipients: i32,
106 pub opened_count: i32,
107 pub will_attend_count: i32,
108 pub will_not_attend_count: i32,
109
110 pub reminder_sent_at: Option<DateTime<Utc>>, pub created_at: DateTime<Utc>,
115 pub updated_at: DateTime<Utc>,
116 pub created_by: Uuid,
117}
118
119impl Convocation {
120 pub fn new(
134 organization_id: Uuid,
135 building_id: Uuid,
136 meeting_id: Uuid,
137 meeting_type: ConvocationType,
138 meeting_date: DateTime<Utc>,
139 language: String,
140 created_by: Uuid,
141 ) -> Result<Self, String> {
142 if !["FR", "NL", "DE", "EN"].contains(&language.to_uppercase().as_str()) {
144 return Err(format!(
145 "Invalid language '{}'. Must be FR, NL, DE, or EN",
146 language
147 ));
148 }
149
150 let minimum_notice_days = meeting_type.minimum_notice_days();
152 let minimum_send_date = meeting_date - Duration::days(minimum_notice_days);
153
154 let now = Utc::now();
156 if minimum_send_date < now {
157 return Err(format!(
158 "Meeting date too soon. {} meeting requires {} days notice. Minimum send date would be {}",
159 match meeting_type {
160 ConvocationType::Ordinary => "Ordinary",
161 ConvocationType::Extraordinary => "Extraordinary",
162 ConvocationType::SecondConvocation => "Second convocation",
163 },
164 minimum_notice_days,
165 minimum_send_date.format("%Y-%m-%d %H:%M")
166 ));
167 }
168
169 Ok(Self {
170 id: Uuid::new_v4(),
171 organization_id,
172 building_id,
173 meeting_id,
174 meeting_type,
175 meeting_date,
176 status: ConvocationStatus::Draft,
177 minimum_send_date,
178 actual_send_date: None,
179 scheduled_send_date: None,
180 pdf_file_path: None,
181 language: language.to_uppercase(),
182 total_recipients: 0,
183 opened_count: 0,
184 will_attend_count: 0,
185 will_not_attend_count: 0,
186 reminder_sent_at: None,
187 created_at: now,
188 updated_at: now,
189 created_by,
190 })
191 }
192
193 pub fn schedule(&mut self, send_date: DateTime<Utc>) -> Result<(), String> {
195 if self.status != ConvocationStatus::Draft {
196 return Err(format!(
197 "Cannot schedule convocation in status '{:?}'. Must be Draft",
198 self.status
199 ));
200 }
201
202 if send_date > self.minimum_send_date {
204 return Err(format!(
205 "Scheduled send date {} is after minimum send date {}. Meeting would not have required notice period",
206 send_date.format("%Y-%m-%d %H:%M"),
207 self.minimum_send_date.format("%Y-%m-%d %H:%M")
208 ));
209 }
210
211 self.scheduled_send_date = Some(send_date);
212 self.status = ConvocationStatus::Scheduled;
213 self.updated_at = Utc::now();
214 Ok(())
215 }
216
217 pub fn mark_sent(
219 &mut self,
220 pdf_file_path: String,
221 total_recipients: i32,
222 ) -> Result<(), String> {
223 if self.status != ConvocationStatus::Draft && self.status != ConvocationStatus::Scheduled {
224 return Err(format!(
225 "Cannot send convocation in status '{:?}'",
226 self.status
227 ));
228 }
229
230 if total_recipients <= 0 {
231 return Err("Total recipients must be greater than 0".to_string());
232 }
233
234 self.status = ConvocationStatus::Sent;
235 self.actual_send_date = Some(Utc::now());
236 self.pdf_file_path = Some(pdf_file_path);
237 self.total_recipients = total_recipients;
238 self.updated_at = Utc::now();
239 Ok(())
240 }
241
242 pub fn cancel(&mut self) -> Result<(), String> {
244 if self.status == ConvocationStatus::Cancelled {
245 return Err("Convocation is already cancelled".to_string());
246 }
247
248 self.status = ConvocationStatus::Cancelled;
249 self.updated_at = Utc::now();
250 Ok(())
251 }
252
253 pub fn mark_reminder_sent(&mut self) -> Result<(), String> {
255 if self.status != ConvocationStatus::Sent {
256 return Err("Cannot send reminder for unsent convocation".to_string());
257 }
258
259 self.reminder_sent_at = Some(Utc::now());
260 self.updated_at = Utc::now();
261 Ok(())
262 }
263
264 pub fn update_tracking_counts(
266 &mut self,
267 opened_count: i32,
268 will_attend_count: i32,
269 will_not_attend_count: i32,
270 ) {
271 self.opened_count = opened_count;
272 self.will_attend_count = will_attend_count;
273 self.will_not_attend_count = will_not_attend_count;
274 self.updated_at = Utc::now();
275 }
276
277 pub fn respects_legal_deadline(&self) -> bool {
279 match &self.actual_send_date {
280 Some(sent_at) => *sent_at <= self.minimum_send_date,
281 None => false, }
283 }
284
285 pub fn days_until_meeting(&self) -> i64 {
287 let now = Utc::now();
288 let duration = self.meeting_date.signed_duration_since(now);
289 duration.num_days()
290 }
291
292 pub fn should_send_reminder(&self) -> bool {
294 if self.status != ConvocationStatus::Sent {
295 return false;
296 }
297
298 if self.reminder_sent_at.is_some() {
299 return false; }
301
302 let days_until = self.days_until_meeting();
303 days_until <= 3 && days_until >= 0
304 }
305
306 pub fn opening_rate(&self) -> f64 {
308 if self.total_recipients == 0 {
309 return 0.0;
310 }
311 (self.opened_count as f64 / self.total_recipients as f64) * 100.0
312 }
313
314 pub fn attendance_rate(&self) -> f64 {
316 if self.total_recipients == 0 {
317 return 0.0;
318 }
319 (self.will_attend_count as f64 / self.total_recipients as f64) * 100.0
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn test_meeting_type_minimum_notice_days() {
329 assert_eq!(ConvocationType::Ordinary.minimum_notice_days(), 15);
330 assert_eq!(ConvocationType::Extraordinary.minimum_notice_days(), 8);
331 assert_eq!(ConvocationType::SecondConvocation.minimum_notice_days(), 8);
332 }
333
334 #[test]
335 fn test_create_convocation_success() {
336 let org_id = Uuid::new_v4();
337 let building_id = Uuid::new_v4();
338 let meeting_id = Uuid::new_v4();
339 let creator_id = Uuid::new_v4();
340 let meeting_date = Utc::now() + Duration::days(20);
341
342 let convocation = Convocation::new(
343 org_id,
344 building_id,
345 meeting_id,
346 ConvocationType::Ordinary,
347 meeting_date,
348 "FR".to_string(),
349 creator_id,
350 );
351
352 assert!(convocation.is_ok());
353 let conv = convocation.unwrap();
354 assert_eq!(conv.meeting_type, ConvocationType::Ordinary);
355 assert_eq!(conv.language, "FR");
356 assert_eq!(conv.status, ConvocationStatus::Draft);
357 assert_eq!(conv.total_recipients, 0);
358 }
359
360 #[test]
361 fn test_create_convocation_meeting_too_soon() {
362 let meeting_date = Utc::now() + Duration::days(5); let result = Convocation::new(
365 Uuid::new_v4(),
366 Uuid::new_v4(),
367 Uuid::new_v4(),
368 ConvocationType::Ordinary, meeting_date,
370 "FR".to_string(),
371 Uuid::new_v4(),
372 );
373
374 assert!(result.is_err());
375 assert!(result.unwrap_err().contains("Meeting date too soon"));
376 }
377
378 #[test]
379 fn test_create_convocation_invalid_language() {
380 let meeting_date = Utc::now() + Duration::days(20);
381
382 let result = Convocation::new(
383 Uuid::new_v4(),
384 Uuid::new_v4(),
385 Uuid::new_v4(),
386 ConvocationType::Ordinary,
387 meeting_date,
388 "ES".to_string(), Uuid::new_v4(),
390 );
391
392 assert!(result.is_err());
393 assert!(result.unwrap_err().contains("Invalid language"));
394 }
395
396 #[test]
397 fn test_schedule_convocation() {
398 let meeting_date = Utc::now() + Duration::days(20);
399 let mut convocation = Convocation::new(
400 Uuid::new_v4(),
401 Uuid::new_v4(),
402 Uuid::new_v4(),
403 ConvocationType::Ordinary,
404 meeting_date,
405 "FR".to_string(),
406 Uuid::new_v4(),
407 )
408 .unwrap();
409
410 let send_date = Utc::now() + Duration::days(3); let result = convocation.schedule(send_date);
412
413 assert!(result.is_ok());
414 assert_eq!(convocation.status, ConvocationStatus::Scheduled);
415 assert_eq!(convocation.scheduled_send_date, Some(send_date));
416 }
417
418 #[test]
419 fn test_schedule_convocation_too_late() {
420 let meeting_date = Utc::now() + Duration::days(20);
421 let mut convocation = Convocation::new(
422 Uuid::new_v4(),
423 Uuid::new_v4(),
424 Uuid::new_v4(),
425 ConvocationType::Ordinary,
426 meeting_date,
427 "FR".to_string(),
428 Uuid::new_v4(),
429 )
430 .unwrap();
431
432 let send_date = meeting_date - Duration::days(10); let result = convocation.schedule(send_date);
435
436 assert!(result.is_err());
437 assert!(result.unwrap_err().contains("after minimum send date"));
438 }
439
440 #[test]
441 fn test_mark_sent() {
442 let meeting_date = Utc::now() + Duration::days(20);
443 let mut convocation = Convocation::new(
444 Uuid::new_v4(),
445 Uuid::new_v4(),
446 Uuid::new_v4(),
447 ConvocationType::Ordinary,
448 meeting_date,
449 "FR".to_string(),
450 Uuid::new_v4(),
451 )
452 .unwrap();
453
454 let result = convocation.mark_sent("/uploads/convocations/conv-123.pdf".to_string(), 50);
455
456 assert!(result.is_ok());
457 assert_eq!(convocation.status, ConvocationStatus::Sent);
458 assert!(convocation.actual_send_date.is_some());
459 assert_eq!(convocation.total_recipients, 50);
460 assert_eq!(
461 convocation.pdf_file_path,
462 Some("/uploads/convocations/conv-123.pdf".to_string())
463 );
464 }
465
466 #[test]
467 fn test_should_send_reminder() {
468 let far_meeting_date = Utc::now() + Duration::days(10);
470 let mut convocation_far = Convocation::new(
471 Uuid::new_v4(),
472 Uuid::new_v4(),
473 Uuid::new_v4(),
474 ConvocationType::Extraordinary, far_meeting_date,
476 "FR".to_string(),
477 Uuid::new_v4(),
478 )
479 .unwrap();
480
481 convocation_far
482 .mark_sent("/uploads/conv.pdf".to_string(), 30)
483 .unwrap();
484
485 assert!(!convocation_far.should_send_reminder());
487
488 }
493
494 #[test]
495 fn test_opening_rate() {
496 let meeting_date = Utc::now() + Duration::days(20);
497 let mut convocation = Convocation::new(
498 Uuid::new_v4(),
499 Uuid::new_v4(),
500 Uuid::new_v4(),
501 ConvocationType::Ordinary,
502 meeting_date,
503 "FR".to_string(),
504 Uuid::new_v4(),
505 )
506 .unwrap();
507
508 convocation
509 .mark_sent("/uploads/conv.pdf".to_string(), 100)
510 .unwrap();
511 convocation.update_tracking_counts(75, 50, 10);
512
513 assert_eq!(convocation.opening_rate(), 75.0);
514 assert_eq!(convocation.attendance_rate(), 50.0);
515 }
516
517 #[test]
518 fn test_respects_legal_deadline() {
519 let meeting_date = Utc::now() + Duration::days(20);
520 let mut convocation = Convocation::new(
521 Uuid::new_v4(),
522 Uuid::new_v4(),
523 Uuid::new_v4(),
524 ConvocationType::Ordinary,
525 meeting_date,
526 "FR".to_string(),
527 Uuid::new_v4(),
528 )
529 .unwrap();
530
531 assert!(!convocation.respects_legal_deadline());
533
534 convocation
536 .mark_sent("/uploads/conv.pdf".to_string(), 30)
537 .unwrap();
538 assert!(convocation.respects_legal_deadline());
539 }
540}