Skip to main content

koprogo_api/application/use_cases/
notice_use_cases.rs

1use crate::application::dto::{
2    CreateNoticeDto, NoticeResponseDto, NoticeSummaryDto, SetExpirationDto, UpdateNoticeDto,
3};
4use crate::application::ports::{NoticeRepository, UserRepository};
5use crate::domain::entities::{Notice, NoticeCategory, NoticeStatus, NoticeType};
6use std::sync::Arc;
7use uuid::Uuid;
8
9pub struct NoticeUseCases {
10    notice_repo: Arc<dyn NoticeRepository>,
11    user_repo: Arc<dyn UserRepository>,
12}
13
14impl NoticeUseCases {
15    pub fn new(notice_repo: Arc<dyn NoticeRepository>, user_repo: Arc<dyn UserRepository>) -> Self {
16        Self {
17            notice_repo,
18            user_repo,
19        }
20    }
21
22    /// Check if user has building admin privileges (admin, superadmin, syndic,
23    /// or `community.moderator` — Story 5.3 #587, ce dernier rôle existe
24    /// précisément pour porter cette capacité sans être syndic).
25    fn is_building_admin(role: &str) -> bool {
26        role == "admin" || role == "superadmin" || role == "syndic" || role == "community.moderator"
27    }
28
29    /// Resolve user_id to display name via user lookup
30    async fn resolve_author_name(&self, user_id: Uuid) -> String {
31        match self.user_repo.find_by_id(user_id).await {
32            Ok(Some(user)) => format!("{} {}", user.first_name, user.last_name),
33            _ => "Unknown Author".to_string(),
34        }
35    }
36
37    /// Create a new notice (Draft status)
38    ///
39    /// # Authorization
40    /// - Any authenticated user in the organization can post a notice
41    ///   (syndic, admin, owner — all are valid authors)
42    pub async fn create_notice(
43        &self,
44        user_id: Uuid,
45        _organization_id: Uuid,
46        dto: CreateNoticeDto,
47    ) -> Result<NoticeResponseDto, String> {
48        // author_id is the user's own ID (notices.author_id now references users.id)
49        let notice = Notice::new(
50            dto.building_id,
51            user_id,
52            dto.notice_type,
53            dto.category,
54            dto.title,
55            dto.content,
56            dto.event_date,
57            dto.event_location,
58            dto.contact_info,
59        )?;
60
61        // Set expiration if provided
62        let mut notice = notice;
63        if let Some(expires_at) = dto.expires_at {
64            notice.set_expiration(Some(expires_at))?;
65        }
66
67        // Persist notice
68        let created = self.notice_repo.create(&notice).await?;
69
70        let author_name = self.resolve_author_name(user_id).await;
71        Ok(NoticeResponseDto::from_notice(created, author_name))
72    }
73
74    /// Get notice by ID with author name enrichment
75    pub async fn get_notice(&self, notice_id: Uuid) -> Result<NoticeResponseDto, String> {
76        let notice = self
77            .notice_repo
78            .find_by_id(notice_id)
79            .await?
80            .ok_or("Notice not found".to_string())?;
81
82        let author_name = self.resolve_author_name(notice.author_id).await;
83        Ok(NoticeResponseDto::from_notice(notice, author_name))
84    }
85
86    /// List all notices for a building (all statuses)
87    ///
88    /// # Returns
89    /// - Notices sorted by pinned (DESC), created_at (DESC)
90    pub async fn list_building_notices(
91        &self,
92        building_id: Uuid,
93    ) -> Result<Vec<NoticeSummaryDto>, String> {
94        let notices = self.notice_repo.find_by_building(building_id).await?;
95        self.enrich_notices_summary(notices).await
96    }
97
98    /// List published notices for a building (visible to members)
99    ///
100    /// # Returns
101    /// - Only Published notices, sorted by pinned (DESC), published_at (DESC)
102    pub async fn list_published_notices(
103        &self,
104        building_id: Uuid,
105    ) -> Result<Vec<NoticeSummaryDto>, String> {
106        let notices = self
107            .notice_repo
108            .find_published_by_building(building_id)
109            .await?;
110        self.enrich_notices_summary(notices).await
111    }
112
113    /// List pinned notices for a building (important announcements)
114    pub async fn list_pinned_notices(
115        &self,
116        building_id: Uuid,
117    ) -> Result<Vec<NoticeSummaryDto>, String> {
118        let notices = self
119            .notice_repo
120            .find_pinned_by_building(building_id)
121            .await?;
122        self.enrich_notices_summary(notices).await
123    }
124
125    /// List notices by type (Announcement, Event, LostAndFound, ClassifiedAd)
126    pub async fn list_notices_by_type(
127        &self,
128        building_id: Uuid,
129        notice_type: NoticeType,
130    ) -> Result<Vec<NoticeSummaryDto>, String> {
131        let notices = self
132            .notice_repo
133            .find_by_type(building_id, notice_type)
134            .await?;
135        self.enrich_notices_summary(notices).await
136    }
137
138    /// List notices by category (General, Maintenance, Social, etc.)
139    pub async fn list_notices_by_category(
140        &self,
141        building_id: Uuid,
142        category: NoticeCategory,
143    ) -> Result<Vec<NoticeSummaryDto>, String> {
144        let notices = self
145            .notice_repo
146            .find_by_category(building_id, category)
147            .await?;
148        self.enrich_notices_summary(notices).await
149    }
150
151    /// List notices by status (Draft, Published, Archived, Expired)
152    pub async fn list_notices_by_status(
153        &self,
154        building_id: Uuid,
155        status: NoticeStatus,
156    ) -> Result<Vec<NoticeSummaryDto>, String> {
157        let notices = self.notice_repo.find_by_status(building_id, status).await?;
158        self.enrich_notices_summary(notices).await
159    }
160
161    /// List all notices created by an author
162    pub async fn list_author_notices(
163        &self,
164        author_id: Uuid,
165    ) -> Result<Vec<NoticeSummaryDto>, String> {
166        let notices = self.notice_repo.find_by_author(author_id).await?;
167        self.enrich_notices_summary(notices).await
168    }
169
170    /// Update a notice (Draft only)
171    ///
172    /// # Authorization
173    /// - Only author can update their notice
174    /// - Only Draft notices can be updated
175    pub async fn update_notice(
176        &self,
177        notice_id: Uuid,
178        user_id: Uuid,
179        _organization_id: Uuid,
180        dto: UpdateNoticeDto,
181    ) -> Result<NoticeResponseDto, String> {
182        let mut notice = self
183            .notice_repo
184            .find_by_id(notice_id)
185            .await?
186            .ok_or("Notice not found".to_string())?;
187
188        // Authorization: only author can update
189        if notice.author_id != user_id {
190            return Err("Unauthorized: only author can update notice".to_string());
191        }
192
193        // Update content (domain validates Draft status)
194        notice.update_content(
195            dto.title,
196            dto.content,
197            dto.category,
198            dto.event_date,
199            dto.event_location,
200            dto.contact_info,
201            dto.expires_at,
202        )?;
203
204        // Persist changes
205        let updated = self.notice_repo.update(&notice).await?;
206
207        // Return enriched response
208        self.get_notice(updated.id).await
209    }
210
211    /// Publish a notice (Draft → Published)
212    ///
213    /// # Authorization
214    /// - Only author can publish their notice
215    pub async fn publish_notice(
216        &self,
217        notice_id: Uuid,
218        user_id: Uuid,
219        _organization_id: Uuid,
220    ) -> Result<NoticeResponseDto, String> {
221        let mut notice = self
222            .notice_repo
223            .find_by_id(notice_id)
224            .await?
225            .ok_or("Notice not found".to_string())?;
226
227        // Authorization: only author can publish
228        if notice.author_id != user_id {
229            return Err("Unauthorized: only author can publish notice".to_string());
230        }
231
232        // Publish (domain validates state transition)
233        notice.publish()?;
234
235        // Persist changes
236        let updated = self.notice_repo.update(&notice).await?;
237
238        // Return enriched response
239        self.get_notice(updated.id).await
240    }
241
242    /// Archive a notice (Published/Expired → Archived)
243    ///
244    /// # Authorization
245    /// - Author archives their own notice : self-service, no reason needed.
246    /// - Building admin (syndic, `community.moderator`, admin, superadmin)
247    ///   archiving SOMEONE ELSE's notice : MODÉRATION — a reason is
248    ///   mandatory (audit trail). Story 5.3 (#587), INV-4.
249    pub async fn archive_notice(
250        &self,
251        notice_id: Uuid,
252        user_id: Uuid,
253        _organization_id: Uuid,
254        actor_role: &str,
255        reason: Option<String>,
256    ) -> Result<NoticeResponseDto, String> {
257        let mut notice = self
258            .notice_repo
259            .find_by_id(notice_id)
260            .await?
261            .ok_or("Notice not found".to_string())?;
262
263        // Authorization: only author or building admin can archive
264        let is_author = notice.author_id == user_id;
265        let is_admin = Self::is_building_admin(actor_role);
266
267        if !is_author && !is_admin {
268            return Err(
269                "Unauthorized: only author or building admin can archive notice".to_string(),
270            );
271        }
272
273        // Modération d'un contenu d'autrui : motif obligatoire (audit).
274        if !is_author && is_admin {
275            reason
276                .filter(|r| !r.trim().is_empty())
277                .ok_or_else(|| crate::application::error::MOTIF_MODERATION_REQUIS.to_string())?;
278        }
279
280        // Archive (domain validates state transition)
281        notice.archive()?;
282
283        // Persist changes
284        let updated = self.notice_repo.update(&notice).await?;
285
286        // Return enriched response
287        self.get_notice(updated.id).await
288    }
289
290    /// Pin a notice to top of board (Published only)
291    ///
292    /// # Authorization
293    /// - Only building admin (admin, superadmin, or syndic) can pin notices
294    pub async fn pin_notice(
295        &self,
296        notice_id: Uuid,
297        actor_role: &str,
298    ) -> Result<NoticeResponseDto, String> {
299        // Authorization: only building admin can pin
300        if !Self::is_building_admin(actor_role) {
301            return Err(
302                "Unauthorized: only building admin (admin, superadmin, or syndic) can pin notices"
303                    .to_string(),
304            );
305        }
306
307        let mut notice = self
308            .notice_repo
309            .find_by_id(notice_id)
310            .await?
311            .ok_or("Notice not found".to_string())?;
312
313        // Pin (domain validates Published status)
314        notice.pin()?;
315
316        // Persist changes
317        let updated = self.notice_repo.update(&notice).await?;
318
319        // Return enriched response
320        self.get_notice(updated.id).await
321    }
322
323    /// Unpin a notice
324    ///
325    /// # Authorization
326    /// - Only building admin (admin, superadmin, or syndic) can unpin notices
327    pub async fn unpin_notice(
328        &self,
329        notice_id: Uuid,
330        actor_role: &str,
331    ) -> Result<NoticeResponseDto, String> {
332        // Authorization: only building admin can unpin
333        if !Self::is_building_admin(actor_role) {
334            return Err("Unauthorized: only building admin (admin, superadmin, or syndic) can unpin notices".to_string());
335        }
336
337        let mut notice = self
338            .notice_repo
339            .find_by_id(notice_id)
340            .await?
341            .ok_or("Notice not found".to_string())?;
342
343        // Unpin
344        notice.unpin()?;
345
346        // Persist changes
347        let updated = self.notice_repo.update(&notice).await?;
348
349        // Return enriched response
350        self.get_notice(updated.id).await
351    }
352
353    /// Set expiration date for a notice
354    ///
355    /// # Authorization
356    /// - Only author can set expiration
357    pub async fn set_expiration(
358        &self,
359        notice_id: Uuid,
360        user_id: Uuid,
361        _organization_id: Uuid,
362        dto: SetExpirationDto,
363    ) -> Result<NoticeResponseDto, String> {
364        let mut notice = self
365            .notice_repo
366            .find_by_id(notice_id)
367            .await?
368            .ok_or("Notice not found".to_string())?;
369
370        // Authorization: only author can set expiration
371        if notice.author_id != user_id {
372            return Err("Unauthorized: only author can set expiration".to_string());
373        }
374
375        // Set expiration (domain validates future date)
376        notice.set_expiration(dto.expires_at)?;
377
378        // Persist changes
379        let updated = self.notice_repo.update(&notice).await?;
380
381        // Return enriched response
382        self.get_notice(updated.id).await
383    }
384
385    /// Delete a notice
386    ///
387    /// # Authorization
388    /// - Only author can delete their notice
389    /// - Cannot delete Published/Archived notices (must archive first)
390    pub async fn delete_notice(
391        &self,
392        notice_id: Uuid,
393        user_id: Uuid,
394        _organization_id: Uuid,
395    ) -> Result<(), String> {
396        let notice = self
397            .notice_repo
398            .find_by_id(notice_id)
399            .await?
400            .ok_or("Notice not found".to_string())?;
401
402        // Authorization: only author can delete
403        if notice.author_id != user_id {
404            return Err("Unauthorized: only author can delete notice".to_string());
405        }
406
407        // Business rule: cannot delete Published or Archived notices
408        match notice.status {
409            NoticeStatus::Published | NoticeStatus::Archived => {
410                return Err(format!(
411                    "Cannot delete notice in status {:?}. Archive it first.",
412                    notice.status
413                ));
414            }
415            _ => {}
416        }
417
418        // Delete notice
419        self.notice_repo.delete(notice_id).await?;
420
421        Ok(())
422    }
423
424    /// Automatically expire notices that have passed their expiration date
425    ///
426    /// # Background Job
427    /// - Should be called periodically (e.g., daily cron job)
428    /// - Finds all Published notices with expires_at in the past
429    /// - Transitions them to Expired status
430    pub async fn auto_expire_notices(&self, building_id: Uuid) -> Result<Vec<Uuid>, String> {
431        let expired_notices = self.notice_repo.find_expired(building_id).await?;
432
433        let mut expired_ids = Vec::new();
434
435        for mut notice in expired_notices {
436            // Expire notice (domain validates state transition)
437            if let Err(e) = notice.expire() {
438                log::warn!("Failed to expire notice {}: {}. Skipping.", notice.id, e);
439                continue;
440            }
441
442            // Persist changes
443            match self.notice_repo.update(&notice).await {
444                Ok(_) => {
445                    expired_ids.push(notice.id);
446                    log::info!("Auto-expired notice: {}", notice.id);
447                }
448                Err(e) => {
449                    log::error!("Failed to update expired notice {}: {}", notice.id, e);
450                }
451            }
452        }
453
454        Ok(expired_ids)
455    }
456
457    /// Get notice statistics for a building
458    pub async fn get_statistics(&self, building_id: Uuid) -> Result<NoticeStatistics, String> {
459        let total_count = self.notice_repo.count_by_building(building_id).await?;
460        let published_count = self
461            .notice_repo
462            .count_published_by_building(building_id)
463            .await?;
464        let pinned_count = self
465            .notice_repo
466            .count_pinned_by_building(building_id)
467            .await?;
468
469        Ok(NoticeStatistics {
470            total_count,
471            published_count,
472            pinned_count,
473        })
474    }
475
476    // Helper method to enrich notices with author names
477    async fn enrich_notices_summary(
478        &self,
479        notices: Vec<Notice>,
480    ) -> Result<Vec<NoticeSummaryDto>, String> {
481        let mut enriched = Vec::new();
482
483        for notice in notices {
484            let author_name = self.resolve_author_name(notice.author_id).await;
485            enriched.push(NoticeSummaryDto::from_notice(notice, author_name));
486        }
487
488        Ok(enriched)
489    }
490}
491
492/// Notice statistics for a building
493#[derive(Debug, serde::Serialize)]
494pub struct NoticeStatistics {
495    pub total_count: i64,
496    pub published_count: i64,
497    pub pinned_count: i64,
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use crate::application::ports::{NoticeRepository, UserRepository};
504    use crate::domain::entities::{
505        Notice, NoticeCategory, NoticeStatus, NoticeType, User, UserRole,
506    };
507    use async_trait::async_trait;
508    use chrono::Utc;
509    use std::collections::HashMap;
510    use std::sync::Mutex;
511    use uuid::Uuid;
512
513    // ─── Mock NoticeRepository ──────────────────────────────────────────
514
515    struct MockNoticeRepo {
516        notices: Mutex<HashMap<Uuid, Notice>>,
517    }
518
519    impl MockNoticeRepo {
520        fn new() -> Self {
521            Self {
522                notices: Mutex::new(HashMap::new()),
523            }
524        }
525
526        fn with_notice(notice: Notice) -> Self {
527            let mut map = HashMap::new();
528            map.insert(notice.id, notice);
529            Self {
530                notices: Mutex::new(map),
531            }
532        }
533    }
534
535    #[async_trait]
536    impl NoticeRepository for MockNoticeRepo {
537        async fn create(&self, notice: &Notice) -> Result<Notice, String> {
538            let mut store = self.notices.lock().unwrap();
539            store.insert(notice.id, notice.clone());
540            Ok(notice.clone())
541        }
542
543        async fn find_by_id(&self, id: Uuid) -> Result<Option<Notice>, String> {
544            let store = self.notices.lock().unwrap();
545            Ok(store.get(&id).cloned())
546        }
547
548        async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Notice>, String> {
549            let store = self.notices.lock().unwrap();
550            Ok(store
551                .values()
552                .filter(|n| n.building_id == building_id)
553                .cloned()
554                .collect())
555        }
556
557        async fn find_published_by_building(
558            &self,
559            building_id: Uuid,
560        ) -> Result<Vec<Notice>, String> {
561            let store = self.notices.lock().unwrap();
562            Ok(store
563                .values()
564                .filter(|n| n.building_id == building_id && n.status == NoticeStatus::Published)
565                .cloned()
566                .collect())
567        }
568
569        async fn find_pinned_by_building(&self, building_id: Uuid) -> Result<Vec<Notice>, String> {
570            let store = self.notices.lock().unwrap();
571            Ok(store
572                .values()
573                .filter(|n| n.building_id == building_id && n.is_pinned)
574                .cloned()
575                .collect())
576        }
577
578        async fn find_by_type(
579            &self,
580            building_id: Uuid,
581            notice_type: NoticeType,
582        ) -> Result<Vec<Notice>, String> {
583            let store = self.notices.lock().unwrap();
584            Ok(store
585                .values()
586                .filter(|n| n.building_id == building_id && n.notice_type == notice_type)
587                .cloned()
588                .collect())
589        }
590
591        async fn find_by_category(
592            &self,
593            building_id: Uuid,
594            category: NoticeCategory,
595        ) -> Result<Vec<Notice>, String> {
596            let store = self.notices.lock().unwrap();
597            Ok(store
598                .values()
599                .filter(|n| n.building_id == building_id && n.category == category)
600                .cloned()
601                .collect())
602        }
603
604        async fn find_by_status(
605            &self,
606            building_id: Uuid,
607            status: NoticeStatus,
608        ) -> Result<Vec<Notice>, String> {
609            let store = self.notices.lock().unwrap();
610            Ok(store
611                .values()
612                .filter(|n| n.building_id == building_id && n.status == status)
613                .cloned()
614                .collect())
615        }
616
617        async fn find_by_author(&self, author_id: Uuid) -> Result<Vec<Notice>, String> {
618            let store = self.notices.lock().unwrap();
619            Ok(store
620                .values()
621                .filter(|n| n.author_id == author_id)
622                .cloned()
623                .collect())
624        }
625
626        async fn find_expired(&self, building_id: Uuid) -> Result<Vec<Notice>, String> {
627            let store = self.notices.lock().unwrap();
628            Ok(store
629                .values()
630                .filter(|n| {
631                    n.building_id == building_id
632                        && n.status == NoticeStatus::Published
633                        && n.is_expired()
634                })
635                .cloned()
636                .collect())
637        }
638
639        async fn update(&self, notice: &Notice) -> Result<Notice, String> {
640            let mut store = self.notices.lock().unwrap();
641            store.insert(notice.id, notice.clone());
642            Ok(notice.clone())
643        }
644
645        async fn delete(&self, id: Uuid) -> Result<(), String> {
646            let mut store = self.notices.lock().unwrap();
647            store.remove(&id);
648            Ok(())
649        }
650
651        async fn count_by_building(&self, building_id: Uuid) -> Result<i64, String> {
652            let store = self.notices.lock().unwrap();
653            Ok(store
654                .values()
655                .filter(|n| n.building_id == building_id)
656                .count() as i64)
657        }
658
659        async fn count_published_by_building(&self, building_id: Uuid) -> Result<i64, String> {
660            let store = self.notices.lock().unwrap();
661            Ok(store
662                .values()
663                .filter(|n| n.building_id == building_id && n.status == NoticeStatus::Published)
664                .count() as i64)
665        }
666
667        async fn count_pinned_by_building(&self, building_id: Uuid) -> Result<i64, String> {
668            let store = self.notices.lock().unwrap();
669            Ok(store
670                .values()
671                .filter(|n| n.building_id == building_id && n.is_pinned)
672                .count() as i64)
673        }
674    }
675
676    // ─── Mock UserRepository ────────────────────────────────────────────
677
678    struct MockUserRepo {
679        users: Mutex<HashMap<Uuid, User>>,
680    }
681
682    impl MockUserRepo {
683        /// Le filtre du vrai dépôt, reproduit : recherche sur le courriel, le
684        /// prénom et le nom, puis rôle exact. `all` et le vide ne filtrent
685        /// pas.
686        fn filtrer(
687            store: &HashMap<Uuid, User>,
688            recherche: Option<String>,
689            role: Option<String>,
690        ) -> Vec<User> {
691            let terme = recherche
692                .map(|r| r.trim().to_lowercase())
693                .filter(|r| !r.is_empty());
694            let role = role
695                .map(|r| r.trim().to_string())
696                .filter(|r| !r.is_empty() && r != "all");
697
698            let mut retenus: Vec<User> = store
699                .values()
700                .filter(|u| match &terme {
701                    None => true,
702                    Some(t) => {
703                        u.email.to_lowercase().contains(t)
704                            || u.first_name.to_lowercase().contains(t)
705                            || u.last_name.to_lowercase().contains(t)
706                    }
707                })
708                .filter(|u| match &role {
709                    None => true,
710                    Some(r) => u.role.to_string() == *r,
711                })
712                .cloned()
713                .collect();
714            // Le vrai dépôt ordonne par `created_at DESC` : un double au
715            // hasard rendrait les tests de pagination non reproductibles.
716            retenus.sort_by_key(|u| std::cmp::Reverse(u.created_at));
717            retenus
718        }
719
720        fn new() -> Self {
721            Self {
722                users: Mutex::new(HashMap::new()),
723            }
724        }
725
726        fn with_user(user: User) -> Self {
727            let mut map = HashMap::new();
728            map.insert(user.id, user);
729            Self {
730                users: Mutex::new(map),
731            }
732        }
733    }
734
735    #[async_trait]
736    impl UserRepository for MockUserRepo {
737        async fn create(&self, user: &User) -> Result<User, String> {
738            let mut store = self.users.lock().unwrap();
739            store.insert(user.id, user.clone());
740            Ok(user.clone())
741        }
742
743        // Le double reproduit le filtre du vrai dépôt plutôt que de rendre
744        // tout : un double plus permissif que la production fait passer des
745        // tests qui échoueraient contre elle.
746        async fn find_page(
747            &self,
748            recherche: Option<String>,
749            role: Option<String>,
750            limit: i64,
751            offset: i64,
752        ) -> Result<Vec<User>, String> {
753            let retenus = Self::filtrer(&self.users.lock().unwrap(), recherche, role);
754            Ok(retenus
755                .into_iter()
756                .skip(offset.max(0) as usize)
757                .take(limit.max(0) as usize)
758                .collect())
759        }
760
761        async fn count_matching(
762            &self,
763            recherche: Option<String>,
764            role: Option<String>,
765        ) -> Result<i64, String> {
766            let retenus = Self::filtrer(&self.users.lock().unwrap(), recherche, role);
767            Ok(retenus.len() as i64)
768        }
769
770        async fn find_by_id(&self, id: Uuid) -> Result<Option<User>, String> {
771            let store = self.users.lock().unwrap();
772            Ok(store.get(&id).cloned())
773        }
774
775        async fn find_by_email(&self, email: &str) -> Result<Option<User>, String> {
776            let store = self.users.lock().unwrap();
777            Ok(store.values().find(|u| u.email == email).cloned())
778        }
779
780        async fn find_all(&self) -> Result<Vec<User>, String> {
781            let store = self.users.lock().unwrap();
782            Ok(store.values().cloned().collect())
783        }
784
785        async fn find_by_organization(&self, org_id: Uuid) -> Result<Vec<User>, String> {
786            let store = self.users.lock().unwrap();
787            Ok(store
788                .values()
789                .filter(|u| u.organization_id == Some(org_id))
790                .cloned()
791                .collect())
792        }
793
794        async fn update(&self, user: &User) -> Result<User, String> {
795            let mut store = self.users.lock().unwrap();
796            store.insert(user.id, user.clone());
797            Ok(user.clone())
798        }
799
800        async fn update_password(&self, _id: Uuid, _h: &str) -> Result<bool, String> {
801            Ok(true)
802        }
803
804        async fn activate(&self, id: Uuid) -> Result<Option<User>, String> {
805            let mut store = self.users.lock().unwrap();
806            if let Some(u) = store.get_mut(&id) {
807                u.is_active = true;
808                return Ok(Some(u.clone()));
809            }
810            Ok(None)
811        }
812
813        async fn deactivate(&self, id: Uuid) -> Result<Option<User>, String> {
814            let mut store = self.users.lock().unwrap();
815            if let Some(u) = store.get_mut(&id) {
816                u.is_active = false;
817                return Ok(Some(u.clone()));
818            }
819            Ok(None)
820        }
821
822        async fn delete(&self, id: Uuid) -> Result<bool, String> {
823            let mut store = self.users.lock().unwrap();
824            Ok(store.remove(&id).is_some())
825        }
826
827        async fn count_by_organization(&self, org_id: Uuid) -> Result<i64, String> {
828            let store = self.users.lock().unwrap();
829            Ok(store
830                .values()
831                .filter(|u| u.organization_id == Some(org_id))
832                .count() as i64)
833        }
834    }
835
836    // ─── Helpers ────────────────────────────────────────────────────────
837
838    fn make_user(id: Uuid) -> User {
839        User {
840            id,
841            email: format!("user-{}@example.com", &id.to_string()[..8]),
842            password_hash: "hash".to_string(),
843            first_name: "Jean".to_string(),
844            last_name: "Dupont".to_string(),
845            role: UserRole::Owner,
846            organization_id: Some(Uuid::new_v4()),
847            is_active: true,
848            processing_restricted: false,
849            processing_restricted_at: None,
850            marketing_opt_out: false,
851            marketing_opt_out_at: None,
852            created_at: Utc::now(),
853            updated_at: Utc::now(),
854        }
855    }
856
857    fn make_draft_notice(building_id: Uuid, author_id: Uuid) -> Notice {
858        Notice::new(
859            building_id,
860            author_id,
861            NoticeType::Announcement,
862            NoticeCategory::General,
863            "Test Notice Title".to_string(),
864            "This is a test notice content for unit testing.".to_string(),
865            None,
866            None,
867            None,
868        )
869        .unwrap()
870    }
871
872    fn make_published_notice(building_id: Uuid, author_id: Uuid) -> Notice {
873        let mut notice = make_draft_notice(building_id, author_id);
874        notice.publish().unwrap();
875        notice
876    }
877
878    // ─── Tests ──────────────────────────────────────────────────────────
879
880    #[tokio::test]
881    async fn test_create_notice_success() {
882        let user_id = Uuid::new_v4();
883        let org_id = Uuid::new_v4();
884        let building_id = Uuid::new_v4();
885        let user = make_user(user_id);
886
887        let uc = NoticeUseCases::new(
888            Arc::new(MockNoticeRepo::new()),
889            Arc::new(MockUserRepo::with_user(user)),
890        );
891
892        let dto = CreateNoticeDto {
893            building_id,
894            notice_type: NoticeType::Announcement,
895            category: NoticeCategory::General,
896            title: "Important Building Notice".to_string(),
897            content: "Please be aware of upcoming maintenance work.".to_string(),
898            event_date: None,
899            event_location: None,
900            contact_info: None,
901            expires_at: None,
902        };
903
904        let result = uc.create_notice(user_id, org_id, dto).await;
905        assert!(result.is_ok());
906        let resp = result.unwrap();
907        assert_eq!(resp.title, "Important Building Notice");
908        assert_eq!(resp.status, NoticeStatus::Draft);
909        assert_eq!(resp.author_id, user_id);
910        assert_eq!(resp.author_name, "Jean Dupont");
911        assert!(!resp.is_pinned);
912    }
913
914    /// Issue #781 (RN-11, recette 4 du 2026-09-06) — @happy, preuve de
915    /// non-régression.
916    ///
917    /// Un syndic SANS fiche de copropriétaire peut créer une annonce : un
918    /// avis émane de la copropriété, pas d'une personne nommée — à la
919    /// différence de skill/shared_object/resource_booking, `create_notice`
920    /// ne résout aucun `Owner` et n'a même pas de dépendance vers
921    /// `OwnerRepository`. C'est la preuve, vérifiée en recette le
922    /// 2026-09-06, que le refus des trois autres modules n'est pas une
923    /// panne générale du produit.
924    #[tokio::test]
925    async fn happy_syndic_sans_fiche_coproprietaire_peut_creer_une_annonce() {
926        let user_id = Uuid::new_v4(); // syndic, aucune fiche `owners` liée
927        let org_id = Uuid::new_v4();
928        let building_id = Uuid::new_v4();
929        let mut syndic = make_user(user_id);
930        syndic.role = UserRole::Syndic;
931
932        let uc = NoticeUseCases::new(
933            Arc::new(MockNoticeRepo::new()),
934            Arc::new(MockUserRepo::with_user(syndic)),
935        );
936
937        let dto = CreateNoticeDto {
938            building_id,
939            notice_type: NoticeType::Announcement,
940            category: NoticeCategory::General,
941            title: "Entretien des communs".to_string(),
942            content: "Les communs seront entretenus la semaine prochaine.".to_string(),
943            event_date: None,
944            event_location: None,
945            contact_info: None,
946            expires_at: None,
947        };
948
949        let result = uc.create_notice(user_id, org_id, dto).await;
950        assert!(
951            result.is_ok(),
952            "un syndic sans fiche de copropriétaire doit pouvoir créer une \
953             annonce : {:?}",
954            result.err()
955        );
956        assert_eq!(result.unwrap().author_id, user_id);
957    }
958
959    #[tokio::test]
960    async fn test_get_notice_success() {
961        let user_id = Uuid::new_v4();
962        let building_id = Uuid::new_v4();
963        let notice = make_draft_notice(building_id, user_id);
964        let notice_id = notice.id;
965        let user = make_user(user_id);
966
967        let uc = NoticeUseCases::new(
968            Arc::new(MockNoticeRepo::with_notice(notice)),
969            Arc::new(MockUserRepo::with_user(user)),
970        );
971
972        let result = uc.get_notice(notice_id).await;
973        assert!(result.is_ok());
974        let resp = result.unwrap();
975        assert_eq!(resp.id, notice_id);
976        assert_eq!(resp.author_name, "Jean Dupont");
977    }
978
979    #[tokio::test]
980    async fn test_get_notice_not_found() {
981        let uc = NoticeUseCases::new(
982            Arc::new(MockNoticeRepo::new()),
983            Arc::new(MockUserRepo::new()),
984        );
985
986        let result = uc.get_notice(Uuid::new_v4()).await;
987        assert!(result.is_err());
988        assert_eq!(result.unwrap_err(), "Notice not found");
989    }
990
991    #[tokio::test]
992    async fn test_publish_notice_success() {
993        let user_id = Uuid::new_v4();
994        let org_id = Uuid::new_v4();
995        let building_id = Uuid::new_v4();
996        let notice = make_draft_notice(building_id, user_id);
997        let notice_id = notice.id;
998        let user = make_user(user_id);
999
1000        let uc = NoticeUseCases::new(
1001            Arc::new(MockNoticeRepo::with_notice(notice)),
1002            Arc::new(MockUserRepo::with_user(user)),
1003        );
1004
1005        let result = uc.publish_notice(notice_id, user_id, org_id).await;
1006        assert!(result.is_ok());
1007        let resp = result.unwrap();
1008        assert_eq!(resp.status, NoticeStatus::Published);
1009        assert!(resp.published_at.is_some());
1010    }
1011
1012    #[tokio::test]
1013    async fn test_publish_notice_unauthorized() {
1014        let author_id = Uuid::new_v4();
1015        let other_user_id = Uuid::new_v4();
1016        let org_id = Uuid::new_v4();
1017        let building_id = Uuid::new_v4();
1018        let notice = make_draft_notice(building_id, author_id);
1019        let notice_id = notice.id;
1020
1021        let uc = NoticeUseCases::new(
1022            Arc::new(MockNoticeRepo::with_notice(notice)),
1023            Arc::new(MockUserRepo::new()),
1024        );
1025
1026        let result = uc.publish_notice(notice_id, other_user_id, org_id).await;
1027        assert!(result.is_err());
1028        assert!(result
1029            .unwrap_err()
1030            .contains("Unauthorized: only author can publish notice"));
1031    }
1032
1033    #[tokio::test]
1034    async fn test_archive_notice_by_author() {
1035        let user_id = Uuid::new_v4();
1036        let org_id = Uuid::new_v4();
1037        let building_id = Uuid::new_v4();
1038        let notice = make_published_notice(building_id, user_id);
1039        let notice_id = notice.id;
1040        let user = make_user(user_id);
1041
1042        let uc = NoticeUseCases::new(
1043            Arc::new(MockNoticeRepo::with_notice(notice)),
1044            Arc::new(MockUserRepo::with_user(user)),
1045        );
1046
1047        let result = uc
1048            .archive_notice(notice_id, user_id, org_id, "owner", None)
1049            .await;
1050        assert!(result.is_ok());
1051        let resp = result.unwrap();
1052        assert_eq!(resp.status, NoticeStatus::Archived);
1053        assert!(resp.archived_at.is_some());
1054    }
1055
1056    #[tokio::test]
1057    async fn test_archive_notice_by_admin() {
1058        let author_id = Uuid::new_v4();
1059        let admin_id = Uuid::new_v4();
1060        let org_id = Uuid::new_v4();
1061        let building_id = Uuid::new_v4();
1062        let notice = make_published_notice(building_id, author_id);
1063        let notice_id = notice.id;
1064        let admin_user = make_user(admin_id);
1065
1066        let uc = NoticeUseCases::new(
1067            Arc::new(MockNoticeRepo::with_notice(notice)),
1068            Arc::new(MockUserRepo::with_user(admin_user)),
1069        );
1070
1071        // Admin (not the author) can archive — Story 5.3 (#587) ajoute un
1072        // motif obligatoire dès qu'on modère le contenu d'autrui (audit) :
1073        // l'assertion "l'admin peut archiver" reste vraie, elle exige
1074        // maintenant ce motif en plus, comme n'importe quelle modération.
1075        let result = uc
1076            .archive_notice(
1077                notice_id,
1078                admin_id,
1079                org_id,
1080                "admin",
1081                Some("Contenu obsolète signalé".to_string()),
1082            )
1083            .await;
1084        assert!(result.is_ok());
1085        assert_eq!(result.unwrap().status, NoticeStatus::Archived);
1086    }
1087
1088    #[tokio::test]
1089    async fn test_archive_notice_unauthorized_non_author_non_admin() {
1090        let author_id = Uuid::new_v4();
1091        let other_user_id = Uuid::new_v4();
1092        let org_id = Uuid::new_v4();
1093        let building_id = Uuid::new_v4();
1094        let notice = make_published_notice(building_id, author_id);
1095        let notice_id = notice.id;
1096
1097        let uc = NoticeUseCases::new(
1098            Arc::new(MockNoticeRepo::with_notice(notice)),
1099            Arc::new(MockUserRepo::new()),
1100        );
1101
1102        let result = uc
1103            .archive_notice(notice_id, other_user_id, org_id, "owner", None)
1104            .await;
1105        assert!(result.is_err());
1106        assert!(result
1107            .unwrap_err()
1108            .contains("Unauthorized: only author or building admin can archive notice"));
1109    }
1110
1111    // ------------------------------------------------------------------------
1112    // Story 5.3 (#587), INV-4 — Syndic = community.moderator sur les annonces
1113    // ------------------------------------------------------------------------
1114
1115    #[tokio::test]
1116    async fn negative_moderation_dune_annonce_sans_motif_est_refusee() {
1117        let author_id = Uuid::new_v4();
1118        let syndic_id = Uuid::new_v4();
1119        let org_id = Uuid::new_v4();
1120        let building_id = Uuid::new_v4();
1121        let notice = make_published_notice(building_id, author_id);
1122        let notice_id = notice.id;
1123
1124        let uc = NoticeUseCases::new(
1125            Arc::new(MockNoticeRepo::with_notice(notice)),
1126            Arc::new(MockUserRepo::new()),
1127        );
1128
1129        let result = uc
1130            .archive_notice(notice_id, syndic_id, org_id, "syndic", None)
1131            .await;
1132        assert!(result.is_err());
1133        assert_eq!(
1134            result.unwrap_err(),
1135            crate::application::error::MOTIF_MODERATION_REQUIS
1136        );
1137
1138        let result_blank = uc
1139            .archive_notice(
1140                notice_id,
1141                syndic_id,
1142                org_id,
1143                "syndic",
1144                Some("   ".to_string()),
1145            )
1146            .await;
1147        assert!(result_blank.is_err());
1148        assert_eq!(
1149            result_blank.unwrap_err(),
1150            crate::application::error::MOTIF_MODERATION_REQUIS
1151        );
1152    }
1153
1154    #[tokio::test]
1155    async fn happy_community_moderator_archive_avec_motif() {
1156        // Story 3.1 a introduit `UserRole::CommunityModerator` précisément
1157        // pour porter cette capacité sans être syndic : `is_building_admin`
1158        // doit le reconnaître au même titre que "syndic".
1159        let author_id = Uuid::new_v4();
1160        let moderator_id = Uuid::new_v4();
1161        let org_id = Uuid::new_v4();
1162        let building_id = Uuid::new_v4();
1163        let notice = make_published_notice(building_id, author_id);
1164        let notice_id = notice.id;
1165
1166        let uc = NoticeUseCases::new(
1167            Arc::new(MockNoticeRepo::with_notice(notice)),
1168            Arc::new(MockUserRepo::new()),
1169        );
1170
1171        let result = uc
1172            .archive_notice(
1173                notice_id,
1174                moderator_id,
1175                org_id,
1176                "community.moderator",
1177                Some("Annonce en doublon".to_string()),
1178            )
1179            .await;
1180        assert!(
1181            result.is_ok(),
1182            "un community.moderator doit pouvoir archiver avec motif : {:?}",
1183            result.err()
1184        );
1185        assert_eq!(result.unwrap().status, NoticeStatus::Archived);
1186    }
1187
1188    #[tokio::test]
1189    async fn test_pin_notice_admin_success() {
1190        let user_id = Uuid::new_v4();
1191        let building_id = Uuid::new_v4();
1192        let notice = make_published_notice(building_id, user_id);
1193        let notice_id = notice.id;
1194        let user = make_user(user_id);
1195
1196        let uc = NoticeUseCases::new(
1197            Arc::new(MockNoticeRepo::with_notice(notice)),
1198            Arc::new(MockUserRepo::with_user(user)),
1199        );
1200
1201        let result = uc.pin_notice(notice_id, "syndic").await;
1202        assert!(result.is_ok());
1203        assert!(result.unwrap().is_pinned);
1204    }
1205
1206    #[tokio::test]
1207    async fn test_pin_notice_unauthorized_owner() {
1208        let user_id = Uuid::new_v4();
1209        let building_id = Uuid::new_v4();
1210        let notice = make_published_notice(building_id, user_id);
1211        let notice_id = notice.id;
1212
1213        let uc = NoticeUseCases::new(
1214            Arc::new(MockNoticeRepo::with_notice(notice)),
1215            Arc::new(MockUserRepo::new()),
1216        );
1217
1218        let result = uc.pin_notice(notice_id, "owner").await;
1219        assert!(result.is_err());
1220        assert!(result
1221            .unwrap_err()
1222            .contains("Unauthorized: only building admin"));
1223    }
1224
1225    #[tokio::test]
1226    async fn test_delete_notice_success_draft() {
1227        let user_id = Uuid::new_v4();
1228        let org_id = Uuid::new_v4();
1229        let building_id = Uuid::new_v4();
1230        let notice = make_draft_notice(building_id, user_id);
1231        let notice_id = notice.id;
1232
1233        let uc = NoticeUseCases::new(
1234            Arc::new(MockNoticeRepo::with_notice(notice)),
1235            Arc::new(MockUserRepo::new()),
1236        );
1237
1238        let result = uc.delete_notice(notice_id, user_id, org_id).await;
1239        assert!(result.is_ok());
1240    }
1241
1242    #[tokio::test]
1243    async fn test_delete_notice_blocked_for_published() {
1244        let user_id = Uuid::new_v4();
1245        let org_id = Uuid::new_v4();
1246        let building_id = Uuid::new_v4();
1247        let notice = make_published_notice(building_id, user_id);
1248        let notice_id = notice.id;
1249
1250        let uc = NoticeUseCases::new(
1251            Arc::new(MockNoticeRepo::with_notice(notice)),
1252            Arc::new(MockUserRepo::new()),
1253        );
1254
1255        let result = uc.delete_notice(notice_id, user_id, org_id).await;
1256        assert!(result.is_err());
1257        assert!(result
1258            .unwrap_err()
1259            .contains("Cannot delete notice in status"));
1260    }
1261}