Skip to main content

koprogo_api/domain/copropriete/
ag_session.rs

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/// Plateforme de visioconférence supportée
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub enum VideoPlatform {
11    Zoom,
12    MicrosoftTeams,
13    GoogleMeet,
14    Jitsi, // Open-source, recommandé pour copropriétés (RGPD)
15    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/// Statut de la session vidéo
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46pub enum AgSessionStatus {
47    Scheduled, // Lien créé, pas encore démarré
48    Live,      // Session en cours
49    Ended,     // Session terminée normalement
50    Cancelled, // Session annulée
51}
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/// Session de visioconférence pour une Assemblée Générale (Art. 3.87 §1 CC)
75///
76/// L'Art. 3.87 §1 CC permet aux copropriétaires de participer à l'AG
77/// "physiquement ou à distance au moyen d'une communication électronique".
78/// Cette entité gère la session vidéo associée à une réunion.
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
80pub struct AgSession {
81    pub id: Uuid,
82    pub organization_id: Uuid,
83    pub meeting_id: Uuid, // Lien vers la réunion AG
84    pub platform: VideoPlatform,
85    pub video_url: String,        // URL de la réunion (généré ou saisi)
86    pub host_url: Option<String>, // URL hôte (avec droits admin, privé)
87    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    // Quorum combiné — Art. 3.87 §5 CC
93    // présentiels + participants distanciels comptent ensemble
94    pub remote_attendees_count: i32, // Nb de participants en visio
95    /// Millièmes représentés par les distanciels — `Decimal` exact (ADR-0008 :
96    /// une quote-part n'est jamais un `f64`, elle alimente un seuil légal).
97    pub remote_voting_power: Decimal,
98    /// % de contribution distancielle au quorum total. `Decimal` et non `f64` :
99    /// la valeur est **persistée** en `NUMERIC(8,4)`, un f64 intermédiaire
100    /// introduirait l'aller-retour Decimal→f64→Decimal interdit par ADR-0008.
101    pub quorum_remote_contribution: Decimal,
102
103    // Accès et sécurité
104    pub access_password: Option<String>, // Mot de passe de réunion (haché si nécessaire)
105    pub waiting_room_enabled: bool,      // Salle d'attente activée (recommandée)
106    pub recording_enabled: bool,         // Enregistrement (RGPD : consentement requis)
107    pub recording_url: Option<String>,   // URL enregistrement post-session
108
109    pub created_at: DateTime<Utc>,
110    pub updated_at: DateTime<Utc>,
111    pub created_by: Uuid,
112}
113
114impl AgSession {
115    /// Crée une nouvelle session de visioconférence
116    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    /// Démarre la session (Scheduled → Live)
168    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    /// Termine la session (Live → Ended)
182    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    /// Annule la session (Scheduled → Cancelled)
197    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    /// Enregistre un participant distant et met à jour le quorum distanciel
210    ///
211    /// Art. 3.87 §5 CC : les participants en visio comptent pour le quorum
212    /// au même titre que les présents physiquement.
213    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    /// Calcule le quorum combiné (présentiel + distanciel) en pourcentage.
240    ///
241    /// Art. 3.87 §5 CC : les participants en visio comptent au même titre que
242    /// les présents physiquement. Calcul en `Decimal` exact de bout en bout —
243    /// aucun aller-retour vers `f64` sur le chemin du seuil légal (ADR-0008).
244    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    /// Le quorum **double** combiné est-il atteint ? Art. 3.87 §5 CC.
266    ///
267    /// Applique la règle de `Meeting::double_quorum_reached` — la même que le
268    /// chemin présentiel (Story H9) — aux totaux **combinés** présentiel +
269    /// distanciel :
270    ///   - têtes : présents physiquement + `remote_attendees_count` ;
271    ///   - quotités : `physical_quotas` + `remote_voting_power`.
272    ///
273    /// Avant #661, ce chemin ne testait que les quotités, avec son propre
274    /// littéral `> 50.0` en `f64` : une AG hybride pouvait être déclarée en
275    /// quorum sans que le volet « têtes » exigé par la loi soit vérifié.
276    ///
277    /// C'est **cette** méthode que doit appeler la couche application : aucun
278    /// seuil légal ne doit être ré-écrit dans un use case.
279    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        // Valide les entrées (et interdit le double comptage) avant de juger.
287        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    /// Vérifie si la session est active (Live)
309    pub fn is_live(&self) -> bool {
310        self.status == AgSessionStatus::Live
311    }
312
313    /// Durée de la session en minutes (si terminée)
314    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(), // HTTP not allowed
359            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    // ------------------------------------------------------------------
430    // Quorum combiné Art. 3.87 §5 CC — Issue #661
431    // 4 catégories obligatoires : @happy / @edge / @security / @negative.
432    // Toutes les assertions sont des égalités Decimal EXACTES : plus aucune
433    // tolérance `.abs() < 0.01`, qui masquait précisément la dérive f64 que
434    // l'issue dénonce sur un seuil juridique.
435    // ------------------------------------------------------------------
436
437    /// @happy — un participant distant alimente le quorum distanciel.
438    #[test]
439    fn test_record_remote_join_and_quorum() {
440        let mut session = make_session();
441        session.start().unwrap();
442
443        // 150 millièmes rejoignent en visio sur 1000 total
444        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        // 2e participant : 200 millièmes
450        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    /// @happy — quorum combiné présentiel + distanciel atteint.
456    #[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(); // 20% en visio
461
462        // 310 présents + 200 visio = 510/1000 = 51% des quotités, et 6 têtes
463        // sur 10 (5 physiques + 1 distant) → quorum double atteint.
464        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    /// @edge — les deux bornes de 50% de l'Art. 3.87 §5 CC, qui ne sont **pas**
476    /// de même nature :
477    ///   - quotités : « au moins la moitié » → **inclusif**, 50% pile suffit ;
478    ///   - têtes    : « plus de la moitié »  → **strict**, 50% pile ne suffit pas.
479    ///
480    /// C'est le test que l'ancien code en `f64` ne pouvait pas garantir : une
481    /// borne exacte y dépendait de l'arrondi binaire, pas de la loi.
482    #[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        // 300 présents + 200 visio = 500/1000 = exactement 50% des quotités.
489        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        // Quotités à 50% pile + têtes 6/10 (> 50%) → ATTEINT (borne inclusive).
495        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        // Mêmes quotités, mais têtes 5/10 (4 physiques + 1 distant) = 50% pile
503        // → NON atteint (borne stricte côté têtes).
504        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    /// @security #661 — les quotités seules n'emportent jamais le quorum.
513    /// Avant le correctif, ce chemin ne testait que les quotités : une AG
514    /// hybride pouvait être déclarée en quorum avec deux copropriétaires
515    /// détenant la moitié de l'immeuble.
516    #[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        // 550/1000 quotités (≥ 50%) mais 2 têtes sur 10 → refusé.
523        assert!(!session
524            .is_combined_quorum_reached(dec!(300), dec!(1000), 1, 10)
525            .unwrap());
526    }
527
528    /// @edge — alternative de l'Art. 3.87 §5 : > 3/4 des quotités emportent le
529    /// quorum quelles que soient les têtes.
530    #[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        // 800/1000 = 80% > 75%, avec seulement 2 têtes sur 10.
537        assert!(session
538            .is_combined_quorum_reached(dec!(500), dec!(1000), 1, 10)
539            .unwrap());
540
541        // 750/1000 = 75% PILE : la borne des 3/4 est stricte, donc l'alternative
542        // ne joue pas, et les têtes insuffisantes font échouer le quorum.
543        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    /// @edge — tiers non représentable en binaire : 1/3 des quotes-parts.
552    /// En `f64`, (100/300)*100 ne vaut pas 33.333… mais 33.33333333333333**57**.
553    #[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        // 200/300 = 66.66…% des quotités, 6 têtes sur 10 → quorum atteint,
564        // sans qu'aucun arrondi n'entre dans la décision.
565        assert!(session
566            .is_combined_quorum_reached(dec!(100), dec!(300), 5, 10)
567            .unwrap());
568    }
569
570    /// @security — un pouvoir de vote distant ne peut pas dépasser le total du
571    /// bâtiment ni être négatif : sinon un participant gonfle artificiellement
572    /// le quorum et fait valider une AG qui n'a pas lieu d'être.
573    #[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        // Aucune des deux tentatives n'a pollué l'état.
581        assert_eq!(session.remote_attendees_count, 0);
582        assert_eq!(session.remote_voting_power, Decimal::ZERO);
583    }
584
585    /// @security — le cumul présentiel + distanciel ne peut pas excéder le
586    /// total du bâtiment (double comptage d'un copropriétaire présent ET
587    /// connecté en visio).
588    #[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    /// @negative — entrées invalides : erreur typée, jamais de panic ni de NaN.
599    #[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    /// @negative — enregistrer un participant sur une session non démarrée est
616    /// refusé (le quorum ne se constitue que pendant la séance).
617    #[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    /// @security #661 — le chemin distanciel et le chemin présentiel jugent
624    /// contre la MÊME règle. Ce test échoue si quelqu'un réintroduit un seuil
625    /// local dans l'un des deux.
626    #[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        // Pour tout jeu d'entrées, le verdict du chemin hybride doit être celui
633        // de `Meeting::double_quorum_reached` sur les totaux combinés.
634        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}