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

298 lines
8.8 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 serde::{Deserialize, Serialize};
use sqlx::types::Uuid;
use auth::crypto::hash_password;
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list_employees).post(create_employee))
.route("/{id}", get(get_employee).patch(update_employee).delete(delete_employee))
}
#[derive(Deserialize)]
struct ListQuery {
q: Option<String>,
status: Option<String>,
page: Option<i64>,
per_page: Option<i64>,
}
#[derive(Serialize)]
struct EmployeeRow {
id: Uuid,
first_name: String,
last_name: String,
email: String,
employee_code: Option<String>,
department_name: Option<String>,
designation_name: Option<String>,
role_code: String,
status: String,
joined_at: String,
created_at: String,
}
#[derive(Serialize)]
struct ListResponse {
employees: Vec<EmployeeRow>,
total: i64,
page: i64,
per_page: i64,
}
async fn list_employees(
auth: AuthUser,
State(state): State<AppState>,
Query(q): Query<ListQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
let page = q.page.unwrap_or(1).max(1);
let per_page = q.per_page.unwrap_or(20).clamp(1, 100);
let offset = (page - 1) * per_page;
let search = q.q.unwrap_or_default().to_lowercase();
let status = q.status.unwrap_or_default().to_uppercase();
let rows = sqlx::query!(
r#"
SELECT
e.id,
e.first_name,
e.last_name,
e.email,
e.employee_code,
e.role_code,
e.status,
e.joined_at,
e.created_at,
d.name AS "department_name?",
des.name AS "designation_name?"
FROM employees e
LEFT JOIN departments d ON d.id = e.department_id
LEFT JOIN designations des ON des.id = e.designation_id
WHERE ($1 = '' OR LOWER(e.first_name || ' ' || e.last_name) LIKE '%' || $1 || '%'
OR LOWER(e.email) LIKE '%' || $1 || '%'
OR LOWER(COALESCE(e.employee_code,'')) LIKE '%' || $1 || '%')
AND ($2 = '' OR e.status = $2)
ORDER BY e.created_at DESC
LIMIT $3 OFFSET $4
"#,
search,
status,
per_page,
offset
)
.fetch_all(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let employees = rows
.into_iter()
.map(|r| EmployeeRow {
id: r.id,
first_name: r.first_name,
last_name: r.last_name,
email: r.email,
employee_code: r.employee_code,
department_name: r.department_name,
designation_name: r.designation_name,
role_code: r.role_code,
status: r.status,
joined_at: r.joined_at.to_string(),
created_at: r.created_at.to_rfc3339(),
})
.collect::<Vec<_>>();
let total: i64 = sqlx::query_scalar!(
r#"
SELECT COUNT(*)
FROM employees e
WHERE ($1 = '' OR LOWER(e.first_name || ' ' || e.last_name) LIKE '%' || $1 || '%'
OR LOWER(e.email) LIKE '%' || $1 || '%'
OR LOWER(COALESCE(e.employee_code,'')) LIKE '%' || $1 || '%')
AND ($2 = '' OR e.status = $2)
"#,
search,
status
)
.fetch_one(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.unwrap_or(0);
Ok(Json(ListResponse { employees, total, page, per_page }))
}
async fn get_employee(
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 r = sqlx::query!(
r#"
SELECT
e.id, e.first_name, e.last_name, e.email, e.employee_code,
e.role_code, e.status, e.joined_at, e.created_at,
d.name AS "department_name?",
des.name AS "designation_name?"
FROM employees e
LEFT JOIN departments d ON d.id = e.department_id
LEFT JOIN designations des ON des.id = e.designation_id
WHERE e.id = $1
"#,
id
)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.ok_or((StatusCode::NOT_FOUND, "Employee not found".to_string()))?;
Ok(Json(EmployeeRow {
id: r.id,
first_name: r.first_name,
last_name: r.last_name,
email: r.email,
employee_code: r.employee_code,
department_name: r.department_name,
designation_name: r.designation_name,
role_code: r.role_code,
status: r.status,
joined_at: r.joined_at.to_string(),
created_at: r.created_at.to_rfc3339(),
}))
}
#[derive(Deserialize)]
struct CreateEmployeePayload {
first_name: String,
last_name: String,
email: String,
password: String,
employee_code: Option<String>,
department_id: Option<Uuid>,
designation_id: Option<Uuid>,
role_code: Option<String>,
}
async fn create_employee(
auth: AuthUser,
State(state): State<AppState>,
Json(p): Json<CreateEmployeePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
let password_hash = hash_password(&p.password)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Crypto error: {e}")))?;
let role_code = p.role_code.unwrap_or_else(|| "STAFF".to_string());
let email = p.email.trim().to_lowercase();
let r = sqlx::query!(
r#"
INSERT INTO employees (first_name, last_name, email, password_hash, employee_code,
department_id, designation_id, role_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
"#,
p.first_name,
p.last_name,
email,
password_hash,
p.employee_code,
p.department_id,
p.designation_id,
role_code,
)
.fetch_one(&state.pool)
.await
.map_err(|e| {
if e.to_string().contains("unique") {
(StatusCode::CONFLICT, "Email already exists".to_string())
} else {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
}
})?;
get_employee(auth, State(state), Path(r.id)).await
}
#[derive(Deserialize)]
struct UpdateEmployeePayload {
first_name: Option<String>,
last_name: Option<String>,
employee_code: Option<String>,
department_id: Option<Uuid>,
designation_id: Option<Uuid>,
role_code: Option<String>,
status: Option<String>,
}
async fn update_employee(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(p): Json<UpdateEmployeePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
sqlx::query!(
r#"
UPDATE employees
SET
first_name = COALESCE($1, first_name),
last_name = COALESCE($2, last_name),
employee_code = COALESCE($3, employee_code),
department_id = COALESCE($4, department_id),
designation_id = COALESCE($5, designation_id),
role_code = COALESCE($6, role_code),
status = COALESCE($7, status),
updated_at = NOW()
WHERE id = $8
"#,
p.first_name,
p.last_name,
p.employee_code,
p.department_id,
p.designation_id,
p.role_code,
p.status,
id,
)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
get_employee(auth, State(state), Path(id)).await
}
async fn delete_employee(
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 employees WHERE id = $1", 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, "Employee not found".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}