Skip to main content

koprogo_api/infrastructure/web/handlers/
shared_object_handlers.rs

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