Skip to main content

koprogo_api/infrastructure/web/handlers/
mandate_handlers.rs

1//! HTTP handlers for the Mandate feature (Story 3.4 — FR7 INV-14).
2//!
3//! Routes:
4//! - `POST   /mandates`             — syndic / superadmin issues a mandate.
5//! - `GET    /mandates?subject={u}` — list active mandates for a user.
6//! - `GET    /mandates/{id}`        — details of a mandate.
7//! - `POST   /mandates/{id}/revoke` — early revocation.
8//!
9//! Auth: syndic / superadmin for write paths; the subject can also read
10//! their own mandates (`?subject=<self>`).
11
12use crate::application::error::AppError;
13use crate::domain::entities::{Mandate, MandateKind, MandateScope};
14use crate::infrastructure::web::{AppState, AuthenticatedUser};
15use actix_web::{get, post, web, HttpResponse};
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18use std::str::FromStr;
19use uuid::Uuid;
20
21// ---------------------------------------------------------------------------
22// DTOs
23// ---------------------------------------------------------------------------
24
25#[derive(Debug, Deserialize, utoipa::ToSchema)]
26pub struct IssueMandateRequest {
27    pub subject_user_id: Uuid,
28    pub kind: String,
29    pub scope_kind: String,
30    pub scope_id: Uuid,
31    pub reason: String,
32    /// Optional — defaults to `now()` server-side.
33    pub valid_from: Option<DateTime<Utc>>,
34    /// Mandatory. Returning 422-like validation if missing.
35    pub valid_until: DateTime<Utc>,
36}
37
38#[derive(Debug, Serialize, utoipa::ToSchema)]
39pub struct MandateResponse {
40    pub id: Uuid,
41    pub subject_user_id: Uuid,
42    pub kind: String,
43    pub scope_kind: String,
44    pub scope_id: Uuid,
45    pub issued_by: Uuid,
46    pub reason: String,
47    pub valid_from: DateTime<Utc>,
48    pub valid_until: DateTime<Utc>,
49    pub revoked_at: Option<DateTime<Utc>>,
50    pub created_at: DateTime<Utc>,
51    pub updated_at: DateTime<Utc>,
52}
53
54impl From<Mandate> for MandateResponse {
55    fn from(m: Mandate) -> Self {
56        Self {
57            id: m.id,
58            subject_user_id: m.subject_user_id,
59            kind: m.kind.to_string(),
60            scope_kind: m.scope.kind_str().to_string(),
61            scope_id: m.scope.id(),
62            issued_by: m.issued_by,
63            reason: m.reason,
64            valid_from: m.valid_from,
65            valid_until: m.valid_until,
66            revoked_at: m.revoked_at,
67            created_at: m.created_at,
68            updated_at: m.updated_at,
69        }
70    }
71}
72
73#[derive(Debug, Deserialize)]
74pub struct ListMandatesQuery {
75    pub subject: Option<Uuid>,
76}
77
78// ---------------------------------------------------------------------------
79// Guards
80// ---------------------------------------------------------------------------
81
82fn require_syndic_or_superadmin(user: &AuthenticatedUser) -> Result<(), AppError> {
83    match user.role.as_str() {
84        "syndic" | "superadmin" => Ok(()),
85        _ => Err(AppError::Forbidden(
86            "Only syndic or superadmin can manage mandates".to_string(),
87        )),
88    }
89}
90
91// ---------------------------------------------------------------------------
92// POST /mandates — issue
93// ---------------------------------------------------------------------------
94
95#[utoipa::path(
96    post,
97    path = "/mandates",
98    tag = "Mandate",
99    summary = "Issue a mandate (syndic / superadmin only)",
100    responses(
101        (status = 201, description = "Mandate issued", body = MandateResponse),
102        (status = 400, description = "Validation error"),
103        (status = 403, description = "Forbidden — only syndic or superadmin"),
104    ),
105)]
106#[post("/mandates")]
107pub async fn issue_mandate(
108    state: web::Data<AppState>,
109    user: AuthenticatedUser,
110    body: web::Json<IssueMandateRequest>,
111) -> Result<HttpResponse, AppError> {
112    require_syndic_or_superadmin(&user)?;
113    let req = body.into_inner();
114
115    let kind = MandateKind::from_str(&req.kind)?;
116    let scope = MandateScope::from_parts(&req.scope_kind, req.scope_id)?;
117    let valid_from = req.valid_from.unwrap_or_else(Utc::now);
118
119    let mandate = state
120        .mandate_use_cases
121        .issue(
122            req.subject_user_id,
123            kind,
124            scope,
125            user.user_id,
126            req.reason,
127            valid_from,
128            req.valid_until,
129        )
130        .await?;
131
132    Ok(HttpResponse::Created().json(MandateResponse::from(mandate)))
133}
134
135// ---------------------------------------------------------------------------
136// GET /mandates?subject={uuid} — list active mandates
137// ---------------------------------------------------------------------------
138
139#[utoipa::path(
140    get,
141    path = "/mandates",
142    tag = "Mandate",
143    summary = "List active mandates for a subject user",
144    params(
145        ("subject" = Option<Uuid>, Query, description = "Subject user id. Defaults to the caller.")
146    ),
147    responses(
148        (status = 200, description = "Active mandates", body = Vec<MandateResponse>),
149        (status = 403, description = "Forbidden — caller cannot view this subject's mandates"),
150    ),
151)]
152#[get("/mandates")]
153pub async fn list_mandates(
154    state: web::Data<AppState>,
155    user: AuthenticatedUser,
156    query: web::Query<ListMandatesQuery>,
157) -> Result<HttpResponse, AppError> {
158    let subject = query.into_inner().subject.unwrap_or(user.user_id);
159    let is_self = subject == user.user_id;
160    let is_admin = matches!(user.role.as_str(), "syndic" | "superadmin");
161
162    if !is_self && !is_admin {
163        return Err(AppError::Forbidden(
164            "Only syndic / superadmin can view another user's mandates".to_string(),
165        ));
166    }
167
168    let mandates = state
169        .mandate_use_cases
170        .list_active_for_subject(subject)
171        .await?;
172    let response: Vec<MandateResponse> = mandates.into_iter().map(MandateResponse::from).collect();
173    Ok(HttpResponse::Ok().json(response))
174}
175
176// ---------------------------------------------------------------------------
177// GET /mandates/{id} — details
178// ---------------------------------------------------------------------------
179
180#[utoipa::path(
181    get,
182    path = "/mandates/{id}",
183    tag = "Mandate",
184    summary = "Get mandate details",
185    responses(
186        (status = 200, description = "Mandate details", body = MandateResponse),
187        (status = 403, description = "Forbidden — not allowed to view this mandate"),
188        (status = 404, description = "Mandate not found"),
189    ),
190)]
191#[get("/mandates/{id}")]
192pub async fn get_mandate(
193    state: web::Data<AppState>,
194    user: AuthenticatedUser,
195    path: web::Path<Uuid>,
196) -> Result<HttpResponse, AppError> {
197    let id = path.into_inner();
198    let mandate = state.mandate_use_cases.get(id).await?;
199
200    let is_subject = mandate.subject_user_id == user.user_id;
201    let is_admin = matches!(user.role.as_str(), "syndic" | "superadmin");
202    if !is_subject && !is_admin {
203        return Err(AppError::Forbidden(
204            "Only syndic / superadmin or the mandated user can view this mandate".to_string(),
205        ));
206    }
207
208    Ok(HttpResponse::Ok().json(MandateResponse::from(mandate)))
209}
210
211// ---------------------------------------------------------------------------
212// POST /mandates/{id}/revoke — early revocation
213// ---------------------------------------------------------------------------
214
215#[utoipa::path(
216    post,
217    path = "/mandates/{id}/revoke",
218    tag = "Mandate",
219    summary = "Revoke a mandate before its natural expiry (syndic / superadmin)",
220    responses(
221        (status = 204, description = "Mandate revoked"),
222        (status = 403, description = "Forbidden"),
223        (status = 404, description = "Mandate not found"),
224    ),
225)]
226#[post("/mandates/{id}/revoke")]
227pub async fn revoke_mandate(
228    state: web::Data<AppState>,
229    user: AuthenticatedUser,
230    path: web::Path<Uuid>,
231) -> Result<HttpResponse, AppError> {
232    require_syndic_or_superadmin(&user)?;
233    let id = path.into_inner();
234
235    // Cloisonnement : révoquer le mandat d'un avocat, d'un notaire ou d'un
236    // architecte met fin à sa mission. `require_syndic_or_superadmin` ne
237    // vérifie que le RÔLE — un syndic de l'organisation A pouvait révoquer le
238    // mandat d'un professionnel de l'organisation B (#772).
239    //
240    // Le périmètre est porté par `MandateScope`, qui vise soit un immeuble,
241    // soit une ACP. On remonte donc par l'un ou par l'autre.
242    let mandate = state.mandate_use_cases.get(id).await?;
243    match mandate.scope {
244        crate::domain::entities::MandateScope::Building(building_id) => {
245            crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
246                &user,
247                building_id,
248                &state.building_use_cases,
249                &state.acp_use_cases,
250            )
251            .await?
252        }
253        crate::domain::entities::MandateScope::Acp(acp_id) => {
254            crate::infrastructure::web::middleware::scope_guard::verify_acp_org_access(
255                &user,
256                acp_id,
257                &state.acp_use_cases,
258            )
259            .await?
260        }
261    }
262
263    state.mandate_use_cases.revoke(id).await?;
264    Ok(HttpResponse::NoContent().finish())
265}