Skip to main content

koprogo_api/domain/copropriete/
board_alert.rs

1//! L'alerte du conseil de copropriété — Story 4.7.
2//!
3//! Art. 3.90 § 1er confie au conseil la mission de « veiller à la bonne
4//! exécution par le syndic de ses missions », mais ne lui donne aucun canal
5//! pour agir sur ce constat : un titre sans instrument. `create_alert` est cet
6//! instrument minimal — un membre en mandat signale un fait à l'assemblée
7//! suivante ; celle-ci en décide.
8//!
9//! Voir issue #582.
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15/// Gravité déclarée par le membre qui émet l'alerte.
16#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
17pub enum AlertSeverity {
18    Info,
19    Warning,
20    Critical,
21}
22
23impl std::fmt::Display for AlertSeverity {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            AlertSeverity::Info => write!(f, "info"),
27            AlertSeverity::Warning => write!(f, "warning"),
28            AlertSeverity::Critical => write!(f, "critical"),
29        }
30    }
31}
32
33impl std::str::FromStr for AlertSeverity {
34    type Err = String;
35
36    fn from_str(s: &str) -> Result<Self, Self::Err> {
37        match s.to_lowercase().as_str() {
38            "info" => Ok(AlertSeverity::Info),
39            "warning" => Ok(AlertSeverity::Warning),
40            "critical" => Ok(AlertSeverity::Critical),
41            _ => Err(format!("Invalid alert severity: {}", s)),
42        }
43    }
44}
45
46/// Ce qui empêche la création d'une alerte. Typé — pas de `String` — pour
47/// que la couche application le mappe explicitement (cf. `AppError::from`).
48#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
49pub enum AlerteRefusee {
50    #[error("Le texte de l'alerte ne peut pas être vide")]
51    TexteVide,
52}
53
54/// Une alerte émise par le conseil de copropriété à destination de la
55/// prochaine assemblée générale.
56///
57/// `target_meeting_id` fixe la cible au moment de la création — c'est la
58/// traduction opérationnelle de `target=AG_next` : la couche application
59/// résout « la prochaine AG » (l'AG à venir de l'immeuble) et fige cette
60/// résolution ici, plutôt que de la recalculer dynamiquement à chaque
61/// lecture (une AG qui se tient entre-temps ne doit pas faire migrer une
62/// alerte déjà émise vers l'AG suivante).
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct BoardAlert {
65    pub id: Uuid,
66    pub building_id: Uuid,
67    /// Le membre du conseil (mandat en cours au moment de l'émission) qui
68    /// a levé l'alerte — référence `BoardMember.id`, pas `owner_id` : elle
69    /// trace le mandat précis, pas seulement la personne.
70    pub raised_by_board_member_id: Uuid,
71    pub text: String,
72    pub severity: AlertSeverity,
73    pub target_meeting_id: Uuid,
74    pub created_at: DateTime<Utc>,
75}
76
77impl BoardAlert {
78    pub fn new(
79        building_id: Uuid,
80        raised_by_board_member_id: Uuid,
81        text: String,
82        severity: AlertSeverity,
83        target_meeting_id: Uuid,
84    ) -> Result<Self, AlerteRefusee> {
85        if text.trim().is_empty() {
86            return Err(AlerteRefusee::TexteVide);
87        }
88
89        Ok(Self {
90            id: Uuid::new_v4(),
91            building_id,
92            raised_by_board_member_id,
93            text,
94            severity,
95            target_meeting_id,
96            created_at: Utc::now(),
97        })
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    fn ids() -> (Uuid, Uuid, Uuid) {
106        (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4())
107    }
108
109    // ── @happy ───────────────────────────────────────────────────────
110
111    #[test]
112    fn happy_create_alert_succeeds() {
113        let (building_id, member_id, meeting_id) = ids();
114        let alert = BoardAlert::new(
115            building_id,
116            member_id,
117            "L'ascenseur est bloqué depuis trois jours".to_string(),
118            AlertSeverity::Warning,
119            meeting_id,
120        )
121        .unwrap();
122
123        assert_eq!(alert.building_id, building_id);
124        assert_eq!(alert.raised_by_board_member_id, member_id);
125        assert_eq!(alert.target_meeting_id, meeting_id);
126        assert_eq!(alert.severity, AlertSeverity::Warning);
127    }
128
129    // ── @edge ────────────────────────────────────────────────────────
130
131    /// Un texte fait uniquement d'espaces n'informe personne — même refus
132    /// que `BoardDecision::new` sur `decision_text` vide.
133    #[test]
134    fn edge_whitespace_only_text_is_refused() {
135        let (building_id, member_id, meeting_id) = ids();
136        let result = BoardAlert::new(
137            building_id,
138            member_id,
139            "   \t  ".to_string(),
140            AlertSeverity::Info,
141            meeting_id,
142        );
143        assert_eq!(result.unwrap_err(), AlerteRefusee::TexteVide);
144    }
145
146    // ── @negative ────────────────────────────────────────────────────
147
148    #[test]
149    fn negative_empty_text_is_refused() {
150        let (building_id, member_id, meeting_id) = ids();
151        let result = BoardAlert::new(
152            building_id,
153            member_id,
154            String::new(),
155            AlertSeverity::Critical,
156            meeting_id,
157        );
158        assert_eq!(result.unwrap_err(), AlerteRefusee::TexteVide);
159    }
160
161    #[test]
162    fn negative_unknown_severity_string_fails_to_parse() {
163        let result: Result<AlertSeverity, String> = "urgentissime".parse();
164        assert!(result.is_err());
165    }
166
167    #[test]
168    fn happy_severity_display_and_from_str_round_trip() {
169        for s in [
170            AlertSeverity::Info,
171            AlertSeverity::Warning,
172            AlertSeverity::Critical,
173        ] {
174            let parsed: AlertSeverity = s.to_string().parse().unwrap();
175            assert_eq!(parsed, s);
176        }
177    }
178}