koprogo_api/domain/entities/
convocation_recipient.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Attendance status for recipient
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub enum AttendanceStatus {
8    /// No response yet
9    Pending,
10    /// Will attend the meeting
11    WillAttend,
12    /// Will not attend
13    WillNotAttend,
14    /// Attended (marked after meeting)
15    Attended,
16    /// Did not attend (marked after meeting)
17    DidNotAttend,
18}
19
20impl AttendanceStatus {
21    pub fn to_db_string(&self) -> &'static str {
22        match self {
23            AttendanceStatus::Pending => "pending",
24            AttendanceStatus::WillAttend => "will_attend",
25            AttendanceStatus::WillNotAttend => "will_not_attend",
26            AttendanceStatus::Attended => "attended",
27            AttendanceStatus::DidNotAttend => "did_not_attend",
28        }
29    }
30
31    pub fn from_db_string(s: &str) -> Result<Self, String> {
32        match s {
33            "pending" => Ok(AttendanceStatus::Pending),
34            "will_attend" => Ok(AttendanceStatus::WillAttend),
35            "will_not_attend" => Ok(AttendanceStatus::WillNotAttend),
36            "attended" => Ok(AttendanceStatus::Attended),
37            "did_not_attend" => Ok(AttendanceStatus::DidNotAttend),
38            _ => Err(format!("Invalid attendance status: {}", s)),
39        }
40    }
41}
42
43/// Individual recipient of a convocation
44///
45/// Tracks delivery, opening, and attendance for each owner
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ConvocationRecipient {
48    pub id: Uuid,
49    pub convocation_id: Uuid,
50    pub owner_id: Uuid,
51    pub email: String,
52
53    // Email tracking
54    pub email_sent_at: Option<DateTime<Utc>>,
55    pub email_opened_at: Option<DateTime<Utc>>, // Email read receipt
56    pub email_failed: bool,
57    pub email_failure_reason: Option<String>,
58
59    // Reminder tracking
60    pub reminder_sent_at: Option<DateTime<Utc>>,
61    pub reminder_opened_at: Option<DateTime<Utc>>,
62
63    // Attendance tracking
64    pub attendance_status: AttendanceStatus,
65    pub attendance_updated_at: Option<DateTime<Utc>>,
66
67    // Proxy delegation (if owner delegates voting power)
68    pub proxy_owner_id: Option<Uuid>, // Delegated to this owner
69
70    // Audit
71    pub created_at: DateTime<Utc>,
72    pub updated_at: DateTime<Utc>,
73}
74
75impl ConvocationRecipient {
76    /// Create a new convocation recipient
77    pub fn new(convocation_id: Uuid, owner_id: Uuid, email: String) -> Result<Self, String> {
78        // Validate email
79        if email.is_empty() || !email.contains('@') {
80            return Err(format!("Invalid email address: {}", email));
81        }
82
83        let now = Utc::now();
84
85        Ok(Self {
86            id: Uuid::new_v4(),
87            convocation_id,
88            owner_id,
89            email,
90            email_sent_at: None,
91            email_opened_at: None,
92            email_failed: false,
93            email_failure_reason: None,
94            reminder_sent_at: None,
95            reminder_opened_at: None,
96            attendance_status: AttendanceStatus::Pending,
97            attendance_updated_at: None,
98            proxy_owner_id: None,
99            created_at: now,
100            updated_at: now,
101        })
102    }
103
104    /// Mark email as sent
105    pub fn mark_email_sent(&mut self) {
106        self.email_sent_at = Some(Utc::now());
107        self.updated_at = Utc::now();
108    }
109
110    /// Mark email as failed
111    pub fn mark_email_failed(&mut self, reason: String) {
112        self.email_failed = true;
113        self.email_failure_reason = Some(reason);
114        self.updated_at = Utc::now();
115    }
116
117    /// Mark email as opened (read receipt)
118    pub fn mark_email_opened(&mut self) -> Result<(), String> {
119        if self.email_sent_at.is_none() {
120            return Err("Cannot mark email as opened before it's sent".to_string());
121        }
122
123        if self.email_opened_at.is_some() {
124            return Ok(()); // Already marked as opened, idempotent
125        }
126
127        self.email_opened_at = Some(Utc::now());
128        self.updated_at = Utc::now();
129        Ok(())
130    }
131
132    /// Mark reminder as sent
133    pub fn mark_reminder_sent(&mut self) -> Result<(), String> {
134        if self.email_sent_at.is_none() {
135            return Err("Cannot send reminder before initial email".to_string());
136        }
137
138        self.reminder_sent_at = Some(Utc::now());
139        self.updated_at = Utc::now();
140        Ok(())
141    }
142
143    /// Mark reminder as opened
144    pub fn mark_reminder_opened(&mut self) -> Result<(), String> {
145        if self.reminder_sent_at.is_none() {
146            return Err("Cannot mark reminder as opened before it's sent".to_string());
147        }
148
149        self.reminder_opened_at = Some(Utc::now());
150        self.updated_at = Utc::now();
151        Ok(())
152    }
153
154    /// Update attendance status
155    pub fn update_attendance_status(&mut self, status: AttendanceStatus) -> Result<(), String> {
156        // Cannot change attendance after meeting (Attended/DidNotAttend is final)
157        if matches!(
158            self.attendance_status,
159            AttendanceStatus::Attended | AttendanceStatus::DidNotAttend
160        ) {
161            return Err(format!(
162                "Cannot change attendance after meeting. Current status: {:?}",
163                self.attendance_status
164            ));
165        }
166
167        self.attendance_status = status;
168        self.attendance_updated_at = Some(Utc::now());
169        self.updated_at = Utc::now();
170        Ok(())
171    }
172
173    /// Set proxy delegation
174    pub fn set_proxy(&mut self, proxy_owner_id: Uuid) -> Result<(), String> {
175        if proxy_owner_id == self.owner_id {
176            return Err("Cannot delegate to self".to_string());
177        }
178
179        self.proxy_owner_id = Some(proxy_owner_id);
180        self.updated_at = Utc::now();
181        Ok(())
182    }
183
184    /// Remove proxy delegation
185    pub fn remove_proxy(&mut self) {
186        self.proxy_owner_id = None;
187        self.updated_at = Utc::now();
188    }
189
190    /// Check if email was opened
191    pub fn has_opened_email(&self) -> bool {
192        self.email_opened_at.is_some()
193    }
194
195    /// Check if reminder was opened
196    pub fn has_opened_reminder(&self) -> bool {
197        self.reminder_opened_at.is_some()
198    }
199
200    /// Check if recipient needs reminder (email sent but not opened, no reminder sent yet)
201    pub fn needs_reminder(&self) -> bool {
202        self.email_sent_at.is_some()
203            && self.email_opened_at.is_none()
204            && self.reminder_sent_at.is_none()
205            && !self.email_failed
206    }
207
208    /// Check if owner has confirmed attendance (either will attend or will not attend)
209    pub fn has_confirmed_attendance(&self) -> bool {
210        matches!(
211            self.attendance_status,
212            AttendanceStatus::WillAttend | AttendanceStatus::WillNotAttend
213        )
214    }
215
216    /// Get days since email sent (if sent)
217    pub fn days_since_email_sent(&self) -> Option<i64> {
218        self.email_sent_at.map(|sent_at| {
219            let now = Utc::now();
220            now.signed_duration_since(sent_at).num_days()
221        })
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_create_recipient_success() {
231        let conv_id = Uuid::new_v4();
232        let owner_id = Uuid::new_v4();
233
234        let recipient =
235            ConvocationRecipient::new(conv_id, owner_id, "owner@example.com".to_string());
236
237        assert!(recipient.is_ok());
238        let r = recipient.unwrap();
239        assert_eq!(r.convocation_id, conv_id);
240        assert_eq!(r.owner_id, owner_id);
241        assert_eq!(r.email, "owner@example.com");
242        assert_eq!(r.attendance_status, AttendanceStatus::Pending);
243        assert!(!r.email_failed);
244    }
245
246    #[test]
247    fn test_create_recipient_invalid_email() {
248        let result =
249            ConvocationRecipient::new(Uuid::new_v4(), Uuid::new_v4(), "invalid-email".to_string());
250
251        assert!(result.is_err());
252        assert!(result.unwrap_err().contains("Invalid email"));
253    }
254
255    #[test]
256    fn test_mark_email_opened() {
257        let mut recipient = ConvocationRecipient::new(
258            Uuid::new_v4(),
259            Uuid::new_v4(),
260            "owner@example.com".to_string(),
261        )
262        .unwrap();
263
264        // Cannot mark opened before sent
265        assert!(recipient.mark_email_opened().is_err());
266
267        // Mark sent first
268        recipient.mark_email_sent();
269        assert!(recipient.email_sent_at.is_some());
270
271        // Now can mark opened
272        assert!(recipient.mark_email_opened().is_ok());
273        assert!(recipient.has_opened_email());
274
275        // Idempotent
276        assert!(recipient.mark_email_opened().is_ok());
277    }
278
279    #[test]
280    fn test_mark_email_failed() {
281        let mut recipient = ConvocationRecipient::new(
282            Uuid::new_v4(),
283            Uuid::new_v4(),
284            "owner@example.com".to_string(),
285        )
286        .unwrap();
287
288        recipient.mark_email_failed("Invalid email address".to_string());
289
290        assert!(recipient.email_failed);
291        assert_eq!(
292            recipient.email_failure_reason,
293            Some("Invalid email address".to_string())
294        );
295    }
296
297    #[test]
298    fn test_needs_reminder() {
299        let mut recipient = ConvocationRecipient::new(
300            Uuid::new_v4(),
301            Uuid::new_v4(),
302            "owner@example.com".to_string(),
303        )
304        .unwrap();
305
306        // Not sent yet
307        assert!(!recipient.needs_reminder());
308
309        // Sent but not opened
310        recipient.mark_email_sent();
311        assert!(recipient.needs_reminder());
312
313        // Opened
314        recipient.mark_email_opened().unwrap();
315        assert!(!recipient.needs_reminder());
316    }
317
318    #[test]
319    fn test_update_attendance_status() {
320        let mut recipient = ConvocationRecipient::new(
321            Uuid::new_v4(),
322            Uuid::new_v4(),
323            "owner@example.com".to_string(),
324        )
325        .unwrap();
326
327        // Update to will attend
328        assert!(recipient
329            .update_attendance_status(AttendanceStatus::WillAttend)
330            .is_ok());
331        assert_eq!(recipient.attendance_status, AttendanceStatus::WillAttend);
332        assert!(recipient.has_confirmed_attendance());
333
334        // Change mind to will not attend
335        assert!(recipient
336            .update_attendance_status(AttendanceStatus::WillNotAttend)
337            .is_ok());
338        assert_eq!(recipient.attendance_status, AttendanceStatus::WillNotAttend);
339
340        // Mark as attended (final)
341        assert!(recipient
342            .update_attendance_status(AttendanceStatus::Attended)
343            .is_ok());
344
345        // Cannot change after meeting
346        assert!(recipient
347            .update_attendance_status(AttendanceStatus::DidNotAttend)
348            .is_err());
349    }
350
351    #[test]
352    fn test_set_proxy() {
353        let mut recipient = ConvocationRecipient::new(
354            Uuid::new_v4(),
355            Uuid::new_v4(),
356            "owner@example.com".to_string(),
357        )
358        .unwrap();
359
360        let proxy_owner = Uuid::new_v4();
361
362        // Set proxy
363        assert!(recipient.set_proxy(proxy_owner).is_ok());
364        assert_eq!(recipient.proxy_owner_id, Some(proxy_owner));
365
366        // Cannot delegate to self
367        assert!(recipient.set_proxy(recipient.owner_id).is_err());
368
369        // Remove proxy
370        recipient.remove_proxy();
371        assert_eq!(recipient.proxy_owner_id, None);
372    }
373
374    #[test]
375    fn test_mark_reminder_sent() {
376        let mut recipient = ConvocationRecipient::new(
377            Uuid::new_v4(),
378            Uuid::new_v4(),
379            "owner@example.com".to_string(),
380        )
381        .unwrap();
382
383        // Cannot send reminder before initial email
384        assert!(recipient.mark_reminder_sent().is_err());
385
386        // Send initial email first
387        recipient.mark_email_sent();
388
389        // Now can send reminder
390        assert!(recipient.mark_reminder_sent().is_ok());
391        assert!(recipient.reminder_sent_at.is_some());
392    }
393}