Skip to main content

koprogo_api/infrastructure/web/handlers/
marketplace_handlers.rs

1use actix_web::{get, post, web, HttpResponse, ResponseError};
2use chrono::Datelike;
3use uuid::Uuid;
4
5use crate::application::dto::{
6    ContractEvaluationsAnnualReportDto, CreateServiceProviderDto, SearchServiceProvidersQuery,
7};
8use crate::infrastructure::web::middleware::scope_guard::verify_building_org_access;
9use crate::infrastructure::web::middleware::AuthenticatedUser;
10use crate::infrastructure::web::AppState;
11
12/// GET /api/v1/marketplace/providers
13/// Search for service providers (public - no authentication required)
14#[get("/marketplace/providers")]
15pub async fn search_service_providers(
16    state: web::Data<AppState>,
17    query: web::Query<SearchServiceProvidersQuery>,
18) -> Result<HttpResponse, actix_web::Error> {
19    let results = state
20        .service_provider_use_cases
21        .search(&query.into_inner())
22        .await
23        .map_err(actix_web::error::ErrorInternalServerError)?;
24    Ok(HttpResponse::Ok().json(results))
25}
26
27/// GET /api/v1/marketplace/providers/{slug}
28/// Get public service provider profile (no authentication required)
29#[get("/marketplace/providers/{slug}")]
30pub async fn get_provider_by_slug(
31    state: web::Data<AppState>,
32    slug: web::Path<String>,
33) -> Result<HttpResponse, actix_web::Error> {
34    let slug_str = slug.into_inner();
35    match state
36        .service_provider_use_cases
37        .find_by_slug(&slug_str)
38        .await
39        .map_err(actix_web::error::ErrorInternalServerError)?
40    {
41        Some(provider) => Ok(HttpResponse::Ok().json(provider)),
42        None => Ok(HttpResponse::NotFound().json(serde_json::json!({
43            "error": format!("Provider not found: {}", slug_str)
44        }))),
45    }
46}
47
48/// POST /api/v1/service-providers
49///
50/// Réservée au syndic et au superadministrateur.
51///
52/// ── Ce que la documentation disait, et ce que le code faisait ────────────
53///
54/// Le commentaire portait déjà « syndic/admin only ». **Aucun contrôle ne
55/// l'appliquait** : la route se contentait de lire l'organisation de
56/// l'appelant, si bien qu'un copropriétaire — ou tout rôle authentifié —
57/// pouvait inscrire un prestataire au catalogue de son cabinet.
58///
59/// Une règle écrite en commentaire et absente du code est pire qu'une règle
60/// absente : la revue la lit, la croit appliquée, et passe à la suite. Le
61/// cliquet #864 comptait d'ailleurs cette route parmi celles qui prennent
62/// une identité sans s'en servir pour décider — il avait raison, et sur ce
63/// point précis, pas sur le cloisonnement qui, lui, était bien là.
64///
65/// Le refus est un **403**, pas un 400 : l'appelant est authentifié et son
66/// organisation est connue ; ce qui manque est le droit, pas la donnée.
67#[post("/service-providers")]
68pub async fn create_service_provider(
69    state: web::Data<AppState>,
70    request: web::Json<CreateServiceProviderDto>,
71    user: AuthenticatedUser,
72) -> Result<HttpResponse, actix_web::Error> {
73    if !matches!(user.role.as_str(), "syndic" | "superadmin") {
74        return Ok(HttpResponse::Forbidden().json(serde_json::json!({
75            "error": "Only syndic or superadmin can register a service provider",
76            "code": "invalid_role",
77        })));
78    }
79
80    let org_id = user
81        .organization_id
82        .ok_or_else(|| actix_web::error::ErrorBadRequest("Organization ID required"))?;
83
84    let response = state
85        .service_provider_use_cases
86        .create(org_id, request.into_inner())
87        .await
88        .map_err(actix_web::error::ErrorBadRequest)?;
89
90    Ok(HttpResponse::Created().json(response))
91}
92
93/// GET /api/v1/buildings/{building_id}/reports/contract-evaluations/annual
94/// Get annual contract evaluations report (L13 legal report)
95#[get("/buildings/{building_id}/reports/contract-evaluations/annual")]
96pub async fn get_contract_evaluations_annual(
97    state: web::Data<AppState>,
98    building_id: web::Path<Uuid>,
99    web::Query(params): web::Query<std::collections::HashMap<String, String>>,
100    user: AuthenticatedUser,
101) -> Result<HttpResponse, actix_web::Error> {
102    let building_id = building_id.into_inner();
103
104    // Cloisonnement : ce rapport annuel d'évaluation des contrats est celui
105    // d'un immeuble précis — l'obligation de l'Art. 3.89 § 5, 12° porte sur
106    // les contrats de fournitures régulières d'UNE copropriété.
107    //
108    // L'identité était prise puis ignorée, tout comme l'état applicatif :
109    // `_state` ET `_user`, deux underscores sur la même signature (#772).
110    if let Err(err) = verify_building_org_access(
111        &user,
112        building_id,
113        &state.building_use_cases,
114        &state.acp_use_cases,
115    )
116    .await
117    {
118        return Ok(err.error_response());
119    }
120    let year = params
121        .get("year")
122        .and_then(|y| y.parse::<i32>().ok())
123        .unwrap_or_else(|| chrono::Local::now().year());
124
125    // TODO: Implement ContractEvaluationRepository for real data
126    // For now, return empty report (evaluations are a separate entity not yet persisted)
127    let report = ContractEvaluationsAnnualReportDto {
128        building_id: building_id.to_string(),
129        report_year: year,
130        total_evaluations: 0,
131        total_providers_evaluated: 0,
132        average_global_score: 0.0,
133        recommendation_rate: 0.0,
134        evaluations: vec![],
135    };
136
137    Ok(HttpResponse::Ok().json(report))
138}