use std::collections::HashMap;
use std::sync::Arc;
use axum::Router;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use rust_decimal::Decimal;
use backbone_core::http::BackboneCrudHandler;
#[cfg(feature = "auth")]
use backbone_auth::middleware::AuthContext;
#[cfg(feature = "auth")]
use backbone_auth::AuthMiddleware;
use crate::domain::entity::*;
use crate::application::service::{SalarySlipService, ServiceError};
use crate::presentation::dto::{CreateSalarySlipDto, UpdateSalarySlipDto, PatchSalarySlipDto, SalarySlipResponseDto};
#[derive(Debug, thiserror::Error)]
pub enum SalarySlipError {
#[error("Not found: {0}")]
NotFound(String),
#[error("Validation error: {0}")]
Validation(String),
#[error("Database error: {0}")]
Database(String),
#[error("Internal error: {0}")]
Internal(String),
}
impl From<ServiceError> for SalarySlipError {
fn from(err: ServiceError) -> Self {
match err {
ServiceError::NotFound => Self::NotFound(err.to_string()),
ServiceError::Validation(ref msg) => Self::Validation(msg.clone()),
ServiceError::AlreadyExists(ref msg) => Self::Validation(msg.clone()),
ServiceError::Repository(ref e) => Self::Database(e.to_string()),
ServiceError::Internal(ref msg) => Self::Internal(msg.clone()),
}
}
}
impl axum::response::IntoResponse for SalarySlipError {
fn into_response(self) -> axum::response::Response {
use axum::http::StatusCode;
use axum::Json;
let (status, code) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, "SALARYSLIP_NOT_FOUND"),
Self::Validation(_) => (StatusCode::BAD_REQUEST, "SALARYSLIP_VALIDATION_ERROR"),
Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "SALARYSLIP_DATABASE_ERROR"),
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "SALARYSLIP_INTERNAL_ERROR"),
};
let body = serde_json::json!({
"success": false,
"error": code,
"message": self.to_string(),
});
(status, Json(body)).into_response()
}
}
pub fn create_salary_slip_routes(service: Arc<SalarySlipService>) -> Router {
BackboneCrudHandler::<SalarySlipService, SalarySlip, CreateSalarySlipDto, UpdateSalarySlipDto, SalarySlipResponseDto>::routes(
service,
"/salary_slips",
)
}
pub fn create_salary_slip_read_routes(service: Arc<SalarySlipService>) -> Router {
BackboneCrudHandler::<SalarySlipService, SalarySlip, CreateSalarySlipDto, UpdateSalarySlipDto, SalarySlipResponseDto>::read_routes(
service,
"/salary_slips",
)
}
pub fn create_salary_slip_write_routes(service: Arc<SalarySlipService>) -> Router {
BackboneCrudHandler::<SalarySlipService, SalarySlip, CreateSalarySlipDto, UpdateSalarySlipDto, SalarySlipResponseDto>::write_routes(
service,
"/salary_slips",
)
}
#[cfg(feature = "auth")]
pub fn create_protected_salary_slip_routes<A: AuthMiddleware + Send + Sync + 'static>(
service: Arc<SalarySlipService>,
auth: Arc<A>,
) -> Router {
use axum::middleware;
use axum::response::IntoResponse;
let auth_layer = auth.clone();
create_salary_slip_routes(service)
.layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
let auth = auth_layer.clone();
async move {
let token = req.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
.unwrap_or("");
match auth.authenticate(token).await {
Ok(ctx) => {
req.extensions_mut().insert(ctx);
next.run(req).await
}
Err(_) => {
(axum::http::StatusCode::UNAUTHORIZED,
axum::Json(serde_json::json!({
"success": false,
"error": "unauthorized",
"message": "Authentication required"
}))
).into_response()
}
}
}
}))
}
#[derive(serde::Deserialize)]
pub struct SalarySlipHistoryQuery {
pub as_of: Option<chrono::DateTime<chrono::Utc>>,
}
pub async fn salary_slip_history(
axum::extract::State(pool): axum::extract::State<sqlx::PgPool>,
axum::extract::Path(id): axum::extract::Path<uuid::Uuid>,
axum::extract::Query(q): axum::extract::Query<SalarySlipHistoryQuery>,
) -> axum::response::Response {
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde_json::json;
use sqlx::Row;
let subject_id = id.to_string();
let mut conn = match pool.acquire().await {
Ok(c) => c,
Err(e) => {
return (
StatusCode::SERVICE_UNAVAILABLE,
axum::Json(json!({
"error": "history_pool_unavailable",
"message": e.to_string(),
})),
)
.into_response()
}
};
if let Some(scope) = backbone_orm::org_scope::current_org_scope() {
if let Err(e) = backbone_orm::org_scope::bind_org_scope_on(&mut conn, &scope).await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(json!({
"error": "history_scope_bind",
"message": e.to_string(),
})),
)
.into_response()
}
}
const SUBJECT_TYPE: &str = "payroll.salary_slips";
match q.as_of {
None => {
let rows = match sqlx::query("SELECT action, actor, changed, reason, occurred_at, txid, ROW_NUMBER() OVER (ORDER BY occurred_at, txid) AS position, COUNT(*) OVER () AS total FROM auditlog.audit_trails WHERE subject_type = $1 AND subject_id = $2 ORDER BY occurred_at DESC, txid DESC")
.bind(SUBJECT_TYPE)
.bind(&subject_id)
.fetch_all(&mut *conn)
.await
{
Ok(r) => r,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(json!({
"error": "history_read",
"message": e.to_string(),
})),
)
.into_response()
}
};
let total: i64 = rows.first().map(|r| r.get("total")).unwrap_or(0);
let entries: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
json!({
"position": r.get::<i64, _>("position"),
"action": r.get::<String, _>("action"),
"actor": r.get::<String, _>("actor"),
"changed": r.get::<serde_json::Value, _>("changed"),
"reason": r.get::<Option<String>, _>("reason"),
"occurred_at": r.get::<chrono::DateTime<chrono::Utc>, _>("occurred_at"),
"txid": r.get::<String, _>("txid"),
})
})
.collect();
(
StatusCode::OK,
axum::Json(json!({
"subject_type": SUBJECT_TYPE,
"subject_id": subject_id,
"total": total,
"entries": entries,
})),
)
.into_response()
}
Some(as_of) => {
let rows = match sqlx::query("SELECT action, changed, occurred_at FROM auditlog.audit_trails WHERE subject_type = $1 AND subject_id = $2 AND occurred_at <= $3 ORDER BY occurred_at ASC, txid ASC")
.bind(SUBJECT_TYPE)
.bind(&subject_id)
.bind(as_of)
.fetch_all(&mut *conn)
.await
{
Ok(r) => r,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(json!({
"error": "history_read",
"message": e.to_string(),
})),
)
.into_response()
}
};
if rows.is_empty() {
return (
StatusCode::NOT_FOUND,
axum::Json(json!({
"error": "history_before_subject",
"message": "as_of precedes the subject's first captured event",
})),
)
.into_response();
}
let mut image = serde_json::Map::new();
let mut deleted_at: Option<chrono::DateTime<chrono::Utc>> = None;
for r in &rows {
let action: String = r.get("action");
let changed: serde_json::Value = r.get("changed");
if action == "delete" {
image.clear();
deleted_at = Some(r.get("occurred_at"));
continue;
}
deleted_at = None;
if let Some(fields) = changed.as_object() {
for (field, diff) in fields {
if let Some(to) = diff.get("to") {
image.insert(field.clone(), to.clone());
}
}
}
}
if let Some(at) = deleted_at {
return (
StatusCode::NOT_FOUND,
axum::Json(json!({
"error": "subject_deleted_before_as_of",
"deleted_at": at,
})),
)
.into_response();
}
(
StatusCode::OK,
axum::Json(json!({
"as_of": as_of,
"image": serde_json::Value::Object(image),
})),
)
.into_response()
}
}
}
pub fn create_salary_slip_history_route(pool: sqlx::PgPool) -> axum::Router {
axum::Router::new()
.route("/salary_slips/:id/history", axum::routing::get(salary_slip_history))
.with_state(pool)
}