diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index 6701828..e44ed50 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -157,6 +157,55 @@ fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str> vec!["JOB_SEEKER".to_string()] } +fn role_display_name_from_code(code: &str) -> String { + code + .split('_') + .filter(|part| !part.is_empty()) + .map(|part| { + let lower = part.to_lowercase(); + let mut chars = lower.chars(); + match chars.next() { + Some(first) => format!("{}{}", first.to_uppercase(), chars.collect::()), + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +async fn ensure_role_exists(pool: &sqlx::PgPool, role_code: &str) -> Option { + let normalized = normalize_role_key(role_code); + if normalized.is_empty() { + return None; + } + + if let Ok(found) = sqlx::query_scalar::<_, Uuid>("SELECT id FROM roles WHERE code = $1") + .bind(&normalized) + .fetch_optional(pool) + .await + { + if found.is_some() { + return found; + } + } + + let display_name = role_display_name_from_code(&normalized); + sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO roles (name, code, audience, is_active) + VALUES ($1, $2, 'USER', true) + ON CONFLICT (code) + DO UPDATE SET updated_at = NOW() + RETURNING id + "#, + ) + .bind(display_name) + .bind(normalized) + .fetch_one(pool) + .await + .ok() +} + // ── Handlers ────────────────────────────────────────────────────────────────── /// POST /api/auth/check-email @@ -175,11 +224,22 @@ async fn check_email( ); } - let exists = UserRepository::get_by_email(&state.pool, &email).await.is_ok(); + let user = UserRepository::get_by_email(&state.pool, &email).await.ok(); + let exists = user.is_some(); + let roles = if let Some(ref found_user) = user { + UserRepository::get_user_role_keys(&state.pool, found_user.id) + .await + .unwrap_or_default() + } else { + Vec::new() + }; + let active_role = roles.first().cloned(); ( StatusCode::OK, Json(serde_json::json!({ - "exists": exists + "exists": exists, + "active_role": active_role, + "roles": roles, })), ) } @@ -234,20 +294,27 @@ async fn register( payload.profession.as_deref(), ); for role_key in role_candidates { - let role = sqlx::query_scalar::<_, Uuid>("SELECT id FROM roles WHERE key = $1") - .bind(&role_key) - .fetch_optional(&state.pool) - .await - .ok() - .flatten(); - - if let Some(role_id) = role { + let role_id = ensure_role_exists(&state.pool, &role_key).await; + if let Some(role_id) = role_id { let _ = sqlx::query( r#" - INSERT INTO user_roles (user_id, role_id, status, approved_at) - VALUES ($1, $2, 'APPROVED', NOW()) - ON CONFLICT (user_id, role_id) - DO UPDATE SET status = 'APPROVED', approved_at = NOW() + UPDATE user_roles + SET status = 'APPROVED' + WHERE user_id = $1 AND role_id = $2 + "#, + ) + .bind(user.id) + .bind(role_id) + .execute(&state.pool) + .await; + + let _ = sqlx::query( + r#" + INSERT INTO user_roles (user_id, role_id, status) + SELECT $1, $2, 'APPROVED' + WHERE NOT EXISTS ( + SELECT 1 FROM user_roles WHERE user_id = $1 AND role_id = $2 + ) "#, ) .bind(user.id) diff --git a/apps/users/src/handlers/roles.rs b/apps/users/src/handlers/roles.rs index 2f7deb1..4a80148 100644 --- a/apps/users/src/handlers/roles.rs +++ b/apps/users/src/handlers/roles.rs @@ -168,7 +168,7 @@ async fn list_roles( r#" SELECT r.id, - r.key, + r.code AS key, r.name, r.audience, r.description, @@ -182,10 +182,10 @@ async fn list_roles( COUNT(DISTINCT rp.id) AS permissions_count FROM roles r LEFT JOIN departments d ON d.id = r.department_id - LEFT JOIN employees e ON e.role_code = r.key + LEFT JOIN employees e ON e.role_code = r.code LEFT JOIN role_permissions rp ON rp.role_id = r.id WHERE ($1 = '' OR r.audience = $1) - AND ($2 = '' OR LOWER(r.name) LIKE '%' || $2 || '%' OR LOWER(r.key) LIKE '%' || $2 || '%') + AND ($2 = '' OR LOWER(r.name) LIKE '%' || $2 || '%' OR LOWER(r.code) LIKE '%' || $2 || '%') GROUP BY r.id, d.name ORDER BY r.created_at DESC LIMIT $3 OFFSET $4 @@ -203,7 +203,7 @@ async fn list_roles( r#" SELECT COUNT(*) FROM roles r WHERE ($1 = '' OR r.audience = $1) - AND ($2 = '' OR LOWER(r.name) LIKE '%' || $2 || '%' OR LOWER(r.key) LIKE '%' || $2 || '%') + AND ($2 = '' OR LOWER(r.name) LIKE '%' || $2 || '%' OR LOWER(r.code) LIKE '%' || $2 || '%') "#, ) .bind(&audience) @@ -241,7 +241,7 @@ async fn get_role( let row = sqlx::query_as::<_, RoleDetailRow>( r#" SELECT - r.id, r.key, r.name, r.audience, r.description, + r.id, r.code AS key, r.name, r.audience, r.description, r.department_id, d.name AS department_name, r.is_active, r.can_approve_requests, r.can_manage_system_settings, r.created_at @@ -290,9 +290,9 @@ async fn create_role( let role = sqlx::query_as::<_, InsertedRoleRow>( r#" - INSERT INTO roles (key, name, audience, description, department_id, is_active, can_approve_requests, can_manage_system_settings) + INSERT INTO roles (code, name, audience, description, department_id, is_active, can_approve_requests, can_manage_system_settings) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, key, name, audience, description, department_id, is_active, can_approve_requests, can_manage_system_settings, created_at + RETURNING id, code AS key, name, audience, description, department_id, is_active, can_approve_requests, can_manage_system_settings, created_at "#, ) .bind(&payload.key) diff --git a/crates/db/src/models/config.rs b/crates/db/src/models/config.rs index b6f3fd7..9abc25b 100644 --- a/crates/db/src/models/config.rs +++ b/crates/db/src/models/config.rs @@ -32,10 +32,9 @@ pub struct CreateOnboardingConfigPayload { #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct DashboardConfigListItem { pub id: Uuid, - pub role_id: Uuid, pub role_key: String, pub audience: String, - pub version: i32, + pub config_json: serde_json::Value, pub is_active: bool, pub updated_at: DateTime, } @@ -43,10 +42,9 @@ pub struct DashboardConfigListItem { #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct DashboardConfig { pub id: Uuid, - pub role_id: Uuid, + pub role_key: String, pub audience: String, pub config_json: serde_json::Value, - pub version: i32, pub is_active: bool, pub updated_at: DateTime, } @@ -174,15 +172,22 @@ impl ConfigRepository { pool: &PgPool, payload: CreateDashboardConfigPayload, ) -> Result { + let role_key = sqlx::query_scalar::<_, String>( + "SELECT code FROM roles WHERE id = $1", + ) + .bind(payload.role_id) + .fetch_one(pool) + .await?; + // Soft-disable previous active configs for this role sqlx::query( r#" UPDATE dashboard_configs SET is_active = false - WHERE role_id = $1 AND audience = $2::text AND is_active = true + WHERE UPPER(role_key) = UPPER($1) AND audience = $2::text AND is_active = true "#, ) - .bind(payload.role_id) + .bind(&role_key) .bind(&payload.audience) .execute(pool) .await?; @@ -190,18 +195,17 @@ impl ConfigRepository { // Insert new config let config = sqlx::query_as::<_, DashboardConfig>( r#" - INSERT INTO dashboard_configs (role_id, audience, config_json, version, is_active) + INSERT INTO dashboard_configs (role_key, audience, widgets, is_active) VALUES ( $1, $2::text, $3, - COALESCE((SELECT MAX(version) FROM dashboard_configs WHERE role_id = $1 AND audience = $2::text), 0) + 1, true ) - RETURNING id, role_id, audience, config_json, version, is_active, updated_at + RETURNING id, role_key, audience, widgets as config_json, is_active, updated_at "#, ) - .bind(payload.role_id) + .bind(&role_key) .bind(&payload.audience) .bind(payload.config_json) .fetch_one(pool) @@ -215,14 +219,21 @@ impl ConfigRepository { role_id: Uuid, audience: &str, ) -> Result { - let config = sqlx::query_as::<_, DashboardConfig>( - r#" - SELECT id, role_id, audience, config_json, version, is_active, updated_at - FROM dashboard_configs - WHERE role_id = $1 AND audience = $2 AND is_active = true - "#, + let role_key = sqlx::query_scalar::<_, String>( + "SELECT code FROM roles WHERE id = $1", ) .bind(role_id) + .fetch_one(pool) + .await?; + + let config = sqlx::query_as::<_, DashboardConfig>( + r#" + SELECT id, role_key, audience, widgets as config_json, is_active, updated_at + FROM dashboard_configs + WHERE UPPER(role_key) = UPPER($1) AND audience = $2 AND is_active = true + "#, + ) + .bind(role_key) .bind(audience) .fetch_one(pool) .await?; @@ -236,10 +247,8 @@ impl ConfigRepository { let configs = sqlx::query_as::<_, DashboardConfigListItem>( r#" SELECT - c.id, c.role_id, r.key as role_key, c.audience, - c.version, c.is_active, c.updated_at + c.id, c.role_key, c.audience, c.widgets as config_json, c.is_active, c.updated_at FROM dashboard_configs c - JOIN roles r ON c.role_id = r.id ORDER BY c.updated_at DESC "#, ) @@ -256,13 +265,12 @@ impl ConfigRepository { ) -> Result { let config = sqlx::query_as::<_, DashboardConfig>( r#" - SELECT c.id, c.role_id, c.audience, c.config_json, c.version, c.is_active, c.updated_at + SELECT c.id, c.role_key, c.audience, c.widgets as config_json, c.is_active, c.updated_at FROM dashboard_configs c - JOIN roles r ON c.role_id = r.id - WHERE r.key = $1 AND c.audience = $2 AND c.is_active = true + WHERE UPPER(c.role_key) = UPPER($1) AND c.audience = $2 AND c.is_active = true "#, ) - .bind(role_key.to_uppercase()) + .bind(role_key) .bind(audience) .fetch_one(pool) .await?; diff --git a/crates/db/src/models/user.rs b/crates/db/src/models/user.rs index 1e787f1..c3d96a9 100644 --- a/crates/db/src/models/user.rs +++ b/crates/db/src/models/user.rs @@ -102,15 +102,15 @@ impl UserRepository { .await } - /// Returns all approved role keys for a user (e.g. ["COMPANY", "JOB_SEEKER"]) + /// Returns all approved role codes for a user (e.g. ["COMPANY", "DEVELOPER"]) pub async fn get_user_role_keys(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { let rows = sqlx::query_scalar::<_, String>( r#" - SELECT r.key + SELECT r.code FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = $1 AND ur.status = 'APPROVED' - ORDER BY ur.approved_at ASC + ORDER BY ur.created_at ASC "#, ) .bind(user_id)