Fix role/config schema alignment and external dashboard runtime loading

This commit is contained in:
Tracewebstudio Dev 2026-04-15 00:16:25 +02:00
parent 2a65d79aea
commit 92ded2b43d
4 changed files with 122 additions and 47 deletions

View file

@ -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::<String>()),
None => String::new(),
}
})
.collect::<Vec<String>>()
.join(" ")
}
async fn ensure_role_exists(pool: &sqlx::PgPool, role_code: &str) -> Option<Uuid> {
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)

View file

@ -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)

View file

@ -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<Utc>,
}
@ -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<Utc>,
}
@ -174,15 +172,22 @@ impl ConfigRepository {
pool: &PgPool,
payload: CreateDashboardConfigPayload,
) -> Result<DashboardConfig, sqlx::Error> {
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<DashboardConfig, sqlx::Error> {
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<DashboardConfig, sqlx::Error> {
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?;

View file

@ -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<Vec<String>, 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)