chore: checkpoint current workspace changes

This commit is contained in:
Ashwin Kumar 2026-03-25 22:15:07 +01:00
parent ac27184ae2
commit b82f294331
10 changed files with 462 additions and 41 deletions

View file

@ -17,12 +17,12 @@ pub fn router() -> Router<PgPool> {
Router::new()
.route("/profile/me", get(get_profile).patch(update_profile))
.route("/jobs", get(list_jobs).post(create_job))
.route("/jobs/:id", get(get_job).patch(update_job))
.route("/jobs/:id/submit", post(submit_job))
.route("/jobs/:id/close", post(close_job))
.route("/jobs/:id/applications", get(list_applications))
.route("/applications/:id/status", patch(update_application_status))
.route("/applications/:id/contact", get(view_contact))
.route("/jobs/{id}", get(get_job).patch(update_job))
.route("/jobs/{id}/submit", post(submit_job))
.route("/jobs/{id}/close", post(close_job))
.route("/jobs/{id}/applications", get(list_applications))
.route("/applications/{id}/status", patch(update_application_status))
.route("/applications/{id}/contact", get(view_contact))
}
#[derive(Deserialize)]

View file

@ -18,11 +18,11 @@ pub fn router() -> Router<PgPool> {
Router::new()
.route("/profile/me", get(get_profile).patch(update_profile))
.route("/requirements", get(list_requirements).post(create_requirement))
.route("/requirements/:id", get(get_requirement).patch(update_requirement))
.route("/requirements/:id/submit", post(submit_requirement))
.route("/requirements/:id/requests", get(list_requests))
.route("/requirements/:id/requests/:lead_id/approve", post(approve_request))
.route("/requirements/:id/requests/:lead_id/reject", post(reject_request))
.route("/requirements/{id}", get(get_requirement).patch(update_requirement))
.route("/requirements/{id}/submit", post(submit_requirement))
.route("/requirements/{id}/requests", get(list_requests))
.route("/requirements/{id}/requests/{lead_id}/approve", post(approve_request))
.route("/requirements/{id}/requests/{lead_id}/reject", post(reject_request))
}
#[derive(Deserialize)]

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/permissions")
|| path.starts_with("/api/admin/onboarding-config")
|| path.starts_with("/api/admin/dashboard-config")
|| path.starts_with("/api/admin/users")

View file

@ -3,5 +3,6 @@ pub mod auth;
pub mod config;
pub mod notifications;
pub mod onboarding;
pub mod permissions;
pub mod roles;
pub mod user_roles;

View file

