Fix role/config schema alignment and external dashboard runtime loading
This commit is contained in:
parent
2a65d79aea
commit
92ded2b43d
4 changed files with 122 additions and 47 deletions
|
|
@ -157,6 +157,55 @@ fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str>
|
||||||
vec!["JOB_SEEKER".to_string()]
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// POST /api/auth/check-email
|
/// 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,
|
StatusCode::OK,
|
||||||
Json(serde_json::json!({
|
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(),
|
payload.profession.as_deref(),
|
||||||
);
|
);
|
||||||
for role_key in role_candidates {
|
for role_key in role_candidates {
|
||||||
let role = sqlx::query_scalar::<_, Uuid>("SELECT id FROM roles WHERE key = $1")
|
let role_id = ensure_role_exists(&state.pool, &role_key).await;
|
||||||
.bind(&role_key)
|
if let Some(role_id) = role_id {
|
||||||
.fetch_optional(&state.pool)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten();
|
|
||||||
|
|
||||||
if let Some(role_id) = role {
|
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO user_roles (user_id, role_id, status, approved_at)
|
UPDATE user_roles
|
||||||
VALUES ($1, $2, 'APPROVED', NOW())
|
SET status = 'APPROVED'
|
||||||
ON CONFLICT (user_id, role_id)
|
WHERE user_id = $1 AND role_id = $2
|
||||||
DO UPDATE SET status = 'APPROVED', approved_at = NOW()
|
"#,
|
||||||
|
)
|
||||||
|
.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)
|
.bind(user.id)
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,7 @@ async fn list_roles(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
r.id,
|
r.id,
|
||||||
r.key,
|
r.code AS key,
|
||||||
r.name,
|
r.name,
|
||||||
r.audience,
|
r.audience,
|
||||||
r.description,
|
r.description,
|
||||||
|
|
@ -182,10 +182,10 @@ async fn list_roles(
|
||||||
COUNT(DISTINCT rp.id) AS permissions_count
|
COUNT(DISTINCT rp.id) AS permissions_count
|
||||||
FROM roles r
|
FROM roles r
|
||||||
LEFT JOIN departments d ON d.id = r.department_id
|
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
|
LEFT JOIN role_permissions rp ON rp.role_id = r.id
|
||||||
WHERE ($1 = '' OR r.audience = $1)
|
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
|
GROUP BY r.id, d.name
|
||||||
ORDER BY r.created_at DESC
|
ORDER BY r.created_at DESC
|
||||||
LIMIT $3 OFFSET $4
|
LIMIT $3 OFFSET $4
|
||||||
|
|
@ -203,7 +203,7 @@ async fn list_roles(
|
||||||
r#"
|
r#"
|
||||||
SELECT COUNT(*) FROM roles r
|
SELECT COUNT(*) FROM roles r
|
||||||
WHERE ($1 = '' OR r.audience = $1)
|
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)
|
.bind(&audience)
|
||||||
|
|
@ -241,7 +241,7 @@ async fn get_role(
|
||||||
let row = sqlx::query_as::<_, RoleDetailRow>(
|
let row = sqlx::query_as::<_, RoleDetailRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
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.department_id, d.name AS department_name,
|
||||||
r.is_active, r.can_approve_requests, r.can_manage_system_settings,
|
r.is_active, r.can_approve_requests, r.can_manage_system_settings,
|
||||||
r.created_at
|
r.created_at
|
||||||
|
|
@ -290,9 +290,9 @@ async fn create_role(
|
||||||
|
|
||||||
let role = sqlx::query_as::<_, InsertedRoleRow>(
|
let role = sqlx::query_as::<_, InsertedRoleRow>(
|
||||||
r#"
|
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)
|
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)
|
.bind(&payload.key)
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,9 @@ pub struct CreateOnboardingConfigPayload {
|
||||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
pub struct DashboardConfigListItem {
|
pub struct DashboardConfigListItem {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub role_id: Uuid,
|
|
||||||
pub role_key: String,
|
pub role_key: String,
|
||||||
pub audience: String,
|
pub audience: String,
|
||||||
pub version: i32,
|
pub config_json: serde_json::Value,
|
||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
@ -43,10 +42,9 @@ pub struct DashboardConfigListItem {
|
||||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
pub struct DashboardConfig {
|
pub struct DashboardConfig {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub role_id: Uuid,
|
pub role_key: String,
|
||||||
pub audience: String,
|
pub audience: String,
|
||||||
pub config_json: serde_json::Value,
|
pub config_json: serde_json::Value,
|
||||||
pub version: i32,
|
|
||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
@ -174,15 +172,22 @@ impl ConfigRepository {
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
payload: CreateDashboardConfigPayload,
|
payload: CreateDashboardConfigPayload,
|
||||||
) -> Result<DashboardConfig, sqlx::Error> {
|
) -> 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
|
// Soft-disable previous active configs for this role
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE dashboard_configs
|
UPDATE dashboard_configs
|
||||||
SET is_active = false
|
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)
|
.bind(&payload.audience)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -190,18 +195,17 @@ impl ConfigRepository {
|
||||||
// Insert new config
|
// Insert new config
|
||||||
let config = sqlx::query_as::<_, DashboardConfig>(
|
let config = sqlx::query_as::<_, DashboardConfig>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO dashboard_configs (role_id, audience, config_json, version, is_active)
|
INSERT INTO dashboard_configs (role_key, audience, widgets, is_active)
|
||||||
VALUES (
|
VALUES (
|
||||||
$1,
|
$1,
|
||||||
$2::text,
|
$2::text,
|
||||||
$3,
|
$3,
|
||||||
COALESCE((SELECT MAX(version) FROM dashboard_configs WHERE role_id = $1 AND audience = $2::text), 0) + 1,
|
|
||||||
true
|
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.audience)
|
||||||
.bind(payload.config_json)
|
.bind(payload.config_json)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
|
|
@ -215,14 +219,21 @@ impl ConfigRepository {
|
||||||
role_id: Uuid,
|
role_id: Uuid,
|
||||||
audience: &str,
|
audience: &str,
|
||||||
) -> Result<DashboardConfig, sqlx::Error> {
|
) -> Result<DashboardConfig, sqlx::Error> {
|
||||||
let config = sqlx::query_as::<_, DashboardConfig>(
|
let role_key = sqlx::query_scalar::<_, String>(
|
||||||
r#"
|
"SELECT code FROM roles WHERE id = $1",
|
||||||
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
|
|
||||||
"#,
|
|
||||||
)
|
)
|
||||||
.bind(role_id)
|
.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)
|
.bind(audience)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -236,10 +247,8 @@ impl ConfigRepository {
|
||||||
let configs = sqlx::query_as::<_, DashboardConfigListItem>(
|
let configs = sqlx::query_as::<_, DashboardConfigListItem>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
c.id, c.role_id, r.key as role_key, c.audience,
|
c.id, c.role_key, c.audience, c.widgets as config_json, c.is_active, c.updated_at
|
||||||
c.version, c.is_active, c.updated_at
|
|
||||||
FROM dashboard_configs c
|
FROM dashboard_configs c
|
||||||
JOIN roles r ON c.role_id = r.id
|
|
||||||
ORDER BY c.updated_at DESC
|
ORDER BY c.updated_at DESC
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -256,13 +265,12 @@ impl ConfigRepository {
|
||||||
) -> Result<DashboardConfig, sqlx::Error> {
|
) -> Result<DashboardConfig, sqlx::Error> {
|
||||||
let config = sqlx::query_as::<_, DashboardConfig>(
|
let config = sqlx::query_as::<_, DashboardConfig>(
|
||||||
r#"
|
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
|
FROM dashboard_configs c
|
||||||
JOIN roles r ON c.role_id = r.id
|
WHERE UPPER(c.role_key) = UPPER($1) AND c.audience = $2 AND c.is_active = true
|
||||||
WHERE r.key = $1 AND c.audience = $2 AND c.is_active = true
|
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(role_key.to_uppercase())
|
.bind(role_key)
|
||||||
.bind(audience)
|
.bind(audience)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
|
||||||
|
|
@ -102,15 +102,15 @@ impl UserRepository {
|
||||||
.await
|
.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> {
|
pub async fn get_user_role_keys(pool: &PgPool, user_id: Uuid) -> Result<Vec<String>, sqlx::Error> {
|
||||||
let rows = sqlx::query_scalar::<_, String>(
|
let rows = sqlx::query_scalar::<_, String>(
|
||||||
r#"
|
r#"
|
||||||
SELECT r.key
|
SELECT r.code
|
||||||
FROM user_roles ur
|
FROM user_roles ur
|
||||||
JOIN roles r ON ur.role_id = r.id
|
JOIN roles r ON ur.role_id = r.id
|
||||||
WHERE ur.user_id = $1 AND ur.status = 'APPROVED'
|
WHERE ur.user_id = $1 AND ur.status = 'APPROVED'
|
||||||
ORDER BY ur.approved_at ASC
|
ORDER BY ur.created_at ASC
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue