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 <noreply@anthropic.com>
This commit is contained in:
parent
89d9e3b861
commit
3b28d9fd36
5 changed files with 434 additions and 0 deletions
397
apps/users/src/handlers/designations.rs
Normal file
397
apps/users/src/handlers/designations.rs
Normal file
|
|
@ -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<AppState> {
|
||||
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<String>,
|
||||
status: Option<String>,
|
||||
department_id: Option<Uuid>,
|
||||
page: Option<i64>,
|
||||
per_page: Option<i64>,
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DesignationRow {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
code: Option<String>,
|
||||
department_id: Option<Uuid>,
|
||||
department_name: Option<String>,
|
||||
description: Option<String>,
|
||||
level: Option<String>,
|
||||
can_manage_team: bool,
|
||||
can_approve: bool,
|
||||
is_active: bool,
|
||||
status: String,
|
||||
total_employees: i64,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ListResponse {
|
||||
designations: Vec<DesignationRow>,
|
||||
total: i64,
|
||||
page: i64,
|
||||
per_page: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateDesignationPayload {
|
||||
name: String,
|
||||
code: Option<String>,
|
||||
department_id: Option<Uuid>,
|
||||
description: Option<String>,
|
||||
level: Option<String>,
|
||||
can_manage_team: Option<bool>,
|
||||
can_approve: Option<bool>,
|
||||
is_active: Option<bool>,
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateDesignationPayload {
|
||||
name: Option<String>,
|
||||
code: Option<String>,
|
||||
department_id: Option<Uuid>,
|
||||
description: Option<String>,
|
||||
level: Option<String>,
|
||||
can_manage_team: Option<bool>,
|
||||
can_approve: Option<bool>,
|
||||
is_active: Option<bool>,
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_designations(
|
||||
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
|
||||
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::<bool, _>("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<i64>>(
|
||||
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<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
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::<bool, _>("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<AppState>,
|
||||
Json(payload): Json<CreateDesignationPayload>,
|
||||
) -> 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 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<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateDesignationPayload>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
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::<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 department_id = payload
|
||||
.department_id
|
||||
.or_else(|| current.get::<Option<Uuid>, _>("department_id"));
|
||||
let description = payload
|
||||
.description
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.or_else(|| current.get::<Option<String>, _>("description"));
|
||||
let level = payload
|
||||
.level
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.or_else(|| current.get::<Option<String>, _>("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<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
Loading…
Add table
Reference in a new issue