Skip to main content

koprogo_api/infrastructure/web/handlers/
notification_handlers.rs

1use crate::application::dto::{
2    CreateNotificationRequest, MarkReadRequest, UpdatePreferenceRequest,
3};
4use crate::domain::entities::NotificationType;
5use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
6use crate::infrastructure::web::{AppState, AuthenticatedUser};
7use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
8use uuid::Uuid;
9
10/// Parse notification type from both snake_case and PascalCase
11fn parse_notification_type(s: &str) -> Option<NotificationType> {
12    match s {
13        "expense_created" | "ExpenseCreated" => Some(NotificationType::ExpenseCreated),
14        "meeting_convocation" | "MeetingConvocation" => Some(NotificationType::MeetingConvocation),
15        "payment_received" | "PaymentReceived" => Some(NotificationType::PaymentReceived),
16        "ticket_resolved" | "TicketResolved" => Some(NotificationType::TicketResolved),
17        "document_added" | "DocumentAdded" => Some(NotificationType::DocumentAdded),
18        "board_message" | "BoardMessage" => Some(NotificationType::BoardMessage),
19        "payment_reminder" | "PaymentReminder" => Some(NotificationType::PaymentReminder),
20        "budget_approved" | "BudgetApproved" => Some(NotificationType::BudgetApproved),
21        "resolution_vote" | "ResolutionVote" => Some(NotificationType::ResolutionVote),
22        "system" | "System" => Some(NotificationType::System),
23        _ => None,
24    }
25}
26
27// ==================== Notification Endpoints ====================
28
29#[utoipa::path(
30    post,
31    path = "/notifications",
32    tag = "Notifications",
33    summary = "Create a notification",
34    request_body = CreateNotificationRequest,
35    responses(
36        (status = 201, description = "Notification created"),
37        (status = 400, description = "Invalid request"),
38        (status = 401, description = "Unauthorized"),
39    ),
40    security(("bearer_auth" = []))
41)]
42#[post("/notifications")]
43pub async fn create_notification(
44    state: web::Data<AppState>,
45    user: AuthenticatedUser,
46    request: web::Json<CreateNotificationRequest>,
47) -> impl Responder {
48    let organization_id = match user.require_organization() {
49        Ok(org_id) => org_id,
50        Err(e) => {
51            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
52        }
53    };
54
55    match state
56        .notification_use_cases
57        .create_notification(organization_id, request.into_inner())
58        .await
59    {
60        Ok(notification) => {
61            AuditLogEntry::new(
62                AuditEventType::NotificationCreated,
63                Some(user.user_id),
64                Some(organization_id),
65            )
66            .with_resource("Notification", notification.id)
67            .log();
68
69            HttpResponse::Created().json(notification)
70        }
71        Err(err) => {
72            AuditLogEntry::new(
73                AuditEventType::NotificationCreated,
74                Some(user.user_id),
75                Some(organization_id),
76            )
77            .with_error(err.clone())
78            .log();
79
80            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
81        }
82    }
83}
84
85#[utoipa::path(
86    get,
87    path = "/notifications/{id}",
88    tag = "Notifications",
89    summary = "Get a notification by ID",
90    params(
91        ("id" = Uuid, Path, description = "Notification ID")
92    ),
93    responses(
94        (status = 200, description = "Notification retrieved"),
95        (status = 401, description = "Unauthorized"),
96        (status = 404, description = "Notification not found"),
97    ),
98    security(("bearer_auth" = []))
99)]
100#[get("/notifications/{id}")]
101pub async fn get_notification(
102    state: web::Data<AppState>,
103    user: AuthenticatedUser,
104    id: web::Path<Uuid>,
105) -> impl Responder {
106    match state.notification_use_cases.get_notification(*id).await {
107        Ok(Some(notification)) => {
108            // Cloisonnement (#882) : une notification est adressée à UN
109            // destinataire (`user_id`), pas à toute une organisation. L'identité
110            // était prise et jetée (`_user`) : n'importe quel utilisateur
111            // authentifié pouvait lire le titre et le message d'une
112            // notification d'un autre, dans une autre organisation.
113            if !user.is_superadmin() && notification.user_id != user.user_id {
114                return HttpResponse::Forbidden().json(serde_json::json!({
115                    "error": "Notification does not belong to you"
116                }));
117            }
118            HttpResponse::Ok().json(notification)
119        }
120        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
121            "error": "Notification not found"
122        })),
123        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
124    }
125}
126
127#[utoipa::path(
128    get,
129    path = "/notifications/my",
130    tag = "Notifications",
131    summary = "List my notifications",
132    responses(
133        (status = 200, description = "Notifications retrieved"),
134        (status = 401, description = "Unauthorized"),
135    ),
136    security(("bearer_auth" = []))
137)]
138#[get("/notifications/my")]
139pub async fn list_my_notifications(
140    state: web::Data<AppState>,
141    user: AuthenticatedUser,
142) -> impl Responder {
143    match state
144        .notification_use_cases
145        .list_user_notifications(user.user_id)
146        .await
147    {
148        Ok(notifications) => HttpResponse::Ok().json(notifications),
149        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
150    }
151}
152
153#[utoipa::path(
154    get,
155    path = "/notifications/unread",
156    tag = "Notifications",
157    summary = "List unread notifications",
158    responses(
159        (status = 200, description = "Unread notifications retrieved"),
160        (status = 401, description = "Unauthorized"),
161    ),
162    security(("bearer_auth" = []))
163)]
164#[get("/notifications/unread")]
165pub async fn list_unread_notifications(
166    state: web::Data<AppState>,
167    user: AuthenticatedUser,
168) -> impl Responder {
169    match state
170        .notification_use_cases
171        .list_unread_notifications(user.user_id)
172        .await
173    {
174        Ok(notifications) => HttpResponse::Ok().json(notifications),
175        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
176    }
177}
178
179#[utoipa::path(
180    put,
181    path = "/notifications/{id}/read",
182    tag = "Notifications",
183    summary = "Mark a notification as read",
184    params(
185        ("id" = Uuid, Path, description = "Notification ID")
186    ),
187    request_body = MarkReadRequest,
188    responses(
189        (status = 200, description = "Notification marked as read"),
190        (status = 400, description = "Invalid request"),
191        (status = 401, description = "Unauthorized"),
192    ),
193    security(("bearer_auth" = []))
194)]
195#[put("/notifications/{id}/read")]
196pub async fn mark_notification_read(
197    state: web::Data<AppState>,
198    user: AuthenticatedUser,
199    id: web::Path<Uuid>,
200    _request: web::Json<MarkReadRequest>,
201) -> impl Responder {
202    let organization_id = match user.require_organization() {
203        Ok(org_id) => org_id,
204        Err(e) => {
205            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
206        }
207    };
208
209    match state.notification_use_cases.mark_as_read(*id).await {
210        Ok(notification) => {
211            AuditLogEntry::new(
212                AuditEventType::NotificationRead,
213                Some(user.user_id),
214                Some(organization_id),
215            )
216            .with_resource("Notification", notification.id)
217            .log();
218
219            HttpResponse::Ok().json(notification)
220        }
221        Err(err) => {
222            AuditLogEntry::new(
223                AuditEventType::NotificationRead,
224                Some(user.user_id),
225                Some(organization_id),
226            )
227            .with_error(err.clone())
228            .log();
229
230            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
231        }
232    }
233}
234
235#[utoipa::path(
236    put,
237    path = "/notifications/read-all",
238    tag = "Notifications",
239    summary = "Mark all notifications as read",
240    responses(
241        (status = 200, description = "All notifications marked as read"),
242        (status = 401, description = "Unauthorized"),
243    ),
244    security(("bearer_auth" = []))
245)]
246#[put("/notifications/read-all")]
247pub async fn mark_all_notifications_read(
248    state: web::Data<AppState>,
249    user: AuthenticatedUser,
250) -> impl Responder {
251    let organization_id = match user.require_organization() {
252        Ok(org_id) => org_id,
253        Err(e) => {
254            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
255        }
256    };
257
258    match state
259        .notification_use_cases
260        .mark_all_read(user.user_id)
261        .await
262    {
263        Ok(count) => {
264            AuditLogEntry::new(
265                AuditEventType::NotificationRead,
266                Some(user.user_id),
267                Some(organization_id),
268            )
269            .with_details(format!("Marked {} notifications as read", count))
270            .log();
271
272            HttpResponse::Ok().json(serde_json::json!({"marked_read": count}))
273        }
274        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
275    }
276}
277
278#[utoipa::path(
279    delete,
280    path = "/notifications/{id}",
281    tag = "Notifications",
282    summary = "Delete a notification",
283    params(
284        ("id" = Uuid, Path, description = "Notification ID")
285    ),
286    responses(
287        (status = 204, description = "Notification deleted"),
288        (status = 400, description = "Invalid request"),
289        (status = 401, description = "Unauthorized"),
290        (status = 404, description = "Notification not found"),
291    ),
292    security(("bearer_auth" = []))
293)]
294#[delete("/notifications/{id}")]
295pub async fn delete_notification(
296    state: web::Data<AppState>,
297    user: AuthenticatedUser,
298    id: web::Path<Uuid>,
299) -> impl Responder {
300    let organization_id = match user.require_organization() {
301        Ok(org_id) => org_id,
302        Err(e) => {
303            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
304        }
305    };
306
307    match state.notification_use_cases.delete_notification(*id).await {
308        Ok(true) => {
309            AuditLogEntry::new(
310                AuditEventType::NotificationDeleted,
311                Some(user.user_id),
312                Some(organization_id),
313            )
314            .with_resource("Notification", *id)
315            .log();
316
317            HttpResponse::NoContent().finish()
318        }
319        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
320            "error": "Notification not found"
321        })),
322        Err(err) => {
323            AuditLogEntry::new(
324                AuditEventType::NotificationDeleted,
325                Some(user.user_id),
326                Some(organization_id),
327            )
328            .with_error(err.clone())
329            .log();
330
331            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
332        }
333    }
334}
335
336#[utoipa::path(
337    get,
338    path = "/notifications/stats",
339    tag = "Notifications",
340    summary = "Get notification statistics for current user",
341    responses(
342        (status = 200, description = "Statistics retrieved"),
343        (status = 401, description = "Unauthorized"),
344    ),
345    security(("bearer_auth" = []))
346)]
347#[get("/notifications/stats")]
348pub async fn get_notification_stats(
349    state: web::Data<AppState>,
350    user: AuthenticatedUser,
351) -> impl Responder {
352    match state
353        .notification_use_cases
354        .get_user_stats(user.user_id)
355        .await
356    {
357        Ok(stats) => HttpResponse::Ok().json(stats),
358        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
359    }
360}
361
362// ==================== Notification Preference Endpoints ====================
363
364#[utoipa::path(
365    get,
366    path = "/notification-preferences",
367    tag = "Notifications",
368    summary = "Get all notification preferences for current user",
369    responses(
370        (status = 200, description = "Preferences retrieved"),
371        (status = 401, description = "Unauthorized"),
372    ),
373    security(("bearer_auth" = []))
374)]
375#[get("/notification-preferences")]
376pub async fn get_user_preferences(
377    state: web::Data<AppState>,
378    user: AuthenticatedUser,
379) -> impl Responder {
380    match state
381        .notification_use_cases
382        .get_user_preferences(user.user_id)
383        .await
384    {
385        Ok(preferences) => HttpResponse::Ok().json(preferences),
386        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
387    }
388}
389
390#[utoipa::path(
391    get,
392    path = "/notification-preferences/{notification_type}",
393    tag = "Notifications",
394    summary = "Get a specific notification preference by type",
395    params(
396        ("notification_type" = String, Path, description = "Notification type (e.g. expense_created, meeting_convocation)")
397    ),
398    responses(
399        (status = 200, description = "Preference retrieved"),
400        (status = 400, description = "Invalid notification type"),
401        (status = 401, description = "Unauthorized"),
402        (status = 404, description = "Preference not found"),
403    ),
404    security(("bearer_auth" = []))
405)]
406#[get("/notification-preferences/{notification_type}")]
407pub async fn get_preference(
408    state: web::Data<AppState>,
409    user: AuthenticatedUser,
410    notification_type: web::Path<String>,
411) -> impl Responder {
412    let notification_type = match parse_notification_type(notification_type.as_str()) {
413        Some(nt) => nt,
414        None => {
415            return HttpResponse::BadRequest().json(serde_json::json!({
416                "error": format!("Invalid notification type: {}", notification_type)
417            }))
418        }
419    };
420
421    match state
422        .notification_use_cases
423        .get_preference(user.user_id, notification_type)
424        .await
425    {
426        Ok(Some(preference)) => HttpResponse::Ok().json(preference),
427        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
428            "error": "Preference not found"
429        })),
430        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
431    }
432}
433
434#[utoipa::path(
435    put,
436    path = "/notification-preferences/{notification_type}",
437    tag = "Notifications",
438    summary = "Update a notification preference by type",
439    params(
440        ("notification_type" = String, Path, description = "Notification type (e.g. expense_created, meeting_convocation)")
441    ),
442    request_body = UpdatePreferenceRequest,
443    responses(
444        (status = 200, description = "Preference updated"),
445        (status = 400, description = "Invalid notification type or request"),
446        (status = 401, description = "Unauthorized"),
447    ),
448    security(("bearer_auth" = []))
449)]
450#[put("/notification-preferences/{notification_type}")]
451pub async fn update_preference(
452    state: web::Data<AppState>,
453    user: AuthenticatedUser,
454    notification_type: web::Path<String>,
455    request: web::Json<UpdatePreferenceRequest>,
456) -> impl Responder {
457    let organization_id = match user.require_organization() {
458        Ok(org_id) => org_id,
459        Err(e) => {
460            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
461        }
462    };
463
464    let notification_type = match parse_notification_type(notification_type.as_str()) {
465        Some(nt) => nt,
466        None => {
467            return HttpResponse::BadRequest().json(serde_json::json!({
468                "error": format!("Invalid notification type: {}", notification_type)
469            }))
470        }
471    };
472
473    match state
474        .notification_use_cases
475        .update_preference(user.user_id, notification_type, request.into_inner())
476        .await
477    {
478        Ok(preference) => {
479            AuditLogEntry::new(
480                AuditEventType::NotificationPreferenceUpdated,
481                Some(user.user_id),
482                Some(organization_id),
483            )
484            .with_resource("NotificationPreference", preference.id)
485            .log();
486
487            HttpResponse::Ok().json(preference)
488        }
489        Err(err) => {
490            AuditLogEntry::new(
491                AuditEventType::NotificationPreferenceUpdated,
492                Some(user.user_id),
493                Some(organization_id),
494            )
495            .with_error(err.clone())
496            .log();
497
498            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
499        }
500    }
501}