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

436 lines
14 KiB
Rust
Raw Normal View History

use crate::AppState;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list_roles).post(create_role))
.route("/{id}", get(get_role).patch(update_role).delete(delete_role))
}
// ── Query params ─────────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct ListQuery {
audience: Option<String>,
q: Option<String>,
page: Option<i64>,
per_page: Option<i64>,
}
// ── Response types ───────────────────────────────────────────────────────────
#[derive(Serialize)]
struct RoleRow {
id: Uuid,
key: String,
name: String,
audience: String,
description: Option<String>,
department_id: Option<Uuid>,
department_name: Option<String>,
is_active: bool,
can_approve_requests: bool,
can_manage_system_settings: bool,
users_assigned: i64,
permissions_count: i64,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Serialize)]
struct ListResponse {
roles: Vec<RoleRow>,
total: i64,
page: i64,
per_page: i64,
}
#[derive(Serialize)]
struct RoleDetail {
id: Uuid,
key: String,
name: String,
audience: String,
description: Option<String>,
department_id: Option<Uuid>,
department_name: Option<String>,
is_active: bool,
can_approve_requests: bool,
can_manage_system_settings: bool,
permission_keys: Vec<String>,
created_at: chrono::DateTime<chrono::Utc>,
}
// ── Request types ────────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct CreateRolePayload {
key: String,
name: String,
audience: String,
description: Option<String>,
department_id: Option<Uuid>,
is_active: Option<bool>,
can_approve_requests: Option<bool>,
can_manage_system_settings: Option<bool>,
permission_keys: Option<Vec<String>>,
}
#[derive(Deserialize)]
struct UpdateRolePayload {
name: Option<String>,
description: Option<String>,
department_id: Option<Uuid>,
is_active: Option<bool>,
can_approve_requests: Option<bool>,
can_manage_system_settings: Option<bool>,
permission_keys: Option<Vec<String>>,
}
// ── FromRow structs ──────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct RoleListRow {
id: Uuid,
key: String,
name: String,
audience: String,
description: Option<String>,
department_id: Option<Uuid>,
department_name: Option<String>,
is_active: bool,
can_approve_requests: bool,
can_manage_system_settings: bool,
created_at: chrono::DateTime<chrono::Utc>,
users_assigned: Option<i64>,
permissions_count: Option<i64>,
}
#[derive(sqlx::FromRow)]
struct RoleDetailRow {
id: Uuid,
key: String,
name: String,
audience: String,
description: Option<String>,
department_id: Option<Uuid>,
department_name: Option<String>,
is_active: bool,
can_approve_requests: bool,
can_manage_system_settings: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(sqlx::FromRow)]
struct InsertedRoleRow {
id: Uuid,
key: String,
name: String,
audience: String,
description: Option<String>,
department_id: Option<Uuid>,
is_active: bool,
can_approve_requests: bool,
can_manage_system_settings: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(sqlx::FromRow)]
struct CurrentRoleRow {
name: String,
description: Option<String>,
department_id: Option<Uuid>,
is_active: bool,
can_approve_requests: bool,
can_manage_system_settings: bool,
}
// ── Handlers ─────────────────────────────────────────────────────────────────
async fn list_roles(
State(state): State<AppState>,
Query(params): Query<ListQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let page = params.page.unwrap_or(1).max(1);
let per_page = params.per_page.unwrap_or(20).min(100);
let offset = (page - 1) * per_page;
let search = params.q.as_deref().unwrap_or("").to_lowercase();
let audience = params.audience.as_deref().unwrap_or("").to_string();
let rows = sqlx::query_as::<_, RoleListRow>(
r#"
SELECT
r.id,
r.code AS key,
r.name,
r.audience,
r.description,
r.department_id,
d.name AS department_name,
r.is_active,
r.can_approve_requests,
r.can_manage_system_settings,
r.created_at,
COUNT(DISTINCT e.id) AS users_assigned,
COUNT(DISTINCT rp.id) AS permissions_count
FROM roles r
LEFT JOIN departments d ON d.id = r.department_id
LEFT JOIN employees e ON e.role_code = r.code
LEFT JOIN role_permissions rp ON rp.role_id = r.id
WHERE ($1 = '' OR r.audience = $1)
AND ($2 = '' OR LOWER(r.name) LIKE '%' || $2 || '%' OR LOWER(r.code) LIKE '%' || $2 || '%')
GROUP BY r.id, d.name
ORDER BY r.created_at DESC
LIMIT $3 OFFSET $4
"#,
)
.bind(&audience)
.bind(&search)
.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 ($1 = '' OR r.audience = $1)
AND ($2 = '' OR LOWER(r.name) LIKE '%' || $2 || '%' OR LOWER(r.code) LIKE '%' || $2 || '%')
"#,
)
.bind(&audience)
.bind(&search)
.fetch_one(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let roles = rows
.into_iter()
.map(|r| RoleRow {
id: r.id,
key: r.key,
name: r.name,
audience: r.audience,
description: r.description,
department_id: r.department_id,
department_name: r.department_name,
is_active: r.is_active,
can_approve_requests: r.can_approve_requests,
can_manage_system_settings: r.can_manage_system_settings,
users_assigned: r.users_assigned.unwrap_or(0),
permissions_count: r.permissions_count.unwrap_or(0),
created_at: r.created_at,
})
.collect();
Ok(Json(ListResponse { roles, total, page, per_page }))
}
async fn get_role(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let row = sqlx::query_as::<_, RoleDetailRow>(
r#"
SELECT
r.id, r.code AS key, r.name, r.audience, r.description,
r.department_id, d.name AS department_name,
r.is_active, r.can_approve_requests, r.can_manage_system_settings,
r.created_at
FROM roles r
LEFT JOIN departments d ON d.id = r.department_id
WHERE r.id = $1
"#,
)
.bind(id)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.ok_or((StatusCode::NOT_FOUND, "Role not found".to_string()))?;
let permission_keys: Vec<String> = sqlx::query_scalar::<_, String>(
"SELECT permission_key FROM role_permissions WHERE role_id = $1 ORDER BY permission_key",
)
.bind(id)
.fetch_all(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
Ok(Json(RoleDetail {
id: row.id,
key: row.key,
name: row.name,
audience: row.audience,
description: row.description,
department_id: row.department_id,
department_name: row.department_name,
is_active: row.is_active,
can_approve_requests: row.can_approve_requests,
can_manage_system_settings: row.can_manage_system_settings,
permission_keys,
created_at: row.created_at,
}))
}
async fn create_role(
State(state): State<AppState>,
Json(payload): Json<CreateRolePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let is_active = payload.is_active.unwrap_or(true);
let can_approve = payload.can_approve_requests.unwrap_or(false);
let can_manage = payload.can_manage_system_settings.unwrap_or(false);
let role = sqlx::query_as::<_, InsertedRoleRow>(
r#"
INSERT INTO roles (code, name, audience, description, department_id, is_active, can_approve_requests, can_manage_system_settings)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, code AS key, name, audience, description, department_id, is_active, can_approve_requests, can_manage_system_settings, created_at
"#,
)
.bind(&payload.key)
.bind(&payload.name)
.bind(&payload.audience)
.bind(&payload.description)
.bind(payload.department_id)
.bind(is_active)
.bind(can_approve)
.bind(can_manage)
.fetch_one(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Insert permission keys
if let Some(keys) = &payload.permission_keys {
for key in keys {
sqlx::query(
"INSERT INTO role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(role.id)
.bind(key)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
}
}
let permission_keys: Vec<String> = sqlx::query_scalar::<_, String>(
"SELECT permission_key FROM role_permissions WHERE role_id = $1 ORDER BY permission_key",
)
.bind(role.id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
Ok((
StatusCode::CREATED,
Json(RoleDetail {
id: role.id,
key: role.key,
name: role.name,
audience: role.audience,
description: role.description,
department_id: role.department_id,
department_name: None,
is_active: role.is_active,
can_approve_requests: role.can_approve_requests,
can_manage_system_settings: role.can_manage_system_settings,
permission_keys,
created_at: role.created_at,
}),
))
}
async fn update_role(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateRolePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
// Fetch current values first
let current = sqlx::query_as::<_, CurrentRoleRow>(
"SELECT name, description, department_id, is_active, can_approve_requests, can_manage_system_settings FROM roles WHERE id = $1",
)
.bind(id)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.ok_or((StatusCode::NOT_FOUND, "Role not found".to_string()))?;
let name = payload.name.unwrap_or(current.name);
let description = payload.description.or(current.description);
let department_id = payload.department_id.or(current.department_id);
let is_active = payload.is_active.unwrap_or(current.is_active);
let can_approve = payload.can_approve_requests.unwrap_or(current.can_approve_requests);
let can_manage = payload.can_manage_system_settings.unwrap_or(current.can_manage_system_settings);
sqlx::query(
r#"
UPDATE roles SET
name = $1,
description = $2,
department_id = $3,
is_active = $4,
can_approve_requests = $5,
can_manage_system_settings = $6
WHERE id = $7
"#,
)
.bind(name)
.bind(description)
.bind(department_id)
.bind(is_active)
.bind(can_approve)
.bind(can_manage)
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Replace permissions if provided
if let Some(keys) = &payload.permission_keys {
sqlx::query("DELETE FROM role_permissions WHERE role_id = $1")
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
for key in keys {
sqlx::query(
"INSERT INTO role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(id)
.bind(key)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
}
}
// Return updated role
get_role(State(state), Path(id)).await
}
async fn delete_role(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let result = sqlx::query("DELETE FROM roles WHERE id = $1")
.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, "Role not found".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}