nxtgauge-backend-rust/apps/users/src/handlers/designations.rs

419 lines
13 KiB
Rust
Raw Normal View History

use crate::AppState;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use contracts::auth_middleware::{AuthUser, require_admin};
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(
auth: AuthUser,
State(state): State<AppState>,
Query(params): Query<ListQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_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(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_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(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<CreateDesignationPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_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(auth, State(state), Path(id)).await?;
Ok((StatusCode::CREATED, response))
}
async fn update_designation(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateDesignationPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_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(auth, State(state), Path(id)).await
}
async fn delete_designation(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_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)
}