From 89d9e3b861c56280246c6838ef03f19f3a790397 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Thu, 26 Mar 2026 20:58:43 +0100 Subject: [PATCH] chore: sync local changes --- apps/users/src/handlers/departments.rs | 394 ++++++++++++++++++ apps/users/src/handlers/mod.rs | 1 + apps/users/src/main.rs | 1 + ...000_departments_management_fields.down.sql | 12 + ...10000_departments_management_fields.up.sql | 19 + 5 files changed, 427 insertions(+) create mode 100644 apps/users/src/handlers/departments.rs create mode 100644 crates/db/migrations/20260326110000_departments_management_fields.down.sql create mode 100644 crates/db/migrations/20260326110000_departments_management_fields.up.sql diff --git a/apps/users/src/handlers/departments.rs b/apps/users/src/handlers/departments.rs new file mode 100644 index 0000000..59f4c1f --- /dev/null +++ b/apps/users/src/handlers/departments.rs @@ -0,0 +1,394 @@ +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_departments).post(create_department)) + .route("/{id}", get(get_department).patch(update_department).delete(delete_department)) +} + +#[derive(Deserialize)] +struct ListQuery { + q: Option, + status: Option, + page: Option, + per_page: Option, + limit: Option, +} + +#[derive(Serialize)] +struct DepartmentRow { + id: Uuid, + name: String, + code: Option, + description: Option, + department_head: Option, + department_email: Option, + is_active: bool, + status: String, + visibility: String, + transfers_enabled: bool, + total_employees: i64, + created_at: DateTime, + updated_at: DateTime, +} + +#[derive(Serialize)] +struct ListResponse { + departments: Vec, + total: i64, + page: i64, + per_page: i64, +} + +#[derive(Deserialize)] +struct CreateDepartmentPayload { + name: String, + code: Option, + description: Option, + department_head: Option, + department_email: Option, + is_active: Option, + status: Option, + visibility: Option, + transfers_enabled: Option, +} + +#[derive(Deserialize)] +struct UpdateDepartmentPayload { + name: Option, + code: Option, + description: Option, + department_head: Option, + department_email: Option, + is_active: Option, + status: Option, + visibility: Option, + transfers_enabled: 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, + } +} + +fn normalize_visibility(value: Option, fallback: &str) -> String { + match value.unwrap_or_else(|| fallback.to_string()).to_ascii_uppercase().as_str() { + "EXTERNAL" => "EXTERNAL".to_string(), + _ => "INTERNAL".to_string(), + } +} + +async fn list_departments( + 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 + d.id, + d.name, + d.code, + d.description, + d.department_head, + d.department_email, + d.is_active, + d.visibility, + d.transfers_enabled, + d.created_at, + d.updated_at, + COUNT(e.id) AS total_employees + FROM departments d + LEFT JOIN employees e ON e.department_id = d.id + WHERE ($1 = '' OR LOWER(d.name) LIKE '%' || $1 || '%' OR LOWER(COALESCE(d.code, '')) LIKE '%' || $1 || '%') + AND ( + $2 = '' + OR ($2 = 'active' AND d.is_active = true) + OR ($2 = 'inactive' AND d.is_active = false) + ) + GROUP BY d.id + ORDER BY d.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 departments = rows + .into_iter() + .map(|row| DepartmentRow { + id: row.get("id"), + name: row.get("name"), + code: row.get("code"), + description: row.get("description"), + department_head: row.get("department_head"), + department_email: row.get("department_email"), + is_active: row.get("is_active"), + status: if row.get::("is_active") { + "ACTIVE".to_string() + } else { + "INACTIVE".to_string() + }, + visibility: row.get::("visibility"), + transfers_enabled: row.get("transfers_enabled"), + 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 departments d + WHERE ($1 = '' OR LOWER(d.name) LIKE '%' || $1 || '%' OR LOWER(COALESCE(d.code, '')) LIKE '%' || $1 || '%') + AND ( + $2 = '' + OR ($2 = 'active' AND d.is_active = true) + OR ($2 = 'inactive' AND d.is_active = false) + ) + "#, + ) + .bind(&search) + .bind(&status) + .fetch_one(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? + .unwrap_or(0); + + Ok(Json(ListResponse { + departments, + total, + page, + per_page, + })) +} + +async fn get_department( + State(state): State, + Path(id): Path, +) -> Result { + let row = sqlx::query( + r#" + SELECT + d.id, + d.name, + d.code, + d.description, + d.department_head, + d.department_email, + d.is_active, + d.visibility, + d.transfers_enabled, + d.created_at, + d.updated_at, + COUNT(e.id) AS total_employees + FROM departments d + LEFT JOIN employees e ON e.department_id = d.id + WHERE d.id = $1 + GROUP BY d.id + "#, + ) + .bind(id) + .fetch_optional(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? + .ok_or((StatusCode::NOT_FOUND, "Department not found".to_string()))?; + + Ok(Json(DepartmentRow { + id: row.get("id"), + name: row.get("name"), + code: row.get("code"), + description: row.get("description"), + department_head: row.get("department_head"), + department_email: row.get("department_email"), + is_active: row.get("is_active"), + status: if row.get::("is_active") { + "ACTIVE".to_string() + } else { + "INACTIVE".to_string() + }, + visibility: row.get("visibility"), + transfers_enabled: row.get("transfers_enabled"), + total_employees: row.get("total_employees"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + })) +} + +async fn create_department( + 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 visibility = normalize_visibility(payload.visibility, "INTERNAL"); + + let row = sqlx::query( + r#" + INSERT INTO departments ( + name, code, description, department_head, department_email, is_active, visibility, transfers_enabled + ) + 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.description.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())) + .bind(payload.department_head.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())) + .bind(payload.department_email.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())) + .bind(is_active) + .bind(visibility) + .bind(payload.transfers_enabled.unwrap_or(false)) + .fetch_one(&state.pool) + .await + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("departments_name_key") || msg.contains("departments_code_key") { + (StatusCode::CONFLICT, "Department 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_department(State(state), Path(id)).await?; + Ok((StatusCode::CREATED, response)) +} + +async fn update_department( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> Result { + let current = sqlx::query( + r#" + SELECT + name, code, description, department_head, department_email, is_active, visibility, transfers_enabled + FROM departments + 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, "Department 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 description = payload + .description + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .or_else(|| current.get::, _>("description")); + let department_head = payload + .department_head + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .or_else(|| current.get::, _>("department_head")); + let department_email = payload + .department_email + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .or_else(|| current.get::, _>("department_email")); + let is_active = derive_is_active(&payload.status, payload.is_active, current.get("is_active")); + let visibility = normalize_visibility(payload.visibility, ¤t.get::("visibility")); + let transfers_enabled = payload + .transfers_enabled + .unwrap_or_else(|| current.get("transfers_enabled")); + + sqlx::query( + r#" + UPDATE departments + SET + name = $1, + code = $2, + description = $3, + department_head = $4, + department_email = $5, + is_active = $6, + visibility = $7, + transfers_enabled = $8, + updated_at = NOW() + WHERE id = $9 + "#, + ) + .bind(name) + .bind(code) + .bind(description) + .bind(department_head) + .bind(department_email) + .bind(is_active) + .bind(visibility) + .bind(transfers_enabled) + .bind(id) + .execute(&state.pool) + .await + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("departments_name_key") || msg.contains("departments_code_key") { + (StatusCode::CONFLICT, "Department name or code already exists".to_string()) + } else { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + } + })?; + + get_department(State(state), Path(id)).await +} + +async fn delete_department( + State(state): State, + Path(id): Path, +) -> Result { + let result = sqlx::query("DELETE FROM departments 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, "Department 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 77ded43..dc05473 100644 --- a/apps/users/src/handlers/mod.rs +++ b/apps/users/src/handlers/mod.rs @@ -2,6 +2,7 @@ pub mod approvals; pub mod auth; pub mod config; pub mod dashboard; +pub mod departments; pub mod notifications; pub mod onboarding; pub mod permissions; diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index 09397bc..761fd28 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -57,6 +57,7 @@ async fn main() { // ── Roles & User Self-Service ───────────────────────────────────── .nest("/api/admin/roles", handlers::roles::router()) .nest("/api/admin/permissions", handlers::permissions::router()) + .nest("/api/admin/departments", handlers::departments::router()) .nest("/api/me/roles", handlers::user_roles::router()) // ── Notifications ───────────────────────────────────────────────── .nest("/api/me/notifications", handlers::notifications::router()) diff --git a/crates/db/migrations/20260326110000_departments_management_fields.down.sql b/crates/db/migrations/20260326110000_departments_management_fields.down.sql new file mode 100644 index 0000000..925c00a --- /dev/null +++ b/crates/db/migrations/20260326110000_departments_management_fields.down.sql @@ -0,0 +1,12 @@ +DROP INDEX IF EXISTS idx_departments_is_active; +DROP INDEX IF EXISTS idx_departments_code_unique; + +ALTER TABLE departments + DROP COLUMN IF EXISTS updated_at, + DROP COLUMN IF EXISTS transfers_enabled, + DROP COLUMN IF EXISTS visibility, + DROP COLUMN IF EXISTS is_active, + DROP COLUMN IF EXISTS department_email, + DROP COLUMN IF EXISTS department_head, + DROP COLUMN IF EXISTS description, + DROP COLUMN IF EXISTS code; diff --git a/crates/db/migrations/20260326110000_departments_management_fields.up.sql b/crates/db/migrations/20260326110000_departments_management_fields.up.sql new file mode 100644 index 0000000..54809e9 --- /dev/null +++ b/crates/db/migrations/20260326110000_departments_management_fields.up.sql @@ -0,0 +1,19 @@ +ALTER TABLE departments + ADD COLUMN IF NOT EXISTS code VARCHAR(64), + ADD COLUMN IF NOT EXISTS description TEXT, + ADD COLUMN IF NOT EXISTS department_head VARCHAR(255), + ADD COLUMN IF NOT EXISTS department_email VARCHAR(255), + ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN IF NOT EXISTS visibility VARCHAR(20) NOT NULL DEFAULT 'INTERNAL', + ADD COLUMN IF NOT EXISTS transfers_enabled BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +UPDATE departments +SET updated_at = COALESCE(updated_at, created_at, NOW()); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_departments_code_unique + ON departments (LOWER(code)) + WHERE code IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_departments_is_active + ON departments (is_active);