From 3b28d9fd36903d8459422eef3f7971874031152e Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Fri, 27 Mar 2026 19:20:55 +0100 Subject: [PATCH] feat: add designation management CRUD backend Full CRUD handler for designations with department JOIN, employee count, level/can_manage_team/can_approve fields, and migration to extend the minimal designations table with all management columns. Co-Authored-By: Claude Sonnet 4.6 --- apps/users/src/handlers/designations.rs | 397 ++++++++++++++++++ apps/users/src/handlers/mod.rs | 1 + apps/users/src/main.rs | 1 + ...00_designations_management_fields.down.sql | 13 + ...0000_designations_management_fields.up.sql | 22 + 5 files changed, 434 insertions(+) create mode 100644 apps/users/src/handlers/designations.rs create mode 100644 crates/db/migrations/20260327100000_designations_management_fields.down.sql create mode 100644 crates/db/migrations/20260327100000_designations_management_fields.up.sql diff --git a/apps/users/src/handlers/designations.rs b/apps/users/src/handlers/designations.rs new file mode 100644 index 0000000..b88d55a --- /dev/null +++ b/apps/users/src/handlers/designations.rs @@ -0,0 +1,397 @@ +use crate::AppState; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + routing::get, + Json, Router, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::Row; +use uuid::Uuid; + +pub fn router() -> Router { + Router::new() + .route("/", get(list_designations).post(create_designation)) + .route("/{id}", get(get_designation).patch(update_designation).delete(delete_designation)) +} + +#[derive(Deserialize)] +struct ListQuery { + q: Option, + status: Option, + department_id: Option, + page: Option, + per_page: Option, + limit: Option, +} + +#[derive(Serialize)] +struct DesignationRow { + id: Uuid, + name: String, + code: Option, + department_id: Option, + department_name: Option, + description: Option, + level: Option, + can_manage_team: bool, + can_approve: bool, + is_active: bool, + status: String, + total_employees: i64, + created_at: DateTime, + updated_at: DateTime, +} + +#[derive(Serialize)] +struct ListResponse { + designations: Vec, + total: i64, + page: i64, + per_page: i64, +} + +#[derive(Deserialize)] +struct CreateDesignationPayload { + name: String, + code: Option, + department_id: Option, + description: Option, + level: Option, + can_manage_team: Option, + can_approve: Option, + is_active: Option, + status: Option, +} + +#[derive(Deserialize)] +struct UpdateDesignationPayload { + name: Option, + code: Option, + department_id: Option, + description: Option, + level: Option, + can_manage_team: Option, + can_approve: Option, + is_active: Option, + status: Option, +} + +fn derive_is_active(status: &Option, is_active: Option, current: bool) -> bool { + if let Some(active) = is_active { + return active; + } + match status.as_deref().unwrap_or_default().to_ascii_uppercase().as_str() { + "ACTIVE" => true, + "INACTIVE" => false, + _ => current, + } +} + +async fn list_designations( + State(state): State, + Query(params): Query, +) -> Result { + let page = params.page.unwrap_or(1).max(1); + let per_page = params.per_page.or(params.limit).unwrap_or(20).clamp(1, 100); + let offset = (page - 1) * per_page; + let search = params.q.unwrap_or_default().to_lowercase(); + let status = params.status.unwrap_or_default().to_lowercase(); + + let rows = sqlx::query( + r#" + SELECT + des.id, + des.name, + des.code, + des.department_id, + dep.name AS department_name, + des.description, + des.level, + des.can_manage_team, + des.can_approve, + des.is_active, + des.created_at, + des.updated_at, + COUNT(e.id) AS total_employees + FROM designations des + LEFT JOIN departments dep ON dep.id = des.department_id + LEFT JOIN employees e ON e.designation_id = des.id + WHERE ($1 = '' OR LOWER(des.name) LIKE '%' || $1 || '%' OR LOWER(COALESCE(des.code, '')) LIKE '%' || $1 || '%') + AND ( + $2 = '' + OR ($2 = 'active' AND des.is_active = true) + OR ($2 = 'inactive' AND des.is_active = false) + ) + AND ($3::uuid IS NULL OR des.department_id = $3) + GROUP BY des.id, dep.name + ORDER BY des.created_at DESC + LIMIT $4 OFFSET $5 + "#, + ) + .bind(&search) + .bind(&status) + .bind(params.department_id) + .bind(per_page) + .bind(offset) + .fetch_all(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + let designations = rows + .into_iter() + .map(|row| DesignationRow { + id: row.get("id"), + name: row.get("name"), + code: row.get("code"), + department_id: row.get("department_id"), + department_name: row.get("department_name"), + description: row.get("description"), + level: row.get("level"), + can_manage_team: row.get("can_manage_team"), + can_approve: row.get("can_approve"), + is_active: row.get("is_active"), + status: if row.get::("is_active") { + "ACTIVE".to_string() + } else { + "INACTIVE".to_string() + }, + total_employees: row.get("total_employees"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }) + .collect(); + + let total: i64 = sqlx::query_scalar::<_, Option>( + r#" + SELECT COUNT(*) FROM designations des + WHERE ($1 = '' OR LOWER(des.name) LIKE '%' || $1 || '%' OR LOWER(COALESCE(des.code, '')) LIKE '%' || $1 || '%') + AND ( + $2 = '' + OR ($2 = 'active' AND des.is_active = true) + OR ($2 = 'inactive' AND des.is_active = false) + ) + AND ($3::uuid IS NULL OR des.department_id = $3) + "#, + ) + .bind(&search) + .bind(&status) + .bind(params.department_id) + .fetch_one(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? + .unwrap_or(0); + + Ok(Json(ListResponse { + designations, + total, + page, + per_page, + })) +} + +async fn get_designation( + State(state): State, + Path(id): Path, +) -> Result { + let row = sqlx::query( + r#" + SELECT + des.id, + des.name, + des.code, + des.department_id, + dep.name AS department_name, + des.description, + des.level, + des.can_manage_team, + des.can_approve, + des.is_active, + des.created_at, + des.updated_at, + COUNT(e.id) AS total_employees + FROM designations des + LEFT JOIN departments dep ON dep.id = des.department_id + LEFT JOIN employees e ON e.designation_id = des.id + WHERE des.id = $1 + GROUP BY des.id, dep.name + "#, + ) + .bind(id) + .fetch_optional(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? + .ok_or((StatusCode::NOT_FOUND, "Designation not found".to_string()))?; + + Ok(Json(DesignationRow { + id: row.get("id"), + name: row.get("name"), + code: row.get("code"), + department_id: row.get("department_id"), + department_name: row.get("department_name"), + description: row.get("description"), + level: row.get("level"), + can_manage_team: row.get("can_manage_team"), + can_approve: row.get("can_approve"), + is_active: row.get("is_active"), + status: if row.get::("is_active") { + "ACTIVE".to_string() + } else { + "INACTIVE".to_string() + }, + total_employees: row.get("total_employees"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + })) +} + +async fn create_designation( + State(state): State, + Json(payload): Json, +) -> Result { + let name = payload.name.trim(); + if name.is_empty() { + return Err((StatusCode::BAD_REQUEST, "Name is required".to_string())); + } + + let is_active = derive_is_active(&payload.status, payload.is_active, true); + + let row = sqlx::query( + r#" + INSERT INTO designations ( + name, code, department_id, description, level, can_manage_team, can_approve, is_active + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id + "#, + ) + .bind(name) + .bind(payload.code.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())) + .bind(payload.department_id) + .bind(payload.description.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())) + .bind(payload.level.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())) + .bind(payload.can_manage_team.unwrap_or(false)) + .bind(payload.can_approve.unwrap_or(false)) + .bind(is_active) + .fetch_one(&state.pool) + .await + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("designations_name_key") || msg.contains("idx_designations_code_unique") { + (StatusCode::CONFLICT, "Designation name or code already exists".to_string()) + } else { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + } + })?; + + let id: Uuid = row.get("id"); + let response = get_designation(State(state), Path(id)).await?; + Ok((StatusCode::CREATED, response)) +} + +async fn update_designation( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> Result { + let current = sqlx::query( + r#" + SELECT name, code, department_id, description, level, can_manage_team, can_approve, is_active + FROM designations + 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, "Designation not found".to_string()))?; + + let name = payload + .name + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| current.get::("name")); + let code = payload + .code + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .or_else(|| current.get::, _>("code")); + let department_id = payload + .department_id + .or_else(|| current.get::, _>("department_id")); + let description = payload + .description + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .or_else(|| current.get::, _>("description")); + let level = payload + .level + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .or_else(|| current.get::, _>("level")); + let is_active = derive_is_active(&payload.status, payload.is_active, current.get("is_active")); + let can_manage_team = payload + .can_manage_team + .unwrap_or_else(|| current.get("can_manage_team")); + let can_approve = payload + .can_approve + .unwrap_or_else(|| current.get("can_approve")); + + sqlx::query( + r#" + UPDATE designations + SET + name = $1, + code = $2, + department_id = $3, + description = $4, + level = $5, + can_manage_team = $6, + can_approve = $7, + is_active = $8, + updated_at = NOW() + WHERE id = $9 + "#, + ) + .bind(name) + .bind(code) + .bind(department_id) + .bind(description) + .bind(level) + .bind(can_manage_team) + .bind(can_approve) + .bind(is_active) + .bind(id) + .execute(&state.pool) + .await + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("designations_name_key") || msg.contains("idx_designations_code_unique") { + (StatusCode::CONFLICT, "Designation name or code already exists".to_string()) + } else { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + } + })?; + + get_designation(State(state), Path(id)).await +} + +async fn delete_designation( + State(state): State, + Path(id): Path, +) -> Result { + let result = sqlx::query("DELETE FROM designations 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, "Designation not found".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} diff --git a/apps/users/src/handlers/mod.rs b/apps/users/src/handlers/mod.rs index dc05473..c4fef50 100644 --- a/apps/users/src/handlers/mod.rs +++ b/apps/users/src/handlers/mod.rs @@ -3,6 +3,7 @@ pub mod auth; pub mod config; pub mod dashboard; pub mod departments; +pub mod designations; pub mod notifications; pub mod onboarding; pub mod permissions; diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index 761fd28..6107ec7 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -58,6 +58,7 @@ async fn main() { .nest("/api/admin/roles", handlers::roles::router()) .nest("/api/admin/permissions", handlers::permissions::router()) .nest("/api/admin/departments", handlers::departments::router()) + .nest("/api/admin/designations", handlers::designations::router()) .nest("/api/me/roles", handlers::user_roles::router()) // ── Notifications ───────────────────────────────────────────────── .nest("/api/me/notifications", handlers::notifications::router()) diff --git a/crates/db/migrations/20260327100000_designations_management_fields.down.sql b/crates/db/migrations/20260327100000_designations_management_fields.down.sql new file mode 100644 index 0000000..9ac769e --- /dev/null +++ b/crates/db/migrations/20260327100000_designations_management_fields.down.sql @@ -0,0 +1,13 @@ +DROP INDEX IF EXISTS idx_designations_department_id; +DROP INDEX IF EXISTS idx_designations_is_active; +DROP INDEX IF EXISTS idx_designations_code_unique; + +ALTER TABLE designations + DROP COLUMN IF EXISTS updated_at, + DROP COLUMN IF EXISTS is_active, + DROP COLUMN IF EXISTS can_approve, + DROP COLUMN IF EXISTS can_manage_team, + DROP COLUMN IF EXISTS level, + DROP COLUMN IF EXISTS description, + DROP COLUMN IF EXISTS department_id, + DROP COLUMN IF EXISTS code; diff --git a/crates/db/migrations/20260327100000_designations_management_fields.up.sql b/crates/db/migrations/20260327100000_designations_management_fields.up.sql new file mode 100644 index 0000000..66167d7 --- /dev/null +++ b/crates/db/migrations/20260327100000_designations_management_fields.up.sql @@ -0,0 +1,22 @@ +ALTER TABLE designations + ADD COLUMN IF NOT EXISTS code VARCHAR(64), + ADD COLUMN IF NOT EXISTS department_id UUID REFERENCES departments(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS description TEXT, + ADD COLUMN IF NOT EXISTS level VARCHAR(100), + ADD COLUMN IF NOT EXISTS can_manage_team BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS can_approve BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +UPDATE designations +SET updated_at = COALESCE(updated_at, created_at, NOW()); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_designations_code_unique + ON designations (LOWER(code)) + WHERE code IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_designations_is_active + ON designations (is_active); + +CREATE INDEX IF NOT EXISTS idx_designations_department_id + ON designations (department_id);