feat(users): admin runtime configs + external roles + employees APIs

This commit is contained in:
Ashwin Kumar 2026-03-27 21:25:31 +01:00
parent 3b28d9fd36
commit 7dec3e85fb
8 changed files with 896 additions and 5 deletions

View file

@ -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
}

View file

@ -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<AppState> {
.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!("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::<Vec<_>>()
} 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::<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()));
}
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<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 = 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<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", 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>,
@ -236,4 +415,3 @@ async fn get_dashboard_config_by_key(
)),
}
}

View file

@ -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<String>, fallback: &str) -> String {
}
async fn list_departments(
auth: AuthUser,
State(state): State<AppState>,
Query(params): Query<ListQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<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 row = sqlx::query(
r#"
SELECT
@ -243,9 +252,13 @@ async fn get_department(
}
async fn create_department(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<CreateDepartmentPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateDepartmentPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<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 departments WHERE id = $1")
.bind(id)
.execute(&state.pool)

View file

@ -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<String>, is_active: Option<bool>, current: b
}
async fn list_designations(
auth: AuthUser,
State(state): State<AppState>,
Query(params): Query<ListQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<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 row = sqlx::query(
r#"
SELECT
@ -248,9 +257,13 @@ async fn get_designation(
}
async fn create_designation(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<CreateDesignationPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateDesignationPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<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 designations WHERE id = $1")
.bind(id)
.execute(&state.pool)

View file

@ -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<AppState> {
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<String>,
status: Option<String>, // ACTIVE | INACTIVE | SUSPENDED
page: Option<i64>,
per_page: Option<i64>,
sort: Option<String>, // name_asc | name_desc | joined_asc | joined_desc
}
#[derive(Serialize)]
struct EmployeeRow {
id: Uuid,
user_id: Uuid,
name: String,
email: String,
phone: Option<String>,
employee_id: Option<String>,
department_name: Option<String>,
designation_name: Option<String>,
role_name: Option<String>,
role_key: Option<String>,
joining_date: String,
status: String,
updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Serialize)]
struct ListResponse {
employees: Vec<EmployeeRow>,
total: i64,
page: i64,
per_page: i64,
}
async fn list_employees(
auth: AuthUser,
State(state): State<AppState>,
Query(q): Query<ListQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<chrono::Utc> = row.get::<chrono::DateTime<chrono::Utc>, _>("joining_date");
EmployeeRow {
id: row.get("id"),
user_id: row.get("user_id"),
name: row.get::<Option<String>, _>("full_name").unwrap_or_default(),
email: row.get("email"),
phone: row.get::<Option<String>, _>("phone"),
employee_id: row.get::<Option<String>, _>("employee_code"),
department_name: row.get::<Option<String>, _>("department_name"),
designation_name: row.get::<Option<String>, _>("designation_name"),
role_name: row.get::<Option<String>, _>("role_name"),
role_key: row.get::<Option<String>, _>("role_key"),
joining_date: joining.to_rfc3339(),
status: row.get::<Option<String>, _>("user_status").unwrap_or_else(|| "ACTIVE".to_string()),
updated_at: Some(row.get::<chrono::DateTime<chrono::Utc>, _>("updated_at")),
}
}).collect::<Vec<_>>();
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<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 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<chrono::Utc> = row.get::<chrono::DateTime<chrono::Utc>, _>("joining_date");
Ok(Json(EmployeeRow {
id: row.get("id"),
user_id: row.get("user_id"),
name: row.get::<Option<String>, _>("full_name").unwrap_or_default(),
email: row.get("email"),
phone: row.get::<Option<String>, _>("phone"),
employee_id: row.get::<Option<String>, _>("employee_code"),
department_name: row.get::<Option<String>, _>("department_name"),
designation_name: row.get::<Option<String>, _>("designation_name"),
role_name: row.get::<Option<String>, _>("role_name"),
role_key: row.get::<Option<String>, _>("role_key"),
joining_date: joining.to_rfc3339(),
status: row.get::<Option<String>, _>("user_status").unwrap_or_else(|| "ACTIVE".to_string()),
updated_at: Some(row.get::<chrono::DateTime<chrono::Utc>, _>("updated_at")),
}))
}
#[derive(Deserialize)]
struct CreateEmployeePayload {
user_id: Uuid,
role_id: Uuid,
department_id: Option<Uuid>,
designation_id: Option<Uuid>,
employee_code: Option<String>,
}
async fn create_employee(
auth: AuthUser,
State(state): State<AppState>,
Json(p): Json<CreateEmployeePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<Uuid>,
department_id: Option<Uuid>,
designation_id: Option<Uuid>,
employee_code: Option<String>,
}
async fn update_employee(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(p): Json<UpdateEmployeePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<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 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)
}

View file

@ -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<AppState> {
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<String>,
status: Option<String>, // ACTIVE | INACTIVE
vertical: Option<String>, // jobs | marketplace
category: Option<String>, // provider | employer | consumer | specialist
page: Option<i64>,
per_page: Option<i64>,
}
#[derive(Serialize)]
struct ExternalRoleRow {
id: Uuid,
name: String,
code: String,
vertical: Option<String>,
category: Option<String>,
onboarding_schema_id: Option<String>,
modules: Vec<String>,
permissions: serde_json::Map<String, JsonValue>,
requires_onboarding_approval: bool,
requires_lead_approval: bool,
requires_job_approval: bool,
feature_limits: JsonValue,
status: String,
assigned_users: i64,
assigned_user_types: Vec<String>,
created_date: chrono::DateTime<chrono::Utc>,
updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Serialize)]
struct ListResponse {
roles: Vec<ExternalRoleRow>,
total: i64,
page: i64,
per_page: i64,
}
async fn list_external_roles(
auth: AuthUser,
State(state): State<AppState>,
Query(q): Query<ListQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<ExternalRoleRow> = 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<chrono::Utc>,
updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
async fn get_external_role(
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 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<bool>,
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<AppState>,
Json(payload): Json<CreateExternalRolePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<String>,
is_active: Option<bool>,
runtime: Option<JsonValue>,
}
async fn update_external_role(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateExternalRolePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<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 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)
}

View file

@ -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;

View file

@ -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())