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