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 pub async fn create_session(
30 &self,
31 organization_id: Uuid,
32 dto: CreateAgSessionDto,
33 created_by: Uuid,
34 ) -> Result<AgSessionResponse, String> {
35 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 fn make_meeting(org_id: Uuid) -> Meeting {
437 let future_date = Utc::now() + Duration::days(30);
438 Meeting::new(
439 Uuid::new_v4(), 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 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 #[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(); 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 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 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 {
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 {
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 assert_eq!(resp.remote_voting_power, dec!(150));
655 assert_eq!(resp.quorum_remote_contribution, dec!(15));
656 }
657}