1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub enum VideoPlatform {
8 Zoom,
9 MicrosoftTeams,
10 GoogleMeet,
11 Jitsi, Whereby,
13 Other,
14}
15
16impl VideoPlatform {
17 pub fn from_db_string(s: &str) -> Result<Self, String> {
18 match s {
19 "zoom" => Ok(Self::Zoom),
20 "microsoft_teams" => Ok(Self::MicrosoftTeams),
21 "google_meet" => Ok(Self::GoogleMeet),
22 "jitsi" => Ok(Self::Jitsi),
23 "whereby" => Ok(Self::Whereby),
24 "other" => Ok(Self::Other),
25 _ => Err(format!("Unknown video platform: {}", s)),
26 }
27 }
28
29 pub fn to_db_str(&self) -> &'static str {
30 match self {
31 Self::Zoom => "zoom",
32 Self::MicrosoftTeams => "microsoft_teams",
33 Self::GoogleMeet => "google_meet",
34 Self::Jitsi => "jitsi",
35 Self::Whereby => "whereby",
36 Self::Other => "other",
37 }
38 }
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub enum AgSessionStatus {
44 Scheduled, Live, Ended, Cancelled, }
49
50impl AgSessionStatus {
51 pub fn from_db_string(s: &str) -> Result<Self, String> {
52 match s {
53 "scheduled" => Ok(Self::Scheduled),
54 "live" => Ok(Self::Live),
55 "ended" => Ok(Self::Ended),
56 "cancelled" => Ok(Self::Cancelled),
57 _ => Err(format!("Unknown ag session status: {}", s)),
58 }
59 }
60
61 pub fn to_db_str(&self) -> &'static str {
62 match self {
63 Self::Scheduled => "scheduled",
64 Self::Live => "live",
65 Self::Ended => "ended",
66 Self::Cancelled => "cancelled",
67 }
68 }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
77pub struct AgSession {
78 pub id: Uuid,
79 pub organization_id: Uuid,
80 pub meeting_id: Uuid, pub platform: VideoPlatform,
82 pub video_url: String, pub host_url: Option<String>, pub status: AgSessionStatus,
85 pub scheduled_start: DateTime<Utc>,
86 pub actual_start: Option<DateTime<Utc>>,
87 pub actual_end: Option<DateTime<Utc>>,
88
89 pub remote_attendees_count: i32, pub remote_voting_power: f64, pub quorum_remote_contribution: f64, pub access_password: Option<String>, pub waiting_room_enabled: bool, pub recording_enabled: bool, pub recording_url: Option<String>, pub created_at: DateTime<Utc>,
102 pub updated_at: DateTime<Utc>,
103 pub created_by: Uuid,
104}
105
106impl AgSession {
107 pub fn new(
109 organization_id: Uuid,
110 meeting_id: Uuid,
111 platform: VideoPlatform,
112 video_url: String,
113 host_url: Option<String>,
114 scheduled_start: DateTime<Utc>,
115 access_password: Option<String>,
116 waiting_room_enabled: bool,
117 recording_enabled: bool,
118 created_by: Uuid,
119 ) -> Result<Self, String> {
120 if video_url.trim().is_empty() {
121 return Err("L'URL de la session vidéo est obligatoire".to_string());
122 }
123
124 if !video_url.starts_with("https://") {
125 return Err(
126 "L'URL de la session vidéo doit utiliser HTTPS (sécurité obligatoire)".to_string(),
127 );
128 }
129
130 if scheduled_start <= Utc::now() {
131 return Err("La session doit être planifiée dans le futur".to_string());
132 }
133
134 let now = Utc::now();
135 Ok(Self {
136 id: Uuid::new_v4(),
137 organization_id,
138 meeting_id,
139 platform,
140 video_url,
141 host_url,
142 status: AgSessionStatus::Scheduled,
143 scheduled_start,
144 actual_start: None,
145 actual_end: None,
146 remote_attendees_count: 0,
147 remote_voting_power: 0.0,
148 quorum_remote_contribution: 0.0,
149 access_password,
150 waiting_room_enabled,
151 recording_enabled,
152 recording_url: None,
153 created_at: now,
154 updated_at: now,
155 created_by,
156 })
157 }
158
159 pub fn start(&mut self) -> Result<(), String> {
161 if self.status != AgSessionStatus::Scheduled {
162 return Err(format!(
163 "Impossible de démarrer une session en statut {:?}",
164 self.status
165 ));
166 }
167 self.status = AgSessionStatus::Live;
168 self.actual_start = Some(Utc::now());
169 self.updated_at = Utc::now();
170 Ok(())
171 }
172
173 pub fn end(&mut self, recording_url: Option<String>) -> Result<(), String> {
175 if self.status != AgSessionStatus::Live {
176 return Err(format!(
177 "Impossible de terminer une session en statut {:?}",
178 self.status
179 ));
180 }
181 self.status = AgSessionStatus::Ended;
182 self.actual_end = Some(Utc::now());
183 self.recording_url = recording_url;
184 self.updated_at = Utc::now();
185 Ok(())
186 }
187
188 pub fn cancel(&mut self) -> Result<(), String> {
190 if self.status != AgSessionStatus::Scheduled {
191 return Err(format!(
192 "Impossible d'annuler une session en statut {:?} (uniquement Scheduled)",
193 self.status
194 ));
195 }
196 self.status = AgSessionStatus::Cancelled;
197 self.updated_at = Utc::now();
198 Ok(())
199 }
200
201 pub fn record_remote_join(
206 &mut self,
207 voting_power: f64,
208 total_building_quotas: f64,
209 ) -> Result<(), String> {
210 if self.status != AgSessionStatus::Live {
211 return Err(
212 "Impossible d'enregistrer un participant : session non démarrée".to_string(),
213 );
214 }
215 if voting_power < 0.0 || voting_power > total_building_quotas {
216 return Err(format!(
217 "Pouvoir de vote invalide : {} (total bâtiment : {})",
218 voting_power, total_building_quotas
219 ));
220 }
221 self.remote_attendees_count += 1;
222 self.remote_voting_power += voting_power;
223 if total_building_quotas > 0.0 {
224 self.quorum_remote_contribution =
225 (self.remote_voting_power / total_building_quotas) * 100.0;
226 }
227 self.updated_at = Utc::now();
228 Ok(())
229 }
230
231 pub fn calculate_combined_quorum(
235 &self,
236 physical_quotas: f64,
237 total_building_quotas: f64,
238 ) -> Result<f64, String> {
239 if total_building_quotas <= 0.0 {
240 return Err("Total des quotas du bâtiment doit être positif".to_string());
241 }
242 let combined = physical_quotas + self.remote_voting_power;
243 Ok((combined / total_building_quotas) * 100.0)
244 }
245
246 pub fn is_live(&self) -> bool {
248 self.status == AgSessionStatus::Live
249 }
250
251 pub fn duration_minutes(&self) -> Option<i64> {
253 match (self.actual_start, self.actual_end) {
254 (Some(start), Some(end)) => Some((end - start).num_minutes()),
255 _ => None,
256 }
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 fn make_session() -> AgSession {
265 let future = Utc::now() + chrono::Duration::hours(2);
266 AgSession::new(
267 Uuid::new_v4(),
268 Uuid::new_v4(),
269 VideoPlatform::Jitsi,
270 "https://meet.jit.si/koprogo-ago-2026".to_string(),
271 None,
272 future,
273 None,
274 true,
275 false,
276 Uuid::new_v4(),
277 )
278 .unwrap()
279 }
280
281 #[test]
282 fn test_create_ag_session_success() {
283 let session = make_session();
284 assert_eq!(session.status, AgSessionStatus::Scheduled);
285 assert_eq!(session.remote_attendees_count, 0);
286 assert!(session.waiting_room_enabled);
287 }
288
289 #[test]
290 fn test_create_session_rejects_http_url() {
291 let future = Utc::now() + chrono::Duration::hours(2);
292 let result = AgSession::new(
293 Uuid::new_v4(),
294 Uuid::new_v4(),
295 VideoPlatform::Zoom,
296 "http://zoom.us/j/123".to_string(), None,
298 future,
299 None,
300 true,
301 false,
302 Uuid::new_v4(),
303 );
304 assert!(result.is_err());
305 assert!(result.unwrap_err().contains("HTTPS"));
306 }
307
308 #[test]
309 fn test_create_session_rejects_past_date() {
310 let past = Utc::now() - chrono::Duration::hours(1);
311 let result = AgSession::new(
312 Uuid::new_v4(),
313 Uuid::new_v4(),
314 VideoPlatform::Jitsi,
315 "https://meet.jit.si/test".to_string(),
316 None,
317 past,
318 None,
319 true,
320 false,
321 Uuid::new_v4(),
322 );
323 assert!(result.is_err());
324 }
325
326 #[test]
327 fn test_start_session() {
328 let mut session = make_session();
329 assert!(session.start().is_ok());
330 assert_eq!(session.status, AgSessionStatus::Live);
331 assert!(session.actual_start.is_some());
332 }
333
334 #[test]
335 fn test_start_session_twice_fails() {
336 let mut session = make_session();
337 session.start().unwrap();
338 assert!(session.start().is_err());
339 }
340
341 #[test]
342 fn test_end_session() {
343 let mut session = make_session();
344 session.start().unwrap();
345 assert!(session
346 .end(Some("https://recording.example.com/abc".to_string()))
347 .is_ok());
348 assert_eq!(session.status, AgSessionStatus::Ended);
349 assert!(session.actual_end.is_some());
350 assert!(session.recording_url.is_some());
351 }
352
353 #[test]
354 fn test_cancel_session() {
355 let mut session = make_session();
356 assert!(session.cancel().is_ok());
357 assert_eq!(session.status, AgSessionStatus::Cancelled);
358 }
359
360 #[test]
361 fn test_cancel_live_session_fails() {
362 let mut session = make_session();
363 session.start().unwrap();
364 assert!(session.cancel().is_err());
365 }
366
367 #[test]
368 fn test_record_remote_join_and_quorum() {
369 let mut session = make_session();
370 session.start().unwrap();
371
372 assert!(session.record_remote_join(150.0, 1000.0).is_ok());
374 assert_eq!(session.remote_attendees_count, 1);
375 assert!((session.remote_voting_power - 150.0).abs() < 0.01);
376 assert!((session.quorum_remote_contribution - 15.0).abs() < 0.01);
377
378 assert!(session.record_remote_join(200.0, 1000.0).is_ok());
380 assert_eq!(session.remote_attendees_count, 2);
381 assert!((session.remote_voting_power - 350.0).abs() < 0.01);
382 }
383
384 #[test]
385 fn test_calculate_combined_quorum() {
386 let mut session = make_session();
387 session.start().unwrap();
388 session.record_remote_join(200.0, 1000.0).unwrap(); let combined = session.calculate_combined_quorum(300.0, 1000.0).unwrap();
392 assert!((combined - 50.0).abs() < 0.01);
393
394 let combined2 = session.calculate_combined_quorum(310.0, 1000.0).unwrap();
396 assert!(combined2 > 50.0);
397 }
398}