From 7dec3e85fb0bc452ac60ee569c5c14e32e09302a Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Fri, 27 Mar 2026 21:25:31 +0100 Subject: [PATCH] feat(users): admin runtime configs + external roles + employees APIs --- apps/gateway/src/main.rs | 5 + apps/users/src/handlers/config.rs | 180 ++++++++++- apps/users/src/handlers/departments.rs | 25 +- apps/users/src/handlers/designations.rs | 25 +- apps/users/src/handlers/employees.rs | 289 +++++++++++++++++ apps/users/src/handlers/external_roles.rs | 372 ++++++++++++++++++++++ apps/users/src/handlers/mod.rs | 2 + apps/users/src/main.rs | 3 + 8 files changed, 896 insertions(+), 5 deletions(-) create mode 100644 apps/users/src/handlers/employees.rs create mode 100644 apps/users/src/handlers/external_roles.rs diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index 81a241e..0887b9e 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -74,6 +74,7 @@ impl Services { || path.starts_with("/api/runtime-config") || path.starts_with("/api/config") || path.starts_with("/api/admin/roles") + || path.starts_with("/api/admin/external-roles") || path.starts_with("/api/admin/permissions") || path.starts_with("/api/admin/onboarding-config") || path.starts_with("/api/admin/dashboard-config") @@ -149,6 +150,10 @@ impl Services { else if path.starts_with("/api/credits") { Some(self.payments_url.clone()) } + // Admin runtime config management defaults to users service + else if path.starts_with("/api/admin/runtime-configs") { + Some(self.users_url.clone()) + } else { None } diff --git a/apps/users/src/handlers/config.rs b/apps/users/src/handlers/config.rs index e0c5d2d..954c52c 100644 --- a/apps/users/src/handlers/config.rs +++ b/apps/users/src/handlers/config.rs @@ -6,6 +6,7 @@ use axum::{ routing::{get, post}, Json, Router, }; +use contracts::auth_middleware::{AuthUser, require_admin}; use db::models::config::{ ConfigRepository, CreateDashboardConfigPayload, CreateOnboardingConfigPayload, CreateRuntimeConfigPayload, @@ -34,6 +35,184 @@ pub fn runtime_router() -> Router { .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, @@ -236,4 +415,3 @@ async fn get_dashboard_config_by_key( )), } } - diff --git a/apps/users/src/handlers/departments.rs b/apps/users/src/handlers/departments.rs index 59f4c1f..25d2e13 100644 --- a/apps/users/src/handlers/departments.rs +++ b/apps/users/src/handlers/departments.rs @@ -6,6 +6,7 @@ use axum::{ routing::get, Json, Router, }; +use contracts::auth_middleware::{AuthUser, require_admin}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::Row; @@ -96,9 +97,13 @@ fn normalize_visibility(value: Option, fallback: &str) -> String { } async fn list_departments( + auth: AuthUser, State(state): State, Query(params): Query, ) -> Result { + if let Err(_e) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } let page = params.page.unwrap_or(1).max(1); let per_page = params.per_page.or(params.limit).unwrap_or(20).clamp(1, 100); let offset = (page - 1) * per_page; @@ -191,9 +196,13 @@ async fn list_departments( } async fn get_department( + auth: AuthUser, State(state): State, Path(id): Path, ) -> Result { + if let Err(_e) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } let row = sqlx::query( r#" SELECT @@ -243,9 +252,13 @@ async fn get_department( } async fn create_department( + auth: AuthUser, State(state): State, Json(payload): Json, ) -> Result { + if let Err(_e) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } let name = payload.name.trim(); if name.is_empty() { return Err((StatusCode::BAD_REQUEST, "Name is required".to_string())); @@ -283,15 +296,19 @@ async fn create_department( })?; let id: Uuid = row.get("id"); - let response = get_department(State(state), Path(id)).await?; + let response = get_department(auth, State(state), Path(id)).await?; Ok((StatusCode::CREATED, response)) } async fn update_department( + auth: AuthUser, State(state): State, Path(id): Path, Json(payload): Json, ) -> Result { + if let Err(_e) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } let current = sqlx::query( r#" SELECT @@ -373,13 +390,17 @@ async fn update_department( } })?; - get_department(State(state), Path(id)).await + get_department(auth, State(state), Path(id)).await } async fn delete_department( + 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 departments WHERE id = $1") .bind(id) .execute(&state.pool) diff --git a/apps/users/src/handlers/designations.rs b/apps/users/src/handlers/designations.rs index b88d55a..eca836d 100644 --- a/apps/users/src/handlers/designations.rs +++ b/apps/users/src/handlers/designations.rs @@ -6,6 +6,7 @@ use axum::{ routing::get, Json, Router, }; +use contracts::auth_middleware::{AuthUser, require_admin}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::Row; @@ -91,9 +92,13 @@ fn derive_is_active(status: &Option, is_active: Option, current: b } async fn list_designations( + auth: AuthUser, State(state): State, Query(params): Query, ) -> Result { + if let Err(_e) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } let page = params.page.unwrap_or(1).max(1); let per_page = params.per_page.or(params.limit).unwrap_or(20).clamp(1, 100); let offset = (page - 1) * per_page; @@ -193,9 +198,13 @@ async fn list_designations( } async fn get_designation( + auth: AuthUser, State(state): State, Path(id): Path, ) -> Result { + if let Err(_e) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } let row = sqlx::query( r#" SELECT @@ -248,9 +257,13 @@ async fn get_designation( } async fn create_designation( + auth: AuthUser, State(state): State, Json(payload): Json, ) -> Result { + if let Err(_e) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } let name = payload.name.trim(); if name.is_empty() { return Err((StatusCode::BAD_REQUEST, "Name is required".to_string())); @@ -287,15 +300,19 @@ async fn create_designation( })?; let id: Uuid = row.get("id"); - let response = get_designation(State(state), Path(id)).await?; + let response = get_designation(auth, State(state), Path(id)).await?; Ok((StatusCode::CREATED, response)) } async fn update_designation( + auth: AuthUser, State(state): State, Path(id): Path, Json(payload): Json, ) -> Result { + if let Err(_e) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } let current = sqlx::query( r#" SELECT name, code, department_id, description, level, can_manage_team, can_approve, is_active @@ -376,13 +393,17 @@ async fn update_designation( } })?; - get_designation(State(state), Path(id)).await + get_designation(auth, State(state), Path(id)).await } async fn delete_designation( + 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 designations WHERE id = $1") .bind(id) .execute(&state.pool) diff --git a/apps/users/src/handlers/employees.rs b/apps/users/src/handlers/employees.rs new file mode 100644 index 0000000..1bd9177 --- /dev/null +++ b/apps/users/src/handlers/employees.rs @@ -0,0 +1,289 @@ +use crate::AppState; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{delete, get, post, patch}, + Json, Router, +}; +use contracts::auth_middleware::{AuthUser, require_admin}; +use serde::{Deserialize, Serialize}; +use sqlx::types::Uuid; +use sqlx::Row; + +pub fn router() -> Router { + Router::new() + .route("/", get(list_employees).post(create_employee)) + .route("/{id}", get(get_employee).patch(update_employee).delete(delete_employee)) +} + +#[derive(Deserialize)] +struct ListQuery { + q: Option, + status: Option, // ACTIVE | INACTIVE | SUSPENDED + page: Option, + per_page: Option, + sort: Option, // name_asc | name_desc | joined_asc | joined_desc +} + +#[derive(Serialize)] +struct EmployeeRow { + id: Uuid, + user_id: Uuid, + name: String, + email: String, + phone: Option, + employee_id: Option, + department_name: Option, + designation_name: Option, + role_name: Option, + role_key: Option, + joining_date: String, + status: String, + updated_at: Option>, +} + +#[derive(Serialize)] +struct ListResponse { + employees: Vec, + total: i64, + page: i64, + per_page: i64, +} + +async fn list_employees( + auth: AuthUser, + State(state): State, + Query(q): Query, +) -> Result { + if let Err(_e) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } + let page = q.page.unwrap_or(1).max(1); + let per_page = q.per_page.unwrap_or(20).clamp(1, 100); + let offset = (page - 1) * per_page; + let search = q.q.unwrap_or_default().to_lowercase(); + let status = q.status.unwrap_or_default().to_uppercase(); + let sort_sql = match q.sort.as_deref() { + Some("name_asc") => "u.full_name ASC", + Some("name_desc") => "u.full_name DESC", + Some("joined_asc") => "e.created_at ASC", + _ => "e.created_at DESC", + }; + let sql = format!(r#" + SELECT + e.id, + e.user_id, + e.employee_code, + e.created_at AS joining_date, + u.full_name, + u.email, + u.phone, + u.status AS user_status, + r.name AS role_name, + r.key AS role_key, + d.name AS department_name, + des.name AS designation_name, + e.created_at AS updated_at + FROM employees e + JOIN users u ON u.id = e.user_id + LEFT JOIN roles r ON r.id = e.role_id + LEFT JOIN departments d ON d.id = e.department_id + LEFT JOIN designations des ON des.id = e.designation_id + WHERE ($1 = '' OR LOWER(u.full_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%' OR LOWER(COALESCE(e.employee_code,'')) LIKE '%' || $1 || '%') + AND ( + $2 = '' + OR ($2 = 'ACTIVE' AND u.status = 'ACTIVE') + OR ($2 = 'INACTIVE' AND u.status = 'INACTIVE') + OR ($2 = 'SUSPENDED' AND u.status = 'SUSPENDED') + ) + ORDER BY {sort} + LIMIT $3 OFFSET $4 + "#, sort = sort_sql); + let rows = sqlx::query(&sql) + .bind(&search) + .bind(&status) + .bind(per_page) + .bind(offset) + .fetch_all(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + let employees = rows.into_iter().map(|row| { + let joining: chrono::DateTime = row.get::, _>("joining_date"); + EmployeeRow { + id: row.get("id"), + user_id: row.get("user_id"), + name: row.get::, _>("full_name").unwrap_or_default(), + email: row.get("email"), + phone: row.get::, _>("phone"), + employee_id: row.get::, _>("employee_code"), + department_name: row.get::, _>("department_name"), + designation_name: row.get::, _>("designation_name"), + role_name: row.get::, _>("role_name"), + role_key: row.get::, _>("role_key"), + joining_date: joining.to_rfc3339(), + status: row.get::, _>("user_status").unwrap_or_else(|| "ACTIVE".to_string()), + updated_at: Some(row.get::, _>("updated_at")), + } + }).collect::>(); + + let total: i64 = sqlx::query_scalar!( + r#" + SELECT COUNT(*) + FROM employees e + JOIN users u ON u.id = e.user_id + WHERE ($1 = '' OR LOWER(u.full_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%' OR LOWER(COALESCE(e.employee_code,'')) LIKE '%' || $1 || '%') + AND ( + $2 = '' + OR ($2 = 'ACTIVE' AND u.status = 'ACTIVE') + OR ($2 = 'INACTIVE' AND u.status = 'INACTIVE') + OR ($2 = 'SUSPENDED' AND u.status = 'SUSPENDED') + ) + "#, + search, + status + ) + .fetch_one(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? + .unwrap_or(0); + + Ok(Json(ListResponse { employees, total, page, per_page })) +} + +async fn get_employee( + auth: AuthUser, + State(state): State, + Path(id): Path, +) -> Result { + if let Err(_e) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } + let sql = r#" + SELECT + e.id, e.user_id, e.employee_code, + e.created_at AS joining_date, e.created_at AS updated_at, + u.full_name, u.email, u.phone, u.status AS user_status, + r.name AS role_name, r.key AS role_key, + d.name AS department_name, + des.name AS designation_name + FROM employees e + JOIN users u ON u.id = e.user_id + LEFT JOIN roles r ON r.id = e.role_id + LEFT JOIN departments d ON d.id = e.department_id + LEFT JOIN designations des ON des.id = e.designation_id + WHERE e.id = $1 + "#; + let row = sqlx::query(sql) + .bind(id) + .fetch_optional(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? + .ok_or((StatusCode::NOT_FOUND, "Employee not found".to_string()))?; + let joining: chrono::DateTime = row.get::, _>("joining_date"); + Ok(Json(EmployeeRow { + id: row.get("id"), + user_id: row.get("user_id"), + name: row.get::, _>("full_name").unwrap_or_default(), + email: row.get("email"), + phone: row.get::, _>("phone"), + employee_id: row.get::, _>("employee_code"), + department_name: row.get::, _>("department_name"), + designation_name: row.get::, _>("designation_name"), + role_name: row.get::, _>("role_name"), + role_key: row.get::, _>("role_key"), + joining_date: joining.to_rfc3339(), + status: row.get::, _>("user_status").unwrap_or_else(|| "ACTIVE".to_string()), + updated_at: Some(row.get::, _>("updated_at")), + })) +} + +#[derive(Deserialize)] +struct CreateEmployeePayload { + user_id: Uuid, + role_id: Uuid, + department_id: Option, + designation_id: Option, + employee_code: Option, +} + +async fn create_employee( + auth: AuthUser, + State(state): State, + Json(p): Json, +) -> Result { + if let Err(_e) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } + let row = sqlx::query!( + r#" + INSERT INTO employees (user_id, role_id, department_id, designation_id, employee_code) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_id) DO UPDATE SET + role_id = EXCLUDED.role_id, + department_id = EXCLUDED.department_id, + designation_id = EXCLUDED.designation_id, + employee_code = EXCLUDED.employee_code + RETURNING id + "#, + p.user_id, p.role_id, p.department_id, p.designation_id, p.employee_code + ) + .fetch_one(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + get_employee(auth, State(state), Path(row.id)).await +} + +#[derive(Deserialize)] +struct UpdateEmployeePayload { + role_id: Option, + department_id: Option, + designation_id: Option, + employee_code: Option, +} + +async fn update_employee( + auth: AuthUser, + State(state): State, + Path(id): Path, + Json(p): Json, +) -> Result { + if let Err(_e) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } + sqlx::query!( + r#" + UPDATE employees + SET + role_id = COALESCE($1, role_id), + department_id = COALESCE($2, department_id), + designation_id = COALESCE($3, designation_id), + employee_code = COALESCE($4, employee_code) + WHERE id = $5 + "#, + p.role_id, p.department_id, p.designation_id, p.employee_code, id + ) + .execute(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + get_employee(auth, State(state), Path(id)).await +} + +async fn delete_employee( + 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 employees 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, "Employee not found".to_string())); + } + Ok(StatusCode::NO_CONTENT) +} diff --git a/apps/users/src/handlers/external_roles.rs b/apps/users/src/handlers/external_roles.rs new file mode 100644 index 0000000..9d45e39 --- /dev/null +++ b/apps/users/src/handlers/external_roles.rs @@ -0,0 +1,372 @@ +use crate::AppState; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{delete, get, post, put}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use sqlx::types::Uuid; +use contracts::auth_middleware::{AuthUser, require_admin}; + +pub fn router() -> Router { + Router::new() + .route("/", get(list_external_roles).post(create_external_role)) + .route("/{id}", get(get_external_role).put(update_external_role).delete(delete_external_role)) +} + +#[derive(Deserialize)] +struct ListQuery { + q: Option, + status: Option, // ACTIVE | INACTIVE + vertical: Option, // jobs | marketplace + category: Option, // provider | employer | consumer | specialist + page: Option, + per_page: Option, +} + +#[derive(Serialize)] +struct ExternalRoleRow { + id: Uuid, + name: String, + code: String, + vertical: Option, + category: Option, + onboarding_schema_id: Option, + modules: Vec, + permissions: serde_json::Map, + requires_onboarding_approval: bool, + requires_lead_approval: bool, + requires_job_approval: bool, + feature_limits: JsonValue, + status: String, + assigned_users: i64, + assigned_user_types: Vec, + created_date: chrono::DateTime, + updated_at: Option>, +} + +#[derive(Serialize)] +struct ListResponse { + roles: Vec, + total: i64, + page: i64, + per_page: i64, +} + +async fn list_external_roles( + auth: AuthUser, + State(state): State, + Query(q): Query, + ) -> Result { + if let Err(_e) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } + let page = q.page.unwrap_or(1).max(1); + let per_page = q.per_page.unwrap_or(20).min(100); + let offset = (page - 1) * per_page; + let search = q.q.unwrap_or_default().to_lowercase(); + let status = q.status.unwrap_or_default().to_uppercase(); + let vertical = q.vertical.unwrap_or_default().to_lowercase(); + let category = q.category.unwrap_or_default().to_lowercase(); + + // Join roles with active runtime_config for that role (optional) and count assigned user_roles + let rows = sqlx::query!( + r#" + SELECT + r.id, + r.name, + r.key as code, + r.is_active, + r.created_at as created_date, + rc.updated_at as "updated_at?", + rc.config_json as "config_json?" + FROM roles r + LEFT JOIN runtime_configs rc + ON rc.role_id = r.id AND rc.is_active = true + WHERE r.audience = 'EXTERNAL' + AND ($1 = '' OR LOWER(r.name) LIKE '%' || $1 || '%' OR LOWER(r.key) LIKE '%' || $1 || '%') + AND ($2 = '' OR (CASE WHEN $2 = 'ACTIVE' THEN r.is_active ELSE NOT r.is_active END)) + ORDER BY r.created_at DESC + LIMIT $3 OFFSET $4 + "#, + search, + status, + per_page, + offset + ) + .fetch_all(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + // Compute total with same filters + let total: i64 = sqlx::query_scalar!( + r#" + SELECT COUNT(*) + FROM roles r + WHERE r.audience = 'EXTERNAL' + AND ($1 = '' OR LOWER(r.name) LIKE '%' || $1 || '%' OR LOWER(r.key) LIKE '%' || $1 || '%') + AND ($2 = '' OR (CASE WHEN $2 = 'ACTIVE' THEN r.is_active ELSE NOT r.is_active END)) + "#, + search, + status + ) + .fetch_one(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? + .unwrap_or(0); + + let mut roles: Vec = Vec::with_capacity(rows.len()); + for row in rows { + let (mut vertical_v, mut category_v, mut onboarding_schema_id, mut modules, mut permissions, mut requires_onboarding, mut requires_lead, mut requires_job, mut feature_limits, mut assigned_user_types) = (None, None, None, Vec::new(), serde_json::Map::new(), false, false, false, serde_json::json!({}), Vec::new()); + if let Some(cfg) = row.config_json { + vertical_v = cfg.get("vertical").and_then(|v| v.as_str()).map(|s| s.to_string()); + category_v = cfg.get("category").and_then(|v| v.as_str()).map(|s| s.to_string()); + onboarding_schema_id = cfg.get("onboarding_schema_id").and_then(|v| v.as_str()).map(|s| s.to_string()); + if let Some(arr) = cfg.get("modules").and_then(|v| v.as_array()) { + modules = arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect(); + } + if let Some(obj) = cfg.get("permissions").and_then(|v| v.as_object()) { + permissions = obj.clone(); + } + requires_onboarding = cfg.pointer("/requires/onboarding").and_then(|v| v.as_bool()).unwrap_or(false); + requires_lead = cfg.pointer("/requires/lead").and_then(|v| v.as_bool()).unwrap_or(false); + requires_job = cfg.pointer("/requires/job").and_then(|v| v.as_bool()).unwrap_or(false); + feature_limits = cfg.get("feature_limits").cloned().unwrap_or_else(|| serde_json::json!({})); + if let Some(arr) = cfg.get("assigned_user_types").and_then(|v| v.as_array()) { + assigned_user_types = arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect(); + } + } + // Additional filters by vertical/category after extracting from config + if !vertical.is_empty() && vertical_v.as_deref() != Some(vertical.as_str()) { + continue; + } + if !category.is_empty() && category_v.as_deref() != Some(category.as_str()) { + continue; + } + // Count assigned users from user_roles (approved) + let assigned_users: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM user_roles WHERE role_id = $1 AND status = 'APPROVED'", + row.id + ) + .fetch_one(&state.pool) + .await + .unwrap_or(Some(0)) + .unwrap_or(0); + + roles.push(ExternalRoleRow { + id: row.id, + name: row.name, + code: row.code, + vertical: vertical_v, + category: category_v, + onboarding_schema_id, + modules, + permissions, + requires_onboarding_approval: requires_onboarding, + requires_lead_approval: requires_lead, + requires_job_approval: requires_job, + feature_limits, + status: if row.is_active { "ACTIVE".to_string() } else { "INACTIVE".to_string() }, + assigned_users, + assigned_user_types, + created_date: row.created_date, + updated_at: row.updated_at, + }); + } + + Ok(Json(ListResponse { roles, total, page, per_page })) +} + +#[derive(Serialize)] +struct ExternalRoleDetail { + id: Uuid, + name: String, + code: String, + audience: String, + is_active: bool, + runtime: JsonValue, + created_at: chrono::DateTime, + updated_at: Option>, +} + +async fn get_external_role( + auth: AuthUser, + State(state): State, + Path(id): Path, + ) -> Result { + if let Err(_e) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } + let row = sqlx::query!( + r#" + SELECT r.id, r.name, r.key as code, r.audience, r.is_active, r.created_at, rc.updated_at as "updated_at?", rc.config_json as "config_json?" + FROM roles r + LEFT JOIN runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true + WHERE r.id = $1 AND r.audience = 'EXTERNAL' + "#, + id + ) + .fetch_optional(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? + .ok_or((StatusCode::NOT_FOUND, "External role not found".to_string()))?; + + Ok(Json(ExternalRoleDetail { + id: row.id, + name: row.name, + code: row.code, + audience: row.audience, + is_active: row.is_active, + runtime: row.config_json.unwrap_or_else(|| serde_json::json!({})), + created_at: row.created_at, + updated_at: row.updated_at, + })) +} + +#[derive(Deserialize)] +struct CreateExternalRolePayload { + name: String, + code: String, + is_active: Option, + runtime: JsonValue, // carries vertical/category/modules/permissions/assigned_user_types/requires/feature_limits/onboarding_schema_id +} + +async fn create_external_role( + auth: AuthUser, + State(state): State, + Json(payload): Json, + ) -> Result { + if let Err(_e) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } + let is_active = payload.is_active.unwrap_or(true); + // Insert role + let role = sqlx::query!( + r#" + INSERT INTO roles (key, name, audience, is_active) + VALUES ($1, $2, 'EXTERNAL', $3) + RETURNING id, key, name, audience, is_active, created_at + "#, + payload.code.to_uppercase(), + payload.name, + is_active + ) + .fetch_one(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + // Create runtime config version 1 + let rc = sqlx::query!( + r#" + INSERT INTO runtime_configs (role_id, config_json, version, is_active) + VALUES ($1, $2, 1, true) + RETURNING updated_at + "#, + role.id, + payload.runtime + ) + .fetch_one(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + Ok(( + StatusCode::CREATED, + Json(ExternalRoleDetail { + id: role.id, + name: role.name, + code: role.key, + audience: role.audience, + is_active: role.is_active, + runtime: payload.runtime, + created_at: role.created_at, + updated_at: Some(rc.updated_at), + }), + )) +} + +#[derive(Deserialize)] +struct UpdateExternalRolePayload { + name: Option, + is_active: Option, + runtime: Option, +} + +async fn update_external_role( + auth: AuthUser, + State(state): State, + Path(id): Path, + Json(payload): Json, + ) -> Result { + if let Err(_e) = require_admin(&auth) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } + // Update role basic fields + if payload.name.is_some() || payload.is_active.is_some() { + sqlx::query!( + r#" + UPDATE roles + SET name = COALESCE($1, name), + is_active = COALESCE($2, is_active) + WHERE id = $3 AND audience = 'EXTERNAL' + "#, + payload.name, + payload.is_active, + id + ) + .execute(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + } + // Create a new runtime config version if provided + if let Some(runtime) = payload.runtime { + sqlx::query!( + r#" + UPDATE runtime_configs + SET is_active = false + WHERE role_id = $1 AND is_active = true + "#, + id + ) + .execute(&state.pool) + .await + .ok(); + sqlx::query!( + r#" + INSERT INTO runtime_configs (role_id, config_json, version, is_active) + VALUES ( + $1, + $2, + COALESCE((SELECT MAX(version) FROM runtime_configs WHERE role_id = $1), 0) + 1, + true + ) + "#, + id, + runtime + ) + .execute(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + } + get_external_role(auth, State(state), Path(id)).await +} + +async fn delete_external_role( + 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 roles WHERE id = $1 AND audience = 'EXTERNAL'", 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, "External role not found".to_string())); + } + Ok(StatusCode::NO_CONTENT) +} diff --git a/apps/users/src/handlers/mod.rs b/apps/users/src/handlers/mod.rs index c4fef50..deca835 100644 --- a/apps/users/src/handlers/mod.rs +++ b/apps/users/src/handlers/mod.rs @@ -4,8 +4,10 @@ pub mod config; pub mod dashboard; pub mod departments; pub mod designations; +pub mod employees; pub mod notifications; pub mod onboarding; pub mod permissions; pub mod roles; pub mod user_roles; +pub mod external_roles; diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index 6107ec7..25e139e 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -59,6 +59,8 @@ async fn main() { .nest("/api/admin/permissions", handlers::permissions::router()) .nest("/api/admin/departments", handlers::departments::router()) .nest("/api/admin/designations", handlers::designations::router()) + .nest("/api/admin/employees", handlers::employees::router()) + .nest("/api/admin/external-roles", handlers::external_roles::router()) .nest("/api/me/roles", handlers::user_roles::router()) // ── Notifications ───────────────────────────────────────────────── .nest("/api/me/notifications", handlers::notifications::router()) @@ -72,6 +74,7 @@ async fn main() { .nest("/api/admin/onboarding-config", handlers::config::onboarding_router()) .nest("/api/admin/dashboard-config", handlers::config::dashboard_router()) .nest("/api/admin/dashboard", handlers::dashboard::router()) + .nest("/api/admin/runtime-configs", handlers::config::admin_runtime_router()) // ── Public Config ───────────────────────────────────────────────── .nest("/api/config/onboarding", handlers::config::onboarding_router()) .nest("/api/config/dashboard", handlers::config::dashboard_router())