1use crate::domain::entities::Meeting;
2use chrono::{DateTime, Utc};
3use rust_decimal::Decimal;
4use rust_decimal_macros::dec;
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub enum VideoPlatform {
11 Zoom,
12 MicrosoftTeams,
13 GoogleMeet,
14 Jitsi, Whereby,
16 Other,
17}
18
19impl VideoPlatform {
20 pub fn from_db_string(s: &str) -> Result<Self, String> {
21 match s {
22 "zoom" => Ok(Self::Zoom),
23 "microsoft_teams" => Ok(Self::MicrosoftTeams),
24 "google_meet" => Ok(Self::GoogleMeet),
25 "jitsi" => Ok(Self::Jitsi),
26 "whereby" => Ok(Self::Whereby),
27 "other" => Ok(Self::Other),
28 _ => Err(format!("Unknown video platform: {}", s)),
29 }
30 }
31
32 pub fn to_db_str(&self) -> &'static str {
33 match self {
34 Self::Zoom => "zoom",
35 Self::MicrosoftTeams => "microsoft_teams",
36 Self::GoogleMeet => "google_meet",
37 Self::Jitsi => "jitsi",
38 Self::Whereby => "whereby",
39 Self::Other => "other",
40 }
41 }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46pub enum AgSessionStatus {
47 Scheduled, Live, Ended, Cancelled, }
52
53impl AgSessionStatus {
54 pub fn from_db_string(s: &str) -> Result<Self, String> {
55 match s {
56 "scheduled" => Ok(Self::Scheduled),
57 "live" => Ok(Self::Live),
58 "ended" => Ok(Self::Ended),
59 "cancelled" => Ok(Self::Cancelled),
60 _ => Err(format!("Unknown ag session status: {}", s)),
61 }
62 }
63
64 pub fn to_db_str(&self) -> &'static str {
65 match self {
66 Self::Scheduled => "scheduled",
67 Self::Live => "live",
68 Self::Ended => "ended",
69 Self::Cancelled => "cancelled",
70 }
71 }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
80pub struct AgSession {
81 pub id: Uuid,
82 pub organization_id: Uuid,
83 pub meeting_id: Uuid, pub platform: VideoPlatform,
85 pub video_url: String, pub host_url: Option<String>, pub status: AgSessionStatus,
88 pub scheduled_start: DateTime<Utc>,
89 pub actual_start: Option<DateTime<Utc>>,
90 pub actual_end: Option<DateTime<Utc>>,
91
92 pub remote_attendees_count: i32, pub remote_voting_power: Decimal,
98 pub quorum_remote_contribution: Decimal,
102
103 pub access_password: Option<String>, pub waiting_room_enabled: bool, pub recording_enabled: bool, pub recording_url: Option<String>, pub created_at: DateTime<Utc>,
110 pub updated_at: DateTime<Utc>,
111 pub created_by: Uuid,
112}
113
114impl AgSession {
115 pub fn new(
117 organization_id: Uuid,
118 meeting_id: Uuid,
119 platform: VideoPlatform,
120 video_url: String,
121 host_url: Option<String>,
122 scheduled_start: DateTime<Utc>,
123 access_password: Option<String>,
124 waiting_room_enabled: bool,
125 recording_enabled: bool,
126 created_by: Uuid,
127 ) -> Result<Self, String> {
128 if video_url.trim().is_empty() {
129 return Err("L'URL de la session vidéo est obligatoire".to_string());
130 }
131
132 if !video_url.starts_with("https://") {
133 return Err(
134 "L'URL de la session vidéo doit utiliser HTTPS (sécurité obligatoire)".to_string(),
135 );
136 }
137
138 if scheduled_start <= Utc::now() {
139 return Err("La session doit être planifiée dans le futur".to_string());
140 }
141
142 let now = Utc::now();
143 Ok(Self {
144 id: Uuid::new_v4(),
145 organization_id,
146 meeting_id,
147 platform,
148 video_url,
149 host_url,
150 status: AgSessionStatus::Scheduled,
151 scheduled_start,
152 actual_start: None,
153 actual_end: None,
154 remote_attendees_count: 0,
155 remote_voting_power: Decimal::ZERO,
156 quorum_remote_contribution: Decimal::ZERO,
157 access_password,
158 waiting_room_enabled,
159 recording_enabled,
160 recording_url: None,
161 created_at: now,
162 updated_at: now,
163 created_by,
164 })
165 }
166
167 pub fn start(&mut self) -> Result<(), String> {
169 if self.status != AgSessionStatus::Scheduled {
170 return Err(format!(
171 "Impossible de démarrer une session en statut {:?}",
172 self.status
173 ));
174 }
175 self.status = AgSessionStatus::Live;
176 self.actual_start = Some(Utc::now());
177 self.updated_at = Utc::now();
178 Ok(())
179 }
180
181 pub fn end(&mut self, recording_url: Option<String>) -> Result<(), String> {
183 if self.status != AgSessionStatus::Live {
184 return Err(format!(
185 "Impossible de terminer une session en statut {:?}",
186 self.status
187 ));
188 }
189 self.status = AgSessionStatus::Ended;
190 self.actual_end = Some(Utc::now());
191 self.recording_url = recording_url;
192 self.updated_at = Utc::now();
193 Ok(())
194 }
195
196 pub fn cancel(&mut self) -> Result<(), String> {
198 if self.status != AgSessionStatus::Scheduled {
199 return Err(format!(
200 "Impossible d'annuler une session en statut {:?} (uniquement Scheduled)",
201 self.status
202 ));
203 }
204 self.status = AgSessionStatus::Cancelled;
205 self.updated_at = Utc::now();
206 Ok(())
207 }
208
209 pub fn record_remote_join(
214 &mut self,
215 voting_power: Decimal,
216 total_building_quotas: Decimal,
217 ) -> Result<(), String> {
218 if self.status != AgSessionStatus::Live {
219 return Err(
220 "Impossible d'enregistrer un participant : session non démarrée".to_string(),
221 );
222 }
223 if voting_power < Decimal::ZERO || voting_power > total_building_quotas {
224 return Err(format!(
225 "Pouvoir de vote invalide : {} (total bâtiment : {})",
226 voting_power, total_building_quotas
227 ));
228 }
229 self.remote_attendees_count += 1;
230 self.remote_voting_power += voting_power;
231 if total_building_quotas > Decimal::ZERO {
232 self.quorum_remote_contribution =
233 (self.remote_voting_power / total_building_quotas) * dec!(100);
234 }
235 self.updated_at = Utc::now();
236 Ok(())
237 }
238
239 pub fn calculate_combined_quorum(
245 &self,
246 physical_quotas: Decimal,
247 total_building_quotas: Decimal,
248 ) -> Result<Decimal, String> {
249 if total_building_quotas <= Decimal::ZERO {
250 return Err("Total des quotas du bâtiment doit être positif".to_string());
251 }
252 if physical_quotas < Decimal::ZERO {
253 return Err("Les quotas présentiels ne peuvent pas être négatifs".to_string());
254 }
255 let combined = physical_quotas + self.remote_voting_power;
256 if combined > total_building_quotas {
257 return Err(format!(
258 "Quorum combiné invalide : {} quotes-parts pour un total bâtiment de {}",
259 combined, total_building_quotas
260 ));
261 }
262 Ok((combined / total_building_quotas) * dec!(100))
263 }
264
265 pub fn is_combined_quorum_reached(
280 &self,
281 physical_quotas: Decimal,
282 total_building_quotas: Decimal,
283 physical_owners_count: i32,
284 total_owners_count: i32,
285 ) -> Result<bool, String> {
286 self.calculate_combined_quorum(physical_quotas, total_building_quotas)?;
288
289 if physical_owners_count < 0 {
290 return Err("Le nombre de présents physiques ne peut pas être négatif".to_string());
291 }
292 let combined_owners = physical_owners_count + self.remote_attendees_count;
293 if total_owners_count > 0 && combined_owners > total_owners_count {
294 return Err(format!(
295 "Quorum en têtes invalide : {} présents pour {} copropriétaires",
296 combined_owners, total_owners_count
297 ));
298 }
299
300 Ok(Meeting::double_quorum_reached(
301 physical_quotas + self.remote_voting_power,
302 total_building_quotas,
303 combined_owners,
304 total_owners_count,
305 ))
306 }
307
308 pub fn is_live(&self) -> bool {
310 self.status == AgSessionStatus::Live
311 }
312
313 pub fn duration_minutes(&self) -> Option<i64> {
315 match (self.actual_start, self.actual_end) {
316 (Some(start), Some(end)) => Some((end - start).num_minutes()),
317 _ => None,
318 }
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 fn make_session() -> AgSession {
327 let future = Utc::now() + chrono::Duration::hours(2);
328 AgSession::new(
329 Uuid::new_v4(),
330 Uuid::new_v4(),
331 VideoPlatform::Jitsi,
332 "https://meet.jit.si/koprogo-ago-2026".to_string(),
333 None,
334 future,
335 None,
336 true,
337 false,
338 Uuid::new_v4(),
339 )
340 .unwrap()
341 }
342
343 #[test]
344 fn test_create_ag_session_success() {
345 let session = make_session();
346 assert_eq!(session.status, AgSessionStatus::Scheduled);
347 assert_eq!(session.remote_attendees_count, 0);
348 assert!(session.waiting_room_enabled);
349 }
350
351 #[test]
352 fn test_create_session_rejects_http_url() {
353 let future = Utc::now() + chrono::Duration::hours(2);
354 let result = AgSession::new(
355 Uuid::new_v4(),
356 Uuid::new_v4(),
357 VideoPlatform::Zoom,
358 "http://zoom.us/j/123".to_string(), None,
360 future,
361 None,
362 true,
363 false,
364 Uuid::new_v4(),
365 );
366 assert!(result.is_err());
367 assert!(result.unwrap_err().contains("HTTPS"));
368 }
369
370 #[test]
371 fn test_create_session_rejects_past_date() {
372 let past = Utc::now() - chrono::Duration::hours(1);
373 let result = AgSession::new(
374 Uuid::new_v4(),
375 Uuid::new_v4(),
376 VideoPlatform::Jitsi,
377 "https://meet.jit.si/test".to_string(),
378 None,
379 past,
380 None,
381 true,
382 false,
383 Uuid::new_v4(),
384 );
385 assert!(result.is_err());
386 }
387
388 #[test]
389 fn test_start_session() {
390 let mut session = make_session();
391 assert!(session.start().is_ok());
392 assert_eq!(session.status, AgSessionStatus::Live);
393 assert!(session.actual_start.is_some());
394 }
395
396 #[test]
397 fn test_start_session_twice_fails() {
398 let mut session = make_session();
399 session.start().unwrap();
400 assert!(session.start().is_err());
401 }
402
403 #[test]
404 fn test_end_session() {
405 let mut session = make_session();
406 session.start().unwrap();
407 assert!(session
408 .end(Some("https://recording.example.com/abc".to_string()))
409 .is_ok());
410 assert_eq!(session.status, AgSessionStatus::Ended);
411 assert!(session.actual_end.is_some());
412 assert!(session.recording_url.is_some());
413 }
414
415 #[test]
416 fn test_cancel_session() {
417 let mut session = make_session();
418 assert!(session.cancel().is_ok());
419 assert_eq!(session.status, AgSessionStatus::Cancelled);
420 }
421
422 #[test]
423 fn test_cancel_live_session_fails() {
424 let mut session = make_session();
425 session.start().unwrap();
426 assert!(session.cancel().is_err());
427 }
428
429 #[test]
439 fn test_record_remote_join_and_quorum() {
440 let mut session = make_session();
441 session.start().unwrap();
442
443 assert!(session.record_remote_join(dec!(150), dec!(1000)).is_ok());
445 assert_eq!(session.remote_attendees_count, 1);
446 assert_eq!(session.remote_voting_power, dec!(150));
447 assert_eq!(session.quorum_remote_contribution, dec!(15));
448
449 assert!(session.record_remote_join(dec!(200), dec!(1000)).is_ok());
451 assert_eq!(session.remote_attendees_count, 2);
452 assert_eq!(session.remote_voting_power, dec!(350));
453 }
454
455 #[test]
457 fn test_calculate_combined_quorum() {
458 let mut session = make_session();
459 session.start().unwrap();
460 session.record_remote_join(dec!(200), dec!(1000)).unwrap(); assert_eq!(
465 session
466 .calculate_combined_quorum(dec!(310), dec!(1000))
467 .unwrap(),
468 dec!(51)
469 );
470 assert!(session
471 .is_combined_quorum_reached(dec!(310), dec!(1000), 5, 10)
472 .unwrap());
473 }
474
475 #[test]
483 fn test_combined_quorum_at_exactly_50_percent() {
484 let mut session = make_session();
485 session.start().unwrap();
486 session.record_remote_join(dec!(200), dec!(1000)).unwrap();
487
488 let pct = session
490 .calculate_combined_quorum(dec!(300), dec!(1000))
491 .unwrap();
492 assert_eq!(pct, dec!(50), "le pourcentage doit valoir 50 exactement");
493
494 assert!(
496 session
497 .is_combined_quorum_reached(dec!(300), dec!(1000), 5, 10)
498 .unwrap(),
499 "quotités : « au moins la moitié » — 50% pile satisfait ce volet"
500 );
501
502 assert!(
505 !session
506 .is_combined_quorum_reached(dec!(300), dec!(1000), 4, 10)
507 .unwrap(),
508 "têtes : « plus de la moitié » — 50% pile ne suffit pas"
509 );
510 }
511
512 #[test]
517 fn test_quotas_alone_do_not_carry_the_quorum() {
518 let mut session = make_session();
519 session.start().unwrap();
520 session.record_remote_join(dec!(250), dec!(1000)).unwrap();
521
522 assert!(!session
524 .is_combined_quorum_reached(dec!(300), dec!(1000), 1, 10)
525 .unwrap());
526 }
527
528 #[test]
531 fn test_three_quarters_of_quotas_carry_the_quorum_alone() {
532 let mut session = make_session();
533 session.start().unwrap();
534 session.record_remote_join(dec!(300), dec!(1000)).unwrap();
535
536 assert!(session
538 .is_combined_quorum_reached(dec!(500), dec!(1000), 1, 10)
539 .unwrap());
540
541 let mut session = make_session();
544 session.start().unwrap();
545 session.record_remote_join(dec!(250), dec!(1000)).unwrap();
546 assert!(!session
547 .is_combined_quorum_reached(dec!(500), dec!(1000), 1, 10)
548 .unwrap());
549 }
550
551 #[test]
554 fn test_combined_quorum_exact_on_non_binary_fraction() {
555 let mut session = make_session();
556 session.start().unwrap();
557 session.record_remote_join(dec!(100), dec!(300)).unwrap();
558
559 assert_eq!(
560 session.quorum_remote_contribution.round_dp(4),
561 dec!(33.3333)
562 );
563 assert!(session
566 .is_combined_quorum_reached(dec!(100), dec!(300), 5, 10)
567 .unwrap());
568 }
569
570 #[test]
574 fn test_record_remote_join_rejects_forged_voting_power() {
575 let mut session = make_session();
576 session.start().unwrap();
577
578 assert!(session.record_remote_join(dec!(1001), dec!(1000)).is_err());
579 assert!(session.record_remote_join(dec!(-1), dec!(1000)).is_err());
580 assert_eq!(session.remote_attendees_count, 0);
582 assert_eq!(session.remote_voting_power, Decimal::ZERO);
583 }
584
585 #[test]
589 fn test_combined_quorum_rejects_double_counting() {
590 let mut session = make_session();
591 session.start().unwrap();
592 session.record_remote_join(dec!(600), dec!(1000)).unwrap();
593
594 let result = session.calculate_combined_quorum(dec!(500), dec!(1000));
595 assert!(result.is_err(), "1100/1000 doit être rejeté, pas arrondi");
596 }
597
598 #[test]
600 fn test_combined_quorum_rejects_invalid_inputs() {
601 let mut session = make_session();
602 session.start().unwrap();
603
604 assert!(session
605 .calculate_combined_quorum(dec!(100), Decimal::ZERO)
606 .is_err());
607 assert!(session
608 .calculate_combined_quorum(dec!(100), dec!(-1000))
609 .is_err());
610 assert!(session
611 .calculate_combined_quorum(dec!(-100), dec!(1000))
612 .is_err());
613 }
614
615 #[test]
618 fn test_record_remote_join_requires_live_session() {
619 let mut session = make_session();
620 assert!(session.record_remote_join(dec!(100), dec!(1000)).is_err());
621 }
622
623 #[test]
627 fn test_quorum_rule_is_shared_with_meeting_path() {
628 let mut session = make_session();
629 session.start().unwrap();
630 session.record_remote_join(dec!(100), dec!(1000)).unwrap();
631
632 for (physical, heads) in [(dec!(0), 0), (dec!(400), 4), (dec!(500), 6), (dec!(900), 1)] {
635 let expected = Meeting::double_quorum_reached(
636 physical + session.remote_voting_power,
637 dec!(1000),
638 heads + session.remote_attendees_count,
639 10,
640 );
641 assert_eq!(
642 session
643 .is_combined_quorum_reached(physical, dec!(1000), heads, 10)
644 .unwrap(),
645 expected,
646 "divergence pour physical={physical}, heads={heads}"
647 );
648 }
649 }
650}