chore: sync local changes

This commit is contained in:
Ashwin Kumar 2026-03-26 20:58:43 +01:00
parent a7a0854a5b
commit 89d9e3b861
5 changed files with 427 additions and 0 deletions

View file

@ -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<AppState> {
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<String>,
status: Option<String>,
page: Option<i64>,
per_page: Option<i64>,
limit: Option<i64>,
}
#[derive(Serialize)]
struct DepartmentRow {
id: Uuid,
name: String,
code: Option<String>,
description: Option<String>,
department_head: Option<String>,
department_email: Option<String>,
is_active: bool,
status: String,
visibility: String,
transfers_enabled: bool,
total_employees: i64,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
#[derive(Serialize)]
struct ListResponse {
departments: Vec<DepartmentRow>,
total: i64,
page: i64,
per_page: i64,
}
#[derive(Deserialize)]
struct CreateDepartmentPayload {
name: String,
code: Option<String>,
description: Option<String>,
department_head: Option<String>,
department_email: Option<String>,
is_active: Option<bool>,
status: Option<String>,
visibility: Option<String>,
transfers_enabled: Option<bool>,
}
#[derive(Deserialize)]
struct UpdateDepartmentPayload {
name: Option<String>,
code: Option<String>,
description: Option<String>,
department_head: Option<String>,
department_email: Option<String>,
is_active: Option<bool>,
status: Option<String>,
visibility: Option<String>,
transfers_enabled: Option<bool>,
}
fn derive_is_active(status: &Option<String>, is_active: Option<bool>, 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<String>, 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<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.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::<bool, _>("is_active") {
"ACTIVE".to_string()
} else {
"INACTIVE".to_string()
},
visibility: row.get::<String, _>("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<i64>>(
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<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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::<bool, _>("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<AppState>,
Json(payload): Json<CreateDepartmentPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateDepartmentPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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::<String, _>("name"));
let code = payload
.code
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.or_else(|| current.get::<Option<String>, _>("code"));
let description = payload
.description
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.or_else(|| current.get::<Option<String>, _>("description"));
let department_head = payload
.department_head
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.or_else(|| current.get::<Option<String>, _>("department_head"));
let department_email = payload
.department_email
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.or_else(|| current.get::<Option<String>, _>("department_email"));
let is_active = derive_is_active(&payload.status, payload.is_active, current.get("is_active"));
let visibility = normalize_visibility(payload.visibility, &current.get::<String, _>("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<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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)
}

View file

@ -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;

View file

@ -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())

View file

@ -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;

View file

@ -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);