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 sqlx::Row; pub fn router() -> Router { Router::new() .route("/provision", axum::routing::post(provision_employee)) .route("/", get(list_employees).post(create_employee)) .route("/{id}", get(get_employee).patch(update_employee).delete(delete_employee)) } #[derive(Deserialize)] struct ListQuery { q: Option, status: Option, // ACTIVE | INACTIVE | SUSPENDED page: Option, per_page: Option, sort: Option, // name_asc | name_desc | joined_asc | joined_desc } #[derive(Serialize)] struct EmployeeRow { id: Uuid, user_id: Uuid, name: String, email: String, phone: Option, employee_id: Option, department_name: Option, designation_name: Option, role_name: Option, role_key: Option, joining_date: String, status: String, updated_at: Option>, } #[derive(Serialize)] struct ListResponse { employees: Vec, total: i64, page: i64, per_page: i64, } async fn list_employees( auth: AuthUser, State(state): State, Query(q): Query, ) -> Result { 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 sort_sql = match q.sort.as_deref() { Some("name_asc") => "u.full_name ASC", Some("name_desc") => "u.full_name DESC", Some("joined_asc") => "e.created_at ASC", _ => "e.created_at DESC", }; let sql = format!(r#" SELECT e.id, e.user_id, e.employee_code, e.created_at AS joining_date, u.full_name, u.email, u.phone, u.status AS user_status, r.name AS role_name, r.key AS role_key, d.name AS department_name, des.name AS designation_name, e.created_at AS updated_at FROM employees e JOIN users u ON u.id = e.user_id LEFT JOIN roles r ON r.id = e.role_id LEFT JOIN departments d ON d.id = e.department_id LEFT JOIN designations des ON des.id = e.designation_id WHERE ($1 = '' OR LOWER(u.full_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%' OR LOWER(COALESCE(e.employee_code,'')) LIKE '%' || $1 || '%') AND ( $2 = '' OR ($2 = 'ACTIVE' AND u.status = 'ACTIVE') OR ($2 = 'INACTIVE' AND u.status = 'INACTIVE') OR ($2 = 'SUSPENDED' AND u.status = 'SUSPENDED') ) ORDER BY {sort} LIMIT $3 OFFSET $4 "#, sort = sort_sql); let rows = sqlx::query(&sql) .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 employees = rows.into_iter().map(|row| { let joining: chrono::DateTime = row.get::, _>("joining_date"); EmployeeRow { id: row.get("id"), user_id: row.get("user_id"), name: row.get::, _>("full_name").unwrap_or_default(), email: row.get("email"), phone: row.get::, _>("phone"), employee_id: row.get::, _>("employee_code"), department_name: row.get::, _>("department_name"), designation_name: row.get::, _>("designation_name"), role_name: row.get::, _>("role_name"), role_key: row.get::, _>("role_key"), joining_date: joining.to_rfc3339(), status: row.get::, _>("user_status").unwrap_or_else(|| "ACTIVE".to_string()), updated_at: Some(row.get::, _>("updated_at")), } }).collect::>(); let total: i64 = sqlx::query_scalar!( r#" SELECT COUNT(*) FROM employees e JOIN users u ON u.id = e.user_id WHERE ($1 = '' OR LOWER(u.full_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%' OR LOWER(COALESCE(e.employee_code,'')) LIKE '%' || $1 || '%') AND ( $2 = '' OR ($2 = 'ACTIVE' AND u.status = 'ACTIVE') OR ($2 = 'INACTIVE' AND u.status = 'INACTIVE') OR ($2 = 'SUSPENDED' AND u.status = 'SUSPENDED') ) "#, 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, Path(id): Path, ) -> Result { if let Err(_e) = require_admin(&auth) { return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); } let sql = r#" SELECT e.id, e.user_id, e.employee_code, e.created_at AS joining_date, e.created_at AS updated_at, u.full_name, u.email, u.phone, u.status AS user_status, r.name AS role_name, r.key AS role_key, d.name AS department_name, des.name AS designation_name FROM employees e JOIN users u ON u.id = e.user_id LEFT JOIN roles r ON r.id = e.role_id LEFT JOIN departments d ON d.id = e.department_id LEFT JOIN designations des ON des.id = e.designation_id WHERE e.id = $1 "#; let row = sqlx::query(sql) .bind(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()))?; let joining: chrono::DateTime = row.get::, _>("joining_date"); Ok(Json(EmployeeRow { id: row.get("id"), user_id: row.get("user_id"), name: row.get::, _>("full_name").unwrap_or_default(), email: row.get("email"), phone: row.get::, _>("phone"), employee_id: row.get::, _>("employee_code"), department_name: row.get::, _>("department_name"), designation_name: row.get::, _>("designation_name"), role_name: row.get::, _>("role_name"), role_key: row.get::, _>("role_key"), joining_date: joining.to_rfc3339(), status: row.get::, _>("user_status").unwrap_or_else(|| "ACTIVE".to_string()), updated_at: Some(row.get::, _>("updated_at")), })) } #[derive(Deserialize)] struct CreateEmployeePayload { user_id: Uuid, role_id: Uuid, department_id: Option, designation_id: Option, employee_code: Option, } async fn create_employee( auth: AuthUser, State(state): State, Json(p): Json, ) -> Result { if let Err(_e) = require_admin(&auth) { return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); } let row = sqlx::query!( r#" INSERT INTO employees (user_id, role_id, department_id, designation_id, employee_code) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id) DO UPDATE SET role_id = EXCLUDED.role_id, department_id = EXCLUDED.department_id, designation_id = EXCLUDED.designation_id, employee_code = EXCLUDED.employee_code RETURNING id "#, p.user_id, p.role_id, p.department_id, p.designation_id, p.employee_code ) .fetch_one(&state.pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; get_employee(auth, State(state), Path(row.id)).await } #[derive(Deserialize)] struct ProvisionEmployeePayload { email: String, full_name: String, role_id: Uuid, department_id: Option, designation_id: Option, employee_code: Option, #[serde(default)] generate_login: bool, password: Option, } async fn provision_employee( auth: AuthUser, State(state): State, Json(p): Json, ) -> Result { if let Err(_e) = require_admin(&auth) { return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); } use db::models::user::{CreateUserPayload, UserRepository}; use auth::crypto::hash_password; let email = p.email.trim().to_lowercase(); // 1. Resolve User let user_id = match UserRepository::get_by_email(&state.pool, &email).await { Ok(user) => user.id, Err(_) => { if !p.generate_login { return Err((StatusCode::BAD_REQUEST, "User not found for this email".to_string())); } let plain_password = p.password.unwrap_or_else(|| format!("{:08}", rand::random::() % 100_000_000)); if plain_password.len() < 8 { return Err((StatusCode::BAD_REQUEST, "Password must be at least 8 characters".to_string())); } let password_hash = hash_password(&plain_password) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Crypto error: {e}")))?; let new_user = UserRepository::create(&state.pool, CreateUserPayload { full_name: p.full_name.clone(), email: email.clone(), phone: None, password_hash, }).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; new_user.id } }; // 2. Link Employee let row = sqlx::query!( r#" INSERT INTO employees (user_id, role_id, department_id, designation_id, employee_code) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id) DO UPDATE SET role_id = EXCLUDED.role_id, department_id = EXCLUDED.department_id, designation_id = EXCLUDED.designation_id, employee_code = EXCLUDED.employee_code RETURNING id "#, user_id, p.role_id, p.department_id, p.designation_id, p.employee_code ) .fetch_one(&state.pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; get_employee(auth, State(state), Path(row.id)).await } #[derive(Deserialize)] struct UpdateEmployeePayload { role_id: Option, department_id: Option, designation_id: Option, employee_code: Option, } async fn update_employee( auth: AuthUser, State(state): State, Path(id): Path, Json(p): Json, ) -> Result { if let Err(_e) = require_admin(&auth) { return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); } sqlx::query!( r#" UPDATE employees SET role_id = COALESCE($1, role_id), department_id = COALESCE($2, department_id), designation_id = COALESCE($3, designation_id), employee_code = COALESCE($4, employee_code) WHERE id = $5 "#, p.role_id, p.department_id, p.designation_id, p.employee_code, 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, Path(id): Path, ) -> Result { 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) }