1use crate::domain::entities::UserRole;
2use crate::domain::entities::UserRoleAssignment;
3use crate::infrastructure::web::{AppState, AuthenticatedUser};
4use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
5use bcrypt::{hash, DEFAULT_COST};
6use serde::Deserialize;
7use serde_json::json;
8use std::collections::HashSet;
9use uuid::Uuid;
10
11const ALLOWED_ROLES: [&str; 4] = ["superadmin", "syndic", "accountant", "owner"];
12
13#[derive(Deserialize, Clone)]
14pub struct RoleAssignmentRequest {
15 pub role: String,
16 pub organization_id: Option<Uuid>,
17 pub is_primary: Option<bool>,
18}
19
20#[derive(Deserialize)]
21pub struct CreateUserRequest {
22 pub email: String,
23 pub password: String,
24 pub first_name: String,
25 pub last_name: String,
26 pub roles: Option<Vec<RoleAssignmentRequest>>,
27 pub role: Option<String>, pub organization_id: Option<Uuid>, }
30
31#[derive(Deserialize)]
32pub struct UpdateUserRequest {
33 pub email: String,
34 pub first_name: String,
35 pub last_name: String,
36 pub roles: Option<Vec<RoleAssignmentRequest>>,
37 pub role: Option<String>, pub organization_id: Option<Uuid>, pub password: Option<String>,
40}
41
42#[derive(Clone, Debug)]
43struct NormalizedRole {
44 id: Uuid,
45 role: String,
46 organization_id: Option<Uuid>,
47 is_primary: bool,
48}
49
50impl NormalizedRole {
51 fn to_assignment(&self, user_id: Uuid) -> UserRoleAssignment {
52 let domain_role = self.role.parse::<UserRole>().expect("already validated");
53 let mut a =
54 UserRoleAssignment::new(user_id, domain_role, self.organization_id, self.is_primary);
55 a.id = self.id;
56 a
57 }
58}
59
60#[get("/users")]
62pub async fn list_users(state: web::Data<AppState>, user: AuthenticatedUser) -> impl Responder {
63 if user.role != "superadmin" {
64 return HttpResponse::Forbidden().json(json!({
65 "error": "Only SuperAdmin can access all users"
66 }));
67 }
68
69 match state.user_use_cases.list_all().await {
70 Ok(users) => HttpResponse::Ok().json(json!({ "data": users })),
71 Err(e) => HttpResponse::InternalServerError().json(json!({
72 "error": format!("Failed to fetch users: {}", e)
73 })),
74 }
75}
76
77#[post("/users")]
79pub async fn create_user(
80 state: web::Data<AppState>,
81 user: AuthenticatedUser,
82 req: web::Json<CreateUserRequest>,
83) -> impl Responder {
84 if user.role != "superadmin" {
85 return HttpResponse::Forbidden().json(json!({
86 "error": "Only SuperAdmin can create users"
87 }));
88 }
89
90 if !req.email.contains('@') {
91 return HttpResponse::BadRequest().json(json!({ "error": "Invalid email format" }));
92 }
93 if req.first_name.trim().len() < 2 || req.last_name.trim().len() < 2 {
94 return HttpResponse::BadRequest().json(json!({
95 "error": "First and last names must be at least 2 characters"
96 }));
97 }
98 if req.password.trim().len() < 6 {
99 return HttpResponse::BadRequest().json(json!({
100 "error": "Password must be at least 6 characters"
101 }));
102 }
103
104 let roles = match normalize_roles(req.roles.clone(), req.role.clone(), req.organization_id) {
105 Ok(r) => r,
106 Err(resp) => return resp,
107 };
108
109 let primary = roles
110 .iter()
111 .find(|r| r.is_primary)
112 .cloned()
113 .expect("normalized roles always have a primary");
114
115 let hashed_password = match hash(req.password.trim(), DEFAULT_COST) {
116 Ok(h) => h,
117 Err(e) => {
118 return HttpResponse::InternalServerError().json(json!({
119 "error": format!("Failed to hash password: {}", e)
120 }))
121 }
122 };
123
124 let assignments: Vec<UserRoleAssignment> = roles
125 .iter()
126 .map(|r| r.to_assignment(Uuid::nil())) .collect();
128
129 let primary_role = primary.role.parse::<UserRole>().expect("already validated");
130
131 match state
132 .user_use_cases
133 .create(
134 req.email.trim().to_lowercase(),
135 hashed_password,
136 req.first_name.trim().to_string(),
137 req.last_name.trim().to_string(),
138 primary_role,
139 primary.organization_id,
140 assignments,
141 )
142 .await
143 {
144 Ok(resp) => HttpResponse::Created().json(resp),
145 Err(e) if e == "email_exists" => HttpResponse::BadRequest().json(json!({
146 "error": "Email already exists"
147 })),
148 Err(e) => HttpResponse::InternalServerError().json(json!({
149 "error": format!("Failed to create user: {}", e)
150 })),
151 }
152}
153
154#[put("/users/{id}")]
156pub async fn update_user(
157 state: web::Data<AppState>,
158 user: AuthenticatedUser,
159 path: web::Path<Uuid>,
160 req: web::Json<UpdateUserRequest>,
161) -> impl Responder {
162 if user.role != "superadmin" {
163 return HttpResponse::Forbidden().json(json!({
164 "error": "Only SuperAdmin can update users"
165 }));
166 }
167
168 if !req.email.contains('@') {
169 return HttpResponse::BadRequest().json(json!({ "error": "Invalid email format" }));
170 }
171 if req.first_name.trim().len() < 2 || req.last_name.trim().len() < 2 {
172 return HttpResponse::BadRequest().json(json!({
173 "error": "First and last names must be at least 2 characters"
174 }));
175 }
176 if let Some(password) = &req.password {
177 if !password.trim().is_empty() && password.trim().len() < 6 {
178 return HttpResponse::BadRequest().json(json!({
179 "error": "Password must be at least 6 characters"
180 }));
181 }
182 }
183
184 let roles = match normalize_roles(req.roles.clone(), req.role.clone(), req.organization_id) {
185 Ok(r) => r,
186 Err(resp) => return resp,
187 };
188
189 let primary = roles
190 .iter()
191 .find(|r| r.is_primary)
192 .cloned()
193 .expect("normalized roles always have a primary");
194
195 let user_id = path.into_inner();
196
197 let password_hash = if let Some(pw) = &req.password {
198 if !pw.trim().is_empty() {
199 match hash(pw.trim(), DEFAULT_COST) {
200 Ok(h) => Some(h),
201 Err(e) => {
202 return HttpResponse::InternalServerError().json(json!({
203 "error": format!("Failed to hash password: {}", e)
204 }))
205 }
206 }
207 } else {
208 None
209 }
210 } else {
211 None
212 };
213
214 let assignments: Vec<UserRoleAssignment> =
215 roles.iter().map(|r| r.to_assignment(user_id)).collect();
216
217 let primary_role = primary.role.parse::<UserRole>().expect("already validated");
218
219 match state
220 .user_use_cases
221 .update(
222 user_id,
223 req.email.trim().to_lowercase(),
224 req.first_name.trim().to_string(),
225 req.last_name.trim().to_string(),
226 primary_role,
227 primary.organization_id,
228 password_hash,
229 assignments,
230 )
231 .await
232 {
233 Ok(Some(resp)) => HttpResponse::Ok().json(resp),
234 Ok(None) => HttpResponse::NotFound().json(json!({ "error": "User not found" })),
235 Err(e) if e == "email_exists" => HttpResponse::BadRequest().json(json!({
236 "error": "Email already exists"
237 })),
238 Err(e) => HttpResponse::InternalServerError().json(json!({
239 "error": format!("Failed to update user: {}", e)
240 })),
241 }
242}
243
244#[put("/users/{id}/activate")]
246pub async fn activate_user(
247 state: web::Data<AppState>,
248 user: AuthenticatedUser,
249 path: web::Path<Uuid>,
250) -> impl Responder {
251 if user.role != "superadmin" {
252 return HttpResponse::Forbidden().json(json!({
253 "error": "Only SuperAdmin can activate users"
254 }));
255 }
256
257 let user_id = path.into_inner();
258 match state.user_use_cases.activate(user_id).await {
259 Ok(Some(resp)) => HttpResponse::Ok().json(resp),
260 Ok(None) => HttpResponse::NotFound().json(json!({ "error": "User not found" })),
261 Err(e) => HttpResponse::InternalServerError().json(json!({
262 "error": format!("Failed to activate user: {}", e)
263 })),
264 }
265}
266
267#[put("/users/{id}/deactivate")]
269pub async fn deactivate_user(
270 state: web::Data<AppState>,
271 user: AuthenticatedUser,
272 path: web::Path<Uuid>,
273) -> impl Responder {
274 if user.role != "superadmin" {
275 return HttpResponse::Forbidden().json(json!({
276 "error": "Only SuperAdmin can deactivate users"
277 }));
278 }
279
280 let user_id = path.into_inner();
281 match state.user_use_cases.deactivate(user_id).await {
282 Ok(Some(resp)) => HttpResponse::Ok().json(resp),
283 Ok(None) => HttpResponse::NotFound().json(json!({ "error": "User not found" })),
284 Err(e) => HttpResponse::InternalServerError().json(json!({
285 "error": format!("Failed to deactivate user: {}", e)
286 })),
287 }
288}
289
290#[delete("/users/{id}")]
292pub async fn delete_user(
293 state: web::Data<AppState>,
294 user: AuthenticatedUser,
295 path: web::Path<Uuid>,
296) -> impl Responder {
297 if user.role != "superadmin" {
298 return HttpResponse::Forbidden().json(json!({
299 "error": "Only SuperAdmin can delete users"
300 }));
301 }
302
303 let user_id = path.into_inner();
304
305 if user.user_id == user_id {
306 return HttpResponse::BadRequest().json(json!({
307 "error": "Cannot delete your own account"
308 }));
309 }
310
311 match state.user_use_cases.delete(user_id).await {
312 Ok(true) => HttpResponse::Ok().json(json!({ "message": "User deleted successfully" })),
313 Ok(false) => HttpResponse::NotFound().json(json!({ "error": "User not found" })),
314 Err(e) => HttpResponse::InternalServerError().json(json!({
315 "error": format!("Failed to delete user: {}", e)
316 })),
317 }
318}
319
320fn normalize_roles(
325 roles: Option<Vec<RoleAssignmentRequest>>,
326 fallback_role: Option<String>,
327 fallback_org: Option<Uuid>,
328) -> Result<Vec<NormalizedRole>, HttpResponse> {
329 let mut entries = roles.unwrap_or_else(|| {
330 fallback_role
331 .map(|role| {
332 vec![RoleAssignmentRequest {
333 role,
334 organization_id: fallback_org,
335 is_primary: Some(true),
336 }]
337 })
338 .unwrap_or_default()
339 });
340
341 if entries.is_empty() {
342 return Err(HttpResponse::BadRequest().json(json!({
343 "error": "At least one role must be specified"
344 })));
345 }
346
347 let mut normalized = Vec::with_capacity(entries.len());
348 let mut seen = HashSet::new();
349 let mut primary_count = 0;
350
351 for entry in entries.drain(..) {
352 let RoleAssignmentRequest {
353 role,
354 organization_id,
355 is_primary,
356 } = entry;
357 let normalized_role = role.trim().to_lowercase();
358 if !ALLOWED_ROLES.contains(&normalized_role.as_str()) {
359 return Err(HttpResponse::BadRequest().json(json!({
360 "error": format!("Invalid role: {}", role)
361 })));
362 }
363
364 let mut organization_id = organization_id;
365 if normalized_role != "superadmin" {
366 if organization_id.is_none() {
367 return Err(HttpResponse::BadRequest().json(json!({
368 "error": format!("Organization is required for role {}", normalized_role)
369 })));
370 }
371 } else {
372 organization_id = None;
373 }
374
375 let is_primary = is_primary.unwrap_or(false);
376 if is_primary {
377 primary_count += 1;
378 if primary_count > 1 {
379 return Err(HttpResponse::BadRequest().json(json!({
380 "error": "Only one primary role can be specified"
381 })));
382 }
383 }
384
385 let key = (normalized_role.clone(), organization_id);
386 if !seen.insert(key) {
387 return Err(HttpResponse::BadRequest().json(json!({
388 "error": "Duplicate role assignment detected"
389 })));
390 }
391
392 normalized.push(NormalizedRole {
393 id: Uuid::new_v4(),
394 role: normalized_role,
395 organization_id,
396 is_primary,
397 });
398 }
399
400 if primary_count == 0 {
401 if let Some(first) = normalized.first_mut() {
402 first.is_primary = true;
403 }
404 }
405
406 normalized.sort_by_key(|r| std::cmp::Reverse(r.is_primary));
407 Ok(normalized)
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use crate::application::use_cases::user_use_cases::RoleResponse;
414 use actix_web::http::StatusCode;
415
416 fn normalize_primary_role(roles: &mut [RoleResponse]) {
417 if roles.is_empty() {
418 return;
419 }
420 if roles.iter().filter(|r| r.is_primary).count() == 0 {
421 roles[0].is_primary = true;
422 }
423 roles.sort_by_key(|r| std::cmp::Reverse(r.is_primary));
424 }
425
426 #[test]
427 fn normalize_roles_marks_first_as_primary_when_none_provided() {
428 let primary_org = Uuid::new_v4();
429 let secondary_org = Uuid::new_v4();
430 let input = vec![
431 RoleAssignmentRequest {
432 role: "syndic".to_string(),
433 organization_id: Some(primary_org),
434 is_primary: None,
435 },
436 RoleAssignmentRequest {
437 role: "accountant".to_string(),
438 organization_id: Some(secondary_org),
439 is_primary: Some(false),
440 },
441 ];
442
443 let normalized = normalize_roles(Some(input), None, None).expect("normalized roles");
444 assert_eq!(normalized.len(), 2);
445 assert!(
446 normalized.first().unwrap().is_primary,
447 "first role should become primary"
448 );
449 assert_eq!(
450 normalized.first().unwrap().organization_id,
451 Some(primary_org)
452 );
453 assert_eq!(normalized.first().unwrap().role, "syndic");
454 }
455
456 #[test]
457 fn normalize_roles_rejects_invalid_role() {
458 let res = normalize_roles(
459 Some(vec![RoleAssignmentRequest {
460 role: "invalid-role".to_string(),
461 organization_id: None,
462 is_primary: None,
463 }]),
464 None,
465 None,
466 );
467
468 let err = res.expect_err("invalid role should fail");
469 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
470 }
471
472 #[test]
473 fn normalize_roles_requires_org_for_non_superadmin() {
474 let res = normalize_roles(
475 Some(vec![RoleAssignmentRequest {
476 role: "syndic".to_string(),
477 organization_id: None,
478 is_primary: Some(true),
479 }]),
480 None,
481 None,
482 );
483
484 let err = res.expect_err("organization required");
485 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
486 }
487
488 #[test]
489 fn normalize_roles_uses_fallback_when_no_roles_provided() {
490 let fallback_org = Uuid::new_v4();
491 let roles = normalize_roles(None, Some("syndic".to_string()), Some(fallback_org))
492 .expect("fallback role");
493
494 assert_eq!(roles.len(), 1);
495 let role = roles.first().unwrap();
496 assert_eq!(role.role, "syndic");
497 assert_eq!(role.organization_id, Some(fallback_org));
498 assert!(role.is_primary);
499 }
500
501 #[test]
502 fn normalize_primary_role_sets_first_when_none_primary() {
503 let mut roles = vec![
504 RoleResponse {
505 id: Uuid::new_v4().to_string(),
506 role: "syndic".to_string(),
507 organization_id: None,
508 is_primary: false,
509 },
510 RoleResponse {
511 id: Uuid::new_v4().to_string(),
512 role: "accountant".to_string(),
513 organization_id: None,
514 is_primary: false,
515 },
516 ];
517 normalize_primary_role(&mut roles);
518 assert!(roles[0].is_primary);
519 }
520}