koprogo_api/infrastructure/web/handlers/
user_handlers.rs

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>,          // backward compatibility
28    pub organization_id: Option<Uuid>, // backward compatibility
29}
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>,          // backward compatibility
38    pub organization_id: Option<Uuid>, // backward compatibility
39    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 /api/v1/users — list all users (SuperAdmin only)
61#[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 /api/v1/users — create user (SuperAdmin only)
78#[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())) // user_id filled by use case
127        .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 /api/v1/users/{id} — update user (SuperAdmin only)
155#[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 /api/v1/users/{id}/activate — activate user (SuperAdmin only)
245#[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 /api/v1/users/{id}/deactivate — deactivate user (SuperAdmin only)
268#[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 /api/v1/users/{id} — delete user (SuperAdmin only)
291#[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
320// ---------------------------------------------------------------------------
321// Pure helper functions — no DB access, kept here because they have tests
322// ---------------------------------------------------------------------------
323
324fn 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}