Skip to main content

koprogo_api/application/use_cases/
ag_session_use_cases.rs

1use crate::application::dto::ag_session_dto::{
2    AgSessionResponse, CombinedQuorumResponse, CreateAgSessionDto, EndAgSessionDto,
3    RecordRemoteJoinDto,
4};
5use crate::application::ports::ag_session_repository::AgSessionRepository;
6use crate::application::ports::meeting_repository::MeetingRepository;
7use crate::domain::entities::ag_session::{AgSession, VideoPlatform};
8use rust_decimal::Decimal;
9use std::sync::Arc;
10use uuid::Uuid;
11
12pub struct AgSessionUseCases {
13    pub ag_session_repo: Arc<dyn AgSessionRepository>,
14    pub meeting_repo: Arc<dyn MeetingRepository>,
15}
16
17impl AgSessionUseCases {
18    pub fn new(
19        ag_session_repo: Arc<dyn AgSessionRepository>,
20        meeting_repo: Arc<dyn MeetingRepository>,
21    ) -> Self {
22        Self {
23            ag_session_repo,
24            meeting_repo,
25        }
26    }
27
28    /// Crée une session de visioconférence pour une AG (B15-3)
29    pub async fn create_session(
30        &self,
31        organization_id: Uuid,
32        dto: CreateAgSessionDto,
33        created_by: Uuid,
34    ) -> Result<AgSessionResponse, String> {
35        // Vérifier que la réunion existe et appartient à l'organisation
36        let meeting = self
37            .meeting_repo
38            .find_by_id(dto.meeting_id)
39            .await?
40            .ok_or_else(|| format!("Réunion {} introuvable", dto.meeting_id))?;
41
42        if meeting.organization_id != organization_id {
43            return Err(
44                "Accès refusé : la réunion n'appartient pas à votre organisation".to_string(),
45            );
46        }
47
48        // Vérifier qu'il n'y a pas déjà une session pour cette réunion
49        if let Some(existing) = self
50            .ag_session_repo
51            .find_by_meeting_id(dto.meeting_id)
52            .await?
53        {
54            return Err(format!(
55                "Une session de visioconférence existe déjà pour cette réunion (id: {})",
56                existing.id
57            ));
58        }
59
60        let platform = VideoPlatform::from_db_string(&dto.platform)?;
61
62        let session = AgSession::new(
63            organization_id,
64            dto.meeting_id,
65            platform,
66            dto.video_url,
67            dto.host_url,
68            dto.scheduled_start,
69            dto.access_password,
70            dto.waiting_room_enabled.unwrap_or(true),
71            dto.recording_enabled.unwrap_or(false),
72            created_by,
73        )?;
74
75        let created = self.ag_session_repo.create(&session).await?;
76        Ok(AgSessionResponse::from(&created))
77    }
78
79    /// Récupère une session par ID
80    pub async fn get_session(
81        &self,
82        id: Uuid,
83        organization_id: Uuid,
84    ) -> Result<AgSessionResponse, String> {
85        let session = self
86            .ag_session_repo
87            .find_by_id(id)
88            .await?
89            .ok_or_else(|| format!("Session {} introuvable", id))?;
90
91        if session.organization_id != organization_id {
92            return Err("Accès refusé".to_string());
93        }
94
95        Ok(AgSessionResponse::from(&session))
96    }
97
98    /// Récupère la session associée à une réunion
99    pub async fn get_session_for_meeting(
100        &self,
101        meeting_id: Uuid,
102        organization_id: Uuid,
103    ) -> Result<Option<AgSessionResponse>, String> {
104        match self.ag_session_repo.find_by_meeting_id(meeting_id).await? {
105            Some(session) if session.organization_id == organization_id => {
106                Ok(Some(AgSessionResponse::from(&session)))
107            }
108            Some(_) => Err("Accès refusé".to_string()),
109            None => Ok(None),
110        }
111    }
112
113    /// Liste les sessions de l'organisation
114    pub async fn list_sessions(
115        &self,
116        organization_id: Uuid,
117    ) -> Result<Vec<AgSessionResponse>, String> {
118        let sessions = self
119            .ag_session_repo
120            .find_by_organization(organization_id)
121            .await?;
122        Ok(sessions.iter().map(AgSessionResponse::from).collect())
123    }
124
125    /// Démarre une session (Scheduled → Live)
126    pub async fn start_session(
127        &self,
128        id: Uuid,
129        organization_id: Uuid,
130    ) -> Result<AgSessionResponse, String> {
131        let mut session = self
132            .ag_session_repo
133            .find_by_id(id)
134            .await?
135            .ok_or_else(|| format!("Session {} introuvable", id))?;
136
137        if session.organization_id != organization_id {
138            return Err("Accès refusé".to_string());
139        }
140
141        session.start()?;
142        let updated = self.ag_session_repo.update(&session).await?;
143        Ok(AgSessionResponse::from(&updated))
144    }
145
146    /// Termine une session (Live → Ended)
147    pub async fn end_session(
148        &self,
149        id: Uuid,
150        organization_id: Uuid,
151        dto: EndAgSessionDto,
152    ) -> Result<AgSessionResponse, String> {
153        let mut session = self
154            .ag_session_repo
155            .find_by_id(id)
156            .await?
157            .ok_or_else(|| format!("Session {} introuvable", id))?;
158
159        if session.organization_id != organization_id {
160            return Err("Accès refusé".to_string());
161        }
162
163        session.end(dto.recording_url)?;
164        let updated = self.ag_session_repo.update(&session).await?;
165        Ok(AgSessionResponse::from(&updated))
166    }
167
168    /// Annule une session (Scheduled → Cancelled)
169    pub async fn cancel_session(
170        &self,
171        id: Uuid,
172        organization_id: Uuid,
173    ) -> Result<AgSessionResponse, String> {
174        let mut session = self
175            .ag_session_repo
176            .find_by_id(id)
177            .await?
178            .ok_or_else(|| format!("Session {} introuvable", id))?;
179
180        if session.organization_id != organization_id {
181            return Err("Accès refusé".to_string());
182        }
183
184        session.cancel()?;
185        let updated = self.ag_session_repo.update(&session).await?;
186        Ok(AgSessionResponse::from(&updated))
187    }
188
189    /// Enregistre un participant distant et recalcule le quorum distanciel
190    pub async fn record_remote_join(
191        &self,
192        id: Uuid,
193        organization_id: Uuid,
194        dto: RecordRemoteJoinDto,
195    ) -> Result<AgSessionResponse, String> {
196        let mut session = self
197            .ag_session_repo
198            .find_by_id(id)
199            .await?
200            .ok_or_else(|| format!("Session {} introuvable", id))?;
201
202        if session.organization_id != organization_id {
203            return Err("Accès refusé".to_string());
204        }
205
206        session.record_remote_join(dto.voting_power, dto.total_building_quotas)?;
207        let updated = self.ag_session_repo.update(&session).await?;
208        Ok(AgSessionResponse::from(&updated))
209    }
210
211    /// Calcule le quorum combiné (présentiel + distanciel) — Art. 3.87 §5 CC
212    pub async fn calculate_combined_quorum(
213        &self,
214        id: Uuid,
215        organization_id: Uuid,
216        physical_quotas: Decimal,
217        total_building_quotas: Decimal,
218        physical_owners_count: i32,
219        total_owners_count: i32,
220    ) -> Result<CombinedQuorumResponse, String> {
221        let session = self
222            .ag_session_repo
223            .find_by_id(id)
224            .await?
225            .ok_or_else(|| format!("Session {} introuvable", id))?;
226
227        if session.organization_id != organization_id {
228            return Err("Accès refusé".to_string());
229        }
230
231        let combined_pct =
232            session.calculate_combined_quorum(physical_quotas, total_building_quotas)?;
233        // Le seuil légal vit dans le domaine (Art. 3.87 §5 CC) — ne jamais le
234        // ré-écrire ici, sous peine de le voir diverger du chemin `Meeting`.
235        let quorum_reached = session.is_combined_quorum_reached(
236            physical_quotas,
237            total_building_quotas,
238            physical_owners_count,
239            total_owners_count,
240        )?;
241
242        Ok(CombinedQuorumResponse {
243            session_id: session.id,
244            meeting_id: session.meeting_id,
245            physical_quotas,
246            remote_quotas: session.remote_voting_power,
247            total_building_quotas,
248            combined_percentage: combined_pct,
249            physical_owners_count,
250            remote_attendees_count: session.remote_attendees_count,
251            total_owners_count,
252            quorum_reached,
253        })
254    }
255
256    /// Supprime une session (uniquement si Scheduled ou Cancelled)
257    pub async fn delete_session(&self, id: Uuid, organization_id: Uuid) -> Result<(), String> {
258        let session = self
259            .ag_session_repo
260            .find_by_id(id)
261            .await?
262            .ok_or_else(|| format!("Session {} introuvable", id))?;
263
264        if session.organization_id != organization_id {
265            return Err("Accès refusé".to_string());
266        }
267
268        use crate::domain::entities::ag_session::AgSessionStatus;
269        if session.status == AgSessionStatus::Live {
270            return Err("Impossible de supprimer une session en cours".to_string());
271        }
272
273        self.ag_session_repo.delete(id).await?;
274        Ok(())
275    }
276
277    /// Liste les sessions en attente de démarrage (platform stats helper)
278    pub async fn list_pending_sessions(&self) -> Result<Vec<AgSessionResponse>, String> {
279        let sessions = self.ag_session_repo.find_pending_start().await?;
280        Ok(sessions.iter().map(AgSessionResponse::from).collect())
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::application::dto::ag_session_dto::{CreateAgSessionDto, RecordRemoteJoinDto};
288    use crate::application::dto::PageRequest;
289    use crate::application::ports::ag_session_repository::AgSessionRepository;
290    use crate::application::ports::meeting_repository::MeetingRepository;
291    use crate::domain::entities::ag_session::{AgSession, AgSessionStatus};
292    use crate::domain::entities::meeting::{Meeting, MeetingType};
293    use async_trait::async_trait;
294    use chrono::{Duration, Utc};
295    use rust_decimal_macros::dec;
296    use std::collections::HashMap;
297    use std::sync::Mutex;
298
299    // ========== Mock AgSessionRepository ==========
300
301    struct MockAgSessionRepository {
302        sessions: Mutex<HashMap<Uuid, AgSession>>,
303    }
304
305    impl MockAgSessionRepository {
306        fn new() -> Self {
307            Self {
308                sessions: Mutex::new(HashMap::new()),
309            }
310        }
311    }
312
313    #[async_trait]
314    impl AgSessionRepository for MockAgSessionRepository {
315        async fn create(&self, session: &AgSession) -> Result<AgSession, String> {
316            let mut sessions = self.sessions.lock().unwrap();
317            sessions.insert(session.id, session.clone());
318            Ok(session.clone())
319        }
320
321        async fn find_by_id(&self, id: Uuid) -> Result<Option<AgSession>, String> {
322            let sessions = self.sessions.lock().unwrap();
323            Ok(sessions.get(&id).cloned())
324        }
325
326        async fn find_by_meeting_id(&self, meeting_id: Uuid) -> Result<Option<AgSession>, String> {
327            let sessions = self.sessions.lock().unwrap();
328            Ok(sessions
329                .values()
330                .find(|s| s.meeting_id == meeting_id)
331                .cloned())
332        }
333
334        async fn find_by_organization(
335            &self,
336            organization_id: Uuid,
337        ) -> Result<Vec<AgSession>, String> {
338            let sessions = self.sessions.lock().unwrap();
339            Ok(sessions
340                .values()
341                .filter(|s| s.organization_id == organization_id)
342                .cloned()
343                .collect())
344        }
345
346        async fn update(&self, session: &AgSession) -> Result<AgSession, String> {
347            let mut sessions = self.sessions.lock().unwrap();
348            sessions.insert(session.id, session.clone());
349            Ok(session.clone())
350        }
351
352        async fn delete(&self, id: Uuid) -> Result<bool, String> {
353            let mut sessions = self.sessions.lock().unwrap();
354            Ok(sessions.remove(&id).is_some())
355        }
356
357        async fn find_pending_start(&self) -> Result<Vec<AgSession>, String> {
358            let sessions = self.sessions.lock().unwrap();
359            Ok(sessions
360                .values()
361                .filter(|s| s.status == AgSessionStatus::Scheduled)
362                .cloned()
363                .collect())
364        }
365    }
366
367    // ========== Mock MeetingRepository ==========
368
369    struct MockMeetingRepository {
370        meetings: Mutex<HashMap<Uuid, Meeting>>,
371    }
372
373    impl MockMeetingRepository {
374        fn new() -> Self {
375            Self {
376                meetings: Mutex::new(HashMap::new()),
377            }
378        }
379
380        fn with_meeting(meeting: Meeting) -> Self {
381            let mut map = HashMap::new();
382            map.insert(meeting.id, meeting);
383            Self {
384                meetings: Mutex::new(map),
385            }
386        }
387    }
388
389    #[async_trait]
390    impl MeetingRepository for MockMeetingRepository {
391        async fn create(&self, meeting: &Meeting) -> Result<Meeting, String> {
392            let mut meetings = self.meetings.lock().unwrap();
393            meetings.insert(meeting.id, meeting.clone());
394            Ok(meeting.clone())
395        }
396
397        async fn find_by_id(&self, id: Uuid) -> Result<Option<Meeting>, String> {
398            let meetings = self.meetings.lock().unwrap();
399            Ok(meetings.get(&id).cloned())
400        }
401
402        async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Meeting>, String> {
403            let meetings = self.meetings.lock().unwrap();
404            Ok(meetings
405                .values()
406                .filter(|m| m.building_id == building_id)
407                .cloned()
408                .collect())
409        }
410
411        async fn update(&self, meeting: &Meeting) -> Result<Meeting, String> {
412            let mut meetings = self.meetings.lock().unwrap();
413            meetings.insert(meeting.id, meeting.clone());
414            Ok(meeting.clone())
415        }
416
417        async fn delete(&self, id: Uuid) -> Result<bool, String> {
418            let mut meetings = self.meetings.lock().unwrap();
419            Ok(meetings.remove(&id).is_some())
420        }
421
422        async fn find_all_paginated(
423            &self,
424            _page_request: &PageRequest,
425            _organization_id: Option<Uuid>,
426        ) -> Result<(Vec<Meeting>, i64), String> {
427            let meetings = self.meetings.lock().unwrap();
428            let all: Vec<Meeting> = meetings.values().cloned().collect();
429            let count = all.len() as i64;
430            Ok((all, count))
431        }
432    }
433
434    // ========== Helpers ==========
435
436    fn make_meeting(org_id: Uuid) -> Meeting {
437        let future_date = Utc::now() + Duration::days(30);
438        Meeting::new(
439            Uuid::new_v4(), // acp_id
440            org_id,
441            Uuid::new_v4(),
442            MeetingType::Ordinary,
443            "AGO 2026".to_string(),
444            Some("Assemblée générale ordinaire".to_string()),
445            future_date,
446            "Salle des fêtes".to_string(),
447        )
448        .unwrap()
449    }
450
451    fn make_create_dto(meeting_id: Uuid) -> CreateAgSessionDto {
452        CreateAgSessionDto {
453            meeting_id,
454            platform: "jitsi".to_string(),
455            video_url: "https://meet.jit.si/koprogo-ago-2026".to_string(),
456            host_url: None,
457            scheduled_start: Utc::now() + Duration::hours(2),
458            access_password: None,
459            waiting_room_enabled: Some(true),
460            recording_enabled: Some(false),
461        }
462    }
463
464    fn make_use_cases(
465        ag_repo: MockAgSessionRepository,
466        meeting_repo: MockMeetingRepository,
467    ) -> AgSessionUseCases {
468        AgSessionUseCases::new(Arc::new(ag_repo), Arc::new(meeting_repo))
469    }
470
471    /// Helper: insert a Scheduled session into the mock repo and return its ID + org_id
472    fn insert_scheduled_session(
473        ag_repo: &MockAgSessionRepository,
474        org_id: Uuid,
475        meeting_id: Uuid,
476    ) -> Uuid {
477        let future = Utc::now() + Duration::hours(2);
478        let session = AgSession::new(
479            org_id,
480            meeting_id,
481            VideoPlatform::Jitsi,
482            "https://meet.jit.si/koprogo-test".to_string(),
483            None,
484            future,
485            None,
486            true,
487            false,
488            Uuid::new_v4(),
489        )
490        .unwrap();
491        let session_id = session.id;
492        ag_repo.sessions.lock().unwrap().insert(session_id, session);
493        session_id
494    }
495
496    // ========== Tests ==========
497
498    #[tokio::test]
499    async fn test_create_session_success() {
500        let org_id = Uuid::new_v4();
501        let meeting = make_meeting(org_id);
502        let meeting_id = meeting.id;
503
504        let ag_repo = MockAgSessionRepository::new();
505        let meeting_repo = MockMeetingRepository::with_meeting(meeting);
506        let uc = make_use_cases(ag_repo, meeting_repo);
507        let created_by = Uuid::new_v4();
508
509        let dto = make_create_dto(meeting_id);
510        let result = uc.create_session(org_id, dto, created_by).await;
511
512        assert!(result.is_ok());
513        let resp = result.unwrap();
514        assert_eq!(resp.organization_id, org_id);
515        assert_eq!(resp.meeting_id, meeting_id);
516        assert_eq!(resp.platform, "jitsi");
517        assert_eq!(resp.status, "scheduled");
518        assert_eq!(resp.remote_attendees_count, 0);
519        assert!(resp.waiting_room_enabled);
520        assert!(!resp.recording_enabled);
521        assert_eq!(resp.created_by, created_by);
522    }
523
524    #[tokio::test]
525    async fn test_create_session_fail_meeting_not_found() {
526        let org_id = Uuid::new_v4();
527        let fake_meeting_id = Uuid::new_v4();
528
529        let ag_repo = MockAgSessionRepository::new();
530        let meeting_repo = MockMeetingRepository::new(); // Empty, no meetings
531        let uc = make_use_cases(ag_repo, meeting_repo);
532        let created_by = Uuid::new_v4();
533
534        let dto = make_create_dto(fake_meeting_id);
535        let result = uc.create_session(org_id, dto, created_by).await;
536
537        assert!(result.is_err());
538        assert!(result.unwrap_err().contains("introuvable"));
539    }
540
541    #[tokio::test]
542    async fn test_create_session_fail_wrong_organization() {
543        let org_id_a = Uuid::new_v4();
544        let org_id_b = Uuid::new_v4();
545        // Meeting belongs to org_id_a
546        let meeting = make_meeting(org_id_a);
547        let meeting_id = meeting.id;
548
549        let ag_repo = MockAgSessionRepository::new();
550        let meeting_repo = MockMeetingRepository::with_meeting(meeting);
551        let uc = make_use_cases(ag_repo, meeting_repo);
552        let created_by = Uuid::new_v4();
553
554        // Try to create session with org_id_b
555        let dto = make_create_dto(meeting_id);
556        let result = uc.create_session(org_id_b, dto, created_by).await;
557
558        assert!(result.is_err());
559        assert!(result
560            .unwrap_err()
561            .contains("n'appartient pas à votre organisation"));
562    }
563
564    #[tokio::test]
565    async fn test_start_session_success() {
566        let org_id = Uuid::new_v4();
567        let meeting_id = Uuid::new_v4();
568
569        let ag_repo = MockAgSessionRepository::new();
570        let session_id = insert_scheduled_session(&ag_repo, org_id, meeting_id);
571        let meeting_repo = MockMeetingRepository::new();
572        let uc = make_use_cases(ag_repo, meeting_repo);
573
574        let result = uc.start_session(session_id, org_id).await;
575
576        assert!(result.is_ok());
577        let resp = result.unwrap();
578        assert_eq!(resp.status, "live");
579        assert!(resp.actual_start.is_some());
580    }
581
582    #[tokio::test]
583    async fn test_cancel_session_success() {
584        let org_id = Uuid::new_v4();
585        let meeting_id = Uuid::new_v4();
586
587        let ag_repo = MockAgSessionRepository::new();
588        let session_id = insert_scheduled_session(&ag_repo, org_id, meeting_id);
589        let meeting_repo = MockMeetingRepository::new();
590        let uc = make_use_cases(ag_repo, meeting_repo);
591
592        let result = uc.cancel_session(session_id, org_id).await;
593
594        assert!(result.is_ok());
595        let resp = result.unwrap();
596        assert_eq!(resp.status, "cancelled");
597    }
598
599    #[tokio::test]
600    async fn test_delete_session_fail_live_session() {
601        let org_id = Uuid::new_v4();
602        let meeting_id = Uuid::new_v4();
603
604        let ag_repo = MockAgSessionRepository::new();
605        let session_id = insert_scheduled_session(&ag_repo, org_id, meeting_id);
606
607        // Start the session to make it Live
608        {
609            let mut sessions = ag_repo.sessions.lock().unwrap();
610            let session = sessions.get_mut(&session_id).unwrap();
611            session.start().unwrap();
612        }
613
614        let meeting_repo = MockMeetingRepository::new();
615        let uc = make_use_cases(ag_repo, meeting_repo);
616
617        let result = uc.delete_session(session_id, org_id).await;
618
619        assert!(result.is_err());
620        assert!(result
621            .unwrap_err()
622            .contains("Impossible de supprimer une session en cours"));
623    }
624
625    #[tokio::test]
626    async fn test_record_remote_join_success() {
627        let org_id = Uuid::new_v4();
628        let meeting_id = Uuid::new_v4();
629
630        let ag_repo = MockAgSessionRepository::new();
631        let session_id = insert_scheduled_session(&ag_repo, org_id, meeting_id);
632
633        // Start the session first (record_remote_join requires Live status)
634        {
635            let mut sessions = ag_repo.sessions.lock().unwrap();
636            let session = sessions.get_mut(&session_id).unwrap();
637            session.start().unwrap();
638        }
639
640        let meeting_repo = MockMeetingRepository::new();
641        let uc = make_use_cases(ag_repo, meeting_repo);
642
643        let dto = RecordRemoteJoinDto {
644            voting_power: dec!(150),
645            total_building_quotas: dec!(1000),
646        };
647
648        let result = uc.record_remote_join(session_id, org_id, dto).await;
649
650        assert!(result.is_ok());
651        let resp = result.unwrap();
652        assert_eq!(resp.remote_attendees_count, 1);
653        // Égalité Decimal exacte (#661) — plus de tolérance flottante.
654        assert_eq!(resp.remote_voting_power, dec!(150));
655        assert_eq!(resp.quorum_remote_contribution, dec!(15));
656    }
657}