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!("SELECT id FROM roles WHERE key = $1", key.to_uppercase()) .fetch_optional(&state.pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? } else { None }; let items = if let Some(rid) = role_id { sqlx::query!( r#" SELECT id, role_id, config_json, version, is_active, updated_at FROM runtime_configs WHERE role_id = $1 ORDER BY version DESC "#, 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!( 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())); } let r = sqlx::query!( "SELECT id, role_id, config_json, version, is_active, updated_at FROM runtime_configs WHERE id = $1", 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 = sqlx::query!("SELECT role_id FROM runtime_configs WHERE id = $1", 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", role.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", 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", 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(); let role = sqlx::query!("SELECT id, key, audience FROM roles WHERE key = $1", 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!( "SELECT permission_key FROM role_permissions WHERE role_id = $1 ORDER BY permission_key", 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)); } } Ok((StatusCode::OK, Json(response))) } 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), )), } }