backbone_payroll/presentation/http/
salary_slip_handler.rs1use std::collections::HashMap;
8use std::sync::Arc;
9
10use axum::Router;
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13use rust_decimal::Decimal;
14
15use backbone_core::http::BackboneCrudHandler;
17
18#[cfg(feature = "auth")]
20use backbone_auth::middleware::AuthContext;
21#[cfg(feature = "auth")]
22use backbone_auth::AuthMiddleware;
23
24use crate::domain::entity::*;
26use crate::application::service::{SalarySlipService, ServiceError};
27
28use crate::presentation::dto::{CreateSalarySlipDto, UpdateSalarySlipDto, PatchSalarySlipDto, SalarySlipResponseDto};
30
31
32#[derive(Debug, thiserror::Error)]
34pub enum SalarySlipError {
35 #[error("Not found: {0}")]
36 NotFound(String),
37 #[error("Validation error: {0}")]
38 Validation(String),
39 #[error("Database error: {0}")]
40 Database(String),
41 #[error("Internal error: {0}")]
42 Internal(String),
43}
44
45impl From<ServiceError> for SalarySlipError {
46 fn from(err: ServiceError) -> Self {
47 match err {
48 ServiceError::NotFound => Self::NotFound(err.to_string()),
49 ServiceError::Validation(ref msg) => Self::Validation(msg.clone()),
50 ServiceError::AlreadyExists(ref msg) => Self::Validation(msg.clone()),
51 ServiceError::Repository(ref e) => Self::Database(e.to_string()),
52 ServiceError::Internal(ref msg) => Self::Internal(msg.clone()),
53 }
54 }
55}
56
57impl axum::response::IntoResponse for SalarySlipError {
58 fn into_response(self) -> axum::response::Response {
59 use axum::http::StatusCode;
60 use axum::Json;
61
62 let (status, code) = match &self {
63 Self::NotFound(_) => (StatusCode::NOT_FOUND, "SALARYSLIP_NOT_FOUND"),
64 Self::Validation(_) => (StatusCode::BAD_REQUEST, "SALARYSLIP_VALIDATION_ERROR"),
65 Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "SALARYSLIP_DATABASE_ERROR"),
66 Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "SALARYSLIP_INTERNAL_ERROR"),
67 };
68
69 let body = serde_json::json!({
70 "success": false,
71 "error": code,
72 "message": self.to_string(),
73 });
74
75 (status, Json(body)).into_response()
76 }
77}
78
79pub fn create_salary_slip_routes(service: Arc<SalarySlipService>) -> Router {
112 BackboneCrudHandler::<SalarySlipService, SalarySlip, CreateSalarySlipDto, UpdateSalarySlipDto, SalarySlipResponseDto>::routes(
113 service,
114 "/salary_slips",
115 )
116}
117
118pub fn create_salary_slip_read_routes(service: Arc<SalarySlipService>) -> Router {
124 BackboneCrudHandler::<SalarySlipService, SalarySlip, CreateSalarySlipDto, UpdateSalarySlipDto, SalarySlipResponseDto>::read_routes(
125 service,
126 "/salary_slips",
127 )
128}
129
130pub fn create_salary_slip_write_routes(service: Arc<SalarySlipService>) -> Router {
142 BackboneCrudHandler::<SalarySlipService, SalarySlip, CreateSalarySlipDto, UpdateSalarySlipDto, SalarySlipResponseDto>::write_routes(
143 service,
144 "/salary_slips",
145 )
146}
147
148#[cfg(feature = "auth")]
154pub fn create_protected_salary_slip_routes<A: AuthMiddleware + Send + Sync + 'static>(
155 service: Arc<SalarySlipService>,
156 auth: Arc<A>,
157) -> Router {
158 use axum::middleware;
159 use axum::response::IntoResponse;
160
161 let auth_layer = auth.clone();
162 create_salary_slip_routes(service)
163 .layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
164 let auth = auth_layer.clone();
165 async move {
166 let token = req.headers()
167 .get(axum::http::header::AUTHORIZATION)
168 .and_then(|h| h.to_str().ok())
169 .and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
170 .unwrap_or("");
171 match auth.authenticate(token).await {
172 Ok(ctx) => {
173 req.extensions_mut().insert(ctx);
174 next.run(req).await
175 }
176 Err(_) => {
177 (axum::http::StatusCode::UNAUTHORIZED,
178 axum::Json(serde_json::json!({
179 "success": false,
180 "error": "unauthorized",
181 "message": "Authentication required"
182 }))
183 ).into_response()
184 }
185 }
186 }
187 }))
188}
189
190#[derive(serde::Deserialize)]
192pub struct SalarySlipHistoryQuery {
193 pub as_of: Option<chrono::DateTime<chrono::Utc>>,
195}
196
197pub async fn salary_slip_history(
204 axum::extract::State(pool): axum::extract::State<sqlx::PgPool>,
205 axum::extract::Path(id): axum::extract::Path<uuid::Uuid>,
206 axum::extract::Query(q): axum::extract::Query<SalarySlipHistoryQuery>,
207) -> axum::response::Response {
208 use axum::http::StatusCode;
209 use axum::response::IntoResponse;
210 use serde_json::json;
211 use sqlx::Row;
212 let subject_id = id.to_string();
213 let mut conn = match pool.acquire().await {
214 Ok(c) => c,
215 Err(e) => {
216 return (
217 StatusCode::SERVICE_UNAVAILABLE,
218 axum::Json(json!({
219 "error": "history_pool_unavailable",
220 "message": e.to_string(),
221 })),
222 )
223 .into_response()
224 }
225 };
226 if let Some(scope) = backbone_orm::org_scope::current_org_scope() {
229 if let Err(e) = backbone_orm::org_scope::bind_org_scope_on(&mut conn, &scope).await {
230 return (
231 StatusCode::INTERNAL_SERVER_ERROR,
232 axum::Json(json!({
233 "error": "history_scope_bind",
234 "message": e.to_string(),
235 })),
236 )
237 .into_response()
238 }
239 }
240 const SUBJECT_TYPE: &str = "payroll.salary_slips";
241 match q.as_of {
242 None => {
243 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")
244 .bind(SUBJECT_TYPE)
245 .bind(&subject_id)
246 .fetch_all(&mut *conn)
247 .await
248 {
249 Ok(r) => r,
250 Err(e) => {
251 return (
252 StatusCode::INTERNAL_SERVER_ERROR,
253 axum::Json(json!({
254 "error": "history_read",
255 "message": e.to_string(),
256 })),
257 )
258 .into_response()
259 }
260 };
261 let total: i64 = rows.first().map(|r| r.get("total")).unwrap_or(0);
262 let entries: Vec<serde_json::Value> = rows
263 .iter()
264 .map(|r| {
265 json!({
266 "position": r.get::<i64, _>("position"),
267 "action": r.get::<String, _>("action"),
268 "actor": r.get::<String, _>("actor"),
269 "changed": r.get::<serde_json::Value, _>("changed"),
270 "reason": r.get::<Option<String>, _>("reason"),
271 "occurred_at": r.get::<chrono::DateTime<chrono::Utc>, _>("occurred_at"),
272 "txid": r.get::<String, _>("txid"),
273 })
274 })
275 .collect();
276 (
277 StatusCode::OK,
278 axum::Json(json!({
279 "subject_type": SUBJECT_TYPE,
280 "subject_id": subject_id,
281 "total": total,
282 "entries": entries,
283 })),
284 )
285 .into_response()
286 }
287 Some(as_of) => {
288 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")
289 .bind(SUBJECT_TYPE)
290 .bind(&subject_id)
291 .bind(as_of)
292 .fetch_all(&mut *conn)
293 .await
294 {
295 Ok(r) => r,
296 Err(e) => {
297 return (
298 StatusCode::INTERNAL_SERVER_ERROR,
299 axum::Json(json!({
300 "error": "history_read",
301 "message": e.to_string(),
302 })),
303 )
304 .into_response()
305 }
306 };
307 if rows.is_empty() {
308 return (
309 StatusCode::NOT_FOUND,
310 axum::Json(json!({
311 "error": "history_before_subject",
312 "message": "as_of precedes the subject's first captured event",
313 })),
314 )
315 .into_response();
316 }
317 let mut image = serde_json::Map::new();
318 let mut deleted_at: Option<chrono::DateTime<chrono::Utc>> = None;
319 for r in &rows {
320 let action: String = r.get("action");
321 let changed: serde_json::Value = r.get("changed");
322 if action == "delete" {
323 image.clear();
324 deleted_at = Some(r.get("occurred_at"));
325 continue;
326 }
327 deleted_at = None;
328 if let Some(fields) = changed.as_object() {
329 for (field, diff) in fields {
330 if let Some(to) = diff.get("to") {
331 image.insert(field.clone(), to.clone());
332 }
333 }
334 }
335 }
336 if let Some(at) = deleted_at {
337 return (
338 StatusCode::NOT_FOUND,
339 axum::Json(json!({
340 "error": "subject_deleted_before_as_of",
341 "deleted_at": at,
342 })),
343 )
344 .into_response();
345 }
346 (
347 StatusCode::OK,
348 axum::Json(json!({
349 "as_of": as_of,
350 "image": serde_json::Value::Object(image),
351 })),
352 )
353 .into_response()
354 }
355 }
356}
357
358pub fn create_salary_slip_history_route(pool: sqlx::PgPool) -> axum::Router {
361 axum::Router::new()
362 .route("/salary_slips/:id/history", axum::routing::get(salary_slip_history))
363 .with_state(pool)
364}