koprogo_api/domain/entities/
ag_session.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Plateforme de visioconférence supportée
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub enum VideoPlatform {
8    Zoom,
9    MicrosoftTeams,
10    GoogleMeet,
11    Jitsi, // Open-source, recommandé pour copropriétés (RGPD)
12    Whereby,
13    Other,
14}
15
16impl VideoPlatform {
17    pub fn from_db_string(s: &str) -> Result<Self, String> {
18        match s {
19            "zoom" => Ok(Self::Zoom),
20            "microsoft_teams" => Ok(Self::MicrosoftTeams),
21            "google_meet" => Ok(Self::GoogleMeet),
22            "jitsi" => Ok(Self::Jitsi),
23            "whereby" => Ok(Self::Whereby),
24            "other" => Ok(Self::Other),
25            _ => Err(format!("Unknown video platform: {}", s)),
26        }
27    }
28
29    pub fn to_db_str(&self) -> &'static str {
30        match self {
31            Self::Zoom => "zoom",
32            Self::MicrosoftTeams => "microsoft_teams",
33            Self::GoogleMeet => "google_meet",
34            Self::Jitsi => "jitsi",
35            Self::Whereby => "whereby",
36            Self::Other => "other",
37        }
38    }
39}
40
41/// Statut de la session vidéo
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub enum AgSessionStatus {
44    Scheduled, // Lien créé, pas encore démarré
45    Live,      // Session en cours
46    Ended,     // Session terminée normalement
47    Cancelled, // Session annulée
48}
49
50impl AgSessionStatus {
51    pub fn from_db_string(s: &str) -> Result<Self, String> {
52        match s {
53            "scheduled" => Ok(Self::Scheduled),
54            "live" => Ok(Self::Live),
55            "ended" => Ok(Self::Ended),
56            "cancelled" => Ok(Self::Cancelled),
57            _ => Err(format!("Unknown ag session status: {}", s)),
58        }
59    }
60
61    pub fn to_db_str(&self) -> &'static str {
62        match self {
63            Self::Scheduled => "scheduled",
64            Self::Live => "live",
65            Self::Ended => "ended",
66            Self::Cancelled => "cancelled",
67        }
68    }
69}
70
71/// Session de visioconférence pour une Assemblée Générale (Art. 3.87 §1 CC)
72///
73/// L'Art. 3.87 §1 CC permet aux copropriétaires de participer à l'AG
74/// "physiquement ou à distance au moyen d'une communication électronique".
75/// Cette entité gère la session vidéo associée à une réunion.
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
77pub struct AgSession {
78    pub id: Uuid,
79    pub organization_id: Uuid,
80    pub meeting_id: Uuid, // Lien vers la réunion AG
81    pub platform: VideoPlatform,
82    pub video_url: String,        // URL de la réunion (généré ou saisi)
83    pub host_url: Option<String>, // URL hôte (avec droits admin, privé)
84    pub status: AgSessionStatus,
85    pub scheduled_start: DateTime<Utc>,
86    pub actual_start: Option<DateTime<Utc>>,
87    pub actual_end: Option<DateTime<Utc>>,
88
89    // Quorum combiné — Art. 3.87 §5 CC
90    // présentiels + participants distanciels comptent ensemble
91    pub remote_attendees_count: i32, // Nb de participants en visio
92    pub remote_voting_power: f64,    // Millièmes représentés par les distanciels
93    pub quorum_remote_contribution: f64, // % contribution distancielle au quorum total
94
95    // Accès et sécurité
96    pub access_password: Option<String>, // Mot de passe de réunion (haché si nécessaire)
97    pub waiting_room_enabled: bool,      // Salle d'attente activée (recommandée)
98    pub recording_enabled: bool,         // Enregistrement (RGPD : consentement requis)
99    pub recording_url: Option<String>,   // URL enregistrement post-session
100
101    pub created_at: DateTime<Utc>,
102    pub updated_at: DateTime<Utc>,
103    pub created_by: Uuid,
104}
105
106impl AgSession {
107    /// Crée une nouvelle session de visioconférence
108    pub fn new(
109        organization_id: Uuid,
110        meeting_id: Uuid,
111        platform: VideoPlatform,
112        video_url: String,
113        host_url: Option<String>,
114        scheduled_start: DateTime<Utc>,
115        access_password: Option<String>,
116        waiting_room_enabled: bool,
117        recording_enabled: bool,
118        created_by: Uuid,
119    ) -> Result<Self, String> {
120        if video_url.trim().is_empty() {
121            return Err("L'URL de la session vidéo est obligatoire".to_string());
122        }
123
124        if !video_url.starts_with("https://") {
125            return Err(
126                "L'URL de la session vidéo doit utiliser HTTPS (sécurité obligatoire)".to_string(),
127            );
128        }
129
130        if scheduled_start <= Utc::now() {
131            return Err("La session doit être planifiée dans le futur".to_string());
132        }
133
134        let now = Utc::now();
135        Ok(Self {
136            id: Uuid::new_v4(),
137            organization_id,
138            meeting_id,
139            platform,
140            video_url,
141            host_url,
142            status: AgSessionStatus::Scheduled,
143            scheduled_start,
144            actual_start: None,
145            actual_end: None,
146            remote_attendees_count: 0,
147            remote_voting_power: 0.0,
148            quorum_remote_contribution: 0.0,
149            access_password,
150            waiting_room_enabled,
151            recording_enabled,
152            recording_url: None,
153            created_at: now,
154            updated_at: now,
155            created_by,
156        })
157    }
158
159    /// Démarre la session (Scheduled → Live)
160    pub fn start(&mut self) -> Result<(), String> {
161        if self.status != AgSessionStatus::Scheduled {
162            return Err(format!(
163                "Impossible de démarrer une session en statut {:?}",
164                self.status
165            ));
166        }
167        self.status = AgSessionStatus::Live;
168        self.actual_start = Some(Utc::now());
169        self.updated_at = Utc::now();
170        Ok(())
171    }
172
173    /// Termine la session (Live → Ended)
174    pub fn end(&mut self, recording_url: Option<String>) -> Result<(), String> {
175        if self.status != AgSessionStatus::Live {
176            return Err(format!(
177                "Impossible de terminer une session en statut {:?}",
178                self.status
179            ));
180        }
181        self.status = AgSessionStatus::Ended;
182        self.actual_end = Some(Utc::now());
183        self.recording_url = recording_url;
184        self.updated_at = Utc::now();
185        Ok(())
186    }
187
188    /// Annule la session (Scheduled → Cancelled)
189    pub fn cancel(&mut self) -> Result<(), String> {
190        if self.status != AgSessionStatus::Scheduled {
191            return Err(format!(
192                "Impossible d'annuler une session en statut {:?} (uniquement Scheduled)",
193                self.status
194            ));
195        }
196        self.status = AgSessionStatus::Cancelled;
197        self.updated_at = Utc::now();
198        Ok(())
199    }
200
201    /// Enregistre un participant distant et met à jour le quorum distanciel
202    ///
203    /// Art. 3.87 §5 CC : les participants en visio comptent pour le quorum
204    /// au même titre que les présents physiquement.
205    pub fn record_remote_join(
206        &mut self,
207        voting_power: f64,
208        total_building_quotas: f64,
209    ) -> Result<(), String> {
210        if self.status != AgSessionStatus::Live {
211            return Err(
212                "Impossible d'enregistrer un participant : session non démarrée".to_string(),
213            );
214        }
215        if voting_power < 0.0 || voting_power > total_building_quotas {
216            return Err(format!(
217                "Pouvoir de vote invalide : {} (total bâtiment : {})",
218                voting_power, total_building_quotas
219            ));
220        }
221        self.remote_attendees_count += 1;
222        self.remote_voting_power += voting_power;
223        if total_building_quotas > 0.0 {
224            self.quorum_remote_contribution =
225                (self.remote_voting_power / total_building_quotas) * 100.0;
226        }
227        self.updated_at = Utc::now();
228        Ok(())
229    }
230
231    /// Calcule le quorum combiné (présentiel + distanciel)
232    ///
233    /// Art. 3.87 §5 CC : nécessite >50% des millièmes
234    pub fn calculate_combined_quorum(
235        &self,
236        physical_quotas: f64,
237        total_building_quotas: f64,
238    ) -> Result<f64, String> {
239        if total_building_quotas <= 0.0 {
240            return Err("Total des quotas du bâtiment doit être positif".to_string());
241        }
242        let combined = physical_quotas + self.remote_voting_power;
243        Ok((combined / total_building_quotas) * 100.0)
244    }
245
246    /// Vérifie si la session est active (Live)
247    pub fn is_live(&self) -> bool {
248        self.status == AgSessionStatus::Live
249    }
250
251    /// Durée de la session en minutes (si terminée)
252    pub fn duration_minutes(&self) -> Option<i64> {
253        match (self.actual_start, self.actual_end) {
254            (Some(start), Some(end)) => Some((end - start).num_minutes()),
255            _ => None,
256        }
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    fn make_session() -> AgSession {
265        let future = Utc::now() + chrono::Duration::hours(2);
266        AgSession::new(
267            Uuid::new_v4(),
268            Uuid::new_v4(),
269            VideoPlatform::Jitsi,
270            "https://meet.jit.si/koprogo-ago-2026".to_string(),
271            None,
272            future,
273            None,
274            true,
275            false,
276            Uuid::new_v4(),
277        )
278        .unwrap()
279    }
280
281    #[test]
282    fn test_create_ag_session_success() {
283        let session = make_session();
284        assert_eq!(session.status, AgSessionStatus::Scheduled);
285        assert_eq!(session.remote_attendees_count, 0);
286        assert!(session.waiting_room_enabled);
287    }
288
289    #[test]
290    fn test_create_session_rejects_http_url() {
291        let future = Utc::now() + chrono::Duration::hours(2);
292        let result = AgSession::new(
293            Uuid::new_v4(),
294            Uuid::new_v4(),
295            VideoPlatform::Zoom,
296            "http://zoom.us/j/123".to_string(), // HTTP not allowed
297            None,
298            future,
299            None,
300            true,
301            false,
302            Uuid::new_v4(),
303        );
304        assert!(result.is_err());
305        assert!(result.unwrap_err().contains("HTTPS"));
306    }
307
308    #[test]
309    fn test_create_session_rejects_past_date() {
310        let past = Utc::now() - chrono::Duration::hours(1);
311        let result = AgSession::new(
312            Uuid::new_v4(),
313            Uuid::new_v4(),
314            VideoPlatform::Jitsi,
315            "https://meet.jit.si/test".to_string(),
316            None,
317            past,
318            None,
319            true,
320            false,
321            Uuid::new_v4(),
322        );
323        assert!(result.is_err());
324    }
325
326    #[test]
327    fn test_start_session() {
328        let mut session = make_session();
329        assert!(session.start().is_ok());
330        assert_eq!(session.status, AgSessionStatus::Live);
331        assert!(session.actual_start.is_some());
332    }
333
334    #[test]
335    fn test_start_session_twice_fails() {
336        let mut session = make_session();
337        session.start().unwrap();
338        assert!(session.start().is_err());
339    }
340
341    #[test]
342    fn test_end_session() {
343        let mut session = make_session();
344        session.start().unwrap();
345        assert!(session
346            .end(Some("https://recording.example.com/abc".to_string()))
347            .is_ok());
348        assert_eq!(session.status, AgSessionStatus::Ended);
349        assert!(session.actual_end.is_some());
350        assert!(session.recording_url.is_some());
351    }
352
353    #[test]
354    fn test_cancel_session() {
355        let mut session = make_session();
356        assert!(session.cancel().is_ok());
357        assert_eq!(session.status, AgSessionStatus::Cancelled);
358    }
359
360    #[test]
361    fn test_cancel_live_session_fails() {
362        let mut session = make_session();
363        session.start().unwrap();
364        assert!(session.cancel().is_err());
365    }
366
367    #[test]
368    fn test_record_remote_join_and_quorum() {
369        let mut session = make_session();
370        session.start().unwrap();
371
372        // 150 millièmes rejoignent en visio sur 1000 total
373        assert!(session.record_remote_join(150.0, 1000.0).is_ok());
374        assert_eq!(session.remote_attendees_count, 1);
375        assert!((session.remote_voting_power - 150.0).abs() < 0.01);
376        assert!((session.quorum_remote_contribution - 15.0).abs() < 0.01);
377
378        // 2e participant : 200 millièmes
379        assert!(session.record_remote_join(200.0, 1000.0).is_ok());
380        assert_eq!(session.remote_attendees_count, 2);
381        assert!((session.remote_voting_power - 350.0).abs() < 0.01);
382    }
383
384    #[test]
385    fn test_calculate_combined_quorum() {
386        let mut session = make_session();
387        session.start().unwrap();
388        session.record_remote_join(200.0, 1000.0).unwrap(); // 20% en visio
389
390        // 300 présents physiquement + 200 en visio = 500/1000 = 50% (pas suffisant, >50% requis)
391        let combined = session.calculate_combined_quorum(300.0, 1000.0).unwrap();
392        assert!((combined - 50.0).abs() < 0.01);
393
394        // 310 présents + 200 visio = 510/1000 = 51% → quorum atteint
395        let combined2 = session.calculate_combined_quorum(310.0, 1000.0).unwrap();
396        assert!(combined2 > 50.0);
397    }
398}