mirror of
https://github.com/Traceworks2023/nxtgauge-backend-rust.git
synced 2026-06-11 22:15:25 +00:00
497 lines
17 KiB
Rust
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)
|
|
}
|