@ -0,0 +1,79 @@
use crate::AppState;
use axum::{http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use serde::Serialize;
pub fn router() -> Router<AppState> {
Router::new().route("/", get(list_permissions))
}
#[derive(Serialize)]
struct PermissionEntry {
key: String,
module: String,
action: String,
}
const MODULES: &[&str] = &[
"Department Management",
"Designation Management",
"Internal Role Management",
"Employee Management",
"External Role Management",
"External Onboarding Management",
"Internal Dashboard Management",
"External Dashboard Management",
"Verification Management",
"Approval Management",
"Users Management",
"Company Management",
"Candidate Management",
"Customer Management",
"Photographer Management",
"Makeup Artist Management",
"Tutor Management",
"Developer Management",
"Fitness Trainer Management",
"Graphic Designer Management",
"Social Media Management",
"Video Editor Management",
"Catering Services Management",
"Jobs Management",
"Leads Management",
"Applications Management",
"Responses Management",
"Review Management",
"Pricing Management",
"Credit Management",
"Coupon Management",
"Discount Management",
"Tax Management",
"Order Management",
"Invoice Management",
"Ledger Management",
"Knowledge Base Management",
"Support Management",
"Report Management",
"Notifications",
];
const ACTIONS: &[&str] = &["View", "Create", "Update", "Delete"];
async fn list_permissions(
_: axum::extract::State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let permissions: Vec<PermissionEntry> = MODULES
.iter()
.flat_map(|module| {
ACTIONS.iter().map(|action| {
let key = format!("{}:{}", module.replace(' ', "_").to_lowercase(), action.to_lowercase());
PermissionEntry {
key,
module: module.to_string(),
action: action.to_string(),
}
})
})
.collect();
Ok(Json(permissions))
}

View file

@ -1,55 +1,376 @@
use crate::AppState;
use axum::{
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
routing::{delete, get, patch, post},
Json, Router,
};
use db::models::role::{CreateRolePayload, RoleRepository};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list_roles).post(create_role))
.route("/{key}", get(get_role_by_key))
.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>>,
}
// ── 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("");
let rows = sqlx::query!(
r#"
SELECT
r.id,
r.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_id = r.id
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.key) LIKE '%' || $2 || '%')
GROUP BY r.id, d.name
ORDER BY r.created_at DESC
LIMIT $3 OFFSET $4
"#,
audience,
search,
per_page,
offset
)
.fetch_all(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let total: i64 = sqlx::query_scalar!(
r#"
SELECT COUNT(*) FROM roles r
WHERE ($1 = '' OR r.audience = $1)
AND ($2 = '' OR LOWER(r.name) LIKE '%' || $2 || '%' OR LOWER(r.key) LIKE '%' || $2 || '%')
"#,
audience,
search
)
.fetch_one(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.unwrap_or(0);
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!(
r#"
SELECT
r.id, r.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
"#,
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!(
"SELECT permission_key FROM role_permissions WHERE role_id = $1 ORDER BY permission_key",
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)> {
match RoleRepository::create(&state.pool, payload).await {
Ok(role) => Ok((StatusCode::CREATED, Json(role))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
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!(
r#"
INSERT INTO roles (key, 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, key, name, audience, description, department_id, is_active, can_approve_requests, can_manage_system_settings, created_at
"#,
payload.key,
payload.name,
payload.audience,
payload.description,
payload.department_id,
is_active,
can_approve,
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",
role.id,
key
)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
}
}
let permission_keys: Vec<String> = sqlx::query_scalar!(
"SELECT permission_key FROM role_permissions WHERE role_id = $1 ORDER BY permission_key",
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 list_roles(
async fn update_role(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateRolePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match RoleRepository::get_all(&state.pool).await {
Ok(roles) => Ok((StatusCode::OK, Json(roles))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
// Fetch current values first
let current = sqlx::query!(
"SELECT name, description, department_id, is_active, can_approve_requests, can_manage_system_settings FROM roles 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, "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
"#,
name,
description,
department_id,
is_active,
can_approve,
can_manage,
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", 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",
id,
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 get_role_by_key(
async fn delete_role(
State(state): State<AppState>,
Path(key): Path<String>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
match RoleRepository::get_by_key(&state.pool, &key).await {
Ok(role) => Ok((StatusCode::OK, Json(role))),
Err(sqlx::Error::RowNotFound) => Err((StatusCode::NOT_FOUND, "Role not found".to_string())),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
let result = sqlx::query!("DELETE FROM roles 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, "Role not found".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}

View file

@ -56,6 +56,7 @@ async fn main() {
.nest("/api/auth", handlers::auth::router())
// ── Roles & User Self-Service ─────────────────────────────────────
.nest("/api/admin/roles", handlers::roles::router())
.nest("/api/admin/permissions", handlers::permissions::router())
.nest("/api/me/roles", handlers::user_roles::router())
// ── Notifications ─────────────────────────────────────────────────
.nest("/api/me/notifications", handlers::notifications::router())

View file

@ -134,9 +134,16 @@ pub fn require_role(auth: &AuthUser, expected_role: &str) -> Result<(), AuthErro
}
}
/// Returns Ok if the user has the ADMIN role.
/// Returns Ok if the user has an internal admin role (ADMIN or SUPER_ADMIN).
pub fn require_admin(auth: &AuthUser) -> Result<(), AuthError> {
if auth.claims.roles.contains(&"ADMIN".to_string()) {
let active = auth.claims.active_role.as_str();
let has_internal_admin =
active == "ADMIN"
|| active == "SUPER_ADMIN"
|| auth.claims.roles.contains(&"ADMIN".to_string())
|| auth.claims.roles.contains(&"SUPER_ADMIN".to_string());
if has_internal_admin {
Ok(())
} else {
Err(AuthError::InsufficientPermissions)

View file

@ -0,0 +1,5 @@
ALTER TABLE roles
DROP COLUMN IF EXISTS description,
DROP COLUMN IF EXISTS department_id,
DROP COLUMN IF EXISTS can_approve_requests,
DROP COLUMN IF EXISTS can_manage_system_settings;

View file

@ -0,0 +1,6 @@
-- Extend roles table for internal role management
ALTER TABLE roles
ADD COLUMN IF NOT EXISTS description TEXT,
ADD COLUMN IF NOT EXISTS department_id UUID REFERENCES departments(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS can_approve_requests BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS can_manage_system_settings BOOLEAN NOT NULL DEFAULT false;