koprogo_api/domain/entities/
notification.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Notification Type - Different categories of notifications
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub enum NotificationType {
8    ExpenseCreated,     // Nouvel appel de fonds
9    MeetingConvocation, // Convocation AG
10    PaymentReceived,    // Paiement reçu
11    TicketResolved,     // Ticket résolu
12    DocumentAdded,      // Document ajouté
13    BoardMessage,       // Message conseil copropriété
14    PaymentReminder,    // Relance paiement
15    BudgetApproved,     // Budget approuvé
16    ResolutionVote,     // Vote sur résolution
17    System,             // Notification système
18}
19
20/// Notification Channel - Delivery method
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22pub enum NotificationChannel {
23    Email, // Email notification
24    InApp, // In-app notification (dashboard)
25    Push,  // Web Push notification (service worker)
26}
27
28/// Notification Status
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
30pub enum NotificationStatus {
31    Pending, // Waiting to be sent
32    Sent,    // Successfully sent
33    Failed,  // Failed to send
34    Read,    // Read by user (for in-app only)
35}
36
37/// Notification Priority
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)]
39pub enum NotificationPriority {
40    Low,      // Basse
41    Medium,   // Moyenne
42    High,     // Haute
43    Critical, // Critique/Urgente
44}
45
46/// Notification Entity
47///
48/// Represents a notification sent to a user via one or more channels.
49/// Supports email, in-app, and web push notifications.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct Notification {
52    pub id: Uuid,
53    pub organization_id: Uuid,
54    pub user_id: Uuid, // Recipient
55    pub notification_type: NotificationType,
56    pub channel: NotificationChannel,
57    pub priority: NotificationPriority,
58    pub status: NotificationStatus,
59    pub title: String,
60    pub message: String,
61    pub link_url: Option<String>, // Optional link to resource
62    pub metadata: Option<String>, // JSON metadata for rich notifications
63    pub sent_at: Option<DateTime<Utc>>,
64    pub read_at: Option<DateTime<Utc>>, // For in-app notifications
65    pub created_at: DateTime<Utc>,
66    pub error_message: Option<String>, // Error details if failed
67}
68
69impl Notification {
70    /// Create a new notification
71    pub fn new(
72        organization_id: Uuid,
73        user_id: Uuid,
74        notification_type: NotificationType,
75        channel: NotificationChannel,
76        priority: NotificationPriority,
77        title: String,
78        message: String,
79    ) -> Result<Self, String> {
80        // Validation
81        if title.trim().is_empty() {
82            return Err("Title cannot be empty".to_string());
83        }
84
85        if title.len() > 200 {
86            return Err("Title cannot exceed 200 characters".to_string());
87        }
88
89        if message.trim().is_empty() {
90            return Err("Message cannot be empty".to_string());
91        }
92
93        if message.len() > 5000 {
94            return Err("Message cannot exceed 5000 characters".to_string());
95        }
96
97        let now = Utc::now();
98
99        Ok(Self {
100            id: Uuid::new_v4(),
101            organization_id,
102            user_id,
103            notification_type,
104            channel,
105            priority,
106            status: NotificationStatus::Pending,
107            title,
108            message,
109            link_url: None,
110            metadata: None,
111            sent_at: None,
112            read_at: None,
113            created_at: now,
114            error_message: None,
115        })
116    }
117
118    /// Set link URL for the notification
119    pub fn with_link(mut self, url: String) -> Self {
120        self.link_url = Some(url);
121        self
122    }
123
124    /// Set metadata for the notification
125    pub fn with_metadata(mut self, metadata: String) -> Self {
126        self.metadata = Some(metadata);
127        self
128    }
129
130    /// Mark notification as sent
131    pub fn mark_sent(&mut self) {
132        self.status = NotificationStatus::Sent;
133        self.sent_at = Some(Utc::now());
134        self.error_message = None;
135    }
136
137    /// Mark notification as failed
138    pub fn mark_failed(&mut self, error: String) {
139        self.status = NotificationStatus::Failed;
140        self.error_message = Some(error);
141    }
142
143    /// Mark notification as read (in-app only)
144    pub fn mark_read(&mut self) -> Result<(), String> {
145        if self.channel != NotificationChannel::InApp {
146            return Err("Only in-app notifications can be marked as read".to_string());
147        }
148
149        if self.status != NotificationStatus::Sent {
150            return Err("Can only mark sent notifications as read".to_string());
151        }
152
153        self.status = NotificationStatus::Read;
154        self.read_at = Some(Utc::now());
155        Ok(())
156    }
157
158    /// Check if notification is unread
159    pub fn is_unread(&self) -> bool {
160        self.channel == NotificationChannel::InApp
161            && self.status == NotificationStatus::Sent
162            && self.read_at.is_none()
163    }
164
165    /// Check if notification is pending
166    pub fn is_pending(&self) -> bool {
167        self.status == NotificationStatus::Pending
168    }
169
170    /// Retry failed notification
171    pub fn retry(&mut self) -> Result<(), String> {
172        if self.status != NotificationStatus::Failed {
173            return Err("Can only retry failed notifications".to_string());
174        }
175
176        self.status = NotificationStatus::Pending;
177        self.error_message = None;
178        Ok(())
179    }
180}
181
182/// User Notification Preferences
183///
184/// Allows users to opt-in/opt-out of specific notification types per channel
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct NotificationPreference {
187    pub id: Uuid,
188    pub user_id: Uuid,
189    pub notification_type: NotificationType,
190    pub email_enabled: bool,
191    pub in_app_enabled: bool,
192    pub push_enabled: bool,
193    pub created_at: DateTime<Utc>,
194    pub updated_at: DateTime<Utc>,
195}
196
197impl NotificationPreference {
198    /// Create new notification preference with default settings
199    pub fn new(user_id: Uuid, notification_type: NotificationType) -> Self {
200        let now = Utc::now();
201
202        Self {
203            id: Uuid::new_v4(),
204            user_id,
205            notification_type,
206            // Default: all channels enabled
207            email_enabled: true,
208            in_app_enabled: true,
209            push_enabled: true,
210            created_at: now,
211            updated_at: now,
212        }
213    }
214
215    /// Update preference for a specific channel
216    pub fn set_channel_enabled(&mut self, channel: NotificationChannel, enabled: bool) {
217        match channel {
218            NotificationChannel::Email => self.email_enabled = enabled,
219            NotificationChannel::InApp => self.in_app_enabled = enabled,
220            NotificationChannel::Push => self.push_enabled = enabled,
221        }
222        self.updated_at = Utc::now();
223    }
224
225    /// Check if a channel is enabled
226    pub fn is_channel_enabled(&self, channel: &NotificationChannel) -> bool {
227        match channel {
228            NotificationChannel::Email => self.email_enabled,
229            NotificationChannel::InApp => self.in_app_enabled,
230            NotificationChannel::Push => self.push_enabled,
231        }
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_create_notification_success() {
241        let notification = Notification::new(
242            Uuid::new_v4(),
243            Uuid::new_v4(),
244            NotificationType::ExpenseCreated,
245            NotificationChannel::Email,
246            NotificationPriority::High,
247            "Nouvel appel de fonds".to_string(),
248            "Un nouvel appel de fonds de 500€ a été créé.".to_string(),
249        );
250
251        assert!(notification.is_ok());
252        let notification = notification.unwrap();
253        assert_eq!(notification.status, NotificationStatus::Pending);
254        assert!(notification.sent_at.is_none());
255    }
256
257    #[test]
258    fn test_create_notification_empty_title() {
259        let result = Notification::new(
260            Uuid::new_v4(),
261            Uuid::new_v4(),
262            NotificationType::System,
263            NotificationChannel::InApp,
264            NotificationPriority::Low,
265            "   ".to_string(),
266            "Message".to_string(),
267        );
268
269        assert!(result.is_err());
270        assert_eq!(result.unwrap_err(), "Title cannot be empty");
271    }
272
273    #[test]
274    fn test_mark_sent() {
275        let mut notification = Notification::new(
276            Uuid::new_v4(),
277            Uuid::new_v4(),
278            NotificationType::PaymentReceived,
279            NotificationChannel::Email,
280            NotificationPriority::Medium,
281            "Paiement reçu".to_string(),
282            "Votre paiement a été reçu.".to_string(),
283        )
284        .unwrap();
285
286        notification.mark_sent();
287
288        assert_eq!(notification.status, NotificationStatus::Sent);
289        assert!(notification.sent_at.is_some());
290        assert!(notification.error_message.is_none());
291    }
292
293    #[test]
294    fn test_mark_failed() {
295        let mut notification = Notification::new(
296            Uuid::new_v4(),
297            Uuid::new_v4(),
298            NotificationType::TicketResolved,
299            NotificationChannel::Email,
300            NotificationPriority::Low,
301            "Ticket résolu".to_string(),
302            "Votre ticket a été résolu.".to_string(),
303        )
304        .unwrap();
305
306        notification.mark_failed("SMTP error".to_string());
307
308        assert_eq!(notification.status, NotificationStatus::Failed);
309        assert_eq!(notification.error_message, Some("SMTP error".to_string()));
310    }
311
312    #[test]
313    fn test_mark_read_in_app() {
314        let mut notification = Notification::new(
315            Uuid::new_v4(),
316            Uuid::new_v4(),
317            NotificationType::DocumentAdded,
318            NotificationChannel::InApp,
319            NotificationPriority::Low,
320            "Nouveau document".to_string(),
321            "Un nouveau document a été ajouté.".to_string(),
322        )
323        .unwrap();
324
325        notification.mark_sent();
326        let result = notification.mark_read();
327
328        assert!(result.is_ok());
329        assert_eq!(notification.status, NotificationStatus::Read);
330        assert!(notification.read_at.is_some());
331    }
332
333    #[test]
334    fn test_cannot_mark_read_email() {
335        let mut notification = Notification::new(
336            Uuid::new_v4(),
337            Uuid::new_v4(),
338            NotificationType::BoardMessage,
339            NotificationChannel::Email,
340            NotificationPriority::Medium,
341            "Message du conseil".to_string(),
342            "Le conseil a envoyé un message.".to_string(),
343        )
344        .unwrap();
345
346        notification.mark_sent();
347        let result = notification.mark_read();
348
349        assert!(result.is_err());
350        assert!(result.unwrap_err().contains("Only in-app"));
351    }
352
353    #[test]
354    fn test_is_unread() {
355        let mut notification = Notification::new(
356            Uuid::new_v4(),
357            Uuid::new_v4(),
358            NotificationType::MeetingConvocation,
359            NotificationChannel::InApp,
360            NotificationPriority::High,
361            "Convocation AG".to_string(),
362            "Vous êtes convoqué à l'AG du 15/12.".to_string(),
363        )
364        .unwrap();
365
366        assert!(!notification.is_unread()); // Pending
367
368        notification.mark_sent();
369        assert!(notification.is_unread()); // Sent but not read
370
371        notification.mark_read().unwrap();
372        assert!(!notification.is_unread()); // Read
373    }
374
375    #[test]
376    fn test_retry_failed_notification() {
377        let mut notification = Notification::new(
378            Uuid::new_v4(),
379            Uuid::new_v4(),
380            NotificationType::PaymentReminder,
381            NotificationChannel::Email,
382            NotificationPriority::High,
383            "Relance paiement".to_string(),
384            "Votre paiement est en retard.".to_string(),
385        )
386        .unwrap();
387
388        notification.mark_failed("Network error".to_string());
389
390        let result = notification.retry();
391        assert!(result.is_ok());
392        assert_eq!(notification.status, NotificationStatus::Pending);
393        assert!(notification.error_message.is_none());
394    }
395
396    #[test]
397    fn test_notification_preference() {
398        let pref = NotificationPreference::new(Uuid::new_v4(), NotificationType::ExpenseCreated);
399
400        assert!(pref.email_enabled);
401        assert!(pref.in_app_enabled);
402        assert!(pref.push_enabled);
403    }
404
405    #[test]
406    fn test_set_channel_enabled() {
407        let mut pref =
408            NotificationPreference::new(Uuid::new_v4(), NotificationType::MeetingConvocation);
409
410        pref.set_channel_enabled(NotificationChannel::Email, false);
411
412        assert!(!pref.email_enabled);
413        assert!(pref.in_app_enabled);
414        assert!(pref.push_enabled);
415    }
416
417    #[test]
418    fn test_is_channel_enabled() {
419        let pref = NotificationPreference::new(Uuid::new_v4(), NotificationType::TicketResolved);
420
421        assert!(pref.is_channel_enabled(&NotificationChannel::Email));
422        assert!(pref.is_channel_enabled(&NotificationChannel::InApp));
423        assert!(pref.is_channel_enabled(&NotificationChannel::Push));
424    }
425}