From b82f29433118c8b430ba4a25f20df4b99d9a65bf Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Wed, 25 Mar 2026 22:15:07 +0100 Subject: [PATCH] chore: checkpoint current workspace changes --- apps/companies/src/handlers.rs | 12 +- apps/customers/src/handlers.rs | 10 +- apps/gateway/src/main.rs | 1 + apps/users/src/handlers/mod.rs | 1 + apps/users/src/handlers/permissions.rs | 79 ++++ apps/users/src/handlers/roles.rs | 377 ++++++++++++++++-- apps/users/src/main.rs | 1 + crates/contracts/src/auth_middleware.rs | 11 +- ...325100000_internal_roles_extended.down.sql | 5 + ...60325100000_internal_roles_extended.up.sql | 6 + 10 files changed, 462 insertions(+), 41 deletions(-) create mode 100644 apps/users/src/handlers/permissions.rs create mode 100644 crates/db/migrations/20260325100000_internal_roles_extended.down.sql create mode 100644 crates/db/migrations/20260325100000_internal_roles_extended.up.sql diff --git a/apps/companies/src/handlers.rs b/apps/companies/src/handlers.rs index 0cd1227..773ceae 100644 --- a/apps/companies/src/handlers.rs +++ b/apps/companies/src/handlers.rs @@ -17,12 +17,12 @@ pub fn router() -> Router { 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)] diff --git a/apps/customers/src/handlers.rs b/apps/customers/src/handlers.rs index aad1b3f..995e14b 100644 --- a/apps/customers/src/handlers.rs +++ b/apps/customers/src/handlers.rs @@ -18,11 +18,11 @@ pub fn router() -> Router { 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)] diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index 2d5a5b4..8b6111d 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -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") diff --git a/apps/users/src/handlers/mod.rs b/apps/users/src/handlers/mod.rs index 674c9ea..72612af 100644 --- a/apps/users/src/handlers/mod.rs +++ b/apps/users/src/handlers/mod.rs @@ -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; diff --git a/apps/users/src/handlers/permissions.rs b/apps/users/src/handlers/permissions.rs new file mode 100644 index 0000000..54df9c6 --- /dev/null +++ b/apps/users/src/handlers/permissions.rs @@ -0,0 +1,79 @@ +use crate::AppState; +use axum::{http::StatusCode, response::IntoResponse, routing::get, Json, Router}; +use serde::Serialize; + +pub fn router() -> Router { + 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, +) -> Result { + let permissions: Vec = 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)) +} diff --git a/apps/users/src/handlers/roles.rs b/apps/users/src/handlers/roles.rs index e54bc8b..e3fed02 100644 --- a/apps/users/src/handlers/roles.rs +++ b/apps/users/src/handlers/roles.rs @@ -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 { 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, + q: Option, + page: Option, + per_page: Option, +} + +// ── Response types ─────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct RoleRow { + id: Uuid, + key: String, + name: String, + audience: String, + description: Option, + department_id: Option, + department_name: Option, + is_active: bool, + can_approve_requests: bool, + can_manage_system_settings: bool, + users_assigned: i64, + permissions_count: i64, + created_at: chrono::DateTime, +} + +#[derive(Serialize)] +struct ListResponse { + roles: Vec, + total: i64, + page: i64, + per_page: i64, +} + +#[derive(Serialize)] +struct RoleDetail { + id: Uuid, + key: String, + name: String, + audience: String, + description: Option, + department_id: Option, + department_name: Option, + is_active: bool, + can_approve_requests: bool, + can_manage_system_settings: bool, + permission_keys: Vec, + created_at: chrono::DateTime, +} + +// ── Request types ──────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct CreateRolePayload { + key: String, + name: String, + audience: String, + description: Option, + department_id: Option, + is_active: Option, + can_approve_requests: Option, + can_manage_system_settings: Option, + permission_keys: Option>, +} + +#[derive(Deserialize)] +struct UpdateRolePayload { + name: Option, + description: Option, + department_id: Option, + is_active: Option, + can_approve_requests: Option, + can_manage_system_settings: Option, + permission_keys: Option>, +} + +// ── Handlers ───────────────────────────────────────────────────────────────── + +async fn list_roles( + State(state): State, + Query(params): Query, +) -> Result { + 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, + Path(id): Path, +) -> Result { + 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 = 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, Json(payload): Json, ) -> Result { - 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 = 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, + Path(id): Path, + Json(payload): Json, ) -> Result { - 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, - Path(key): Path, + Path(id): Path, ) -> Result { - 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) +} diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index 0a09cb6..da67f6e 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -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()) diff --git a/crates/contracts/src/auth_middleware.rs b/crates/contracts/src/auth_middleware.rs index 38eafd4..f89520b 100644 --- a/crates/contracts/src/auth_middleware.rs +++ b/crates/contracts/src/auth_middleware.rs @@ -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) diff --git a/crates/db/migrations/20260325100000_internal_roles_extended.down.sql b/crates/db/migrations/20260325100000_internal_roles_extended.down.sql new file mode 100644 index 0000000..9815e05 --- /dev/null +++ b/crates/db/migrations/20260325100000_internal_roles_extended.down.sql @@ -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; diff --git a/crates/db/migrations/20260325100000_internal_roles_extended.up.sql b/crates/db/migrations/20260325100000_internal_roles_extended.up.sql new file mode 100644 index 0000000..c542821 --- /dev/null +++ b/crates/db/migrations/20260325100000_internal_roles_extended.up.sql @@ -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;