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

497 lines
17 KiB
Rust

use crate::AppState;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, patch},
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("/by-key/{role_key}", get(get_external_role_by_key))
.route("/by-key/{role_key}", patch(update_external_role_by_key))
.route("/{id}", get(get_external_role).put(update_external_role).delete(delete_external_role))
}
#[derive(Deserialize)]
struct ListQuery {
q: Option<String>,
status: Option<String>,
vertical: Option<String>,
category: Option<String>,
page: Option<i64>,
per_page: Option<i64>,
}
#[derive(Serialize)]
struct ExternalRoleRow {
id: Uuid,
name: String,
code: String,
persona_type: Option<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,
}
#[derive(sqlx::FromRow)]
struct ExternalRoleListRow {
id: Uuid,
name: String,
code: String,
persona_type: Option<String>,
is_active: bool,
created_date: chrono::DateTime<chrono::Utc>,
updated_at: Option<chrono::DateTime<chrono::Utc>>,
config_json: Option<serde_json::Value>,
}
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();
let rows = sqlx::query_as::<_, ExternalRoleListRow>(
r#"
SELECT
r.id,
r.name,
r.key as code,
r.persona_type,
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 role_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
"#,
)
.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 total: i64 = sqlx::query_scalar::<_, i64>(
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))
"#,
)
.bind(&search)
.bind(&status)
.fetch_one(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
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();
}
}
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;
}
let assigned_users: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM user_role_assignments WHERE role_id = $1 AND status = 'APPROVED'",
)
.bind(row.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
roles.push(ExternalRoleRow {
id: row.id,
name: row.name,
code: row.code,
persona_type: row.persona_type.or(vertical_v.clone()),
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>>,
}
#[derive(sqlx::FromRow)]
struct ExternalRoleDetailRow {
id: Uuid,
name: String,
code: String,
audience: String,
is_active: bool,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: Option<chrono::DateTime<chrono::Utc>>,
config_json: Option<serde_json::Value>,
}
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_as::<_, ExternalRoleDetailRow>(
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 role_runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true
WHERE r.id = $1 AND r.audience = 'EXTERNAL'
"#,
)
.bind(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,
}))
}
async fn get_external_role_by_key(
auth: AuthUser,
State(state): State<AppState>,
Path(role_key): Path<String>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
let row = sqlx::query_as::<_, ExternalRoleDetailRow>(
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 role_runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true
WHERE r.key = $1 AND r.audience = 'EXTERNAL'
"#,
)
.bind(&role_key)
.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,
}))
}
async fn update_external_role_by_key(
auth: AuthUser,
State(state): State<AppState>,
Path(role_key): Path<String>,
Json(payload): Json<UpdateExternalRolePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
let row: (Uuid,) = sqlx::query_as("SELECT id FROM roles WHERE key = $1 AND audience = 'EXTERNAL'")
.bind(&role_key)
.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()))?;
update_external_role_impl(&state, row.0, payload).await
}
#[derive(Deserialize)]
struct CreateExternalRolePayload {
name: String,
code: String,
is_active: Option<bool>,
persona_type: Option<String>,
runtime: Option<JsonValue>,
}
#[derive(sqlx::FromRow)]
struct InsertedRole {
id: Uuid,
key: String,
name: String,
audience: String,
is_active: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(sqlx::FromRow)]
struct InsertedRc {
updated_at: chrono::DateTime<chrono::Utc>,
}
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);
let role = sqlx::query_as::<_, InsertedRole>(
r#"
INSERT INTO roles (key, name, audience, is_active, persona_type)
VALUES ($1, $2, 'EXTERNAL', $3, $4)
RETURNING id, key, name, audience, is_active, created_at
"#,
)
.bind(payload.code.to_uppercase())
.bind(&payload.name)
.bind(is_active)
.bind(&payload.persona_type)
.fetch_one(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let runtime = payload.runtime.unwrap_or_else(|| serde_json::json!({}));
let rc = sqlx::query_as::<_, InsertedRc>(
r#"
INSERT INTO role_runtime_configs (role_id, config_json, version, is_active)
VALUES ($1, $2, 1, true)
RETURNING updated_at
"#,
)
.bind(role.id)
.bind(&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,
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_external_role_impl(&state, id, payload).await
}
async fn update_external_role_impl(
state: &AppState,
role_id: Uuid,
payload: UpdateExternalRolePayload,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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'
"#,
)
.bind(payload.name)
.bind(payload.is_active)
.bind(role_id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
}
if let Some(runtime) = payload.runtime {
sqlx::query(
r#"
UPDATE role_runtime_configs
SET is_active = false
WHERE role_id = $1 AND is_active = true
"#,
)
.bind(role_id)
.execute(&state.pool)
.await
.ok();
sqlx::query(
r#"
INSERT INTO role_runtime_configs (role_id, config_json, version, is_active)
VALUES (
$1,
$2,
COALESCE((SELECT MAX(version) FROM role_runtime_configs WHERE role_id = $1), 0) + 1,
true
)
"#,
)
.bind(role_id)
.bind(runtime)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
}
// Return the updated role detail
let row = sqlx::query_as::<_, ExternalRoleDetailRow>(
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 role_runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true
WHERE r.id = $1 AND r.audience = 'EXTERNAL'
"#,
)
.bind(role_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,
}))
}
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'")
.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, "External role not found".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}