koprogo_api/infrastructure/web/handlers/
role_assignment_handlers.rs1use crate::application::error::AppError;
21use crate::domain::entities::{UserRole, UserRoleAssignment};
22use crate::infrastructure::web::{AppState, AuthenticatedUser};
23use actix_web::{delete, get, post, web, HttpResponse};
24use chrono::{DateTime, Utc};
25use serde::{Deserialize, Serialize};
26use std::str::FromStr;
27use uuid::Uuid;
28
29#[derive(Debug, Deserialize, utoipa::ToSchema)]
34pub struct AssignRoleRequest {
35 pub role: String,
37 pub organization_id: Option<Uuid>,
39 pub valid_until: Option<DateTime<Utc>>,
42}
43
44#[derive(Debug, Serialize, utoipa::ToSchema)]
45pub struct UserRoleAssignmentResponse {
46 pub id: Uuid,
47 pub user_id: Uuid,
48 pub role: String,
49 pub organization_id: Option<Uuid>,
50 pub is_primary: bool,
51 pub valid_until: Option<DateTime<Utc>>,
52 pub delegated_from_user_id: Option<Uuid>,
53 pub created_at: DateTime<Utc>,
54 pub updated_at: DateTime<Utc>,
55}
56
57impl From<UserRoleAssignment> for UserRoleAssignmentResponse {
58 fn from(a: UserRoleAssignment) -> Self {
59 Self {
60 id: a.id,
61 user_id: a.user_id,
62 role: a.role.to_string(),
63 organization_id: a.organization_id,
64 is_primary: a.is_primary,
65 valid_until: a.valid_until,
66 delegated_from_user_id: a.delegated_from_user_id,
67 created_at: a.created_at,
68 updated_at: a.updated_at,
69 }
70 }
71}
72
73#[derive(Debug, Deserialize)]
74pub struct ListRoleAssignmentsAdminQuery {
75 pub organization_id: Option<Uuid>,
76 pub role: Option<String>,
77}
78
79async fn ensure_can_admin_target(
87 state: &web::Data<AppState>,
88 caller: &AuthenticatedUser,
89 target_user_id: Uuid,
90) -> Result<(), AppError> {
91 if caller.is_superadmin() {
92 return Ok(());
93 }
94 if caller.role != "syndic" {
95 return Err(AppError::Forbidden(
96 "Only superadmin or syndic can administer role assignments".to_string(),
97 ));
98 }
99 let target = state
101 .user_use_cases
102 .list_assignments_for_user(target_user_id)
103 .await?;
104 let same_org = target
106 .iter()
107 .any(|a| a.organization_id.is_some() && a.organization_id == caller.organization_id);
108 let bootstrap = target.is_empty() && caller.organization_id.is_some();
111 if same_org || bootstrap {
112 Ok(())
113 } else {
114 Err(AppError::Forbidden(
115 "Syndic can only administer role assignments inside their own organization".to_string(),
116 ))
117 }
118}
119
120#[utoipa::path(
125 post,
126 path = "/users/{user_id}/role-assignments",
127 tag = "RoleAssignment",
128 summary = "Assign a sub-role to a user (Story B0bis — gap Story 3.1)",
129 request_body = AssignRoleRequest,
130 params(("user_id" = Uuid, Path, description = "Target user UUID")),
131 responses(
132 (status = 201, description = "Role assigned", body = UserRoleAssignmentResponse),
133 (status = 400, description = "Validation error (unknown role, past valid_until)"),
134 (status = 403, description = "Forbidden — not superadmin/syndic"),
135 (status = 404, description = "Target user not found"),
136 (status = 409, description = "Role already actively assigned to this user"),
137 ),
138)]
139#[post("/users/{user_id}/role-assignments")]
140pub async fn assign_role(
141 state: web::Data<AppState>,
142 user: AuthenticatedUser,
143 path: web::Path<Uuid>,
144 body: web::Json<AssignRoleRequest>,
145) -> Result<HttpResponse, AppError> {
146 let target_user_id = path.into_inner();
147 ensure_can_admin_target(&state, &user, target_user_id).await?;
148
149 let req = body.into_inner();
150 let role = UserRole::from_str(&req.role).map_err(AppError::Validation)?;
151
152 let saved = state
153 .user_use_cases
154 .assign_role(
155 target_user_id,
156 role,
157 req.organization_id,
158 req.valid_until,
159 user.user_id,
160 )
161 .await?;
162 Ok(HttpResponse::Created().json(UserRoleAssignmentResponse::from(saved)))
163}
164
165#[utoipa::path(
170 get,
171 path = "/users/{user_id}/role-assignments",
172 tag = "RoleAssignment",
173 summary = "List role assignments for a user",
174 params(("user_id" = Uuid, Path, description = "Target user UUID")),
175 responses(
176 (status = 200, description = "Role assignments", body = Vec<UserRoleAssignmentResponse>),
177 (status = 403, description = "Forbidden"),
178 ),
179)]
180#[get("/users/{user_id}/role-assignments")]
181pub async fn list_role_assignments_for_user(
182 state: web::Data<AppState>,
183 user: AuthenticatedUser,
184 path: web::Path<Uuid>,
185) -> Result<HttpResponse, AppError> {
186 let target_user_id = path.into_inner();
187 let is_self = target_user_id == user.user_id;
188 if !is_self {
189 ensure_can_admin_target(&state, &user, target_user_id).await?;
190 }
191 let rows = state
192 .user_use_cases
193 .list_assignments_for_user(target_user_id)
194 .await?;
195 let response: Vec<UserRoleAssignmentResponse> = rows
196 .into_iter()
197 .map(UserRoleAssignmentResponse::from)
198 .collect();
199 Ok(HttpResponse::Ok().json(response))
200}
201
202#[utoipa::path(
207 delete,
208 path = "/users/{user_id}/role-assignments/{assignment_id}",
209 tag = "RoleAssignment",
210 summary = "Revoke a role assignment",
211 params(
212 ("user_id" = Uuid, Path, description = "Target user UUID"),
213 ("assignment_id" = Uuid, Path, description = "Assignment UUID to revoke"),
214 ),
215 responses(
216 (status = 204, description = "Assignment revoked"),
217 (status = 403, description = "Forbidden"),
218 (status = 404, description = "Assignment not found"),
219 ),
220)]
221#[delete("/users/{user_id}/role-assignments/{assignment_id}")]
222pub async fn revoke_role_assignment(
223 state: web::Data<AppState>,
224 user: AuthenticatedUser,
225 path: web::Path<(Uuid, Uuid)>,
226) -> Result<HttpResponse, AppError> {
227 let (target_user_id, assignment_id) = path.into_inner();
228 ensure_can_admin_target(&state, &user, target_user_id).await?;
229 state
230 .user_use_cases
231 .revoke_assignment(assignment_id)
232 .await?;
233 Ok(HttpResponse::NoContent().finish())
234}
235
236#[utoipa::path(
241 get,
242 path = "/role-assignments",
243 tag = "RoleAssignment",
244 summary = "List role assignments filtered by organization and/or role (superadmin only)",
245 params(
246 ("organization_id" = Option<Uuid>, Query, description = "Filter by organization"),
247 ("role" = Option<String>, Query, description = "Filter by role string (whitelist)"),
248 ),
249 responses(
250 (status = 200, description = "Filtered role assignments", body = Vec<UserRoleAssignmentResponse>),
251 (status = 403, description = "Forbidden — superadmin only"),
252 ),
253)]
254#[get("/role-assignments")]
255pub async fn list_role_assignments_admin(
256 state: web::Data<AppState>,
257 user: AuthenticatedUser,
258 query: web::Query<ListRoleAssignmentsAdminQuery>,
259) -> Result<HttpResponse, AppError> {
260 if !user.is_superadmin() {
261 return Err(AppError::Forbidden(
262 "Only superadmin can list cross-user role assignments".to_string(),
263 ));
264 }
265 let q = query.into_inner();
266 let role_filter = match q.role.as_deref() {
269 None => None,
270 Some(s) => Some(UserRole::from_str(s).map_err(AppError::Validation)?),
271 };
272
273 let all_users = state
278 .user_use_cases
279 .list_all()
280 .await
281 .map_err(AppError::from)?;
282 let mut rows: Vec<UserRoleAssignmentResponse> = Vec::new();
283 for u in all_users {
284 let uid = Uuid::parse_str(&u.id).map_err(|e| AppError::Internal(e.to_string()))?;
285 let assignments = state.user_use_cases.list_assignments_for_user(uid).await?;
286 for a in assignments {
287 let role_match = match &role_filter {
288 None => true,
289 Some(r) => &a.role == r,
290 };
291 let org_match = match q.organization_id {
292 None => true,
293 Some(org) => a.organization_id == Some(org),
294 };
295 if role_match && org_match {
296 rows.push(UserRoleAssignmentResponse::from(a));
297 }
298 }
299 }
300 Ok(HttpResponse::Ok().json(rows))
301}