Skip to main content

koprogo_api/infrastructure/web/handlers/
local_exchange_handlers.rs

1use crate::application::dto::{
2    CancelExchangeDto, CompleteExchangeDto, CreateLocalExchangeDto, DeleteExchangeDto,
3    RateExchangeDto, RequestExchangeDto,
4};
5use crate::domain::entities::ExchangeType;
6use crate::infrastructure::web::classification_erreurs;
7use crate::infrastructure::web::middleware::scope_guard::{
8    verify_building_org_access, verify_exchange_org_access, verify_owner_org_access,
9};
10use crate::infrastructure::web::{AppState, AuthenticatedUser};
11use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
12use uuid::Uuid;
13
14/// POST /api/v1/exchanges
15/// Create a new exchange offer
16#[post("/exchanges")]
17pub async fn create_exchange(
18    data: web::Data<AppState>,
19    auth: AuthenticatedUser,
20    request: web::Json<CreateLocalExchangeDto>,
21) -> impl Responder {
22    match data
23        .local_exchange_use_cases
24        .create_exchange(auth.user_id, request.into_inner())
25        .await
26    {
27        Ok(exchange) => HttpResponse::Created().json(exchange),
28        Err(e) => {
29            // Story 5.3 (#587), INV-4 : ce refus (fiche copropriétaire
30            // absente — cas notamment d'un syndic pur) est un 403, jamais un
31            // 400 générique. Même classification que shared_object_handlers,
32            // pour que le frontend n'ait pas deux comportements différents
33            // pour le même refus selon le module.
34            if classification_erreurs::est_refus_owner_requis(&e)
35                || classification_erreurs::est_interdit(&e)
36            {
37                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
38            } else {
39                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
40            }
41        }
42    }
43}
44
45/// GET /api/v1/exchanges/:id
46/// Get exchange by ID
47#[get("/exchanges/{id}")]
48pub async fn get_exchange(
49    data: web::Data<AppState>,
50    auth: AuthenticatedUser,
51    id: web::Path<Uuid>,
52) -> impl Responder {
53    // Cloisonnement : cet échange relève d'une ACP précise (#772).
54    if let Err(err) = verify_exchange_org_access(
55        &auth,
56        *id,
57        &data.local_exchange_use_cases,
58        &data.building_use_cases,
59        &data.acp_use_cases,
60    )
61    .await
62    {
63        return err.error_response();
64    }
65
66    match data
67        .local_exchange_use_cases
68        .get_exchange(id.into_inner())
69        .await
70    {
71        Ok(exchange) => HttpResponse::Ok().json(exchange),
72        Err(e) => HttpResponse::NotFound().json(serde_json::json!({"error": e})),
73    }
74}
75
76/// GET /api/v1/buildings/:building_id/exchanges
77/// List all exchanges for a building
78#[get("/buildings/{building_id}/exchanges")]
79pub async fn list_building_exchanges(
80    data: web::Data<AppState>,
81    auth: AuthenticatedUser,
82    building_id: web::Path<Uuid>,
83) -> impl Responder {
84    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
85    // utilisateur a le droit de voir. Sans ce contrôle, l'identité était prise
86    // en paramètre puis ignorée — `_auth` — et n'importe quel utilisateur
87    // authentifié lisait les échanges de n'importe quel immeuble (#772).
88    if let Err(err) = verify_building_org_access(
89        &auth,
90        *building_id,
91        &data.building_use_cases,
92        &data.acp_use_cases,
93    )
94    .await
95    {
96        return err.error_response();
97    }
98
99    match data
100        .local_exchange_use_cases
101        .list_building_exchanges(building_id.into_inner())
102        .await
103    {
104        Ok(exchanges) => HttpResponse::Ok().json(exchanges),
105        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
106    }
107}
108
109/// GET /api/v1/buildings/:building_id/exchanges/available
110/// List available exchanges (status = Offered)
111#[get("/buildings/{building_id}/exchanges/available")]
112pub async fn list_available_exchanges(
113    data: web::Data<AppState>,
114    auth: AuthenticatedUser,
115    building_id: web::Path<Uuid>,
116) -> impl Responder {
117    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
118    // utilisateur a le droit de voir. Sans ce contrôle, l'identité était prise
119    // en paramètre puis ignorée — `_auth` — et n'importe quel utilisateur
120    // authentifié lisait les échanges de n'importe quel immeuble (#772).
121    if let Err(err) = verify_building_org_access(
122        &auth,
123        *building_id,
124        &data.building_use_cases,
125        &data.acp_use_cases,
126    )
127    .await
128    {
129        return err.error_response();
130    }
131
132    match data
133        .local_exchange_use_cases
134        .list_available_exchanges(building_id.into_inner())
135        .await
136    {
137        Ok(exchanges) => HttpResponse::Ok().json(exchanges),
138        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
139    }
140}
141
142/// GET /api/v1/owners/:owner_id/exchanges
143/// List exchanges for an owner (as provider OR requester)
144#[get("/owners/{owner_id}/exchanges")]
145pub async fn list_owner_exchanges(
146    data: web::Data<AppState>,
147    auth: AuthenticatedUser,
148    owner_id: web::Path<Uuid>,
149) -> impl Responder {
150    // Cloisonnement : le copropriétaire visé doit relever d'une organisation
151    // que cet utilisateur a le droit de voir. Ces routes disent ce qu'une
152    // personne nommée a échangé, rendu, et combien de crédits elle détient —
153    // son activité dans la copropriété, jour après jour (#772).
154    if let Err(err) = verify_owner_org_access(&auth, *owner_id, &data.owner_use_cases).await {
155        return err.error_response();
156    }
157
158    let owner_id = owner_id.into_inner();
159
160    // Authorization: users can only see their own exchanges
161    // Fetch owner to verify user_id mapping
162    let owner = match data.owner_use_cases.get_owner(owner_id).await {
163        Ok(Some(owner)) => owner,
164        Ok(None) => {
165            return HttpResponse::NotFound().json(serde_json::json!({
166                "error": format!("Owner not found: {}", owner_id)
167            }))
168        }
169        Err(e) => {
170            return HttpResponse::InternalServerError().json(serde_json::json!({
171                "error": format!("Failed to fetch owner: {}", e)
172            }))
173        }
174    };
175
176    // Check if the authenticated user owns this owner record
177    let owner_user_id = owner
178        .user_id
179        .as_ref()
180        .and_then(|id| Uuid::parse_str(id).ok());
181    if owner_user_id != Some(auth.user_id) {
182        return HttpResponse::Forbidden().json(serde_json::json!({
183            "error": "You can only view your own exchanges"
184        }));
185    }
186
187    match data
188        .local_exchange_use_cases
189        .list_owner_exchanges(owner_id)
190        .await
191    {
192        Ok(exchanges) => HttpResponse::Ok().json(exchanges),
193        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
194    }
195}
196
197/// GET /api/v1/buildings/:building_id/exchanges/type/:exchange_type
198/// List exchanges by type (Service, ObjectLoan, SharedPurchase)
199#[get("/buildings/{building_id}/exchanges/type/{exchange_type}")]
200pub async fn list_exchanges_by_type(
201    data: web::Data<AppState>,
202    auth: AuthenticatedUser,
203    path: web::Path<(Uuid, String)>,
204) -> impl Responder {
205    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
206    // utilisateur a le droit de voir. Sans ce contrôle, l'identité était prise
207    // en paramètre puis ignorée — `_auth` — et n'importe quel utilisateur
208    // authentifié lisait les échanges de n'importe quel immeuble (#772).
209    if let Err(err) =
210        verify_building_org_access(&auth, path.0, &data.building_use_cases, &data.acp_use_cases)
211            .await
212    {
213        return err.error_response();
214    }
215
216    let (building_id, exchange_type_str) = path.into_inner();
217
218    // Parse exchange type
219    let exchange_type = match exchange_type_str.as_str() {
220        "Service" => ExchangeType::Service,
221        "ObjectLoan" => ExchangeType::ObjectLoan,
222        "SharedPurchase" => ExchangeType::SharedPurchase,
223        _ => {
224            return HttpResponse::BadRequest().json(serde_json::json!({
225                "error": "Invalid exchange type. Must be Service, ObjectLoan, or SharedPurchase"
226            }));
227        }
228    };
229
230    match data
231        .local_exchange_use_cases
232        .list_exchanges_by_type(building_id, exchange_type)
233        .await
234    {
235        Ok(exchanges) => HttpResponse::Ok().json(exchanges),
236        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
237    }
238}
239
240/// POST /api/v1/exchanges/:id/request
241/// Request an exchange (Offered → Requested)
242#[post("/exchanges/{id}/request")]
243pub async fn request_exchange(
244    data: web::Data<AppState>,
245    auth: AuthenticatedUser,
246    id: web::Path<Uuid>,
247    request: web::Json<RequestExchangeDto>,
248) -> impl Responder {
249    // Cloisonnement : cet échange doit relever d'une ACP que cet utilisateur a
250    // le droit de voir. La route n'ayant pas d'immeuble en chemin, la chaîne
251    // échange → immeuble → ACP est remontée par le garde.
252    //
253    // Les deux notations comptent particulièrement : une note engage la
254    // réputation d'un voisin dans sa propre copropriété (#772).
255    if let Err(err) = verify_exchange_org_access(
256        &auth,
257        *id,
258        &data.local_exchange_use_cases,
259        &data.building_use_cases,
260        &data.acp_use_cases,
261    )
262    .await
263    {
264        return err.error_response();
265    }
266
267    match data
268        .local_exchange_use_cases
269        .request_exchange(id.into_inner(), auth.user_id, request.into_inner())
270        .await
271    {
272        Ok(exchange) => HttpResponse::Ok().json(exchange),
273        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
274    }
275}
276
277/// POST /api/v1/exchanges/:id/start
278/// Start an exchange (Requested → InProgress)
279/// Only provider can start
280#[post("/exchanges/{id}/start")]
281pub async fn start_exchange(
282    data: web::Data<AppState>,
283    auth: AuthenticatedUser,
284    id: web::Path<Uuid>,
285) -> impl Responder {
286    // Cloisonnement : cet échange doit relever d'une ACP que cet utilisateur a
287    // le droit de voir. La route n'ayant pas d'immeuble en chemin, la chaîne
288    // échange → immeuble → ACP est remontée par le garde.
289    //
290    // Les deux notations comptent particulièrement : une note engage la
291    // réputation d'un voisin dans sa propre copropriété (#772).
292    if let Err(err) = verify_exchange_org_access(
293        &auth,
294        *id,
295        &data.local_exchange_use_cases,
296        &data.building_use_cases,
297        &data.acp_use_cases,
298    )
299    .await
300    {
301        return err.error_response();
302    }
303
304    match data
305        .local_exchange_use_cases
306        .start_exchange(id.into_inner(), auth.user_id)
307        .await
308    {
309        Ok(exchange) => HttpResponse::Ok().json(exchange),
310        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
311    }
312}
313
314/// POST /api/v1/exchanges/:id/complete
315/// Complete an exchange (InProgress → Completed)
316/// Updates credit balances automatically
317#[post("/exchanges/{id}/complete")]
318pub async fn complete_exchange(
319    data: web::Data<AppState>,
320    auth: AuthenticatedUser,
321    id: web::Path<Uuid>,
322    request: web::Json<CompleteExchangeDto>,
323) -> impl Responder {
324    // Cloisonnement : cet échange doit relever d'une ACP que cet utilisateur a
325    // le droit de voir. La route n'ayant pas d'immeuble en chemin, la chaîne
326    // échange → immeuble → ACP est remontée par le garde.
327    //
328    // Les deux notations comptent particulièrement : une note engage la
329    // réputation d'un voisin dans sa propre copropriété (#772).
330    if let Err(err) = verify_exchange_org_access(
331        &auth,
332        *id,
333        &data.local_exchange_use_cases,
334        &data.building_use_cases,
335        &data.acp_use_cases,
336    )
337    .await
338    {
339        return err.error_response();
340    }
341
342    match data
343        .local_exchange_use_cases
344        .complete_exchange(id.into_inner(), auth.user_id, request.into_inner())
345        .await
346    {
347        Ok(exchange) => HttpResponse::Ok().json(exchange),
348        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
349    }
350}
351
352/// POST /api/v1/exchanges/:id/cancel
353/// Cancel an exchange
354#[post("/exchanges/{id}/cancel")]
355pub async fn cancel_exchange(
356    data: web::Data<AppState>,
357    auth: AuthenticatedUser,
358    id: web::Path<Uuid>,
359    request: web::Json<CancelExchangeDto>,
360) -> impl Responder {
361    // Cloisonnement : cet échange doit relever d'une ACP que cet utilisateur a
362    // le droit de voir. La route n'ayant pas d'immeuble en chemin, la chaîne
363    // échange → immeuble → ACP est remontée par le garde.
364    //
365    // Les deux notations comptent particulièrement : une note engage la
366    // réputation d'un voisin dans sa propre copropriété (#772).
367    if let Err(err) = verify_exchange_org_access(
368        &auth,
369        *id,
370        &data.local_exchange_use_cases,
371        &data.building_use_cases,
372        &data.acp_use_cases,
373    )
374    .await
375    {
376        return err.error_response();
377    }
378
379    match data
380        .local_exchange_use_cases
381        .cancel_exchange(
382            id.into_inner(),
383            auth.user_id,
384            &auth.role,
385            request.into_inner(),
386        )
387        .await
388    {
389        Ok(exchange) => HttpResponse::Ok().json(exchange),
390        Err(e) => {
391            if classification_erreurs::est_motif_manquant(&e) {
392                HttpResponse::UnprocessableEntity().json(serde_json::json!({"error": e}))
393            } else if classification_erreurs::est_interdit(&e) {
394                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
395            } else if classification_erreurs::est_introuvable(&e) {
396                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
397            } else {
398                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
399            }
400        }
401    }
402}
403
404/// PUT /api/v1/exchanges/:id/rate-provider
405/// Rate the provider (by requester)
406#[put("/exchanges/{id}/rate-provider")]
407pub async fn rate_provider(
408    data: web::Data<AppState>,
409    auth: AuthenticatedUser,
410    id: web::Path<Uuid>,
411    request: web::Json<RateExchangeDto>,
412) -> impl Responder {
413    // Cloisonnement : cet échange doit relever d'une ACP que cet utilisateur a
414    // le droit de voir. La route n'ayant pas d'immeuble en chemin, la chaîne
415    // échange → immeuble → ACP est remontée par le garde.
416    //
417    // Les deux notations comptent particulièrement : une note engage la
418    // réputation d'un voisin dans sa propre copropriété (#772).
419    if let Err(err) = verify_exchange_org_access(
420        &auth,
421        *id,
422        &data.local_exchange_use_cases,
423        &data.building_use_cases,
424        &data.acp_use_cases,
425    )
426    .await
427    {
428        return err.error_response();
429    }
430
431    match data
432        .local_exchange_use_cases
433        .rate_provider(id.into_inner(), auth.user_id, request.into_inner())
434        .await
435    {
436        Ok(exchange) => HttpResponse::Ok().json(exchange),
437        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
438    }
439}
440
441/// PUT /api/v1/exchanges/:id/rate-requester
442/// Rate the requester (by provider)
443#[put("/exchanges/{id}/rate-requester")]
444pub async fn rate_requester(
445    data: web::Data<AppState>,
446    auth: AuthenticatedUser,
447    id: web::Path<Uuid>,
448    request: web::Json<RateExchangeDto>,
449) -> impl Responder {
450    // Cloisonnement : cet échange doit relever d'une ACP que cet utilisateur a
451    // le droit de voir. La route n'ayant pas d'immeuble en chemin, la chaîne
452    // échange → immeuble → ACP est remontée par le garde.
453    //
454    // Les deux notations comptent particulièrement : une note engage la
455    // réputation d'un voisin dans sa propre copropriété (#772).
456    if let Err(err) = verify_exchange_org_access(
457        &auth,
458        *id,
459        &data.local_exchange_use_cases,
460        &data.building_use_cases,
461        &data.acp_use_cases,
462    )
463    .await
464    {
465        return err.error_response();
466    }
467
468    match data
469        .local_exchange_use_cases
470        .rate_requester(id.into_inner(), auth.user_id, request.into_inner())
471        .await
472    {
473        Ok(exchange) => HttpResponse::Ok().json(exchange),
474        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
475    }
476}
477
478/// DELETE /api/v1/exchanges/:id
479/// Delete an exchange (provider, or community moderator with a reason)
480#[delete("/exchanges/{id}")]
481pub async fn delete_exchange(
482    data: web::Data<AppState>,
483    auth: AuthenticatedUser,
484    id: web::Path<Uuid>,
485    // Corps optionnel : le provider qui supprime sa propre offre n'a rien à
486    // motiver et peut continuer à appeler cette route sans corps JSON. Un
487    // corps MALFORMÉ tombe aussi dans `None` (limite connue d'`Option<Json<T>>`,
488    // cf. `quote_handlers::submit_quote`) — sans risque ici : ça retombe sur
489    // "pas de motif fourni", refusé en 422 pour un modérateur, sans effet pour
490    // le provider qui n'en a pas besoin.
491    body: Option<web::Json<DeleteExchangeDto>>,
492) -> impl Responder {
493    let reason = body.and_then(|b| b.into_inner().reason);
494
495    match data
496        .local_exchange_use_cases
497        .delete_exchange(id.into_inner(), auth.user_id, &auth.role, reason)
498        .await
499    {
500        Ok(_) => HttpResponse::NoContent().finish(),
501        Err(e) => {
502            if classification_erreurs::est_motif_manquant(&e) {
503                HttpResponse::UnprocessableEntity().json(serde_json::json!({"error": e}))
504            } else if classification_erreurs::est_interdit(&e) {
505                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
506            } else if classification_erreurs::est_introuvable(&e) {
507                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
508            } else {
509                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
510            }
511        }
512    }
513}
514
515/// GET /api/v1/owners/:owner_id/buildings/:building_id/credit-balance
516/// Get credit balance for an owner in a building
517/// Note: owner_id can be the actual owner ID or the user ID (frontend sends user ID)
518#[get("/owners/{owner_id}/buildings/{building_id}/credit-balance")]
519pub async fn get_credit_balance(
520    data: web::Data<AppState>,
521    auth: AuthenticatedUser,
522    path: web::Path<(Uuid, Uuid)>,
523) -> impl Responder {
524    // Cloisonnement : le copropriétaire visé doit relever d'une organisation
525    // que cet utilisateur a le droit de voir. Ces routes disent ce qu'une
526    // personne nommée a échangé, rendu, et combien de crédits elle détient —
527    // son activité dans la copropriété, jour après jour (#772).
528    if let Err(err) = verify_owner_org_access(&auth, path.0, &data.owner_use_cases).await {
529        return err.error_response();
530    }
531
532    let (path_id, building_id) = path.into_inner();
533
534    // Try to find owner by ID first, then by user_id as fallback
535    // (frontend sends user_id from auth store, not owner_id)
536    let owner = match data.owner_use_cases.get_owner(path_id).await {
537        Ok(Some(owner)) => owner,
538        Ok(None) => {
539            // Fallback: treat path_id as user_id and look up owner
540            match data.owner_use_cases.find_owner_by_user_id(path_id).await {
541                Ok(Some(owner)) => owner,
542                Ok(None) => {
543                    return HttpResponse::NotFound().json(serde_json::json!({
544                        "error": format!("Owner not found for id: {}", path_id)
545                    }))
546                }
547                Err(e) => {
548                    return HttpResponse::InternalServerError().json(serde_json::json!({
549                        "error": format!("Failed to fetch owner: {}", e)
550                    }))
551                }
552            }
553        }
554        Err(e) => {
555            return HttpResponse::InternalServerError().json(serde_json::json!({
556                "error": format!("Failed to fetch owner: {}", e)
557            }))
558        }
559    };
560
561    // Authorization: users can only view their own credit balance
562    let owner_user_id = owner
563        .user_id
564        .as_ref()
565        .and_then(|id| Uuid::parse_str(id).ok());
566    if owner_user_id != Some(auth.user_id) {
567        return HttpResponse::Forbidden().json(serde_json::json!({
568            "error": "You can only view your own credit balance"
569        }));
570    }
571
572    let owner_uuid = match Uuid::parse_str(&owner.id) {
573        Ok(id) => id,
574        Err(_) => {
575            return HttpResponse::InternalServerError().json(serde_json::json!({
576                "error": "Invalid owner ID format"
577            }))
578        }
579    };
580
581    match data
582        .local_exchange_use_cases
583        .get_credit_balance(owner_uuid, building_id)
584        .await
585    {
586        Ok(balance) => HttpResponse::Ok().json(balance),
587        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
588    }
589}
590
591/// GET /api/v1/buildings/:building_id/leaderboard
592/// Get leaderboard (top contributors)
593#[get("/buildings/{building_id}/leaderboard")]
594pub async fn get_leaderboard(
595    data: web::Data<AppState>,
596    auth: AuthenticatedUser,
597    building_id: web::Path<Uuid>,
598    query: web::Query<std::collections::HashMap<String, String>>,
599) -> impl Responder {
600    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
601    // utilisateur a le droit de voir. Sans ce contrôle, l'identité était prise
602    // en paramètre puis ignorée — `_auth` — et n'importe quel utilisateur
603    // authentifié lisait les échanges de n'importe quel immeuble (#772).
604    if let Err(err) = verify_building_org_access(
605        &auth,
606        *building_id,
607        &data.building_use_cases,
608        &data.acp_use_cases,
609    )
610    .await
611    {
612        return err.error_response();
613    }
614
615    let limit = query
616        .get("limit")
617        .and_then(|l| l.parse::<i32>().ok())
618        .unwrap_or(10);
619
620    match data
621        .local_exchange_use_cases
622        .get_leaderboard(building_id.into_inner(), limit)
623        .await
624    {
625        Ok(leaderboard) => HttpResponse::Ok().json(leaderboard),
626        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
627    }
628}
629
630/// GET /api/v1/buildings/:building_id/sel-statistics
631/// Get SEL statistics for a building
632#[get("/buildings/{building_id}/sel-statistics")]
633pub async fn get_sel_statistics(
634    data: web::Data<AppState>,
635    auth: AuthenticatedUser,
636    building_id: web::Path<Uuid>,
637) -> impl Responder {
638    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
639    // utilisateur a le droit de voir. Sans ce contrôle, l'identité était prise
640    // en paramètre puis ignorée — `_auth` — et n'importe quel utilisateur
641    // authentifié lisait les échanges de n'importe quel immeuble (#772).
642    if let Err(err) = verify_building_org_access(
643        &auth,
644        *building_id,
645        &data.building_use_cases,
646        &data.acp_use_cases,
647    )
648    .await
649    {
650        return err.error_response();
651    }
652
653    match data
654        .local_exchange_use_cases
655        .get_statistics(building_id.into_inner())
656        .await
657    {
658        Ok(stats) => HttpResponse::Ok().json(stats),
659        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
660    }
661}
662
663/// GET /api/v1/owners/:owner_id/exchange-summary
664/// Get owner exchange summary
665#[get("/owners/{owner_id}/exchange-summary")]
666pub async fn get_owner_summary(
667    data: web::Data<AppState>,
668    auth: AuthenticatedUser,
669    owner_id: web::Path<Uuid>,
670) -> impl Responder {
671    // Cloisonnement : le copropriétaire visé doit relever d'une organisation
672    // que cet utilisateur a le droit de voir. Ces routes disent ce qu'une
673    // personne nommée a échangé, rendu, et combien de crédits elle détient —
674    // son activité dans la copropriété, jour après jour (#772).
675    if let Err(err) = verify_owner_org_access(&auth, *owner_id, &data.owner_use_cases).await {
676        return err.error_response();
677    }
678
679    let owner_id = owner_id.into_inner();
680
681    // Authorization: users can only view their own exchange summary
682    let owner = match data.owner_use_cases.get_owner(owner_id).await {
683        Ok(Some(owner)) => owner,
684        Ok(None) => {
685            return HttpResponse::NotFound().json(serde_json::json!({
686                "error": format!("Owner not found: {}", owner_id)
687            }))
688        }
689        Err(e) => {
690            return HttpResponse::InternalServerError().json(serde_json::json!({
691                "error": format!("Failed to fetch owner: {}", e)
692            }))
693        }
694    };
695
696    let owner_user_id = owner
697        .user_id
698        .as_ref()
699        .and_then(|id| Uuid::parse_str(id).ok());
700    if owner_user_id != Some(auth.user_id) {
701        return HttpResponse::Forbidden().json(serde_json::json!({
702            "error": "You can only view your own exchange summary"
703        }));
704    }
705
706    match data
707        .local_exchange_use_cases
708        .get_owner_summary(owner_id)
709        .await
710    {
711        Ok(summary) => HttpResponse::Ok().json(summary),
712        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
713    }
714}