Skip to main content

koprogo_api/application/use_cases/
cdc_use_cases.rs

1//! Use cases du conseil de copropriété — Story 4.7 (#582).
2//!
3//! `create_alert` : seul un membre dont le mandat court peut alerter la
4//! prochaine assemblée générale (Art. 3.90 §1er CC — la mission de contrôle
5//! du conseil a besoin d'un instrument, pas seulement d'un titre).
6//!
7//! `elect_members` : élire le conseil suppose une AG qui a abouti. La preuve
8//! du quorum double (Art. 3.87 §5 CC) est déjà portée par
9//! `Meeting::assert_can_complete` au moment de la clôture — ce use case ne la
10//! recalcule pas, il exige simplement que l'AG référencée soit `Completed`.
11
12use crate::application::dto::{
13    BoardAlertResponseDto, BoardMemberResponseDto, CreateBoardAlertDto, ElectCdcMembersDto,
14};
15use crate::application::error::AppError;
16use crate::application::ports::{BoardAlertRepository, BoardMemberRepository, MeetingRepository};
17use crate::domain::entities::{
18    AlertSeverity, BoardAlert, BoardMember, BoardPosition, MeetingStatus,
19};
20use std::sync::Arc;
21use uuid::Uuid;
22
23pub struct CdcUseCases {
24    alert_repository: Arc<dyn BoardAlertRepository>,
25    board_member_repository: Arc<dyn BoardMemberRepository>,
26    meeting_repository: Arc<dyn MeetingRepository>,
27}
28
29impl CdcUseCases {
30    pub fn new(
31        alert_repository: Arc<dyn BoardAlertRepository>,
32        board_member_repository: Arc<dyn BoardMemberRepository>,
33        meeting_repository: Arc<dyn MeetingRepository>,
34    ) -> Self {
35        Self {
36            alert_repository,
37            board_member_repository,
38            meeting_repository,
39        }
40    }
41
42    /// Émet une alerte du conseil à destination de la prochaine AG.
43    ///
44    /// `dto.target_meeting_id` est fourni par l'appelant (typiquement la
45    /// prochaine AGO déjà planifiée pour l'immeuble) plutôt que résolu
46    /// automatiquement : le dépôt n'a aujourd'hui aucune notion de « la
47    /// prochaine AG » côté `MeetingRepository` (une seule requête ad hoc,
48    /// fenêtrée à 7 jours, existe pour le dashboard — non généralisable).
49    /// Ce use case valide en revanche que la cible est bien une AG à venir
50    /// du même immeuble, ce qui est la substance de `target=AG_next`.
51    pub async fn create_alert(
52        &self,
53        caller_owner_id: Uuid,
54        building_id: Uuid,
55        dto: CreateBoardAlertDto,
56    ) -> Result<BoardAlertResponseDto, AppError> {
57        let target_meeting_id = Uuid::parse_str(&dto.target_meeting_id)
58            .map_err(|_| AppError::Validation("Invalid target_meeting_id format".to_string()))?;
59        let severity: AlertSeverity = dto.severity.parse().map_err(AppError::Validation)?;
60
61        // Autorisation : seul un membre du conseil dont le mandat court sur
62        // cet immeuble peut alerter (Story 4.7 @security).
63        let member = self
64            .board_member_repository
65            .find_by_owner_and_building(caller_owner_id, building_id)
66            .await
67            .map_err(AppError::from)?
68            .ok_or_else(|| {
69                AppError::Forbidden(
70                    "Vous n'êtes pas membre du conseil de copropriété de cet immeuble".to_string(),
71                )
72            })?;
73        if !member.is_active() {
74            return Err(AppError::Forbidden(
75                "Votre mandat au conseil de copropriété a pris fin".to_string(),
76            ));
77        }
78
79        let meeting = self
80            .meeting_repository
81            .find_by_id(target_meeting_id)
82            .await
83            .map_err(AppError::from)?
84            .ok_or_else(|| AppError::NotFound("Meeting not found".to_string()))?;
85        if meeting.building_id != building_id {
86            return Err(AppError::Forbidden(
87                "Cette assemblée n'appartient pas à cet immeuble".to_string(),
88            ));
89        }
90        if meeting.status != MeetingStatus::Scheduled {
91            return Err(AppError::Validation(
92                "La cible d'une alerte doit être une assemblée générale à venir".to_string(),
93            ));
94        }
95
96        let alert = BoardAlert::new(building_id, member.id, dto.text, severity, meeting.id)?;
97        let created = self.alert_repository.create(&alert).await?;
98
99        Ok(to_alert_dto(&created))
100    }
101
102    /// Liste les alertes visibles à une AG donnée.
103    pub async fn list_alerts_for_meeting(
104        &self,
105        meeting_id: Uuid,
106    ) -> Result<Vec<BoardAlertResponseDto>, AppError> {
107        let alerts = self
108            .alert_repository
109            .find_by_target_meeting(meeting_id)
110            .await?;
111        Ok(alerts.iter().map(to_alert_dto).collect())
112    }
113
114    /// Élit les membres du conseil à l'issue d'une AG clôturée.
115    ///
116    /// L'AG doit être `Completed` : sa clôture suppose déjà le quorum double
117    /// vérifié (Art. 3.87 §5 CC, `Meeting::assert_can_complete`). Une AG
118    /// encore `Scheduled` ou `Cancelled` n'a jamais prouvé son quorum — 422
119    /// (Story 4.7 @negative).
120    pub async fn elect_members(
121        &self,
122        building_id: Uuid,
123        dto: ElectCdcMembersDto,
124    ) -> Result<Vec<BoardMemberResponseDto>, AppError> {
125        let meeting_id = Uuid::parse_str(&dto.meeting_id)
126            .map_err(|_| AppError::Validation("Invalid meeting_id format".to_string()))?;
127
128        let meeting = self
129            .meeting_repository
130            .find_by_id(meeting_id)
131            .await
132            .map_err(AppError::from)?
133            .ok_or_else(|| AppError::NotFound("Meeting not found".to_string()))?;
134        if meeting.building_id != building_id {
135            return Err(AppError::Validation(
136                "Cette assemblée n'appartient pas à cet immeuble".to_string(),
137            ));
138        }
139        if meeting.status != MeetingStatus::Completed {
140            return Err(AppError::CdcElectionQuorumNotReached { meeting_id });
141        }
142
143        let mandate_start = chrono::Utc::now();
144        let mandate_end = mandate_start + chrono::Duration::days(365);
145
146        let mut elected = Vec::with_capacity(dto.candidates.len());
147        for candidate in &dto.candidates {
148            let owner_id = Uuid::parse_str(&candidate.owner_id)
149                .map_err(|_| AppError::Validation("Invalid owner_id format".to_string()))?;
150            let position: BoardPosition =
151                candidate.position.parse().map_err(AppError::Validation)?;
152
153            let member = BoardMember::new(
154                owner_id,
155                building_id,
156                position,
157                mandate_start,
158                mandate_end,
159                meeting_id,
160            )
161            .map_err(AppError::Validation)?;
162
163            let created = self
164                .board_member_repository
165                .create(&member)
166                .await
167                .map_err(AppError::from)?;
168            elected.push(to_member_dto(&created));
169        }
170
171        Ok(elected)
172    }
173}
174
175fn to_alert_dto(alert: &BoardAlert) -> BoardAlertResponseDto {
176    BoardAlertResponseDto {
177        id: alert.id.to_string(),
178        building_id: alert.building_id.to_string(),
179        raised_by_board_member_id: alert.raised_by_board_member_id.to_string(),
180        text: alert.text.clone(),
181        severity: alert.severity.to_string(),
182        target_meeting_id: alert.target_meeting_id.to_string(),
183        created_at: alert.created_at.to_rfc3339(),
184    }
185}
186
187fn to_member_dto(member: &BoardMember) -> BoardMemberResponseDto {
188    BoardMemberResponseDto {
189        id: member.id.to_string(),
190        owner_id: member.owner_id.to_string(),
191        building_id: member.building_id.to_string(),
192        position: member.position.to_string(),
193        mandate_start: member.mandate_start.to_rfc3339(),
194        mandate_end: member.mandate_end.to_rfc3339(),
195        elected_by_meeting_id: member.elected_by_meeting_id.to_string(),
196        is_active: member.is_active(),
197        days_remaining: member.days_remaining(),
198        expires_soon: member.expires_soon(),
199        created_at: member.created_at.to_rfc3339(),
200        updated_at: member.updated_at.to_rfc3339(),
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::domain::entities::{Meeting, MeetingType};
208    use chrono::{Duration, Utc};
209    use mockall::mock;
210    use mockall::predicate::*;
211
212    mock! {
213        pub AlertRepo {}
214
215        #[async_trait::async_trait]
216        impl BoardAlertRepository for AlertRepo {
217            async fn create(&self, alert: &BoardAlert) -> Result<BoardAlert, AppError>;
218            async fn find_by_target_meeting(&self, meeting_id: Uuid) -> Result<Vec<BoardAlert>, AppError>;
219        }
220    }
221
222    mock! {
223        pub MemberRepo {}
224
225        #[async_trait::async_trait]
226        impl BoardMemberRepository for MemberRepo {
227            async fn create(&self, board_member: &BoardMember) -> Result<BoardMember, String>;
228            async fn find_by_id(&self, id: Uuid) -> Result<Option<BoardMember>, String>;
229            async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<BoardMember>, String>;
230            async fn find_active_by_building(&self, building_id: Uuid) -> Result<Vec<BoardMember>, String>;
231            async fn find_expiring_soon(&self, building_id: Uuid, days_threshold: i32) -> Result<Vec<BoardMember>, String>;
232            async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<BoardMember>, String>;
233            async fn find_by_owner_and_building(&self, owner_id: Uuid, building_id: Uuid) -> Result<Option<BoardMember>, String>;
234            async fn has_active_mandate(&self, owner_id: Uuid, building_id: Uuid) -> Result<bool, String>;
235            async fn update(&self, board_member: &BoardMember) -> Result<BoardMember, String>;
236            async fn delete(&self, id: Uuid) -> Result<bool, String>;
237            async fn count_active_by_building(&self, building_id: Uuid) -> Result<i64, String>;
238        }
239    }
240
241    mock! {
242        pub MeetingRepo {}
243
244        #[async_trait::async_trait]
245        impl MeetingRepository for MeetingRepo {
246            async fn create(&self, meeting: &Meeting) -> Result<Meeting, String>;
247            async fn find_by_id(&self, id: Uuid) -> Result<Option<Meeting>, String>;
248            async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Meeting>, String>;
249            async fn update(&self, meeting: &Meeting) -> Result<Meeting, String>;
250            async fn delete(&self, id: Uuid) -> Result<bool, String>;
251            async fn find_all_paginated(
252                &self,
253                page_request: &crate::application::dto::PageRequest,
254                organization_id: Option<Uuid>,
255            ) -> Result<(Vec<Meeting>, i64), String>;
256        }
257    }
258
259    fn make_meeting(building_id: Uuid, status: MeetingStatus) -> Meeting {
260        let mut meeting = Meeting::new(
261            Uuid::new_v4(),
262            Uuid::new_v4(),
263            building_id,
264            MeetingType::Ordinary,
265            "AGO Story 4.7".to_string(),
266            None,
267            Utc::now() + Duration::days(30),
268            "Salle des fêtes".to_string(),
269        )
270        .unwrap();
271        meeting.status = status;
272        meeting
273    }
274
275    fn make_active_member(owner_id: Uuid, building_id: Uuid) -> BoardMember {
276        BoardMember::new(
277            owner_id,
278            building_id,
279            BoardPosition::Member,
280            Utc::now() - Duration::days(10),
281            Utc::now() + Duration::days(355),
282            Uuid::new_v4(),
283        )
284        .unwrap()
285    }
286
287    fn alert_dto(meeting_id: Uuid) -> CreateBoardAlertDto {
288        CreateBoardAlertDto {
289            text: "L'ascenseur est en panne depuis trois jours".to_string(),
290            severity: "warning".to_string(),
291            target_meeting_id: meeting_id.to_string(),
292        }
293    }
294
295    // ── @happy ───────────────────────────────────────────────────────
296
297    #[tokio::test]
298    async fn happy_active_board_member_can_create_alert() {
299        let owner_id = Uuid::new_v4();
300        let building_id = Uuid::new_v4();
301        let meeting = make_meeting(building_id, MeetingStatus::Scheduled);
302        let meeting_id = meeting.id;
303        let member = make_active_member(owner_id, building_id);
304
305        let mut member_repo = MockMemberRepo::new();
306        member_repo
307            .expect_find_by_owner_and_building()
308            .with(eq(owner_id), eq(building_id))
309            .times(1)
310            .return_once(move |_, _| Ok(Some(member)));
311
312        let mut meeting_repo = MockMeetingRepo::new();
313        meeting_repo
314            .expect_find_by_id()
315            .with(eq(meeting_id))
316            .times(1)
317            .return_once(move |_| Ok(Some(meeting)));
318
319        let mut alert_repo = MockAlertRepo::new();
320        alert_repo
321            .expect_create()
322            .times(1)
323            .returning(|alert| Ok(alert.clone()));
324
325        let uc = CdcUseCases::new(
326            Arc::new(alert_repo),
327            Arc::new(member_repo),
328            Arc::new(meeting_repo),
329        );
330
331        let result = uc
332            .create_alert(owner_id, building_id, alert_dto(meeting_id))
333            .await;
334
335        assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
336        let dto = result.unwrap();
337        assert_eq!(dto.severity, "warning");
338        assert_eq!(dto.target_meeting_id, meeting_id.to_string());
339    }
340
341    #[tokio::test]
342    async fn happy_elect_three_members_from_completed_meeting() {
343        let building_id = Uuid::new_v4();
344        let meeting = make_meeting(building_id, MeetingStatus::Completed);
345        let meeting_id = meeting.id;
346
347        let mut meeting_repo = MockMeetingRepo::new();
348        meeting_repo
349            .expect_find_by_id()
350            .with(eq(meeting_id))
351            .times(1)
352            .return_once(move |_| Ok(Some(meeting)));
353
354        let mut member_repo = MockMemberRepo::new();
355        member_repo
356            .expect_create()
357            .times(3)
358            .returning(|m| Ok(m.clone()));
359
360        let uc = CdcUseCases::new(
361            Arc::new(MockAlertRepo::new()),
362            Arc::new(member_repo),
363            Arc::new(meeting_repo),
364        );
365
366        let dto = ElectCdcMembersDto {
367            meeting_id: meeting_id.to_string(),
368            candidates: vec![
369                crate::application::dto::CdcCandidateDto {
370                    owner_id: Uuid::new_v4().to_string(),
371                    position: "president".to_string(),
372                },
373                crate::application::dto::CdcCandidateDto {
374                    owner_id: Uuid::new_v4().to_string(),
375                    position: "treasurer".to_string(),
376                },
377                crate::application::dto::CdcCandidateDto {
378                    owner_id: Uuid::new_v4().to_string(),
379                    position: "member".to_string(),
380                },
381            ],
382        };
383
384        let result = uc.elect_members(building_id, dto).await;
385        assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
386        assert_eq!(result.unwrap().len(), 3);
387    }
388
389    // ── @edge ────────────────────────────────────────────────────────
390
391    /// Un membre qui vient tout juste de démissionner (mandat échu à
392    /// l'instant) perd ses droits immédiatement — pas seulement après un
393    /// délai (Story 4.7 @edge, cf. `board_member.rs::edge_resign_*`).
394    #[tokio::test]
395    async fn edge_member_resigned_a_moment_ago_is_forbidden() {
396        let owner_id = Uuid::new_v4();
397        let building_id = Uuid::new_v4();
398        let meeting = make_meeting(building_id, MeetingStatus::Scheduled);
399        let meeting_id = meeting.id;
400
401        let mut member = make_active_member(owner_id, building_id);
402        member.resign(Utc::now() - Duration::milliseconds(1));
403
404        let mut member_repo = MockMemberRepo::new();
405        member_repo
406            .expect_find_by_owner_and_building()
407            .with(eq(owner_id), eq(building_id))
408            .times(1)
409            .return_once(move |_, _| Ok(Some(member)));
410
411        let uc = CdcUseCases::new(
412            Arc::new(MockAlertRepo::new()),
413            Arc::new(member_repo),
414            Arc::new(MockMeetingRepo::new()),
415        );
416
417        let result = uc
418            .create_alert(owner_id, building_id, alert_dto(meeting_id))
419            .await;
420
421        match result {
422            Err(AppError::Forbidden(_)) => {}
423            other => panic!("expected Forbidden, got {:?}", other),
424        }
425    }
426
427    // ── @security ────────────────────────────────────────────────────
428
429    #[tokio::test]
430    async fn security_non_elected_owner_cannot_create_alert() {
431        let owner_id = Uuid::new_v4();
432        let building_id = Uuid::new_v4();
433        let meeting_id = Uuid::new_v4();
434
435        let mut member_repo = MockMemberRepo::new();
436        member_repo
437            .expect_find_by_owner_and_building()
438            .with(eq(owner_id), eq(building_id))
439            .times(1)
440            .return_once(|_, _| Ok(None));
441
442        let uc = CdcUseCases::new(
443            Arc::new(MockAlertRepo::new()),
444            Arc::new(member_repo),
445            Arc::new(MockMeetingRepo::new()),
446        );
447
448        let result = uc
449            .create_alert(owner_id, building_id, alert_dto(meeting_id))
450            .await;
451
452        match result {
453            Err(AppError::Forbidden(_)) => {}
454            other => panic!("expected Forbidden, got {:?}", other),
455        }
456    }
457
458    #[tokio::test]
459    async fn security_former_member_past_mandate_end_cannot_create_alert() {
460        let owner_id = Uuid::new_v4();
461        let building_id = Uuid::new_v4();
462        let meeting_id = Uuid::new_v4();
463
464        let expired_member = BoardMember::new(
465            owner_id,
466            building_id,
467            BoardPosition::Member,
468            Utc::now() - Duration::days(400),
469            Utc::now() - Duration::days(35), // mandat déjà échu
470            Uuid::new_v4(),
471        )
472        .unwrap();
473
474        let mut member_repo = MockMemberRepo::new();
475        member_repo
476            .expect_find_by_owner_and_building()
477            .with(eq(owner_id), eq(building_id))
478            .times(1)
479            .return_once(move |_, _| Ok(Some(expired_member)));
480
481        let uc = CdcUseCases::new(
482            Arc::new(MockAlertRepo::new()),
483            Arc::new(member_repo),
484            Arc::new(MockMeetingRepo::new()),
485        );
486
487        let result = uc
488            .create_alert(owner_id, building_id, alert_dto(meeting_id))
489            .await;
490
491        match result {
492            Err(AppError::Forbidden(_)) => {}
493            other => panic!("expected Forbidden, got {:?}", other),
494        }
495    }
496
497    // ── @negative ────────────────────────────────────────────────────
498
499    #[tokio::test]
500    async fn negative_election_without_quorum_returns_422() {
501        let building_id = Uuid::new_v4();
502        // AG encore Scheduled : jamais clôturée, donc quorum jamais prouvé.
503        let meeting = make_meeting(building_id, MeetingStatus::Scheduled);
504        let meeting_id = meeting.id;
505
506        let mut meeting_repo = MockMeetingRepo::new();
507        meeting_repo
508            .expect_find_by_id()
509            .with(eq(meeting_id))
510            .times(1)
511            .return_once(move |_| Ok(Some(meeting)));
512
513        let uc = CdcUseCases::new(
514            Arc::new(MockAlertRepo::new()),
515            Arc::new(MockMemberRepo::new()),
516            Arc::new(meeting_repo),
517        );
518
519        let dto = ElectCdcMembersDto {
520            meeting_id: meeting_id.to_string(),
521            candidates: vec![crate::application::dto::CdcCandidateDto {
522                owner_id: Uuid::new_v4().to_string(),
523                position: "president".to_string(),
524            }],
525        };
526
527        let result = uc.elect_members(building_id, dto).await;
528        match result {
529            Err(AppError::CdcElectionQuorumNotReached { meeting_id: m }) => {
530                assert_eq!(m, meeting_id)
531            }
532            other => panic!("expected CdcElectionQuorumNotReached, got {:?}", other),
533        }
534    }
535
536    #[tokio::test]
537    async fn negative_create_alert_with_unknown_meeting_returns_not_found() {
538        let owner_id = Uuid::new_v4();
539        let building_id = Uuid::new_v4();
540        let meeting_id = Uuid::new_v4();
541        let member = make_active_member(owner_id, building_id);
542
543        let mut member_repo = MockMemberRepo::new();
544        member_repo
545            .expect_find_by_owner_and_building()
546            .with(eq(owner_id), eq(building_id))
547            .times(1)
548            .return_once(move |_, _| Ok(Some(member)));
549
550        let mut meeting_repo = MockMeetingRepo::new();
551        meeting_repo
552            .expect_find_by_id()
553            .with(eq(meeting_id))
554            .times(1)
555            .return_once(|_| Ok(None));
556
557        let uc = CdcUseCases::new(
558            Arc::new(MockAlertRepo::new()),
559            Arc::new(member_repo),
560            Arc::new(meeting_repo),
561        );
562
563        let result = uc
564            .create_alert(owner_id, building_id, alert_dto(meeting_id))
565            .await;
566
567        match result {
568            Err(AppError::NotFound(_)) => {}
569            other => panic!("expected NotFound, got {:?}", other),
570        }
571    }
572}