backbone_accounting/presentation/http/
journal_workflow_handler.rs1use std::sync::Arc;
16
17use axum::{
18 extract::Path, extract::Query, extract::State, response::IntoResponse, routing::post, Json,
19 Router,
20};
21use serde::{Deserialize, Serialize};
22use uuid::Uuid;
23
24use crate::application::service::journal_workflow_service::{
25 JournalWorkflowError, JournalWorkflowService,
26};
27
28#[derive(Debug, Deserialize)]
29pub struct CompanyQuery {
30}
31
32#[derive(Debug, Deserialize)]
33pub struct ApproveBody {
34 pub approved_by: Option<Uuid>,
35}
36
37#[derive(Debug, Deserialize)]
38pub struct RejectBody {
39 pub reason: String,
40 pub rejected_by: Option<Uuid>,
41}
42
43#[derive(Debug, Deserialize)]
44pub struct VoidBody {
45 pub voided_by: Option<Uuid>,
46 pub reason: String,
47}
48
49#[derive(Debug, Serialize)]
50struct WorkflowResponse {
51 success: bool,
52 journal_id: Uuid,
53 status: &'static str,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 post_id: Option<Uuid>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 idempotent_reuse: Option<bool>,
58}
59
60fn err_response(e: JournalWorkflowError) -> (axum::http::StatusCode, Json<serde_json::Value>) {
61 let status = axum::http::StatusCode::from_u16(e.http_status())
62 .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
63 (
64 status,
65 Json(serde_json::json!({
66 "success": false,
67 "error": e.code(),
68 "message": e.to_string(),
69 })),
70 )
71}
72
73async fn submit(
74 State(svc): State<Arc<JournalWorkflowService>>,
75 Path(id): Path<Uuid>,
76 Query(_q): Query<CompanyQuery>,
77) -> impl IntoResponse {
78 match svc.submit(id).await {
79 Ok(()) => Json(WorkflowResponse {
80 success: true,
81 journal_id: id,
82 status: "pending_approval",
83 post_id: None,
84 idempotent_reuse: None,
85 })
86 .into_response(),
87 Err(e) => err_response(e).into_response(),
88 }
89}
90
91async fn approve(
92 State(svc): State<Arc<JournalWorkflowService>>,
93 Path(id): Path<Uuid>,
94 Json(body): Json<ApproveBody>,
95) -> impl IntoResponse {
96 run_approve(svc, id, body.approved_by).await
97}
98
99async fn reject(
100 State(svc): State<Arc<JournalWorkflowService>>,
101 Path(id): Path<Uuid>,
102 Json(body): Json<RejectBody>,
103) -> impl IntoResponse {
104 run_reject(svc, id, body.reason, body.rejected_by).await
105}
106
107async fn void(
108 State(svc): State<Arc<JournalWorkflowService>>,
109 Path(id): Path<Uuid>,
110 Json(body): Json<VoidBody>,
111) -> impl IntoResponse {
112 run_void(svc, id, body.voided_by, body.reason).await
113}
114
115async fn run_approve(
117 svc: Arc<JournalWorkflowService>,
118 id: Uuid,
119 approved_by: Option<Uuid>,
120) -> axum::response::Response {
121 match svc.approve(id, approved_by).await {
122 Ok(r) => Json(WorkflowResponse {
123 success: true,
124 journal_id: r.journal_id,
125 status: "posted",
126 post_id: Some(r.post_id),
127 idempotent_reuse: Some(r.idempotent_reuse),
128 })
129 .into_response(),
130 Err(e) => err_response(e).into_response(),
131 }
132}
133
134async fn run_reject(
136 svc: Arc<JournalWorkflowService>,
137 id: Uuid,
138 reason: String,
139 rejected_by: Option<Uuid>,
140) -> axum::response::Response {
141 match svc.reject(id, reason, rejected_by).await {
142 Ok(()) => Json(WorkflowResponse {
143 success: true,
144 journal_id: id,
145 status: "rejected",
146 post_id: None,
147 idempotent_reuse: None,
148 })
149 .into_response(),
150 Err(e) => err_response(e).into_response(),
151 }
152}
153
154async fn run_void(
156 svc: Arc<JournalWorkflowService>,
157 id: Uuid,
158 voided_by: Option<Uuid>,
159 reason: String,
160) -> axum::response::Response {
161 match svc.void(id, voided_by, reason).await {
162 Ok(r) => Json(WorkflowResponse {
163 success: true,
164 journal_id: id,
165 status: "voided",
166 post_id: Some(r.post_id),
167 idempotent_reuse: Some(r.idempotent_reuse),
168 })
169 .into_response(),
170 Err(e) => err_response(e).into_response(),
171 }
172}
173
174pub fn create_journal_workflow_routes(service: Arc<JournalWorkflowService>) -> Router {
176 Router::new()
177 .route("/journals/:id/submit", post(submit))
178 .route("/journals/:id/approve", post(approve))
179 .route("/journals/:id/reject", post(reject))
180 .route("/journals/:id/void", post(void))
181 .with_state(service)
182}
183
184#[cfg(feature = "auth")]
195use axum::Extension;
196
197#[cfg(feature = "auth")]
198use backbone_auth::{middleware::AuthContext, AuthMiddleware};
199
200#[cfg(feature = "auth")]
201fn principal(auth: &AuthContext) -> Option<Uuid> {
202 Uuid::parse_str(&auth.user_id).ok()
203}
204
205#[cfg(feature = "auth")]
206async fn approve_protected(
207 State(svc): State<Arc<JournalWorkflowService>>,
208 Extension(auth): Extension<AuthContext>,
209 Path(id): Path<Uuid>,
210 Json(body): Json<ApproveBody>,
211) -> impl IntoResponse {
212 run_approve(svc, id, principal(&auth)).await
213}
214
215#[cfg(feature = "auth")]
216async fn reject_protected(
217 State(svc): State<Arc<JournalWorkflowService>>,
218 Extension(auth): Extension<AuthContext>,
219 Path(id): Path<Uuid>,
220 Json(body): Json<RejectBody>,
221) -> impl IntoResponse {
222 run_reject(svc, id, body.reason, principal(&auth)).await
223}
224
225#[cfg(feature = "auth")]
226async fn void_protected(
227 State(svc): State<Arc<JournalWorkflowService>>,
228 Extension(auth): Extension<AuthContext>,
229 Path(id): Path<Uuid>,
230 Json(body): Json<VoidBody>,
231) -> impl IntoResponse {
232 run_void(svc, id, principal(&auth), body.reason).await
233}
234
235#[cfg(feature = "auth")]
236pub fn create_protected_journal_workflow_routes<A: AuthMiddleware + Send + Sync + 'static>(
238 service: Arc<JournalWorkflowService>,
239 auth: Arc<A>,
240) -> Router {
241 use axum::{middleware, response::IntoResponse};
242
243 let auth_layer = auth.clone();
244 Router::new()
245 .route("/journals/:id/submit", post(submit))
246 .route("/journals/:id/approve", post(approve_protected))
247 .route("/journals/:id/reject", post(reject_protected))
248 .route("/journals/:id/void", post(void_protected))
249 .with_state(service)
250 .layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
251 let auth = auth_layer.clone();
252 async move {
253 let token = req
254 .headers()
255 .get(axum::http::header::AUTHORIZATION)
256 .and_then(|h| h.to_str().ok())
257 .and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
258 .unwrap_or("");
259 match auth.authenticate(token).await {
260 Ok(ctx) => {
261 req.extensions_mut().insert(ctx);
262 next.run(req).await
263 }
264 Err(_) => (
265 axum::http::StatusCode::UNAUTHORIZED,
266 axum::Json(serde_json::json!({
267 "success": false, "error": "unauthorized", "message": "Authentication required"
268 })),
269 ).into_response(),
270 }
271 }
272 }))
273}