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, OwnerRepository};
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    owner_repo: Arc<dyn OwnerRepository>,
12}
13
14impl NoticeUseCases {
15    pub fn new(
16        notice_repo: Arc<dyn NoticeRepository>,
17        owner_repo: Arc<dyn OwnerRepository>,
18    ) -> Self {
19        Self {
20            notice_repo,
21            owner_repo,
22        }
23    }
24
25    /// Check if user has building admin privileges (admin, superadmin, or syndic)
26    fn is_building_admin(role: &str) -> bool {
27        role == "admin" || role == "superadmin" || role == "syndic"
28    }
29
30    /// Create a new notice (Draft status)
31    ///
32    /// # Authorization
33    /// - Author must be a member of the building (validated by owner_repo)
34    pub async fn create_notice(
35        &self,
36        author_id: Uuid,
37        dto: CreateNoticeDto,
38    ) -> Result<NoticeResponseDto, String> {
39        // Verify author exists
40        let author = self
41            .owner_repo
42            .find_by_id(author_id)
43            .await?
44            .ok_or("Author not found".to_string())?;
45
46        // Create notice entity (validates business rules)
47        let notice = Notice::new(
48            dto.building_id,
49            author_id,
50            dto.notice_type,
51            dto.category,
52            dto.title,
53            dto.content,
54            dto.event_date,
55            dto.event_location,
56            dto.contact_info,
57        )?;
58
59        // Set expiration if provided
60        let mut notice = notice;
61        if let Some(expires_at) = dto.expires_at {
62            notice.set_expiration(Some(expires_at))?;
63        }
64
65        // Persist notice
66        let created = self.notice_repo.create(&notice).await?;
67
68        // Return enriched response
69        let author_name = format!("{} {}", author.first_name, author.last_name);
70        Ok(NoticeResponseDto::from_notice(created, author_name))
71    }
72
73    /// Get notice by ID with author name enrichment
74    pub async fn get_notice(&self, notice_id: Uuid) -> Result<NoticeResponseDto, String> {
75        let notice = self
76            .notice_repo
77            .find_by_id(notice_id)
78            .await?
79            .ok_or("Notice not found".to_string())?;
80
81        // Enrich with author name
82        let author = self
83            .owner_repo
84            .find_by_id(notice.author_id)
85            .await?
86            .ok_or("Author not found".to_string())?;
87
88        let author_name = format!("{} {}", author.first_name, author.last_name);
89        Ok(NoticeResponseDto::from_notice(notice, author_name))
90    }
91
92    /// List all notices for a building (all statuses)
93    ///
94    /// # Returns
95    /// - Notices sorted by pinned (DESC), created_at (DESC)
96    pub async fn list_building_notices(
97        &self,
98        building_id: Uuid,
99    ) -> Result<Vec<NoticeSummaryDto>, String> {
100        let notices = self.notice_repo.find_by_building(building_id).await?;
101        self.enrich_notices_summary(notices).await
102    }
103
104    /// List published notices for a building (visible to members)
105    ///
106    /// # Returns
107    /// - Only Published notices, sorted by pinned (DESC), published_at (DESC)
108    pub async fn list_published_notices(
109        &self,
110        building_id: Uuid,
111    ) -> Result<Vec<NoticeSummaryDto>, String> {
112        let notices = self
113            .notice_repo
114            .find_published_by_building(building_id)
115            .await?;
116        self.enrich_notices_summary(notices).await
117    }
118
119    /// List pinned notices for a building (important announcements)
120    pub async fn list_pinned_notices(
121        &self,
122        building_id: Uuid,
123    ) -> Result<Vec<NoticeSummaryDto>, String> {
124        let notices = self
125            .notice_repo
126            .find_pinned_by_building(building_id)
127            .await?;
128        self.enrich_notices_summary(notices).await
129    }
130
131    /// List notices by type (Announcement, Event, LostAndFound, ClassifiedAd)
132    pub async fn list_notices_by_type(
133        &self,
134        building_id: Uuid,
135        notice_type: NoticeType,
136    ) -> Result<Vec<NoticeSummaryDto>, String> {
137        let notices = self
138            .notice_repo
139            .find_by_type(building_id, notice_type)
140            .await?;
141        self.enrich_notices_summary(notices).await
142    }
143
144    /// List notices by category (General, Maintenance, Social, etc.)
145    pub async fn list_notices_by_category(
146        &self,
147        building_id: Uuid,
148        category: NoticeCategory,
149    ) -> Result<Vec<NoticeSummaryDto>, String> {
150        let notices = self
151            .notice_repo
152            .find_by_category(building_id, category)
153            .await?;
154        self.enrich_notices_summary(notices).await
155    }
156
157    /// List notices by status (Draft, Published, Archived, Expired)
158    pub async fn list_notices_by_status(
159        &self,
160        building_id: Uuid,
161        status: NoticeStatus,
162    ) -> Result<Vec<NoticeSummaryDto>, String> {
163        let notices = self.notice_repo.find_by_status(building_id, status).await?;
164        self.enrich_notices_summary(notices).await
165    }
166
167    /// List all notices created by an author
168    pub async fn list_author_notices(
169        &self,
170        author_id: Uuid,
171    ) -> Result<Vec<NoticeSummaryDto>, String> {
172        let notices = self.notice_repo.find_by_author(author_id).await?;
173        self.enrich_notices_summary(notices).await
174    }
175
176    /// Update a notice (Draft only)
177    ///
178    /// # Authorization
179    /// - Only author can update their notice
180    /// - Only Draft notices can be updated
181    pub async fn update_notice(
182        &self,
183        notice_id: Uuid,
184        actor_id: Uuid,
185        dto: UpdateNoticeDto,
186    ) -> Result<NoticeResponseDto, String> {
187        let mut notice = self
188            .notice_repo
189            .find_by_id(notice_id)
190            .await?
191            .ok_or("Notice not found".to_string())?;
192
193        // Authorization: only author can update
194        if notice.author_id != actor_id {
195            return Err("Unauthorized: only author can update notice".to_string());
196        }
197
198        // Update content (domain validates Draft status)
199        notice.update_content(
200            dto.title,
201            dto.content,
202            dto.category,
203            dto.event_date,
204            dto.event_location,
205            dto.contact_info,
206            dto.expires_at,
207        )?;
208
209        // Persist changes
210        let updated = self.notice_repo.update(&notice).await?;
211
212        // Return enriched response
213        self.get_notice(updated.id).await
214    }
215
216    /// Publish a notice (Draft → Published)
217    ///
218    /// # Authorization
219    /// - Only author can publish their notice
220    pub async fn publish_notice(
221        &self,
222        notice_id: Uuid,
223        actor_id: Uuid,
224    ) -> Result<NoticeResponseDto, String> {
225        let mut notice = self
226            .notice_repo
227            .find_by_id(notice_id)
228            .await?
229            .ok_or("Notice not found".to_string())?;
230
231        // Authorization: only author can publish
232        if notice.author_id != actor_id {
233            return Err("Unauthorized: only author can publish notice".to_string());
234        }
235
236        // Publish (domain validates state transition)
237        notice.publish()?;
238
239        // Persist changes
240        let updated = self.notice_repo.update(&notice).await?;
241
242        // Return enriched response
243        self.get_notice(updated.id).await
244    }
245
246    /// Archive a notice (Published/Expired → Archived)
247    ///
248    /// # Authorization
249    /// - Only author or building admin can archive
250    pub async fn archive_notice(
251        &self,
252        notice_id: Uuid,
253        actor_id: Uuid,
254        actor_role: &str,
255    ) -> Result<NoticeResponseDto, String> {
256        let mut notice = self
257            .notice_repo
258            .find_by_id(notice_id)
259            .await?
260            .ok_or("Notice not found".to_string())?;
261
262        // Authorization: only author or building admin can archive
263        let is_author = notice.author_id == actor_id;
264        let is_admin = Self::is_building_admin(actor_role);
265
266        if !is_author && !is_admin {
267            return Err(
268                "Unauthorized: only author or building admin can archive notice".to_string(),
269            );
270        }
271
272        // Archive (domain validates state transition)
273        notice.archive()?;
274
275        // Persist changes
276        let updated = self.notice_repo.update(&notice).await?;
277
278        // Return enriched response
279        self.get_notice(updated.id).await
280    }
281
282    /// Pin a notice to top of board (Published only)
283    ///
284    /// # Authorization
285    /// - Only building admin (admin, superadmin, or syndic) can pin notices
286    pub async fn pin_notice(
287        &self,
288        notice_id: Uuid,
289        actor_role: &str,
290    ) -> Result<NoticeResponseDto, String> {
291        // Authorization: only building admin can pin
292        if !Self::is_building_admin(actor_role) {
293            return Err(
294                "Unauthorized: only building admin (admin, superadmin, or syndic) can pin notices"
295                    .to_string(),
296            );
297        }
298
299        let mut notice = self
300            .notice_repo
301            .find_by_id(notice_id)
302            .await?
303            .ok_or("Notice not found".to_string())?;
304
305        // Pin (domain validates Published status)
306        notice.pin()?;
307
308        // Persist changes
309        let updated = self.notice_repo.update(&notice).await?;
310
311        // Return enriched response
312        self.get_notice(updated.id).await
313    }
314
315    /// Unpin a notice
316    ///
317    /// # Authorization
318    /// - Only building admin (admin, superadmin, or syndic) can unpin notices
319    pub async fn unpin_notice(
320        &self,
321        notice_id: Uuid,
322        actor_role: &str,
323    ) -> Result<NoticeResponseDto, String> {
324        // Authorization: only building admin can unpin
325        if !Self::is_building_admin(actor_role) {
326            return Err("Unauthorized: only building admin (admin, superadmin, or syndic) can unpin notices".to_string());
327        }
328
329        let mut notice = self
330            .notice_repo
331            .find_by_id(notice_id)
332            .await?
333            .ok_or("Notice not found".to_string())?;
334
335        // Unpin
336        notice.unpin()?;
337
338        // Persist changes
339        let updated = self.notice_repo.update(&notice).await?;
340
341        // Return enriched response
342        self.get_notice(updated.id).await
343    }
344
345    /// Set expiration date for a notice
346    ///
347    /// # Authorization
348    /// - Only author can set expiration
349    pub async fn set_expiration(
350        &self,
351        notice_id: Uuid,
352        actor_id: Uuid,
353        dto: SetExpirationDto,
354    ) -> Result<NoticeResponseDto, String> {
355        let mut notice = self
356            .notice_repo
357            .find_by_id(notice_id)
358            .await?
359            .ok_or("Notice not found".to_string())?;
360
361        // Authorization: only author can set expiration
362        if notice.author_id != actor_id {
363            return Err("Unauthorized: only author can set expiration".to_string());
364        }
365
366        // Set expiration (domain validates future date)
367        notice.set_expiration(dto.expires_at)?;
368
369        // Persist changes
370        let updated = self.notice_repo.update(&notice).await?;
371
372        // Return enriched response
373        self.get_notice(updated.id).await
374    }
375
376    /// Delete a notice
377    ///
378    /// # Authorization
379    /// - Only author can delete their notice
380    /// - Cannot delete Published/Archived notices (must archive first)
381    pub async fn delete_notice(&self, notice_id: Uuid, actor_id: Uuid) -> Result<(), String> {
382        let notice = self
383            .notice_repo
384            .find_by_id(notice_id)
385            .await?
386            .ok_or("Notice not found".to_string())?;
387
388        // Authorization: only author can delete
389        if notice.author_id != actor_id {
390            return Err("Unauthorized: only author can delete notice".to_string());
391        }
392
393        // Business rule: cannot delete Published or Archived notices
394        match notice.status {
395            NoticeStatus::Published | NoticeStatus::Archived => {
396                return Err(format!(
397                    "Cannot delete notice in status {:?}. Archive it first.",
398                    notice.status
399                ));
400            }
401            _ => {}
402        }
403
404        // Delete notice
405        self.notice_repo.delete(notice_id).await?;
406
407        Ok(())
408    }
409
410    /// Automatically expire notices that have passed their expiration date
411    ///
412    /// # Background Job
413    /// - Should be called periodically (e.g., daily cron job)
414    /// - Finds all Published notices with expires_at in the past
415    /// - Transitions them to Expired status
416    pub async fn auto_expire_notices(&self, building_id: Uuid) -> Result<Vec<Uuid>, String> {
417        let expired_notices = self.notice_repo.find_expired(building_id).await?;
418
419        let mut expired_ids = Vec::new();
420
421        for mut notice in expired_notices {
422            // Expire notice (domain validates state transition)
423            if let Err(e) = notice.expire() {
424                log::warn!("Failed to expire notice {}: {}. Skipping.", notice.id, e);
425                continue;
426            }
427
428            // Persist changes
429            match self.notice_repo.update(&notice).await {
430                Ok(_) => {
431                    expired_ids.push(notice.id);
432                    log::info!("Auto-expired notice: {}", notice.id);
433                }
434                Err(e) => {
435                    log::error!("Failed to update expired notice {}: {}", notice.id, e);
436                }
437            }
438        }
439
440        Ok(expired_ids)
441    }
442
443    /// Get notice statistics for a building
444    pub async fn get_statistics(&self, building_id: Uuid) -> Result<NoticeStatistics, String> {
445        let total_count = self.notice_repo.count_by_building(building_id).await?;
446        let published_count = self
447            .notice_repo
448            .count_published_by_building(building_id)
449            .await?;
450        let pinned_count = self
451            .notice_repo
452            .count_pinned_by_building(building_id)
453            .await?;
454
455        Ok(NoticeStatistics {
456            total_count,
457            published_count,
458            pinned_count,
459        })
460    }
461
462    // Helper method to enrich notices with author names
463    async fn enrich_notices_summary(
464        &self,
465        notices: Vec<Notice>,
466    ) -> Result<Vec<NoticeSummaryDto>, String> {
467        let mut enriched = Vec::new();
468
469        for notice in notices {
470            // Get author name
471            let author = self.owner_repo.find_by_id(notice.author_id).await?;
472            let author_name = if let Some(owner) = author {
473                format!("{} {}", owner.first_name, owner.last_name)
474            } else {
475                "Unknown Author".to_string()
476            };
477
478            enriched.push(NoticeSummaryDto::from_notice(notice, author_name));
479        }
480
481        Ok(enriched)
482    }
483}
484
485/// Notice statistics for a building
486#[derive(Debug, serde::Serialize)]
487pub struct NoticeStatistics {
488    pub total_count: i64,
489    pub published_count: i64,
490    pub pinned_count: i64,
491}