koprogo_api/infrastructure/web/handlers/
mandate_handlers.rs1use 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#[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 pub valid_from: Option<DateTime<Utc>>,
34 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
78fn 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#[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#[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#[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#[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 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}