nxtgauge-backend-rust/apps/users/src/handlers/config.rs

630 lines
23 KiB
Rust

use crate::AppState;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use contracts::auth_middleware::{AuthUser, require_admin};
use db::models::config::{
ConfigRepository, CreateDashboardConfigPayload, CreateOnboardingConfigPayload,
CreateRuntimeConfigPayload,
};
use db::models::user::UserRepository;
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
use uuid::Uuid;
pub fn onboarding_router() -> Router<AppState> {
Router::new()
.route("/", get(list_onboarding_configs).post(create_onboarding_config))
.route("/{role_id}", get(get_active_onboarding_config))
.route("/by-key/{role_key}", get(get_onboarding_config_by_key))
}
pub fn dashboard_router() -> Router<AppState> {
Router::new()
.route("/", get(list_dashboard_configs).post(create_dashboard_config))
.route("/{role_id}", get(get_active_dashboard_config))
.route("/by-key/{role_key}", get(get_dashboard_config_by_key))
}
pub fn runtime_router() -> Router<AppState> {
Router::new()
.route("/", get(get_my_runtime_config).post(create_runtime_config))
.route("/{role_id}", get(get_active_runtime_config))
}
pub fn admin_runtime_router() -> Router<AppState> {
Router::new()
.route("/", get(list_runtime_configs).post(admin_create_runtime_config))
.route("/{id}", get(get_runtime_config_by_id).delete(delete_runtime_config))
.route("/{id}/activate", post(activate_runtime_config))
}
#[derive(Deserialize)]
struct AdminListQuery {
role_id: Option<Uuid>,
role_key: Option<String>,
}
async fn list_runtime_configs(
auth: AuthUser,
State(state): State<AppState>,
Query(q): Query<AdminListQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
// Resolve role_id by role_key if provided
let role_id = if let Some(id) = q.role_id {
Some(id)
} else if let Some(key) = q.role_key.clone() {
sqlx::query_scalar::<_, Uuid>("SELECT id FROM roles WHERE key = $1")
.bind(key.to_uppercase())
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
} else {
None
};
#[derive(sqlx::FromRow)]
struct RcRow {
id: Uuid,
role_id: Uuid,
config_json: serde_json::Value,
version: i32,
is_active: bool,
updated_at: chrono::DateTime<chrono::Utc>,
}
let items = if let Some(rid) = role_id {
sqlx::query_as::<_, RcRow>(
r#"
SELECT id, role_id, config_json, version, is_active, updated_at
FROM runtime_configs
WHERE role_id = $1
ORDER BY version DESC
"#,
)
.bind(rid)
.fetch_all(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.into_iter()
.map(|r| serde_json::json!({
"id": r.id,
"role_id": r.role_id,
"config_json": r.config_json,
"version": r.version,
"is_active": r.is_active,
"updated_at": r.updated_at,
}))
.collect::<Vec<_>>()
} else {
sqlx::query_as::<_, RcRow>(
r#"
SELECT rc.id, rc.role_id, rc.config_json, rc.version, rc.is_active, rc.updated_at
FROM runtime_configs rc
JOIN roles r ON rc.role_id = r.id
WHERE r.audience = 'INTERNAL'
ORDER BY rc.updated_at DESC
"#,
)
.fetch_all(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.into_iter()
.map(|r| serde_json::json!({
"id": r.id,
"role_id": r.role_id,
"config_json": r.config_json,
"version": r.version,
"is_active": r.is_active,
"updated_at": r.updated_at,
}))
.collect::<Vec<_>>()
};
Ok((StatusCode::OK, Json(serde_json::json!({ "items": items }))))
}
async fn get_runtime_config_by_id(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
#[derive(sqlx::FromRow)]
struct RcDetailRow {
id: Uuid,
role_id: Uuid,
config_json: serde_json::Value,
version: i32,
is_active: bool,
updated_at: chrono::DateTime<chrono::Utc>,
}
let r = sqlx::query_as::<_, RcDetailRow>(
"SELECT id, role_id, config_json, version, is_active, updated_at FROM runtime_configs WHERE id = $1",
)
.bind(id)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.ok_or((StatusCode::NOT_FOUND, "Runtime config not found".to_string()))?;
let row = serde_json::json!({
"id": r.id,
"role_id": r.role_id,
"config_json": r.config_json,
"version": r.version,
"is_active": r.is_active,
"updated_at": r.updated_at,
});
Ok((StatusCode::OK, Json(row)))
}
async fn admin_create_runtime_config(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<CreateRuntimeConfigPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
match ConfigRepository::create_runtime_config(&state.pool, payload).await {
Ok(config) => Ok((StatusCode::CREATED, Json(config))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn activate_runtime_config(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
// Fetch role_id for the target config
let role_id: Uuid = sqlx::query_scalar::<_, Uuid>("SELECT role_id FROM runtime_configs WHERE id = $1")
.bind(id)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.ok_or((StatusCode::NOT_FOUND, "Runtime config not found".to_string()))?;
// Disable existing active
sqlx::query("UPDATE runtime_configs SET is_active = false WHERE role_id = $1 AND is_active = true")
.bind(role_id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Activate target
sqlx::query("UPDATE runtime_configs SET is_active = true WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
get_runtime_config_by_id(auth, State(state), Path(id)).await
}
async fn delete_runtime_config(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
let result = sqlx::query("DELETE FROM runtime_configs WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Runtime config not found".to_string()));
}
Ok((StatusCode::NO_CONTENT, "".to_string()))
}
async fn get_my_runtime_config(
auth: contracts::auth_middleware::AuthUser,
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let role_key = auth.claims.active_role.clone().to_uppercase();
#[derive(sqlx::FromRow)]
struct RoleRow {
id: Uuid,
key: String,
audience: String,
}
let role = sqlx::query_as::<_, RoleRow>("SELECT id, key, audience FROM roles WHERE key = $1")
.bind(&role_key)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)))?
.ok_or((StatusCode::NOT_FOUND, format!("Role '{}' not found", role_key)))?;
let config = match ConfigRepository::get_active_runtime_by_role_key(&state.pool, &role_key).await {
Ok(c) => Some(c),
Err(sqlx::Error::RowNotFound) => None,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
))
}
};
// Fetch live user data to merge into the response so the `user` field is always fresh.
let user = UserRepository::get_by_id(&state.pool, auth.user_id)
.await
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
let roles = UserRepository::get_user_role_keys(&state.pool, auth.user_id)
.await
.unwrap_or_default();
// Merge the stored config_json with live user data.
// `config_json` holds role/modules/flags/permissions; we add the `user` sub-object.
let mut response = config
.map(|c| c.config_json)
.unwrap_or_else(|| serde_json::json!({}));
if !response.is_object() {
response = serde_json::json!({});
}
if let Some(obj) = response.as_object_mut() {
obj.insert(
"user".to_string(),
serde_json::json!({
"id": user.id.to_string(),
"full_name": user.full_name.unwrap_or_default(),
"email": user.email,
"roles": roles,
"active_role": role_key,
}),
);
obj.insert("role".to_string(), serde_json::Value::String(role_key.clone()));
obj.insert("audience".to_string(), serde_json::Value::String(role.audience.clone()));
}
if role.audience == "INTERNAL" {
let permission_keys: Vec<String> = sqlx::query_scalar::<_, String>(
"SELECT permission_key FROM role_permissions WHERE role_id = $1 ORDER BY permission_key",
)
.bind(role.id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let mut per_module: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for key in permission_keys {
let key = key.trim().to_uppercase();
if key.is_empty() {
continue;
}
let parsed = parse_permission_key(&key);
if let Some((module, action)) = parsed {
if !module.is_empty() && !action.is_empty() {
per_module.entry(module).or_default().insert(action);
}
}
}
if let Some(obj) = response.as_object_mut() {
let enabled_modules = per_module.keys().cloned().collect::<Vec<_>>();
obj.insert("enabled_modules".to_string(), serde_json::json!(enabled_modules));
let mut permissions_obj = serde_json::Map::new();
for (module, actions) in per_module {
permissions_obj.insert(module, serde_json::json!(actions.into_iter().collect::<Vec<_>>()));
}
obj.insert("permissions".to_string(), serde_json::Value::Object(permissions_obj));
}
} else {
// EXTERNAL role: derive enabled_modules and sidebar config from the active dashboard_config.
// Falls back to the runtime_config's own enabled_modules if no dashboard_config exists.
let dash_config = ConfigRepository::get_active_dashboard_config(
&state.pool,
role.id,
"EXTERNAL",
)
.await
.ok();
if let Some(dash) = dash_config {
let config_json = &dash.config_json;
// Extract sidebar_items (the admin saves this key as snake_case in the API).
// Try both "sidebar_items" and "sidebarItems" for forward-compatibility.
let sidebar_items: Vec<String> = config_json
.get("sidebar_items")
.or_else(|| config_json.get("sidebarItems"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
// Map display-name sidebar items to the module keys the frontend MODULE_NAV_MAP understands.
let enabled_modules: Vec<String> = sidebar_items
.iter()
.filter_map(|item| sidebar_item_to_module_key(item.as_str()))
.map(str::to_string)
.collect();
if let Some(obj) = response.as_object_mut() {
// Only overwrite enabled_modules if the dashboard config has sidebar items defined.
if !enabled_modules.is_empty() {
obj.insert("enabled_modules".to_string(), serde_json::json!(enabled_modules));
}
// Include the full dashboard config so the frontend can read widgets/tabs directly.
obj.insert("dashboard_config".to_string(), config_json.clone());
}
}
}
Ok((StatusCode::OK, Json(response)))
}
/// Maps an admin-configured sidebar display name to the module key used in the frontend's
/// MODULE_NAV_MAP. Returns None for items that don't map to a routable module (e.g. Logout).
fn sidebar_item_to_module_key(item: &str) -> Option<&'static str> {
let normalized = item.trim().to_lowercase();
match normalized.as_str() {
"my dashboard" | "dashboard" => Some("dashboard"),
"my profile" | "profile" => Some("profile"),
"my portfolio" | "portfolio" => Some("portfolio"),
"leads" | "my leads" => Some("leads"),
"my responses" | "responses" | "my requests" => Some("leads"),
"received responses" | "shortlisted responses" => Some("marketplace"),
"marketplace" => Some("marketplace"),
"jobs" | "job postings" => Some("job_postings"),
"applications" | "my applications"
| "shortlisted candidates" => Some("applications"),
"my requirements" | "requirements"
| "post requirement" => Some("requirements"),
"credits" | "tracecoins" | "wallet" => Some("wallet"),
"services" | "my services" => Some("services"),
"explore nxtgauge" | "explore" => Some("onboarding"),
"verification" | "verification status" => Some("verification"),
"notifications" => Some("notifications"),
"help center" | "support" => Some("support"),
"settings" => Some("settings"),
"switch services" | "logout" => None, // UI-only, not a module
_ => None,
}
}
fn parse_permission_key(key: &str) -> Option<(String, String)> {
// Format: MODULE:Action (e.g. DEPARTMENT_MANAGEMENT:View)
if let Some((module, action)) = key.split_once(':') {
let module = module.trim().to_string();
let action = action.trim().to_uppercase();
if !module.is_empty() && matches!(action.as_str(), "VIEW" | "CREATE" | "UPDATE" | "DELETE") {
return Some((module, action));
}
}
// Format: module.action (e.g. departments.view)
if let Some((module, action)) = key.split_once('.') {
let module = module.trim().to_uppercase();
let action = action.trim().to_uppercase();
if !module.is_empty() && matches!(action.as_str(), "VIEW" | "CREATE" | "UPDATE" | "DELETE") {
return Some((module, action));
}
}
// Format: MODULE_ACTION (e.g. DEPARTMENTS_VIEW)
let parts: Vec<&str> = key.split('_').collect();
if parts.len() >= 2 {
let action = parts[parts.len() - 1].trim().to_uppercase();
if matches!(action.as_str(), "VIEW" | "CREATE" | "UPDATE" | "DELETE") {
let module = parts[..parts.len() - 1].join("_").trim().to_string();
if !module.is_empty() {
return Some((module, action));
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::parse_permission_key;
#[test]
fn parses_colon_format() {
let parsed = parse_permission_key("INTERNAL_DASHBOARD_CONFIG:View");
assert_eq!(
parsed,
Some(("INTERNAL_DASHBOARD_CONFIG".to_string(), "VIEW".to_string()))
);
}
#[test]
fn parses_dot_format() {
let parsed = parse_permission_key("verification_management.update");
assert_eq!(
parsed,
Some(("VERIFICATION_MANAGEMENT".to_string(), "UPDATE".to_string()))
);
}
#[test]
fn parses_underscore_suffix_format() {
let parsed = parse_permission_key("APPROVAL_MANAGEMENT_DELETE");
assert_eq!(
parsed,
Some(("APPROVAL_MANAGEMENT".to_string(), "DELETE".to_string()))
);
}
}
async fn create_onboarding_config(
State(state): State<AppState>,
Json(payload): Json<CreateOnboardingConfigPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::create_onboarding_config(&state.pool, payload).await {
Ok(config) => Ok((StatusCode::CREATED, Json(config))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn get_active_onboarding_config(
State(state): State<AppState>,
Path(role_id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::get_active_onboarding_config(&state.pool, role_id).await {
Ok(config) => Ok((StatusCode::OK, Json(config))),
Err(sqlx::Error::RowNotFound) => Err((
StatusCode::NOT_FOUND,
"Active onboarding config not found".to_string(),
)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn list_onboarding_configs(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::get_all_onboarding_configs(&state.pool).await {
Ok(configs) => Ok((StatusCode::OK, Json(configs))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn create_dashboard_config(
State(state): State<AppState>,
Json(payload): Json<CreateDashboardConfigPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::create_dashboard_config(&state.pool, payload).await {
Ok(config) => Ok((StatusCode::CREATED, Json(config))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
#[derive(Deserialize)]
struct DashboardQuery {
audience: String,
}
async fn get_active_dashboard_config(
State(state): State<AppState>,
Path(role_id): Path<Uuid>,
Query(query): Query<DashboardQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::get_active_dashboard_config(&state.pool, role_id, &query.audience).await {
Ok(config) => Ok((StatusCode::OK, Json(config))),
Err(sqlx::Error::RowNotFound) => Err((
StatusCode::NOT_FOUND,
"Active dashboard config not found".to_string(),
)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn list_dashboard_configs(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::get_all_dashboard_configs(&state.pool).await {
Ok(configs) => Ok((StatusCode::OK, Json(configs))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn create_runtime_config(
State(state): State<AppState>,
Json(payload): Json<CreateRuntimeConfigPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::create_runtime_config(&state.pool, payload).await {
Ok(config) => Ok((StatusCode::CREATED, Json(config))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn get_active_runtime_config(
State(state): State<AppState>,
Path(role_id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::get_active_runtime_config(&state.pool, role_id).await {
Ok(config) => Ok((StatusCode::OK, Json(config))),
Err(sqlx::Error::RowNotFound) => Err((
StatusCode::NOT_FOUND,
"Active runtime config not found".to_string(),
)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn get_onboarding_config_by_key(
State(state): State<AppState>,
Path(role_key): Path<String>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::get_active_onboarding_by_role_key(&state.pool, &role_key).await {
Ok(config) => Ok((StatusCode::OK, Json(config))),
Err(sqlx::Error::RowNotFound) => Err((
StatusCode::NOT_FOUND,
format!("Active onboarding config for role '{}' not found", role_key),
)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
async fn get_dashboard_config_by_key(
State(state): State<AppState>,
Path(role_key): Path<String>,
Query(query): Query<DashboardQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match ConfigRepository::get_active_dashboard_by_role_key(&state.pool, &role_key, &query.audience).await {
Ok(config) => Ok((StatusCode::OK, Json(config))),
Err(sqlx::Error::RowNotFound) => Err((
StatusCode::NOT_FOUND,
format!("Active dashboard config for role '{}' not found", role_key),
)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}