koprogo_api/infrastructure/web/handlers/
user_handlers.rs

1use crate::infrastructure::web::{AppState, AuthenticatedUser};
2use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
3use bcrypt::{hash, DEFAULT_COST};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use serde_json::json;
7use sqlx::{Executor, Postgres, Transaction};
8use std::collections::{HashMap, HashSet};
9use uuid::Uuid;
10
11const ALLOWED_ROLES: [&str; 4] = ["superadmin", "syndic", "accountant", "owner"];
12
13#[derive(Serialize, Clone)]
14pub struct RoleResponse {
15    pub id: String,
16    pub role: String,
17    pub organization_id: Option<String>,
18    pub is_primary: bool,
19}
20
21#[derive(Serialize, Clone)]
22pub struct UserResponse {
23    pub id: String,
24    pub email: String,
25    pub first_name: String,
26    pub last_name: String,
27    pub role: String,
28    pub organization_id: Option<String>,
29    pub is_active: bool,
30    pub created_at: DateTime<Utc>,
31    pub roles: Vec<RoleResponse>,
32    pub active_role: Option<RoleResponse>,
33}
34
35#[derive(Deserialize, Clone)]
36pub struct RoleAssignmentRequest {
37    pub role: String,
38    pub organization_id: Option<Uuid>,
39    pub is_primary: Option<bool>,
40}
41
42#[derive(Deserialize)]
43pub struct CreateUserRequest {
44    pub email: String,
45    pub password: String,
46    pub first_name: String,
47    pub last_name: String,
48    pub roles: Option<Vec<RoleAssignmentRequest>>,
49    pub role: Option<String>,          // backward compatibility
50    pub organization_id: Option<Uuid>, // backward compatibility
51}
52
53#[derive(Deserialize)]
54pub struct UpdateUserRequest {
55    pub email: String,
56    pub first_name: String,
57    pub last_name: String,
58    pub roles: Option<Vec<RoleAssignmentRequest>>,
59    pub role: Option<String>,          // backward compatibility
60    pub organization_id: Option<Uuid>, // backward compatibility
61    pub password: Option<String>,
62}
63
64#[derive(Clone, Debug)]
65struct NormalizedRoleAssignment {
66    id: Uuid,
67    role: String,
68    organization_id: Option<Uuid>,
69    is_primary: bool,
70}
71
72/// List all users (SuperAdmin only)
73#[get("/users")]
74pub async fn list_users(state: web::Data<AppState>, user: AuthenticatedUser) -> impl Responder {
75    if user.role != "superadmin" {
76        return HttpResponse::Forbidden().json(json!({
77            "error": "Only SuperAdmin can access all users"
78        }));
79    }
80
81    let rows = match sqlx::query!(
82        r#"
83        SELECT id, email, first_name, last_name, role, organization_id, is_active, created_at
84        FROM users
85        ORDER BY created_at DESC
86        "#
87    )
88    .fetch_all(&state.pool)
89    .await
90    {
91        Ok(rows) => rows,
92        Err(e) => {
93            return HttpResponse::InternalServerError().json(json!({
94                "error": format!("Failed to fetch users: {}", e)
95            }))
96        }
97    };
98
99    let user_ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
100    let roles_map = match load_roles_for_users(&state.pool, &user_ids).await {
101        Ok(map) => map,
102        Err(e) => {
103            return HttpResponse::InternalServerError().json(json!({
104                "error": format!("Failed to fetch user roles: {}", e)
105            }))
106        }
107    };
108
109    let mut users: Vec<UserResponse> = Vec::with_capacity(user_ids.len());
110    for row in rows {
111        let fallback_role = row.role.clone();
112        let fallback_org = row.organization_id;
113        let mut roles = roles_map
114            .get(&row.id)
115            .cloned()
116            .unwrap_or_else(|| vec![fallback_role_response(fallback_role.clone(), fallback_org)]);
117
118        normalize_primary_role(&mut roles);
119        let active_role = roles
120            .iter()
121            .find(|role| role.is_primary)
122            .cloned()
123            .or_else(|| roles.first().cloned());
124
125        users.push(UserResponse {
126            id: row.id.to_string(),
127            email: row.email,
128            first_name: row.first_name,
129            last_name: row.last_name,
130            role: active_role
131                .as_ref()
132                .map(|r| r.role.clone())
133                .unwrap_or(fallback_role),
134            organization_id: active_role
135                .as_ref()
136                .and_then(|r| r.organization_id.clone())
137                .or_else(|| fallback_org.map(|id| id.to_string())),
138            is_active: row.is_active,
139            created_at: row.created_at,
140            roles,
141            active_role,
142        });
143    }
144
145    HttpResponse::Ok().json(json!({ "data": users }))
146}
147
148/// Create user (SuperAdmin only)
149#[post("/users")]
150pub async fn create_user(
151    state: web::Data<AppState>,
152    user: AuthenticatedUser,
153    req: web::Json<CreateUserRequest>,
154) -> impl Responder {
155    if user.role != "superadmin" {
156        return HttpResponse::Forbidden().json(json!({
157            "error": "Only SuperAdmin can create users"
158        }));
159    }
160
161    if !req.email.contains('@') {
162        return HttpResponse::BadRequest().json(json!({
163            "error": "Invalid email format"
164        }));
165    }
166
167    if req.first_name.trim().len() < 2 || req.last_name.trim().len() < 2 {
168        return HttpResponse::BadRequest().json(json!({
169            "error": "First and last names must be at least 2 characters"
170        }));
171    }
172
173    if req.password.trim().len() < 6 {
174        return HttpResponse::BadRequest().json(json!({
175            "error": "Password must be at least 6 characters"
176        }));
177    }
178
179    let roles = match normalize_roles(req.roles.clone(), req.role.clone(), req.organization_id) {
180        Ok(roles) => roles,
181        Err(resp) => return resp,
182    };
183
184    let primary_role = roles
185        .iter()
186        .find(|role| role.is_primary)
187        .cloned()
188        .expect("normalized roles always have a primary role");
189
190    let hashed_password = match hash(req.password.trim(), DEFAULT_COST) {
191        Ok(hash) => hash,
192        Err(e) => {
193            return HttpResponse::InternalServerError().json(json!({
194                "error": format!("Failed to hash password: {}", e)
195            }))
196        }
197    };
198
199    let mut tx = match state.pool.begin().await {
200        Ok(tx) => tx,
201        Err(e) => {
202            return HttpResponse::InternalServerError().json(json!({
203                "error": format!("Failed to begin transaction: {}", e)
204            }))
205        }
206    };
207
208    let user_row = match sqlx::query!(
209        r#"
210        INSERT INTO users (id, email, password_hash, first_name, last_name, role, organization_id, is_active, created_at, updated_at)
211        VALUES ($1, $2, $3, $4, $5, $6, $7, true, NOW(), NOW())
212        RETURNING id
213        "#,
214        Uuid::new_v4(),
215        req.email.trim().to_lowercase(),
216        hashed_password,
217        req.first_name.trim(),
218        req.last_name.trim(),
219        primary_role.role.clone(),
220        primary_role.organization_id
221    )
222    .fetch_one(&mut *tx)
223    .await
224    {
225        Ok(row) => row,
226        Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => {
227            return HttpResponse::BadRequest().json(json!({
228                "error": "Email already exists"
229            }))
230        }
231        Err(e) => {
232            return HttpResponse::InternalServerError().json(json!({
233                "error": format!("Failed to create user: {}", e)
234            }))
235        }
236    };
237
238    if let Err(e) = replace_user_roles(&mut tx, user_row.id, &roles).await {
239        return HttpResponse::InternalServerError().json(json!({
240            "error": format!("Failed to assign roles: {}", e)
241        }));
242    }
243
244    if let Err(e) = tx.commit().await {
245        return HttpResponse::InternalServerError().json(json!({
246            "error": format!("Failed to commit transaction: {}", e)
247        }));
248    }
249
250    match load_user_response(&state.pool, user_row.id).await {
251        Ok(response) => HttpResponse::Created().json(response),
252        Err(e) => HttpResponse::InternalServerError().json(json!({
253            "error": format!("Failed to load created user: {}", e)
254        })),
255    }
256}
257
258/// Update user (SuperAdmin only)
259#[put("/users/{id}")]
260pub async fn update_user(
261    state: web::Data<AppState>,
262    user: AuthenticatedUser,
263    path: web::Path<Uuid>,
264    req: web::Json<UpdateUserRequest>,
265) -> impl Responder {
266    if user.role != "superadmin" {
267        return HttpResponse::Forbidden().json(json!({
268            "error": "Only SuperAdmin can update users"
269        }));
270    }
271
272    if !req.email.contains('@') {
273        return HttpResponse::BadRequest().json(json!({
274            "error": "Invalid email format"
275        }));
276    }
277
278    if req.first_name.trim().len() < 2 || req.last_name.trim().len() < 2 {
279        return HttpResponse::BadRequest().json(json!({
280            "error": "First and last names must be at least 2 characters"
281        }));
282    }
283
284    if let Some(password) = &req.password {
285        if !password.trim().is_empty() && password.trim().len() < 6 {
286            return HttpResponse::BadRequest().json(json!({
287                "error": "Password must be at least 6 characters"
288            }));
289        }
290    }
291
292    let roles = match normalize_roles(req.roles.clone(), req.role.clone(), req.organization_id) {
293        Ok(roles) => roles,
294        Err(resp) => return resp,
295    };
296
297    let primary_role = roles
298        .iter()
299        .find(|role| role.is_primary)
300        .cloned()
301        .expect("normalized roles always have a primary role");
302
303    let user_id = path.into_inner();
304
305    let mut tx = match state.pool.begin().await {
306        Ok(tx) => tx,
307        Err(e) => {
308            return HttpResponse::InternalServerError().json(json!({
309                "error": format!("Failed to begin transaction: {}", e)
310            }))
311        }
312    };
313
314    if let Some(password) = &req.password {
315        if !password.trim().is_empty() {
316            let hashed = match hash(password.trim(), DEFAULT_COST) {
317                Ok(hash) => hash,
318                Err(e) => {
319                    return HttpResponse::InternalServerError().json(json!({
320                        "error": format!("Failed to hash password: {}", e)
321                    }));
322                }
323            };
324
325            if let Err(e) = tx
326                .execute(sqlx::query!(
327                    r#"
328                    UPDATE users
329                    SET password_hash = $1, updated_at = NOW()
330                    WHERE id = $2
331                    "#,
332                    hashed,
333                    user_id
334                ))
335                .await
336            {
337                return HttpResponse::InternalServerError().json(json!({
338                    "error": format!("Failed to update password: {}", e)
339                }));
340            }
341        }
342    }
343
344    let updated = match sqlx::query!(
345        r#"
346        UPDATE users
347        SET email = $1,
348            first_name = $2,
349            last_name = $3,
350            role = $4,
351            organization_id = $5,
352            updated_at = NOW()
353        WHERE id = $6
354        RETURNING id
355        "#,
356        req.email.trim().to_lowercase(),
357        req.first_name.trim(),
358        req.last_name.trim(),
359        primary_role.role.clone(),
360        primary_role.organization_id,
361        user_id
362    )
363    .fetch_optional(&mut *tx)
364    .await
365    {
366        Ok(row) => row,
367        Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => {
368            return HttpResponse::BadRequest().json(json!({
369                "error": "Email already exists"
370            }))
371        }
372        Err(e) => {
373            return HttpResponse::InternalServerError().json(json!({
374                "error": format!("Failed to update user: {}", e)
375            }))
376        }
377    };
378
379    if updated.is_none() {
380        return HttpResponse::NotFound().json(json!({
381            "error": "User not found"
382        }));
383    }
384
385    if let Err(e) = replace_user_roles(&mut tx, user_id, &roles).await {
386        return HttpResponse::InternalServerError().json(json!({
387            "error": format!("Failed to update user roles: {}", e)
388        }));
389    }
390
391    if let Err(e) = tx.commit().await {
392        return HttpResponse::InternalServerError().json(json!({
393            "error": format!("Failed to commit transaction: {}", e)
394        }));
395    }
396
397    match load_user_response(&state.pool, user_id).await {
398        Ok(response) => HttpResponse::Ok().json(response),
399        Err(e) => HttpResponse::InternalServerError().json(json!({
400            "error": format!("Failed to load updated user: {}", e)
401        })),
402    }
403}
404
405/// Activate user (SuperAdmin only)
406#[put("/users/{id}/activate")]
407pub async fn activate_user(
408    state: web::Data<AppState>,
409    user: AuthenticatedUser,
410    path: web::Path<Uuid>,
411) -> impl Responder {
412    if user.role != "superadmin" {
413        return HttpResponse::Forbidden().json(json!({
414            "error": "Only SuperAdmin can activate users"
415        }));
416    }
417
418    let user_id = path.into_inner();
419
420    let updated = sqlx::query!(
421        r#"
422        UPDATE users
423        SET is_active = true, updated_at = NOW()
424        WHERE id = $1
425        RETURNING id
426        "#,
427        user_id
428    )
429    .fetch_optional(&state.pool)
430    .await;
431
432    match updated {
433        Ok(Some(_)) => match load_user_response(&state.pool, user_id).await {
434            Ok(response) => HttpResponse::Ok().json(response),
435            Err(e) => HttpResponse::InternalServerError().json(json!({
436                "error": format!("Failed to load user: {}", e)
437            })),
438        },
439        Ok(None) => HttpResponse::NotFound().json(json!({
440            "error": "User not found"
441        })),
442        Err(e) => HttpResponse::InternalServerError().json(json!({
443            "error": format!("Failed to activate user: {}", e)
444        })),
445    }
446}
447
448/// Deactivate user (SuperAdmin only)
449#[put("/users/{id}/deactivate")]
450pub async fn deactivate_user(
451    state: web::Data<AppState>,
452    user: AuthenticatedUser,
453    path: web::Path<Uuid>,
454) -> impl Responder {
455    if user.role != "superadmin" {
456        return HttpResponse::Forbidden().json(json!({
457            "error": "Only SuperAdmin can deactivate users"
458        }));
459    }
460
461    let user_id = path.into_inner();
462
463    let updated = sqlx::query!(
464        r#"
465        UPDATE users
466        SET is_active = false, updated_at = NOW()
467        WHERE id = $1
468        RETURNING id
469        "#,
470        user_id
471    )
472    .fetch_optional(&state.pool)
473    .await;
474
475    match updated {
476        Ok(Some(_)) => match load_user_response(&state.pool, user_id).await {
477            Ok(response) => HttpResponse::Ok().json(response),
478            Err(e) => HttpResponse::InternalServerError().json(json!({
479                "error": format!("Failed to load user: {}", e)
480            })),
481        },
482        Ok(None) => HttpResponse::NotFound().json(json!({
483            "error": "User not found"
484        })),
485        Err(e) => HttpResponse::InternalServerError().json(json!({
486            "error": format!("Failed to deactivate user: {}", e)
487        })),
488    }
489}
490
491/// Delete user (SuperAdmin only)
492#[delete("/users/{id}")]
493pub async fn delete_user(
494    state: web::Data<AppState>,
495    user: AuthenticatedUser,
496    path: web::Path<Uuid>,
497) -> impl Responder {
498    if user.role != "superadmin" {
499        return HttpResponse::Forbidden().json(json!({
500            "error": "Only SuperAdmin can delete users"
501        }));
502    }
503
504    let user_id = path.into_inner();
505
506    if user.user_id == user_id {
507        return HttpResponse::BadRequest().json(json!({
508            "error": "Cannot delete your own account"
509        }));
510    }
511
512    match sqlx::query!(
513        r#"
514        DELETE FROM users
515        WHERE id = $1
516        "#,
517        user_id
518    )
519    .execute(&state.pool)
520    .await
521    {
522        Ok(result) => {
523            if result.rows_affected() == 0 {
524                HttpResponse::NotFound().json(json!({
525                    "error": "User not found"
526                }))
527            } else {
528                HttpResponse::Ok().json(json!({
529                    "message": "User deleted successfully"
530                }))
531            }
532        }
533        Err(e) => HttpResponse::InternalServerError().json(json!({
534            "error": format!("Failed to delete user: {}", e)
535        })),
536    }
537}
538
539fn normalize_roles(
540    roles: Option<Vec<RoleAssignmentRequest>>,
541    fallback_role: Option<String>,
542    fallback_org: Option<Uuid>,
543) -> Result<Vec<NormalizedRoleAssignment>, HttpResponse> {
544    let mut entries = roles.unwrap_or_else(|| {
545        fallback_role
546            .map(|role| {
547                vec![RoleAssignmentRequest {
548                    role,
549                    organization_id: fallback_org,
550                    is_primary: Some(true),
551                }]
552            })
553            .unwrap_or_default()
554    });
555
556    if entries.is_empty() {
557        return Err(HttpResponse::BadRequest().json(json!({
558            "error": "At least one role must be specified"
559        })));
560    }
561
562    let mut normalized = Vec::with_capacity(entries.len());
563    let mut seen = HashSet::new();
564    let mut primary_count = 0;
565
566    for entry in entries.drain(..) {
567        let RoleAssignmentRequest {
568            role,
569            organization_id,
570            is_primary,
571        } = entry;
572        let normalized_role = role.trim().to_lowercase();
573        if !ALLOWED_ROLES.contains(&normalized_role.as_str()) {
574            return Err(HttpResponse::BadRequest().json(json!({
575                "error": format!("Invalid role: {}", role)
576            })));
577        }
578
579        let mut organization_id = organization_id;
580        if normalized_role != "superadmin" {
581            if organization_id.is_none() {
582                return Err(HttpResponse::BadRequest().json(json!({
583                    "error": format!("Organization is required for role {}", normalized_role)
584                })));
585            }
586        } else {
587            organization_id = None;
588        }
589
590        let is_primary = is_primary.unwrap_or(false);
591        if is_primary {
592            primary_count += 1;
593            if primary_count > 1 {
594                return Err(HttpResponse::BadRequest().json(json!({
595                    "error": "Only one primary role can be specified"
596                })));
597            }
598        }
599
600        let key = (normalized_role.clone(), organization_id);
601        if !seen.insert(key) {
602            return Err(HttpResponse::BadRequest().json(json!({
603                "error": "Duplicate role assignment detected"
604            })));
605        }
606
607        normalized.push(NormalizedRoleAssignment {
608            id: Uuid::new_v4(),
609            role: normalized_role,
610            organization_id,
611            is_primary,
612        });
613    }
614
615    if primary_count == 0 {
616        if let Some(first) = normalized.first_mut() {
617            first.is_primary = true;
618        }
619    }
620
621    normalized.sort_by(|a, b| b.is_primary.cmp(&a.is_primary));
622    Ok(normalized)
623}
624
625async fn replace_user_roles(
626    tx: &mut Transaction<'_, Postgres>,
627    user_id: Uuid,
628    roles: &[NormalizedRoleAssignment],
629) -> Result<(), sqlx::Error> {
630    tx.execute(sqlx::query!(
631        "DELETE FROM user_roles WHERE user_id = $1",
632        user_id
633    ))
634    .await?;
635
636    for assignment in roles {
637        tx.execute(sqlx::query!(
638            r#"
639            INSERT INTO user_roles (id, user_id, role, organization_id, is_primary, created_at, updated_at)
640            VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
641            "#,
642            assignment.id,
643            user_id,
644            assignment.role,
645            assignment.organization_id,
646            assignment.is_primary
647        ))
648        .await?;
649    }
650
651    Ok(())
652}
653
654async fn load_roles_for_users(
655    pool: &crate::infrastructure::pool::DbPool,
656    user_ids: &[Uuid],
657) -> Result<HashMap<Uuid, Vec<RoleResponse>>, sqlx::Error> {
658    if user_ids.is_empty() {
659        return Ok(HashMap::new());
660    }
661
662    let rows = sqlx::query!(
663        r#"
664        SELECT id, user_id, role, organization_id, is_primary, created_at
665        FROM user_roles
666        WHERE user_id = ANY($1)
667        ORDER BY user_id, is_primary DESC, created_at ASC
668        "#,
669        user_ids
670    )
671    .fetch_all(pool)
672    .await?;
673
674    let mut map: HashMap<Uuid, Vec<RoleResponse>> = HashMap::new();
675
676    for row in rows {
677        let entry = RoleResponse {
678            id: row.id.to_string(),
679            role: row.role,
680            organization_id: row.organization_id.map(|id| id.to_string()),
681            is_primary: row.is_primary,
682        };
683        map.entry(row.user_id).or_default().push(entry);
684    }
685
686    for roles in map.values_mut() {
687        normalize_primary_role(roles);
688    }
689
690    Ok(map)
691}
692
693async fn load_user_response(
694    pool: &crate::infrastructure::pool::DbPool,
695    user_id: Uuid,
696) -> Result<UserResponse, sqlx::Error> {
697    let row = sqlx::query!(
698        r#"
699        SELECT id, email, first_name, last_name, role, organization_id, is_active, created_at
700        FROM users
701        WHERE id = $1
702        "#,
703        user_id
704    )
705    .fetch_one(pool)
706    .await?;
707
708    let roles_map = load_roles_for_users(pool, &[user_id]).await?;
709    let mut roles = roles_map.get(&user_id).cloned().unwrap_or_else(|| {
710        vec![fallback_role_response(
711            row.role.clone(),
712            row.organization_id,
713        )]
714    });
715
716    normalize_primary_role(&mut roles);
717    let active_role = roles
718        .iter()
719        .find(|role| role.is_primary)
720        .cloned()
721        .or_else(|| roles.first().cloned());
722
723    Ok(UserResponse {
724        id: row.id.to_string(),
725        email: row.email,
726        first_name: row.first_name,
727        last_name: row.last_name,
728        role: active_role
729            .as_ref()
730            .map(|r| r.role.clone())
731            .unwrap_or(row.role),
732        organization_id: active_role
733            .as_ref()
734            .and_then(|r| r.organization_id.clone())
735            .or_else(|| row.organization_id.map(|id| id.to_string())),
736        is_active: row.is_active,
737        created_at: row.created_at,
738        roles,
739        active_role,
740    })
741}
742
743fn fallback_role_response(role: String, organization_id: Option<Uuid>) -> RoleResponse {
744    RoleResponse {
745        id: Uuid::new_v4().to_string(),
746        role,
747        organization_id: organization_id.map(|id| id.to_string()),
748        is_primary: true,
749    }
750}
751
752fn normalize_primary_role(roles: &mut [RoleResponse]) {
753    if roles.is_empty() {
754        return;
755    }
756
757    if roles.iter().filter(|r| r.is_primary).count() == 0 {
758        roles[0].is_primary = true;
759    }
760
761    roles.sort_by(|a, b| b.is_primary.cmp(&a.is_primary));
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767    use actix_web::http::StatusCode;
768
769    #[test]
770    fn normalize_roles_marks_first_as_primary_when_none_provided() {
771        let primary_org = Uuid::new_v4();
772        let secondary_org = Uuid::new_v4();
773        let input = vec![
774            RoleAssignmentRequest {
775                role: "syndic".to_string(),
776                organization_id: Some(primary_org),
777                is_primary: None,
778            },
779            RoleAssignmentRequest {
780                role: "accountant".to_string(),
781                organization_id: Some(secondary_org),
782                is_primary: Some(false),
783            },
784        ];
785
786        let normalized = normalize_roles(Some(input), None, None).expect("normalized roles");
787        assert_eq!(normalized.len(), 2);
788        assert!(
789            normalized.first().unwrap().is_primary,
790            "first role should become primary"
791        );
792        assert_eq!(
793            normalized.first().unwrap().organization_id,
794            Some(primary_org)
795        );
796        assert_eq!(normalized.first().unwrap().role, "syndic");
797    }
798
799    #[test]
800    fn normalize_roles_rejects_invalid_role() {
801        let res = normalize_roles(
802            Some(vec![RoleAssignmentRequest {
803                role: "invalid-role".to_string(),
804                organization_id: None,
805                is_primary: None,
806            }]),
807            None,
808            None,
809        );
810
811        let err = res.expect_err("invalid role should fail");
812        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
813    }
814
815    #[test]
816    fn normalize_roles_requires_org_for_non_superadmin() {
817        let res = normalize_roles(
818            Some(vec![RoleAssignmentRequest {
819                role: "syndic".to_string(),
820                organization_id: None,
821                is_primary: Some(true),
822            }]),
823            None,
824            None,
825        );
826
827        let err = res.expect_err("organization required");
828        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
829    }
830
831    #[test]
832    fn normalize_roles_uses_fallback_when_no_roles_provided() {
833        let fallback_org = Uuid::new_v4();
834        let roles = normalize_roles(None, Some("syndic".to_string()), Some(fallback_org))
835            .expect("fallback role");
836
837        assert_eq!(roles.len(), 1);
838        let role = roles.first().unwrap();
839        assert_eq!(role.role, "syndic");
840        assert_eq!(role.organization_id, Some(fallback_org));
841        assert!(role.is_primary);
842    }
843}