Skip to main content

koprogo_api/infrastructure/web/handlers/
notice_handlers.rs

1use crate::application::dto::{
2    ArchiveNoticeDto, CreateNoticeDto, SetExpirationDto, UpdateNoticeDto,
3};
4use crate::domain::entities::{NoticeCategory, NoticeStatus, NoticeType};
5use crate::infrastructure::web::app_state::AppState;
6use crate::infrastructure::web::classification_erreurs;
7use crate::infrastructure::web::middleware::scope_guard::verify_notice_org_access;
8use crate::infrastructure::web::middleware::AuthenticatedUser;
9use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
10use uuid::Uuid;
11
12/// Create a new notice (Draft status)
13///
14/// POST /notices
15#[post("/notices")]
16pub async fn create_notice(
17    data: web::Data<AppState>,
18    auth: AuthenticatedUser,
19    request: web::Json<CreateNoticeDto>,
20) -> impl Responder {
21    let org_id = match auth.require_organization() {
22        Ok(id) => id,
23        Err(e) => {
24            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
25        }
26    };
27    match data
28        .notice_use_cases
29        .create_notice(auth.user_id, org_id, request.into_inner())
30        .await
31    {
32        Ok(notice) => HttpResponse::Created().json(notice),
33        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
34    }
35}
36
37/// Get notice by ID with author name enrichment
38///
39/// GET /notices/:id
40#[get("/notices/{id}")]
41pub async fn get_notice(
42    data: web::Data<AppState>,
43    user: AuthenticatedUser,
44    id: web::Path<Uuid>,
45) -> impl Responder {
46    let identifiant = id.into_inner();
47
48    // Cette route ne prenait AUCUNE identité : n'importe qui pouvait la lire
49    // sur simple connaissance de l'identifiant. Le cliquet de #772 ne la
50    // voyait pas — il ne compte que les routes PRENANT une identité sans
51    // s'en servir. Cf. #845.
52    if let Err(err) = crate::infrastructure::web::middleware::scope_guard::verify_notice_org_access(
53        &user,
54        identifiant,
55        &data.notice_use_cases,
56        &data.building_use_cases,
57        &data.acp_use_cases,
58    )
59    .await
60    {
61        return err.error_response();
62    }
63
64    match data.notice_use_cases.get_notice(identifiant).await {
65        Ok(notice) => HttpResponse::Ok().json(notice),
66        Err(e) => {
67            if classification_erreurs::est_introuvable(&e) {
68                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
69            } else {
70                HttpResponse::InternalServerError().json(serde_json::json!({"error": e}))
71            }
72        }
73    }
74}
75
76/// List all notices for a building (all statuses)
77///
78/// GET /buildings/:building_id/notices
79#[get("/buildings/{building_id}/notices")]
80pub async fn list_building_notices(
81    data: web::Data<AppState>,
82    building_id: web::Path<Uuid>,
83    user: AuthenticatedUser,
84) -> impl Responder {
85    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
86    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
87    // un identifiant, sans demander d'identite.
88    if let Err(err) =
89        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
90            &user,
91            *building_id,
92            &data.building_use_cases,
93            &data.acp_use_cases,
94        )
95        .await
96    {
97        return err.error_response();
98    }
99
100    match data
101        .notice_use_cases
102        .list_building_notices(building_id.into_inner())
103        .await
104    {
105        Ok(notices) => HttpResponse::Ok().json(notices),
106        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
107    }
108}
109
110/// List published notices for a building (visible to members)
111///
112/// GET /buildings/:building_id/notices/published
113#[get("/buildings/{building_id}/notices/published")]
114pub async fn list_published_notices(
115    data: web::Data<AppState>,
116    building_id: web::Path<Uuid>,
117    user: AuthenticatedUser,
118) -> impl Responder {
119    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
120    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
121    // un identifiant, sans demander d'identite.
122    if let Err(err) =
123        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
124            &user,
125            *building_id,
126            &data.building_use_cases,
127            &data.acp_use_cases,
128        )
129        .await
130    {
131        return err.error_response();
132    }
133
134    match data
135        .notice_use_cases
136        .list_published_notices(building_id.into_inner())
137        .await
138    {
139        Ok(notices) => HttpResponse::Ok().json(notices),
140        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
141    }
142}
143
144/// List pinned notices for a building (important announcements)
145///
146/// GET /buildings/:building_id/notices/pinned
147#[get("/buildings/{building_id}/notices/pinned")]
148pub async fn list_pinned_notices(
149    data: web::Data<AppState>,
150    building_id: web::Path<Uuid>,
151    user: AuthenticatedUser,
152) -> impl Responder {
153    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
154    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
155    // un identifiant, sans demander d'identite.
156    if let Err(err) =
157        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
158            &user,
159            *building_id,
160            &data.building_use_cases,
161            &data.acp_use_cases,
162        )
163        .await
164    {
165        return err.error_response();
166    }
167
168    match data
169        .notice_use_cases
170        .list_pinned_notices(building_id.into_inner())
171        .await
172    {
173        Ok(notices) => HttpResponse::Ok().json(notices),
174        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
175    }
176}
177
178/// List notices by type (Announcement, Event, LostAndFound, ClassifiedAd)
179///
180/// GET /buildings/:building_id/notices/type/:notice_type
181#[get("/buildings/{building_id}/notices/type/{notice_type}")]
182pub async fn list_notices_by_type(
183    data: web::Data<AppState>,
184    path: web::Path<(Uuid, String)>,
185    user: AuthenticatedUser,
186) -> impl Responder {
187    let (building_id, notice_type_str) = path.into_inner();
188    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
189    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
190    // un identifiant, sans demander d'identite.
191    if let Err(err) =
192        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
193            &user,
194            building_id,
195            &data.building_use_cases,
196            &data.acp_use_cases,
197        )
198        .await
199    {
200        return err.error_response();
201    }
202
203    // Parse notice type
204    let notice_type = match serde_json::from_str::<NoticeType>(&format!("\"{}\"", notice_type_str))
205    {
206        Ok(nt) => nt,
207        Err(_) => {
208            return HttpResponse::BadRequest().json(serde_json::json!({
209                "error": format!("Invalid notice type: {}. Valid types: Announcement, Event, LostAndFound, ClassifiedAd", notice_type_str)
210            }))
211        }
212    };
213
214    match data
215        .notice_use_cases
216        .list_notices_by_type(building_id, notice_type)
217        .await
218    {
219        Ok(notices) => HttpResponse::Ok().json(notices),
220        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
221    }
222}
223
224/// List notices by category (General, Maintenance, Social, etc.)
225///
226/// GET /buildings/:building_id/notices/category/:category
227#[get("/buildings/{building_id}/notices/category/{category}")]
228pub async fn list_notices_by_category(
229    data: web::Data<AppState>,
230    path: web::Path<(Uuid, String)>,
231    user: AuthenticatedUser,
232) -> impl Responder {
233    let (building_id, category_str) = path.into_inner();
234    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
235    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
236    // un identifiant, sans demander d'identite.
237    if let Err(err) =
238        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
239            &user,
240            building_id,
241            &data.building_use_cases,
242            &data.acp_use_cases,
243        )
244        .await
245    {
246        return err.error_response();
247    }
248
249    // Parse category
250    let category = match serde_json::from_str::<NoticeCategory>(&format!("\"{}\"", category_str)) {
251        Ok(c) => c,
252        Err(_) => {
253            return HttpResponse::BadRequest().json(serde_json::json!({
254                "error": format!("Invalid category: {}. Valid categories: General, Maintenance, Social, Security, Environment, Parking, Other", category_str)
255            }))
256        }
257    };
258
259    match data
260        .notice_use_cases
261        .list_notices_by_category(building_id, category)
262        .await
263    {
264        Ok(notices) => HttpResponse::Ok().json(notices),
265        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
266    }
267}
268
269/// List notices by status (Draft, Published, Archived, Expired)
270///
271/// GET /buildings/:building_id/notices/status/:status
272#[get("/buildings/{building_id}/notices/status/{status}")]
273pub async fn list_notices_by_status(
274    data: web::Data<AppState>,
275    path: web::Path<(Uuid, String)>,
276    user: AuthenticatedUser,
277) -> impl Responder {
278    let (building_id, status_str) = path.into_inner();
279    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
280    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
281    // un identifiant, sans demander d'identite.
282    if let Err(err) =
283        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
284            &user,
285            building_id,
286            &data.building_use_cases,
287            &data.acp_use_cases,
288        )
289        .await
290    {
291        return err.error_response();
292    }
293
294    // Parse status
295    let status = match serde_json::from_str::<NoticeStatus>(&format!("\"{}\"", status_str)) {
296        Ok(s) => s,
297        Err(_) => {
298            return HttpResponse::BadRequest().json(serde_json::json!({
299                "error": format!("Invalid status: {}. Valid statuses: Draft, Published, Archived, Expired", status_str)
300            }))
301        }
302    };
303
304    match data
305        .notice_use_cases
306        .list_notices_by_status(building_id, status)
307        .await
308    {
309        Ok(notices) => HttpResponse::Ok().json(notices),
310        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
311    }
312}
313
314/// List all notices created by an author
315///
316/// GET /owners/:author_id/notices
317#[get("/owners/{author_id}/notices")]
318pub async fn list_author_notices(
319    data: web::Data<AppState>,
320    author_id: web::Path<Uuid>,
321    user: AuthenticatedUser,
322) -> impl Responder {
323    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
324    // servait la situation financiere NOMINATIVE d'une personne a quiconque
325    // connaissait son identifiant.
326    if let Err(err) = crate::infrastructure::web::middleware::scope_guard::verify_owner_org_access(
327        &user,
328        *author_id,
329        &data.owner_use_cases,
330    )
331    .await
332    {
333        return err.error_response();
334    }
335
336    match data
337        .notice_use_cases
338        .list_author_notices(author_id.into_inner())
339        .await
340    {
341        Ok(notices) => HttpResponse::Ok().json(notices),
342        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
343    }
344}
345
346/// Update a notice (Draft only)
347///
348/// PUT /notices/:id
349#[put("/notices/{id}")]
350pub async fn update_notice(
351    data: web::Data<AppState>,
352    auth: AuthenticatedUser,
353    id: web::Path<Uuid>,
354    request: web::Json<UpdateNoticeDto>,
355) -> impl Responder {
356    let org_id = match auth.require_organization() {
357        Ok(id) => id,
358        Err(e) => {
359            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
360        }
361    };
362    match data
363        .notice_use_cases
364        .update_notice(id.into_inner(), auth.user_id, org_id, request.into_inner())
365        .await
366    {
367        Ok(notice) => HttpResponse::Ok().json(notice),
368        Err(e) => {
369            if classification_erreurs::est_interdit(&e) {
370                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
371            } else if classification_erreurs::est_introuvable(&e) {
372                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
373            } else {
374                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
375            }
376        }
377    }
378}
379
380/// Publish a notice (Draft → Published)
381///
382/// POST /notices/:id/publish
383#[post("/notices/{id}/publish")]
384pub async fn publish_notice(
385    data: web::Data<AppState>,
386    auth: AuthenticatedUser,
387    id: web::Path<Uuid>,
388) -> impl Responder {
389    let org_id = match auth.require_organization() {
390        Ok(id) => id,
391        Err(e) => {
392            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
393        }
394    };
395    match data
396        .notice_use_cases
397        .publish_notice(id.into_inner(), auth.user_id, org_id)
398        .await
399    {
400        Ok(notice) => HttpResponse::Ok().json(notice),
401        Err(e) => {
402            if classification_erreurs::est_interdit(&e) {
403                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
404            } else if classification_erreurs::est_introuvable(&e) {
405                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
406            } else {
407                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
408            }
409        }
410    }
411}
412
413/// Archive a notice (Published/Expired → Archived)
414///
415/// POST /notices/:id/archive
416#[post("/notices/{id}/archive")]
417pub async fn archive_notice(
418    data: web::Data<AppState>,
419    auth: AuthenticatedUser,
420    id: web::Path<Uuid>,
421    // Corps optionnel : l'auteur qui archive sa propre annonce n'a rien à
422    // motiver et peut continuer à appeler cette route sans corps JSON. Un
423    // corps MALFORMÉ tombe aussi dans `None` (limite connue d'`Option<Json<T>>`,
424    // cf. `quote_handlers::submit_quote`) — sans risque ici : ça retombe sur
425    // "pas de motif fourni", refusé en 422 pour un modérateur, sans effet pour
426    // l'auteur qui n'en a pas besoin.
427    body: Option<web::Json<ArchiveNoticeDto>>,
428) -> impl Responder {
429    let org_id = match auth.require_organization() {
430        Ok(id) => id,
431        Err(e) => {
432            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
433        }
434    };
435    let reason = body.and_then(|b| b.into_inner().reason);
436    match data
437        .notice_use_cases
438        .archive_notice(id.into_inner(), auth.user_id, org_id, &auth.role, reason)
439        .await
440    {
441        Ok(notice) => HttpResponse::Ok().json(notice),
442        Err(e) => {
443            if classification_erreurs::est_motif_manquant(&e) {
444                HttpResponse::UnprocessableEntity().json(serde_json::json!({"error": e}))
445            } else if classification_erreurs::est_interdit(&e) {
446                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
447            } else if classification_erreurs::est_introuvable(&e) {
448                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
449            } else {
450                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
451            }
452        }
453    }
454}
455
456/// Pin a notice to top of board (Published only)
457///
458/// POST /notices/:id/pin
459#[post("/notices/{id}/pin")]
460pub async fn pin_notice(
461    data: web::Data<AppState>,
462    auth: AuthenticatedUser,
463    id: web::Path<Uuid>,
464) -> impl Responder {
465    // Cloisonnement : épingler une annonce la met en tête du tableau
466    // d'affichage de la copropriété. Le faire depuis une autre ACP, c'est
467    // décider de ce que des voisins qui ne sont pas les vôtres verront en
468    // premier (#772).
469    if let Err(err) = verify_notice_org_access(
470        &auth,
471        *id,
472        &data.notice_use_cases,
473        &data.building_use_cases,
474        &data.acp_use_cases,
475    )
476    .await
477    {
478        return err.error_response();
479    }
480
481    match data
482        .notice_use_cases
483        .pin_notice(id.into_inner(), &auth.role)
484        .await
485    {
486        Ok(notice) => HttpResponse::Ok().json(notice),
487        Err(e) => {
488            if classification_erreurs::est_interdit(&e) {
489                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
490            } else if classification_erreurs::est_introuvable(&e) {
491                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
492            } else {
493                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
494            }
495        }
496    }
497}
498
499/// Unpin a notice
500///
501/// POST /notices/:id/unpin
502#[post("/notices/{id}/unpin")]
503pub async fn unpin_notice(
504    data: web::Data<AppState>,
505    auth: AuthenticatedUser,
506    id: web::Path<Uuid>,
507) -> impl Responder {
508    // Cloisonnement : épingler une annonce la met en tête du tableau
509    // d'affichage de la copropriété. Le faire depuis une autre ACP, c'est
510    // décider de ce que des voisins qui ne sont pas les vôtres verront en
511    // premier (#772).
512    if let Err(err) = verify_notice_org_access(
513        &auth,
514        *id,
515        &data.notice_use_cases,
516        &data.building_use_cases,
517        &data.acp_use_cases,
518    )
519    .await
520    {
521        return err.error_response();
522    }
523
524    match data
525        .notice_use_cases
526        .unpin_notice(id.into_inner(), &auth.role)
527        .await
528    {
529        Ok(notice) => HttpResponse::Ok().json(notice),
530        Err(e) => {
531            if classification_erreurs::est_interdit(&e) {
532                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
533            } else if classification_erreurs::est_introuvable(&e) {
534                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
535            } else {
536                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
537            }
538        }
539    }
540}
541
542/// Set expiration date for a notice
543///
544/// PUT /notices/:id/expiration
545#[put("/notices/{id}/expiration")]
546pub async fn set_expiration(
547    data: web::Data<AppState>,
548    auth: AuthenticatedUser,
549    id: web::Path<Uuid>,
550    request: web::Json<SetExpirationDto>,
551) -> impl Responder {
552    let org_id = match auth.require_organization() {
553        Ok(id) => id,
554        Err(e) => {
555            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
556        }
557    };
558    match data
559        .notice_use_cases
560        .set_expiration(id.into_inner(), auth.user_id, org_id, request.into_inner())
561        .await
562    {
563        Ok(notice) => HttpResponse::Ok().json(notice),
564        Err(e) => {
565            if classification_erreurs::est_interdit(&e) {
566                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
567            } else if classification_erreurs::est_introuvable(&e) {
568                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
569            } else {
570                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
571            }
572        }
573    }
574}
575
576/// Delete a notice
577///
578/// DELETE /notices/:id
579#[delete("/notices/{id}")]
580pub async fn delete_notice(
581    data: web::Data<AppState>,
582    auth: AuthenticatedUser,
583    id: web::Path<Uuid>,
584) -> impl Responder {
585    let org_id = match auth.require_organization() {
586        Ok(id) => id,
587        Err(e) => {
588            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
589        }
590    };
591    match data
592        .notice_use_cases
593        .delete_notice(id.into_inner(), auth.user_id, org_id)
594        .await
595    {
596        Ok(_) => HttpResponse::NoContent().finish(),
597        Err(e) => {
598            if classification_erreurs::est_interdit(&e) {
599                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
600            } else if classification_erreurs::est_introuvable(&e) {
601                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
602            } else {
603                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
604            }
605        }
606    }
607}
608
609/// Get notice statistics for a building
610///
611/// GET /buildings/:building_id/notices/statistics
612#[get("/buildings/{building_id}/notices/statistics")]
613pub async fn get_notice_statistics(
614    data: web::Data<AppState>,
615    building_id: web::Path<Uuid>,
616    user: AuthenticatedUser,
617) -> impl Responder {
618    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
619    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
620    // un identifiant, sans demander d'identite.
621    if let Err(err) =
622        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
623            &user,
624            *building_id,
625            &data.building_use_cases,
626            &data.acp_use_cases,
627        )
628        .await
629    {
630        return err.error_response();
631    }
632
633    match data
634        .notice_use_cases
635        .get_statistics(building_id.into_inner())
636        .await
637    {
638        Ok(stats) => HttpResponse::Ok().json(stats),
639        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
640    }
641}