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 { 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 { 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 { 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 { 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, role_key: Option, } async fn list_runtime_configs( auth: AuthUser, State(state): State, Query(q): Query, ) -> Result { 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, } 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::>() } 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::>() }; Ok((StatusCode::OK, Json(serde_json::json!({ "items": items })))) } async fn get_runtime_config_by_id( auth: AuthUser, State(state): State, Path(id): Path, ) -> Result { 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, } 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, Json(payload): Json, ) -> Result { 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, Path(id): Path, ) -> Result { 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, Path(id): Path, ) -> Result { 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, ) -> Result { 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 = 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> = 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::>(); 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::>())); } 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 = 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 = 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, Json(payload): Json, ) -> Result { 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, Path(role_id): Path, ) -> Result { 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, ) -> Result { 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, Json(payload): Json, ) -> Result { 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, Path(role_id): Path, Query(query): Query, ) -> Result { 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, ) -> Result { 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, Json(payload): Json, ) -> Result { 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, Path(role_id): Path, ) -> Result { 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, Path(role_key): Path, ) -> Result { 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, Path(role_key): Path, Query(query): Query, ) -> Result { 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), )), } }