Skip to main content

koprogo_api/infrastructure/web/handlers/
quote_handlers.rs

1use crate::application::dto::{
2    CreateQuoteDto, QuoteComparisonRequestDto, QuoteDecisionDto, SubmitQuoteDto,
3};
4use crate::infrastructure::web::middleware::scope_guard::{
5    verify_building_org_access, verify_quote_org_access,
6};
7use crate::infrastructure::web::middleware::AuthenticatedUser;
8use crate::infrastructure::web::AppState;
9use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
10use uuid::Uuid;
11
12/// POST /api/v1/quotes
13/// Create new quote request (Syndic action)
14#[post("/quotes")]
15pub async fn create_quote(
16    data: web::Data<AppState>,
17    auth: AuthenticatedUser,
18    request: web::Json<CreateQuoteDto>,
19) -> impl Responder {
20    // Cloisonnement AVANT la création (#864).
21    //
22    // L'identité était nommée `_auth` : le souligné disait explicitement
23    // qu'on ne s'en servait pas, et le cas d'usage ne la reçoit même pas —
24    // `create_quote(dto)` ne prend que le DTO. N'importe quel utilisateur
25    // authentifié pouvait donc demander un devis sur l'immeuble de n'importe
26    // quelle copropriété, en connaissant son UUID.
27    //
28    // Trois autres routes de ce fichier appellent déjà
29    // `verify_building_org_access` (lignes 82, 138, 479). La création,
30    // c'est-à-dire le seul geste qui INSCRIT quelque chose au patrimoine
31    // d'une ACP, ne l'appelait pas.
32    let building_id = match Uuid::parse_str(&request.building_id) {
33        Ok(id) => id,
34        Err(_) => {
35            return HttpResponse::BadRequest().json(serde_json::json!({
36                "error": "Invalid building_id format"
37            }))
38        }
39    };
40    if let Err(err) = verify_building_org_access(
41        &auth,
42        building_id,
43        &data.building_use_cases,
44        &data.acp_use_cases,
45    )
46    .await
47    {
48        return err.error_response();
49    }
50
51    match data
52        .quote_use_cases
53        .create_quote(request.into_inner())
54        .await
55    {
56        Ok(quote) => HttpResponse::Created().json(quote),
57        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({
58            "error": e
59        })),
60    }
61}
62
63/// GET /api/v1/quotes/:id
64/// Get quote by ID
65#[get("/quotes/{id}")]
66pub async fn get_quote(
67    data: web::Data<AppState>,
68    auth: AuthenticatedUser,
69    id: web::Path<Uuid>,
70) -> impl Responder {
71    // Cloisonnement : ce devis doit relever d'une ACP que cet utilisateur a le
72    // droit de voir. La route n'ayant pas d'immeuble en chemin, la chaîne
73    // devis → immeuble → ACP est remontée par le garde.
74    //
75    // Ce contrôle porte sur le PÉRIMÈTRE, pas sur le droit d'agir : il dit que
76    // ce devis n'est pas celui d'une autre copropriété, pas que cet
77    // utilisateur a qualité pour l'examiner (#772).
78    if let Err(err) = verify_quote_org_access(
79        &auth,
80        *id,
81        &data.quote_use_cases,
82        &data.building_use_cases,
83        &data.acp_use_cases,
84    )
85    .await
86    {
87        return err.error_response();
88    }
89
90    match data.quote_use_cases.get_quote(id.into_inner()).await {
91        Ok(Some(quote)) => HttpResponse::Ok().json(quote),
92        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
93            "error": "Quote not found"
94        })),
95        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
96            "error": e
97        })),
98    }
99}
100
101/// GET /api/v1/buildings/:building_id/quotes
102/// List all quotes for a building
103#[get("/buildings/{building_id}/quotes")]
104pub async fn list_building_quotes(
105    data: web::Data<AppState>,
106    auth: AuthenticatedUser,
107    building_id: web::Path<Uuid>,
108) -> impl Responder {
109    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
110    // utilisateur a le droit de voir. Un devis dit qui a soumis quel prix pour
111    // quels travaux — le lire hors de son ACP, c'est lire la concurrence.
112    // L'identité était prise puis ignorée — `_auth` (#772).
113    if let Err(err) = verify_building_org_access(
114        &auth,
115        *building_id,
116        &data.building_use_cases,
117        &data.acp_use_cases,
118    )
119    .await
120    {
121        return err.error_response();
122    }
123
124    match data
125        .quote_use_cases
126        .list_by_building(building_id.into_inner())
127        .await
128    {
129        Ok(quotes) => HttpResponse::Ok().json(quotes),
130        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
131            "error": e
132        })),
133    }
134}
135
136/// GET /api/v1/contractors/:contractor_id/quotes
137/// List all quotes for a contractor
138///
139/// Cloisonnement (#882) : classée, non corrigée. `list_by_contractor` rend
140/// Les devis de ce prestataire, **restreints aux immeubles de l'appelant**.
141///
142/// La clé de cette route est le PRESTATAIRE, pas un immeuble : aucun garde
143/// de ce fichier ne s'y appliquait directement, et elle rendait donc TOUS
144/// ses devis, toutes ACP confondues — la même donnée (prix, projet) que
145/// `list_building_quotes` protège déjà par immeuble.
146///
147/// Un cabinet syndic pouvait ainsi lire les prix qu'un prestataire avait
148/// remis à un cabinet concurrent (#976).
149///
150/// Des deux voies envisagées — filtrer par les ACP visibles, ou restreindre
151/// la route au superadministrateur — c'est la première qui est retenue : la
152/// seconde priverait le syndic d'une lecture légitime, celle des devis de
153/// SES immeubles.
154#[get("/contractors/{contractor_id}/quotes")]
155pub async fn list_contractor_quotes(
156    data: web::Data<AppState>,
157    auth: AuthenticatedUser,
158    contractor_id: web::Path<Uuid>,
159) -> impl Responder {
160    let quotes = match data
161        .quote_use_cases
162        .list_by_contractor(contractor_id.into_inner())
163        .await
164    {
165        Ok(quotes) => quotes,
166        Err(e) => {
167            return HttpResponse::InternalServerError().json(serde_json::json!({
168                "error": e
169            }))
170        }
171    };
172
173    // ── Le filtrage par ACP visibles, retenu parmi les deux voies ──────────
174    //
175    // Le commentaire ci-dessus posait le choix : filtrer par les ACP visibles
176    // de l'appelant, ou restreindre la route. C'est la première, parce que la
177    // seconde retirerait au syndic une lecture légitime — les devis de SES
178    // immeubles, remis par ce prestataire.
179    //
180    // Le paramètre s'appelait `_auth` : la convention Rust pour « je prends
181    // cette identité et je ne m'en sers pas ». Il disait vrai, et c'était le
182    // défaut — n'importe quel utilisateur authentifié lisait les devis de
183    // n'importe quel prestataire, donc **les prix pratiqués chez un cabinet
184    // concurrent** (#976).
185    //
186    // Chaque immeuble n'est vérifié qu'UNE fois : un prestataire remet
187    // typiquement plusieurs devis sur la même copropriété, et interroger le
188    // serveur par devis transformerait une lecture en rafale de requêtes.
189    let mut vus: std::collections::HashMap<Uuid, bool> = std::collections::HashMap::new();
190    let mut visibles = Vec::with_capacity(quotes.len());
191
192    for devis in quotes {
193        let building_id = match Uuid::parse_str(&devis.building_id) {
194            Ok(id) => id,
195            // Un identifiant illisible ne s'affiche pas « par défaut » : on
196            // écarte, faute de pouvoir prouver que l'appelant y a droit.
197            Err(_) => continue,
198        };
199
200        let autorise = match vus.get(&building_id) {
201            Some(deja) => *deja,
202            None => {
203                let ok = verify_building_org_access(
204                    &auth,
205                    building_id,
206                    &data.building_use_cases,
207                    &data.acp_use_cases,
208                )
209                .await
210                .is_ok();
211                vus.insert(building_id, ok);
212                ok
213            }
214        };
215
216        if autorise {
217            visibles.push(devis);
218        }
219    }
220
221    HttpResponse::Ok().json(visibles)
222}
223
224/// GET /api/v1/buildings/:building_id/quotes/status/:status
225/// List quotes by status
226#[get("/buildings/{building_id}/quotes/status/{status}")]
227pub async fn list_quotes_by_status(
228    data: web::Data<AppState>,
229    auth: AuthenticatedUser,
230    path: web::Path<(Uuid, String)>,
231) -> impl Responder {
232    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
233    // utilisateur a le droit de voir. Un devis dit qui a soumis quel prix pour
234    // quels travaux — le lire hors de son ACP, c'est lire la concurrence.
235    // L'identité était prise puis ignorée — `_auth` (#772).
236    if let Err(err) =
237        verify_building_org_access(&auth, path.0, &data.building_use_cases, &data.acp_use_cases)
238            .await
239    {
240        return err.error_response();
241    }
242
243    let (building_id, status) = path.into_inner();
244
245    match data
246        .quote_use_cases
247        .list_by_status(building_id, &status)
248        .await
249    {
250        Ok(quotes) => HttpResponse::Ok().json(quotes),
251        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
252            "error": e
253        })),
254    }
255}
256
257/// POST /api/v1/quotes/:id/submit
258/// Submit quote (Contractor/Syndic action). Body is optional: a quote that
259/// already carries price data (cf. `CreateQuoteDto`'s escape hatch) can be
260/// submitted bodyless — otherwise pricing is required in the body.
261#[post("/quotes/{id}/submit")]
262pub async fn submit_quote(
263    data: web::Data<AppState>,
264    auth: AuthenticatedUser,
265    id: web::Path<Uuid>,
266    body: web::Bytes,
267) -> impl Responder {
268    // Cloisonnement : ce devis doit relever d'une ACP que cet utilisateur a le
269    // droit de voir. La route n'ayant pas d'immeuble en chemin, la chaîne
270    // devis → immeuble → ACP est remontée par le garde.
271    //
272    // Ce contrôle porte sur le PÉRIMÈTRE, pas sur le droit d'agir : il dit que
273    // ce devis n'est pas celui d'une autre copropriété, pas que cet
274    // utilisateur a qualité pour l'examiner (#772).
275    if let Err(err) = verify_quote_org_access(
276        &auth,
277        *id,
278        &data.quote_use_cases,
279        &data.building_use_cases,
280        &data.acp_use_cases,
281    )
282    .await
283    {
284        return err.error_response();
285    }
286
287    // No body = "confirm existing pricing" (use case rejects this unless the
288    // quote already has a price). A non-empty body must parse as
289    // SubmitQuoteDto — unlike Option<web::Json<T>>, a malformed body here
290    // is a real 400 rather than silently falling back to "no pricing".
291    let pricing = if body.is_empty() {
292        None
293    } else {
294        match serde_json::from_slice::<SubmitQuoteDto>(&body) {
295            Ok(dto) => Some(dto),
296            Err(e) => {
297                return HttpResponse::BadRequest().json(serde_json::json!({
298                    "error": format!("Invalid pricing payload: {}", e)
299                }))
300            }
301        }
302    };
303
304    match data
305        .quote_use_cases
306        .submit_quote(id.into_inner(), pricing)
307        .await
308    {
309        Ok(quote) => HttpResponse::Ok().json(quote),
310        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({
311            "error": e
312        })),
313    }
314}
315
316/// POST /api/v1/quotes/:id/review
317/// Start quote review (Syndic action)
318#[post("/quotes/{id}/review")]
319pub async fn start_review(
320    data: web::Data<AppState>,
321    auth: AuthenticatedUser,
322    id: web::Path<Uuid>,
323) -> impl Responder {
324    // Cloisonnement : ce devis doit relever d'une ACP que cet utilisateur a le
325    // droit de voir. La route n'ayant pas d'immeuble en chemin, la chaîne
326    // devis → immeuble → ACP est remontée par le garde.
327    //
328    // Ce contrôle porte sur le PÉRIMÈTRE, pas sur le droit d'agir : il dit que
329    // ce devis n'est pas celui d'une autre copropriété, pas que cet
330    // utilisateur a qualité pour l'examiner (#772).
331    if let Err(err) = verify_quote_org_access(
332        &auth,
333        *id,
334        &data.quote_use_cases,
335        &data.building_use_cases,
336        &data.acp_use_cases,
337    )
338    .await
339    {
340        return err.error_response();
341    }
342
343    match data.quote_use_cases.start_review(id.into_inner()).await {
344        Ok(quote) => HttpResponse::Ok().json(quote),
345        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({
346            "error": e
347        })),
348    }
349}
350
351/// POST /api/v1/quotes/:id/accept
352/// Accept quote (Syndic action - winner)
353#[post("/quotes/{id}/accept")]
354pub async fn accept_quote(
355    data: web::Data<AppState>,
356    auth: AuthenticatedUser,
357    id: web::Path<Uuid>,
358    request: web::Json<QuoteDecisionDto>,
359) -> impl Responder {
360    // Cloisonnement : accepter ou rejeter un devis engage l'ACP sur un marché.
361    // Le contrôle porte sur le PÉRIMÈTRE — ce devis n'est pas celui d'une autre
362    // copropriété — et non sur la qualité pour décider (#772).
363    if let Err(err) = verify_quote_org_access(
364        &auth,
365        *id,
366        &data.quote_use_cases,
367        &data.building_use_cases,
368        &data.acp_use_cases,
369    )
370    .await
371    {
372        return err.error_response();
373    }
374
375    match data
376        .quote_use_cases
377        .accept_quote(id.into_inner(), auth.user_id, request.into_inner())
378        .await
379    {
380        Ok(quote) => HttpResponse::Ok().json(quote),
381        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({
382            "error": e
383        })),
384    }
385}
386
387/// POST /api/v1/quotes/:id/reject
388/// Reject quote (Syndic action)
389#[post("/quotes/{id}/reject")]
390pub async fn reject_quote(
391    data: web::Data<AppState>,
392    auth: AuthenticatedUser,
393    id: web::Path<Uuid>,
394    request: web::Json<QuoteDecisionDto>,
395) -> impl Responder {
396    // Cloisonnement : accepter ou rejeter un devis engage l'ACP sur un marché.
397    // Le contrôle porte sur le PÉRIMÈTRE — ce devis n'est pas celui d'une autre
398    // copropriété — et non sur la qualité pour décider (#772).
399    if let Err(err) = verify_quote_org_access(
400        &auth,
401        *id,
402        &data.quote_use_cases,
403        &data.building_use_cases,
404        &data.acp_use_cases,
405    )
406    .await
407    {
408        return err.error_response();
409    }
410
411    match data
412        .quote_use_cases
413        .reject_quote(id.into_inner(), auth.user_id, request.into_inner())
414        .await
415    {
416        Ok(quote) => HttpResponse::Ok().json(quote),
417        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({
418            "error": e
419        })),
420    }
421}
422
423/// POST /api/v1/quotes/:id/withdraw
424/// Withdraw quote (Contractor action)
425#[post("/quotes/{id}/withdraw")]
426pub async fn withdraw_quote(
427    data: web::Data<AppState>,
428    auth: AuthenticatedUser,
429    id: web::Path<Uuid>,
430) -> impl Responder {
431    // Cloisonnement : ce devis doit relever d'une ACP que cet utilisateur a le
432    // droit de voir. La route n'ayant pas d'immeuble en chemin, la chaîne
433    // devis → immeuble → ACP est remontée par le garde.
434    //
435    // Ce contrôle porte sur le PÉRIMÈTRE, pas sur le droit d'agir : il dit que
436    // ce devis n'est pas celui d'une autre copropriété, pas que cet
437    // utilisateur a qualité pour l'examiner (#772).
438    if let Err(err) = verify_quote_org_access(
439        &auth,
440        *id,
441        &data.quote_use_cases,
442        &data.building_use_cases,
443        &data.acp_use_cases,
444    )
445    .await
446    {
447        return err.error_response();
448    }
449
450    match data.quote_use_cases.withdraw_quote(id.into_inner()).await {
451        Ok(quote) => HttpResponse::Ok().json(quote),
452        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({
453            "error": e
454        })),
455    }
456}
457
458/// POST /api/v1/quotes/compare
459/// Compare multiple quotes (Belgian professional best practice: 3 quotes minimum)
460/// Returns quotes sorted by automatic score (best first)
461#[post("/quotes/compare")]
462pub async fn compare_quotes(
463    data: web::Data<AppState>,
464    auth: AuthenticatedUser,
465    request: web::Json<QuoteComparisonRequestDto>,
466) -> impl Responder {
467    // Cloisonnement (#882) : chaque devis comparé doit relever d'une ACP que
468    // cet utilisateur a le droit de voir. Le cas d'usage vérifie seulement que
469    // les devis partagent le même immeuble — une cohérence métier, pas un
470    // périmètre — donc lire la concurrence d'une autre copropriété suffisait
471    // à obtenir ses prix et prestataires, en connaissant trois UUID de devis.
472    for id_str in &request.quote_ids {
473        let quote_id = match Uuid::parse_str(id_str) {
474            Ok(id) => id,
475            Err(_) => {
476                return HttpResponse::BadRequest().json(serde_json::json!({
477                    "error": format!("Invalid quote_id format: {}", id_str)
478                }))
479            }
480        };
481        if let Err(err) = verify_quote_org_access(
482            &auth,
483            quote_id,
484            &data.quote_use_cases,
485            &data.building_use_cases,
486            &data.acp_use_cases,
487        )
488        .await
489        {
490            return err.error_response();
491        }
492    }
493
494    match data
495        .quote_use_cases
496        .compare_quotes(request.into_inner())
497        .await
498    {
499        Ok(comparison) => HttpResponse::Ok().json(comparison),
500        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({
501            "error": e
502        })),
503    }
504}
505
506/// PUT /api/v1/quotes/:id/contractor-rating
507/// Update contractor rating (for scoring algorithm)
508#[put("/quotes/{id}/contractor-rating")]
509pub async fn update_contractor_rating(
510    data: web::Data<AppState>,
511    auth: AuthenticatedUser,
512    id: web::Path<Uuid>,
513    request: web::Json<serde_json::Value>,
514) -> impl Responder {
515    // Cloisonnement : ce devis doit relever d'une ACP que cet utilisateur a le
516    // droit de voir. La route n'ayant pas d'immeuble en chemin, la chaîne
517    // devis → immeuble → ACP est remontée par le garde.
518    //
519    // Ce contrôle porte sur le PÉRIMÈTRE, pas sur le droit d'agir : il dit que
520    // ce devis n'est pas celui d'une autre copropriété, pas que cet
521    // utilisateur a qualité pour l'examiner (#772).
522    if let Err(err) = verify_quote_org_access(
523        &auth,
524        *id,
525        &data.quote_use_cases,
526        &data.building_use_cases,
527        &data.acp_use_cases,
528    )
529    .await
530    {
531        return err.error_response();
532    }
533
534    let rating = match request.get("rating").and_then(|v| v.as_i64()) {
535        Some(r) => r as i32,
536        None => {
537            return HttpResponse::BadRequest().json(serde_json::json!({
538                "error": "Rating field is required and must be an integer (0-100)"
539            }))
540        }
541    };
542
543    match data
544        .quote_use_cases
545        .update_contractor_rating(id.into_inner(), rating)
546        .await
547    {
548        Ok(quote) => HttpResponse::Ok().json(quote),
549        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({
550            "error": e
551        })),
552    }
553}
554
555/// DELETE /api/v1/quotes/:id
556/// Delete quote
557#[delete("/quotes/{id}")]
558pub async fn delete_quote(
559    data: web::Data<AppState>,
560    auth: AuthenticatedUser,
561    id: web::Path<Uuid>,
562) -> impl Responder {
563    // Cloisonnement : ce devis doit relever d'une ACP que cet utilisateur a le
564    // droit de voir. La route n'ayant pas d'immeuble en chemin, la chaîne
565    // devis → immeuble → ACP est remontée par le garde.
566    //
567    // Ce contrôle porte sur le PÉRIMÈTRE, pas sur le droit d'agir : il dit que
568    // ce devis n'est pas celui d'une autre copropriété, pas que cet
569    // utilisateur a qualité pour l'examiner (#772).
570    if let Err(err) = verify_quote_org_access(
571        &auth,
572        *id,
573        &data.quote_use_cases,
574        &data.building_use_cases,
575        &data.acp_use_cases,
576    )
577    .await
578    {
579        return err.error_response();
580    }
581
582    match data.quote_use_cases.delete_quote(id.into_inner()).await {
583        Ok(true) => HttpResponse::NoContent().finish(),
584        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
585            "error": "Quote not found"
586        })),
587        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
588            "error": e
589        })),
590    }
591}
592
593/// GET /api/v1/buildings/:building_id/quotes/count
594/// Count total quotes for building
595#[get("/buildings/{building_id}/quotes/count")]
596pub async fn count_building_quotes(
597    data: web::Data<AppState>,
598    auth: AuthenticatedUser,
599    building_id: web::Path<Uuid>,
600) -> impl Responder {
601    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
602    // utilisateur a le droit de voir. Un devis dit qui a soumis quel prix pour
603    // quels travaux — le lire hors de son ACP, c'est lire la concurrence.
604    // L'identité était prise puis ignorée — `_auth` (#772).
605    if let Err(err) = verify_building_org_access(
606        &auth,
607        *building_id,
608        &data.building_use_cases,
609        &data.acp_use_cases,
610    )
611    .await
612    {
613        return err.error_response();
614    }
615
616    match data
617        .quote_use_cases
618        .count_by_building(building_id.into_inner())
619        .await
620    {
621        Ok(count) => HttpResponse::Ok().json(serde_json::json!({
622            "count": count
623        })),
624        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
625            "error": e
626        })),
627    }
628}
629
630/// GET /api/v1/buildings/:building_id/quotes/status/:status/count
631/// Count quotes by status for building
632#[get("/buildings/{building_id}/quotes/status/{status}/count")]
633pub async fn count_quotes_by_status(
634    data: web::Data<AppState>,
635    auth: AuthenticatedUser,
636    path: web::Path<(Uuid, String)>,
637) -> impl Responder {
638    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
639    // utilisateur a le droit de voir. Un devis dit qui a soumis quel prix pour
640    // quels travaux — le lire hors de son ACP, c'est lire la concurrence.
641    // L'identité était prise puis ignorée — `_auth` (#772).
642    if let Err(err) =
643        verify_building_org_access(&auth, path.0, &data.building_use_cases, &data.acp_use_cases)
644            .await
645    {
646        return err.error_response();
647    }
648
649    let (building_id, status) = path.into_inner();
650
651    match data
652        .quote_use_cases
653        .count_by_status(building_id, &status)
654        .await
655    {
656        Ok(count) => HttpResponse::Ok().json(serde_json::json!({
657            "count": count
658        })),
659        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
660            "error": e
661        })),
662    }
663}
664
665#[cfg(test)]
666mod tests {
667    // Handler tests are covered by E2E tests in tests/e2e/
668
669    #[test]
670    fn test_handler_structure_quotes() {
671        // This test verifies handler function signatures compile
672        // Real testing happens in E2E tests with testcontainers
673    }
674}