1use 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 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 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 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 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 #[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 #[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 #[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), 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 #[tokio::test]
500 async fn negative_election_without_quorum_returns_422() {
501 let building_id = Uuid::new_v4();
502 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}