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 pub async fn create_session(
29 &self,
30 organization_id: Uuid,
31 dto: CreateAgSessionDto,
32 created_by: Uuid,
33 ) -> Result<AgSessionResponse, String> {
34 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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(); 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 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 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 {
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 {
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}