1use crate::application::dto::{PageRequest, PageResponse};
2use crate::domain::entities::UserRole;
3use crate::domain::entities::UserRoleAssignment;
4use crate::infrastructure::web::{AppState, AuthenticatedUser};
5use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
6use bcrypt::{hash, DEFAULT_COST};
7use serde::Deserialize;
8use serde_json::json;
9use std::collections::HashSet;
10use uuid::Uuid;
11
12const ALLOWED_ROLES: [&str; 4] = ["superadmin", "syndic", "accountant", "owner"];
13
14#[derive(Deserialize, Clone)]
15pub struct RoleAssignmentRequest {
16 pub role: String,
17 pub organization_id: Option<Uuid>,
18 pub is_primary: Option<bool>,
19}
20
21#[derive(Deserialize)]
22pub struct CreateUserRequest {
23 pub email: String,
24 pub password: String,
25 pub first_name: String,
26 pub last_name: String,
27 pub roles: Option<Vec<RoleAssignmentRequest>>,
28 pub role: Option<String>, pub organization_id: Option<Uuid>, }
31
32#[derive(Deserialize)]
33pub struct UpdateUserRequest {
34 pub email: String,
35 pub first_name: String,
36 pub last_name: String,
37 pub roles: Option<Vec<RoleAssignmentRequest>>,
38 pub role: Option<String>, pub organization_id: Option<Uuid>, pub password: Option<String>,
41}
42
43#[derive(Clone, Debug)]
44struct NormalizedRole {
45 id: Uuid,
46 role: String,
47 organization_id: Option<Uuid>,
48 is_primary: bool,
49}
50
51impl NormalizedRole {
52 fn to_assignment(&self, user_id: Uuid) -> UserRoleAssignment {
53 let domain_role = self.role.parse::<UserRole>().expect("already validated");
54 let mut a =
55 UserRoleAssignment::new(user_id, domain_role, self.organization_id, self.is_primary);
56 a.id = self.id;
57 a
58 }
59}
60
61#[derive(serde::Deserialize)]
66pub struct RechercheUtilisateur {
67 pub q: Option<String>,
68 pub role: Option<String>,
74}
75
76#[get("/users")]
103pub async fn list_users(
104 state: web::Data<AppState>,
105 user: AuthenticatedUser,
106 page_request: web::Query<PageRequest>,
107 recherche: web::Query<RechercheUtilisateur>,
108) -> impl Responder {
109 if !user.is_superadmin() {
110 return HttpResponse::Forbidden().json(json!({
111 "error": "Only SuperAdmin can access all users"
112 }));
113 }
114
115 let per_page = page_request.per_page.max(1);
116 let page = page_request.page.max(1);
117 let offset = (page - 1) * per_page;
118
119 match state
120 .user_use_cases
121 .list_page(
122 recherche.q.clone(),
123 recherche.role.clone(),
124 per_page,
125 offset,
126 )
127 .await
128 {
129 Ok((users, total)) => {
130 HttpResponse::Ok().json(PageResponse::new(users, page, per_page, total))
131 }
132 Err(e) => HttpResponse::InternalServerError().json(json!({
133 "error": format!("Failed to fetch users: {}", e)
134 })),
135 }
136}
137
138#[utoipa::path(
141 get,
142 path = "/organizations/{organization_id}/users",
143 tag = "Users",
144 summary = "List users for an organization (syndic/accountant own org, superadmin any org)",
145 params(
146 ("organization_id" = Uuid, Path, description = "Organization ID")
147 ),
148 responses(
149 (status = 200, description = "List of users"),
150 (status = 403, description = "Access denied — resource belongs to another organization"),
151 ),
152 security(("bearer_auth" = []))
153)]
154#[get("/organizations/{organization_id}/users")]
155pub async fn list_organization_users(
156 state: web::Data<AppState>,
157 user: AuthenticatedUser,
158 organization_id: web::Path<Uuid>,
159) -> impl Responder {
160 if let Err(e) = user.verify_org_access(*organization_id) {
161 return HttpResponse::Forbidden().json(json!({"error": e}));
162 }
163 match state
164 .user_use_cases
165 .list_by_organization(*organization_id)
166 .await
167 {
168 Ok(users) => HttpResponse::Ok().json(json!({ "data": users })),
169 Err(e) => HttpResponse::InternalServerError().json(json!({
170 "error": format!("Failed to fetch users: {}", e)
171 })),
172 }
173}
174
175#[post("/users")]
177pub async fn create_user(
178 state: web::Data<AppState>,
179 user: AuthenticatedUser,
180 req: web::Json<CreateUserRequest>,
181) -> impl Responder {
182 if !user.is_superadmin() {
183 return HttpResponse::Forbidden().json(json!({
184 "error": "Only SuperAdmin can create users"
185 }));
186 }
187
188 if !req.email.contains('@') {
189 return HttpResponse::BadRequest().json(json!({ "error": "Invalid email format" }));
190 }
191 if req.first_name.trim().len() < 2 || req.last_name.trim().len() < 2 {
192 return HttpResponse::BadRequest().json(json!({
193 "error": "First and last names must be at least 2 characters"
194 }));
195 }
196 if req.password.trim().len() < 6 {
197 return HttpResponse::BadRequest().json(json!({
198 "error": "Password must be at least 6 characters"
199 }));
200 }
201
202 let roles = match normalize_roles(req.roles.clone(), req.role.clone(), req.organization_id) {
203 Ok(r) => r,
204 Err(resp) => return *resp,
205 };
206
207 let primary = roles
208 .iter()
209 .find(|r| r.is_primary)
210 .cloned()
211 .expect("normalized roles always have a primary");
212
213 let hashed_password = match hash(req.password.trim(), DEFAULT_COST) {
214 Ok(h) => h,
215 Err(e) => {
216 return HttpResponse::InternalServerError().json(json!({
217 "error": format!("Failed to hash password: {}", e)
218 }))
219 }
220 };
221
222 let assignments: Vec<UserRoleAssignment> = roles
223 .iter()
224 .map(|r| r.to_assignment(Uuid::nil())) .collect();
226
227 let primary_role = primary.role.parse::<UserRole>().expect("already validated");
228
229 match state
230 .user_use_cases
231 .create(
232 req.email.trim().to_lowercase(),
233 hashed_password,
234 req.first_name.trim().to_string(),
235 req.last_name.trim().to_string(),
236 primary_role,
237 primary.organization_id,
238 assignments,
239 )
240 .await
241 {
242 Ok(resp) => HttpResponse::Created().json(resp),
243 Err(e) if e == "email_exists" => HttpResponse::BadRequest().json(json!({
244 "error": "Email already exists"
245 })),
246 Err(e) => HttpResponse::InternalServerError().json(json!({
247 "error": format!("Failed to create user: {}", e)
248 })),
249 }
250}
251
252#[put("/users/{id}")]
254pub async fn update_user(
255 state: web::Data<AppState>,
256 user: AuthenticatedUser,
257 path: web::Path<Uuid>,
258 req: web::Json<UpdateUserRequest>,
259) -> impl Responder {
260 if !user.is_superadmin() {
261 return HttpResponse::Forbidden().json(json!({
262 "error": "Only SuperAdmin can update users"
263 }));
264 }
265
266 if !req.email.contains('@') {
267 return HttpResponse::BadRequest().json(json!({ "error": "Invalid email format" }));
268 }
269 if req.first_name.trim().len() < 2 || req.last_name.trim().len() < 2 {
270 return HttpResponse::BadRequest().json(json!({
271 "error": "First and last names must be at least 2 characters"
272 }));
273 }
274 if let Some(password) = &req.password {
275 if !password.trim().is_empty() && password.trim().len() < 6 {
276 return HttpResponse::BadRequest().json(json!({
277 "error": "Password must be at least 6 characters"
278 }));
279 }
280 }
281
282 let roles = match normalize_roles(req.roles.clone(), req.role.clone(), req.organization_id) {
283 Ok(r) => r,
284 Err(resp) => return *resp,
285 };
286
287 let primary = roles
288 .iter()
289 .find(|r| r.is_primary)
290 .cloned()
291 .expect("normalized roles always have a primary");
292
293 let user_id = path.into_inner();
294
295 let password_hash = if let Some(pw) = &req.password {
296 if !pw.trim().is_empty() {
297 match hash(pw.trim(), DEFAULT_COST) {
298 Ok(h) => Some(h),
299 Err(e) => {
300 return HttpResponse::InternalServerError().json(json!({
301 "error": format!("Failed to hash password: {}", e)
302 }))
303 }
304 }
305 } else {
306 None
307 }
308 } else {
309 None
310 };
311
312 let assignments: Vec<UserRoleAssignment> =
313 roles.iter().map(|r| r.to_assignment(user_id)).collect();
314
315 let primary_role = primary.role.parse::<UserRole>().expect("already validated");
316
317 match state
318 .user_use_cases
319 .update(
320 user_id,
321 req.email.trim().to_lowercase(),
322 req.first_name.trim().to_string(),
323 req.last_name.trim().to_string(),
324 primary_role,
325 primary.organization_id,
326 password_hash,
327 assignments,
328 )
329 .await
330 {
331 Ok(Some(resp)) => HttpResponse::Ok().json(resp),
332 Ok(None) => HttpResponse::NotFound().json(json!({ "error": "User not found" })),
333 Err(e) if e == "email_exists" => HttpResponse::BadRequest().json(json!({
334 "error": "Email already exists"
335 })),
336 Err(e) => HttpResponse::InternalServerError().json(json!({
337 "error": format!("Failed to update user: {}", e)
338 })),
339 }
340}
341
342#[put("/users/{id}/activate")]
344pub async fn activate_user(
345 state: web::Data<AppState>,
346 user: AuthenticatedUser,
347 path: web::Path<Uuid>,
348) -> impl Responder {
349 if !user.is_superadmin() {
350 return HttpResponse::Forbidden().json(json!({
351 "error": "Only SuperAdmin can activate users"
352 }));
353 }
354
355 let user_id = path.into_inner();
356 match state.user_use_cases.activate(user_id).await {
357 Ok(Some(resp)) => HttpResponse::Ok().json(resp),
358 Ok(None) => HttpResponse::NotFound().json(json!({ "error": "User not found" })),
359 Err(e) => HttpResponse::InternalServerError().json(json!({
360 "error": format!("Failed to activate user: {}", e)
361 })),
362 }
363}
364
365#[put("/users/{id}/deactivate")]
367pub async fn deactivate_user(
368 state: web::Data<AppState>,
369 user: AuthenticatedUser,
370 path: web::Path<Uuid>,
371) -> impl Responder {
372 if !user.is_superadmin() {
373 return HttpResponse::Forbidden().json(json!({
374 "error": "Only SuperAdmin can deactivate users"
375 }));
376 }
377
378 let user_id = path.into_inner();
379 match state.user_use_cases.deactivate(user_id).await {
380 Ok(Some(resp)) => HttpResponse::Ok().json(resp),
381 Ok(None) => HttpResponse::NotFound().json(json!({ "error": "User not found" })),
382 Err(e) => HttpResponse::InternalServerError().json(json!({
383 "error": format!("Failed to deactivate user: {}", e)
384 })),
385 }
386}
387
388#[delete("/users/{id}")]
390pub async fn delete_user(
391 state: web::Data<AppState>,
392 user: AuthenticatedUser,
393 path: web::Path<Uuid>,
394) -> impl Responder {
395 if !user.is_superadmin() {
396 return HttpResponse::Forbidden().json(json!({
397 "error": "Only SuperAdmin can delete users"
398 }));
399 }
400
401 let user_id = path.into_inner();
402
403 if user.user_id == user_id {
404 return HttpResponse::BadRequest().json(json!({
405 "error": "Cannot delete your own account"
406 }));
407 }
408
409 match state.user_use_cases.delete(user_id).await {
410 Ok(true) => HttpResponse::Ok().json(json!({ "message": "User deleted successfully" })),
411 Ok(false) => HttpResponse::NotFound().json(json!({ "error": "User not found" })),
412 Err(e) => HttpResponse::InternalServerError().json(json!({
413 "error": format!("Failed to delete user: {}", e)
414 })),
415 }
416}
417
418fn normalize_roles(
423 roles: Option<Vec<RoleAssignmentRequest>>,
424 fallback_role: Option<String>,
425 fallback_org: Option<Uuid>,
426) -> Result<Vec<NormalizedRole>, Box<HttpResponse>> {
427 let mut entries = roles.unwrap_or_else(|| {
428 fallback_role
429 .map(|role| {
430 vec![RoleAssignmentRequest {
431 role,
432 organization_id: fallback_org,
433 is_primary: Some(true),
434 }]
435 })
436 .unwrap_or_default()
437 });
438
439 if entries.is_empty() {
440 return Err(Box::new(HttpResponse::BadRequest().json(json!({
441 "error": "At least one role must be specified"
442 }))));
443 }
444
445 let mut normalized = Vec::with_capacity(entries.len());
446 let mut seen = HashSet::new();
447 let mut primary_count = 0;
448
449 for entry in entries.drain(..) {
450 let RoleAssignmentRequest {
451 role,
452 organization_id,
453 is_primary,
454 } = entry;
455 let normalized_role = role.trim().to_lowercase();
456 if !ALLOWED_ROLES.contains(&normalized_role.as_str()) {
457 return Err(Box::new(HttpResponse::BadRequest().json(json!({
458 "error": format!("Invalid role: {}", role)
459 }))));
460 }
461
462 let mut organization_id = organization_id;
463 if normalized_role != "superadmin" {
464 if organization_id.is_none() {
465 return Err(Box::new(HttpResponse::BadRequest().json(json!({
466 "error": format!("Organization is required for role {}", normalized_role)
467 }))));
468 }
469 } else {
470 organization_id = None;
471 }
472
473 let is_primary = is_primary.unwrap_or(false);
474 if is_primary {
475 primary_count += 1;
476 if primary_count > 1 {
477 return Err(Box::new(HttpResponse::BadRequest().json(json!({
478 "error": "Only one primary role can be specified"
479 }))));
480 }
481 }
482
483 let key = (normalized_role.clone(), organization_id);
484 if !seen.insert(key) {
485 return Err(Box::new(HttpResponse::BadRequest().json(json!({
486 "error": "Duplicate role assignment detected"
487 }))));
488 }
489
490 normalized.push(NormalizedRole {
491 id: Uuid::new_v4(),
492 role: normalized_role,
493 organization_id,
494 is_primary,
495 });
496 }
497
498 if primary_count == 0 {
499 if let Some(first) = normalized.first_mut() {
500 first.is_primary = true;
501 }
502 }
503
504 normalized.sort_by_key(|r| std::cmp::Reverse(r.is_primary));
505 Ok(normalized)
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511 use crate::application::use_cases::user_use_cases::RoleResponse;
512 use actix_web::http::StatusCode;
513
514 fn normalize_primary_role(roles: &mut [RoleResponse]) {
515 if roles.is_empty() {
516 return;
517 }
518 if roles.iter().filter(|r| r.is_primary).count() == 0 {
519 roles[0].is_primary = true;
520 }
521 roles.sort_by_key(|r| std::cmp::Reverse(r.is_primary));
522 }
523
524 #[test]
525 fn normalize_roles_marks_first_as_primary_when_none_provided() {
526 let primary_org = Uuid::new_v4();
527 let secondary_org = Uuid::new_v4();
528 let input = vec![
529 RoleAssignmentRequest {
530 role: "syndic".to_string(),
531 organization_id: Some(primary_org),
532 is_primary: None,
533 },
534 RoleAssignmentRequest {
535 role: "accountant".to_string(),
536 organization_id: Some(secondary_org),
537 is_primary: Some(false),
538 },
539 ];
540
541 let normalized = normalize_roles(Some(input), None, None).expect("normalized roles");
542 assert_eq!(normalized.len(), 2);
543 assert!(
544 normalized.first().unwrap().is_primary,
545 "first role should become primary"
546 );
547 assert_eq!(
548 normalized.first().unwrap().organization_id,
549 Some(primary_org)
550 );
551 assert_eq!(normalized.first().unwrap().role, "syndic");
552 }
553
554 #[test]
555 fn normalize_roles_rejects_invalid_role() {
556 let res = normalize_roles(
557 Some(vec![RoleAssignmentRequest {
558 role: "invalid-role".to_string(),
559 organization_id: None,
560 is_primary: None,
561 }]),
562 None,
563 None,
564 );
565
566 let err = res.expect_err("invalid role should fail");
567 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
568 }
569
570 #[test]
571 fn normalize_roles_requires_org_for_non_superadmin() {
572 let res = normalize_roles(
573 Some(vec![RoleAssignmentRequest {
574 role: "syndic".to_string(),
575 organization_id: None,
576 is_primary: Some(true),
577 }]),
578 None,
579 None,
580 );
581
582 let err = res.expect_err("organization required");
583 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
584 }
585
586 #[test]
587 fn normalize_roles_uses_fallback_when_no_roles_provided() {
588 let fallback_org = Uuid::new_v4();
589 let roles = normalize_roles(None, Some("syndic".to_string()), Some(fallback_org))
590 .expect("fallback role");
591
592 assert_eq!(roles.len(), 1);
593 let role = roles.first().unwrap();
594 assert_eq!(role.role, "syndic");
595 assert_eq!(role.organization_id, Some(fallback_org));
596 assert!(role.is_primary);
597 }
598
599 #[test]
600 fn normalize_primary_role_sets_first_when_none_primary() {
601 let mut roles = vec![
602 RoleResponse {
603 id: Uuid::new_v4().to_string(),
604 role: "syndic".to_string(),
605 organization_id: None,
606 is_primary: false,
607 },
608 RoleResponse {
609 id: Uuid::new_v4().to_string(),
610 role: "accountant".to_string(),
611 organization_id: None,
612 is_primary: false,
613 },
614 ];
615 normalize_primary_role(&mut roles);
616 assert!(roles[0].is_primary);
617 }
618}