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

372 lines
13 KiB
Rust

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