Skip to main content

koprogo_api/infrastructure/web/handlers/
user_handlers.rs

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>,          // backward compatibility
29    pub organization_id: Option<Uuid>, // backward compatibility
30}
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>,          // backward compatibility
39    pub organization_id: Option<Uuid>, // backward compatibility
40    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/// Le paramètre de recherche de `GET /users`, à côté de `PageRequest`.
62///
63/// Séparé plutôt que fondu dans `PageRequest` : celui-ci est partagé par les
64/// neuf routes paginées du dépôt, et toutes ne se cherchent pas.
65#[derive(serde::Deserialize)]
66pub struct RechercheUtilisateur {
67    pub q: Option<String>,
68    /// Filtre de rôle. `all` et la chaîne vide valent « pas de filtre ».
69    ///
70    /// Porté par le SERVEUR, et pas par l'écran : filtrer une page de
71    /// cinquante côté client montrerait « les syndics parmi les cinquante
72    /// premiers » en les présentant comme « les syndics ».
73    pub role: Option<String>,
74}
75
76/// GET /api/v1/users
77/// Une PAGE d'utilisateurs, filtrable (SuperAdmin only).
78///
79/// ── Ce que cette route faisait, et ce que ça coûtait ──────────────────────
80///
81/// Elle appelait `list_all()` : aucune borne, aucun `PageRequest`. Mesuré le
82/// 2026-09-18 sur la recette, avec le même jeton et à la suite :
83///
84/// ```text
85/// GET /organizations   6 190 octets    18,8 ms   (paginée la veille)
86/// GET /users       2 399 187 octets   667,0 ms   (table entière, 4 120 lignes)
87/// ```
88///
89/// Un facteur 387 en volume. Le pire est que `OrganizationDetail.svelte:86`
90/// demandait déjà `?page=1&per_page=200` — le composant croyait paginer, le
91/// serveur ignorait les deux paramètres. C'est le motif exact de #943, et il
92/// vivait encore ici.
93///
94/// Sur un vrai parc, un syndic de quelques milliers de copropriétaires
95/// recevait 2,4 Mo à chaque ouverture de l'écran. Sur un téléphone en 4G, ce
96/// n'est pas lent : c'est inutilisable.
97///
98/// La clé `data` ne bouge pas — les appelants qui la lisent continuent de
99/// fonctionner — et `pagination` s'y ajoute, avec le total. C'est ce total
100/// qui permet à l'appelant de savoir qu'il ne voit qu'un fragment, au lieu de
101/// le deviner (#953).
102#[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/// GET /api/v1/organizations/{organization_id}/users — list users for an
139/// organization (syndic/accountant own org, superadmin any org).
140#[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 /api/v1/users — create user (SuperAdmin only)
176#[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())) // user_id filled by use case
225        .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 /api/v1/users/{id} — update user (SuperAdmin only)
253#[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 /api/v1/users/{id}/activate — activate user (SuperAdmin only)
343#[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 /api/v1/users/{id}/deactivate — deactivate user (SuperAdmin only)
366#[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 /api/v1/users/{id} — delete user (SuperAdmin only)
389#[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
418// ---------------------------------------------------------------------------
419// Pure helper functions — no DB access, kept here because they have tests
420// ---------------------------------------------------------------------------
421
422fn 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}