Skip to main content

koprogo_api/domain/copropriete/
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, utoipa::ToSchema)]
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
75/// Ce qu'est le mandataire pressenti, du point de vue de l'ACP.
76///
77/// Un booléen nu au site d'appel — `set_proxy(id, true)` — ne dit pas de quoi
78/// il parle. Cette énumération force l'appelant à nommer ce qu'il a résolu, et
79/// laissera place aux autres qualités si le besoin vient (le conjoint non
80/// copropriétaire, par exemple, que l'Art. 3.87 § 7 traite différemment).
81///
82/// Le domaine ne sait pas *comment* on l'établit : c'est au cas d'usage de
83/// remonter `owners.user_id` puis le rôle de cet utilisateur. La règle, elle,
84/// reste ici.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum QualiteDuMandataire {
87    /// Un copropriétaire, ou toute personne que rien n'écarte.
88    Coproprietaire,
89    /// Le syndic en fonction de cette ACP.
90    Syndic,
91}
92
93impl ConvocationRecipient {
94    /// Create a new convocation recipient
95    pub fn new(convocation_id: Uuid, owner_id: Uuid, email: String) -> Result<Self, String> {
96        // Validate email
97        if email.is_empty() || !email.contains('@') {
98            return Err(format!("Invalid email address: {}", email));
99        }
100
101        let now = Utc::now();
102
103        Ok(Self {
104            id: Uuid::new_v4(),
105            convocation_id,
106            owner_id,
107            email,
108            email_sent_at: None,
109            email_opened_at: None,
110            email_failed: false,
111            email_failure_reason: None,
112            reminder_sent_at: None,
113            reminder_opened_at: None,
114            attendance_status: AttendanceStatus::Pending,
115            attendance_updated_at: None,
116            proxy_owner_id: None,
117            created_at: now,
118            updated_at: now,
119        })
120    }
121
122    /// Mark email as sent
123    pub fn mark_email_sent(&mut self) {
124        self.email_sent_at = Some(Utc::now());
125        self.updated_at = Utc::now();
126    }
127
128    /// Mark email as failed
129    pub fn mark_email_failed(&mut self, reason: String) {
130        self.email_failed = true;
131        self.email_failure_reason = Some(reason);
132        self.updated_at = Utc::now();
133    }
134
135    /// Mark email as opened (read receipt)
136    pub fn mark_email_opened(&mut self) -> Result<(), String> {
137        if self.email_sent_at.is_none() {
138            return Err("Cannot mark email as opened before it's sent".to_string());
139        }
140
141        if self.email_opened_at.is_some() {
142            return Ok(()); // Already marked as opened, idempotent
143        }
144
145        self.email_opened_at = Some(Utc::now());
146        self.updated_at = Utc::now();
147        Ok(())
148    }
149
150    /// Mark reminder as sent
151    pub fn mark_reminder_sent(&mut self) -> Result<(), String> {
152        if self.email_sent_at.is_none() {
153            return Err("Cannot send reminder before initial email".to_string());
154        }
155
156        self.reminder_sent_at = Some(Utc::now());
157        self.updated_at = Utc::now();
158        Ok(())
159    }
160
161    /// Mark reminder as opened
162    pub fn mark_reminder_opened(&mut self) -> Result<(), String> {
163        if self.reminder_sent_at.is_none() {
164            return Err("Cannot mark reminder as opened before it's sent".to_string());
165        }
166
167        self.reminder_opened_at = Some(Utc::now());
168        self.updated_at = Utc::now();
169        Ok(())
170    }
171
172    /// Update attendance status
173    pub fn update_attendance_status(&mut self, status: AttendanceStatus) -> Result<(), String> {
174        // Cannot change attendance after meeting (Attended/DidNotAttend is final)
175        if matches!(
176            self.attendance_status,
177            AttendanceStatus::Attended | AttendanceStatus::DidNotAttend
178        ) {
179            return Err(format!(
180                "Cannot change attendance after meeting. Current status: {:?}",
181                self.attendance_status
182            ));
183        }
184
185        self.attendance_status = status;
186        self.attendance_updated_at = Some(Utc::now());
187        self.updated_at = Utc::now();
188        Ok(())
189    }
190
191    /// Enregistre une procuration.
192    ///
193    /// # Art. 3.87 § 7, dernier alinéa
194    ///
195    /// > « Le syndic ne peut intervenir comme mandataire d'un copropriétaire à
196    /// > l'assemblée générale, nonobstant le droit pour lui, s'il est
197    /// > copropriétaire, de participer à ce titre aux délibérations. »
198    ///
199    /// La règle porte sur le **mandat**, pas sur le vote qui en découle : le
200    /// texte interdit d'« intervenir comme mandataire ». On refuse donc ici, à
201    /// l'enregistrement.
202    ///
203    /// C'est ce qui la distingue des deux autres règles du § 7 — le plafond de
204    /// trois procurations et celui des voix. Celles-là ne *peuvent pas* se
205    /// vérifier au moment du mandat : on ignore encore combien de procurations
206    /// le mandataire recevra et quel poids elles pèseront. Elles restent donc
207    /// dans `procurations.rs`, sur l'ensemble des voix d'une séance.
208    ///
209    /// L'enjeu est pratique. Une assemblée tenue sur un mandat prohibé est
210    /// attaquable, et ce sont ses décisions — travaux, budgets, mandats — qui
211    /// tombent avec elle. Refuser à l'enregistrement évite la séance entière ;
212    /// refuser au dépouillement ne fait que constater les dégâts.
213    ///
214    /// La seconde partie de l'alinéa est respectée : rien n'empêche le syndic
215    /// copropriétaire d'être **destinataire** et de voter pour lui-même. Ce
216    /// qu'on refuse, c'est qu'il soit le mandataire d'un autre.
217    pub fn set_proxy(
218        &mut self,
219        proxy_owner_id: Uuid,
220        qualite: QualiteDuMandataire,
221    ) -> Result<(), String> {
222        if proxy_owner_id == self.owner_id {
223            return Err("Cannot delegate to self".to_string());
224        }
225
226        if qualite == QualiteDuMandataire::Syndic {
227            return Err(
228                "Art. 3.87 § 7 : le syndic ne peut intervenir comme mandataire d'un copropriétaire"
229                    .to_string(),
230            );
231        }
232
233        self.proxy_owner_id = Some(proxy_owner_id);
234        self.updated_at = Utc::now();
235        Ok(())
236    }
237
238    /// Remove proxy delegation
239    pub fn remove_proxy(&mut self) {
240        self.proxy_owner_id = None;
241        self.updated_at = Utc::now();
242    }
243
244    /// Check if email was opened
245    pub fn has_opened_email(&self) -> bool {
246        self.email_opened_at.is_some()
247    }
248
249    /// Check if reminder was opened
250    pub fn has_opened_reminder(&self) -> bool {
251        self.reminder_opened_at.is_some()
252    }
253
254    /// Check if recipient needs reminder (email sent but not opened, no reminder sent yet)
255    pub fn needs_reminder(&self) -> bool {
256        self.email_sent_at.is_some()
257            && self.email_opened_at.is_none()
258            && self.reminder_sent_at.is_none()
259            && !self.email_failed
260    }
261
262    /// Check if owner has confirmed attendance (either will attend or will not attend)
263    pub fn has_confirmed_attendance(&self) -> bool {
264        matches!(
265            self.attendance_status,
266            AttendanceStatus::WillAttend | AttendanceStatus::WillNotAttend
267        )
268    }
269
270    /// Get days since email sent (if sent)
271    pub fn days_since_email_sent(&self) -> Option<i64> {
272        self.email_sent_at.map(|sent_at| {
273            let now = Utc::now();
274            now.signed_duration_since(sent_at).num_days()
275        })
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn test_create_recipient_success() {
285        let conv_id = Uuid::new_v4();
286        let owner_id = Uuid::new_v4();
287
288        let recipient =
289            ConvocationRecipient::new(conv_id, owner_id, "owner@example.com".to_string());
290
291        assert!(recipient.is_ok());
292        let r = recipient.unwrap();
293        assert_eq!(r.convocation_id, conv_id);
294        assert_eq!(r.owner_id, owner_id);
295        assert_eq!(r.email, "owner@example.com");
296        assert_eq!(r.attendance_status, AttendanceStatus::Pending);
297        assert!(!r.email_failed);
298    }
299
300    #[test]
301    fn test_create_recipient_invalid_email() {
302        let result =
303            ConvocationRecipient::new(Uuid::new_v4(), Uuid::new_v4(), "invalid-email".to_string());
304
305        assert!(result.is_err());
306        assert!(result.unwrap_err().contains("Invalid email"));
307    }
308
309    #[test]
310    fn test_mark_email_opened() {
311        let mut recipient = ConvocationRecipient::new(
312            Uuid::new_v4(),
313            Uuid::new_v4(),
314            "owner@example.com".to_string(),
315        )
316        .unwrap();
317
318        // Cannot mark opened before sent
319        assert!(recipient.mark_email_opened().is_err());
320
321        // Mark sent first
322        recipient.mark_email_sent();
323        assert!(recipient.email_sent_at.is_some());
324
325        // Now can mark opened
326        assert!(recipient.mark_email_opened().is_ok());
327        assert!(recipient.has_opened_email());
328
329        // Idempotent
330        assert!(recipient.mark_email_opened().is_ok());
331    }
332
333    #[test]
334    fn test_mark_email_failed() {
335        let mut recipient = ConvocationRecipient::new(
336            Uuid::new_v4(),
337            Uuid::new_v4(),
338            "owner@example.com".to_string(),
339        )
340        .unwrap();
341
342        recipient.mark_email_failed("Invalid email address".to_string());
343
344        assert!(recipient.email_failed);
345        assert_eq!(
346            recipient.email_failure_reason,
347            Some("Invalid email address".to_string())
348        );
349    }
350
351    #[test]
352    fn test_needs_reminder() {
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        // Not sent yet
361        assert!(!recipient.needs_reminder());
362
363        // Sent but not opened
364        recipient.mark_email_sent();
365        assert!(recipient.needs_reminder());
366
367        // Opened
368        recipient.mark_email_opened().unwrap();
369        assert!(!recipient.needs_reminder());
370    }
371
372    #[test]
373    fn test_update_attendance_status() {
374        let mut recipient = ConvocationRecipient::new(
375            Uuid::new_v4(),
376            Uuid::new_v4(),
377            "owner@example.com".to_string(),
378        )
379        .unwrap();
380
381        // Update to will attend
382        assert!(recipient
383            .update_attendance_status(AttendanceStatus::WillAttend)
384            .is_ok());
385        assert_eq!(recipient.attendance_status, AttendanceStatus::WillAttend);
386        assert!(recipient.has_confirmed_attendance());
387
388        // Change mind to will not attend
389        assert!(recipient
390            .update_attendance_status(AttendanceStatus::WillNotAttend)
391            .is_ok());
392        assert_eq!(recipient.attendance_status, AttendanceStatus::WillNotAttend);
393
394        // Mark as attended (final)
395        assert!(recipient
396            .update_attendance_status(AttendanceStatus::Attended)
397            .is_ok());
398
399        // Cannot change after meeting
400        assert!(recipient
401            .update_attendance_status(AttendanceStatus::DidNotAttend)
402            .is_err());
403    }
404
405    #[test]
406    fn test_set_proxy() {
407        let mut recipient = ConvocationRecipient::new(
408            Uuid::new_v4(),
409            Uuid::new_v4(),
410            "owner@example.com".to_string(),
411        )
412        .unwrap();
413
414        let proxy_owner = Uuid::new_v4();
415
416        // Set proxy
417        assert!(recipient
418            .set_proxy(proxy_owner, QualiteDuMandataire::Coproprietaire)
419            .is_ok());
420        assert_eq!(recipient.proxy_owner_id, Some(proxy_owner));
421
422        // Cannot delegate to self
423        assert!(recipient
424            .set_proxy(recipient.owner_id, QualiteDuMandataire::Coproprietaire)
425            .is_err());
426
427        // Remove proxy
428        recipient.remove_proxy();
429        assert_eq!(recipient.proxy_owner_id, None);
430    }
431
432    /// Art. 3.87 § 7, dernier alinéa : le syndic ne peut intervenir comme
433    /// mandataire d'un copropriétaire à l'assemblée générale.
434    ///
435    /// La règle porte sur le mandat lui-même, donc sur cet appel, et non
436    /// seulement sur le vote qui en découlerait (#829).
437    #[test]
438    fn le_syndic_ne_peut_pas_recevoir_de_procuration() {
439        let mut destinataire = ConvocationRecipient::new(
440            Uuid::new_v4(),
441            Uuid::new_v4(),
442            "coproprietaire@example.be".to_string(),
443        )
444        .unwrap();
445
446        let syndic = Uuid::new_v4();
447        let erreur = destinataire
448            .set_proxy(syndic, QualiteDuMandataire::Syndic)
449            .expect_err("le mandat au syndic doit être refusé");
450
451        assert!(
452            erreur.contains("3.87"),
453            "le message doit citer l'article qui fonde le refus, reçu : {erreur}"
454        );
455        assert_eq!(
456            destinataire.proxy_owner_id, None,
457            "un mandat refusé ne doit rien laisser derrière lui"
458        );
459    }
460
461    /// Le § 7 réserve expressément au syndic copropriétaire le droit de
462    /// « participer à ce titre aux délibérations ». Être destinataire et voter
463    /// pour soi-même reste donc permis : ce qu'on refuse, c'est qu'il soit le
464    /// mandataire d'un AUTRE.
465    ///
466    /// Sans ce cas, la règle précédente passerait aussi avec une implémentation
467    /// qui écarterait le syndic de toute la convocation.
468    #[test]
469    fn le_syndic_coproprietaire_reste_destinataire_a_part_entiere() {
470        let syndic_coproprietaire = Uuid::new_v4();
471        let mut destinataire = ConvocationRecipient::new(
472            Uuid::new_v4(),
473            syndic_coproprietaire,
474            "syndic@example.be".to_string(),
475        )
476        .unwrap();
477
478        assert!(destinataire
479            .update_attendance_status(AttendanceStatus::WillAttend)
480            .is_ok());
481
482        // Et il peut donner procuration à un copropriétaire, comme n'importe
483        // qui : l'interdiction porte sur le fait de la RECEVOIR.
484        assert!(destinataire
485            .set_proxy(Uuid::new_v4(), QualiteDuMandataire::Coproprietaire)
486            .is_ok());
487    }
488
489    #[test]
490    fn test_mark_reminder_sent() {
491        let mut recipient = ConvocationRecipient::new(
492            Uuid::new_v4(),
493            Uuid::new_v4(),
494            "owner@example.com".to_string(),
495        )
496        .unwrap();
497
498        // Cannot send reminder before initial email
499        assert!(recipient.mark_reminder_sent().is_err());
500
501        // Send initial email first
502        recipient.mark_email_sent();
503
504        // Now can send reminder
505        assert!(recipient.mark_reminder_sent().is_ok());
506        assert!(recipient.reminder_sent_at.is_some());
507    }
508}