Initial: Rust-Backend mit Clean Architecture (domain/application/infrastructure/api)
Vier-Crate-Workspace mit:
- Domain: Account, Car, Tour, Delivery, DeliveryItem, DeliveryNote, Customer,
Article, Warehouse, ScanState, AuditAction — alle mit serde + feature-gated
utoipa::ToSchema.
- Application: Ports (TourRepository, DeliveryRepository, ScanRepository,
DeliveryNoteRepository, CarRepository, AuthService) und Use Cases.
- Infrastructure: Postgres-Adapter via sqlx (PgTourRepository etc.) +
Keycloak-AuthService mit JWKS-Cache + OIDC-Discovery.
- API: Axum 0.8, utoipa-OpenAPI + Swagger-UI, JWT-Bearer-Middleware,
AuthenticatedUser-Extractor.
Endpoints:
- GET /me/tours/today, /tours/{id}, /accounts/{pn}, /me/cars, /health
- POST /sync/tour, /scans (bulk + idempotent via clientScanId),
/deliveries/{id}/{hold,resume,cancel,complete,notes}, /me/cars
- PUT /tours/{id}/delivery-order, /deliveries/{id}/assigned-car, /me/cars/{id}
- PATCH /me/cars/{id}
Datenmodell:
- 6 Migrationen (accounts, tours/deliveries/items + Stammdaten,
scan_audit mit clientScanId-UNIQUE, state_reason refactor,
delivery_notes, cars + FKs nachziehen).
- Business-stabile Beleg-Keys (belegart_id, belegnummer) für ERP-Sync.
- Append-only scan_audit + embedded scan_state als doppelte Wahrheit.
Dev-Setup:
- docker-compose mit Postgres 17 + Keycloak 26
- Keycloak-Realm 'holzleitner' mit Public-Client (PKCE), Testfahrer
(PN 1001) + Audience-/Personalnummer-Mapper
This commit is contained in:
32
crates/api/Cargo.toml
Normal file
32
crates/api/Cargo.toml
Normal file
@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "holzleitner-api"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "holzleitner-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
holzleitner-domain = { workspace = true, features = ["openapi"] }
|
||||
holzleitner-application = { workspace = true, features = ["openapi"] }
|
||||
holzleitner-infrastructure.workspace = true
|
||||
utoipa.workspace = true
|
||||
utoipa-swagger-ui.workspace = true
|
||||
axum.workspace = true
|
||||
tokio.workspace = true
|
||||
tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
envy.workspace = true
|
||||
dotenvy.workspace = true
|
||||
sqlx.workspace = true
|
||||
97
crates/api/src/config.rs
Normal file
97
crates/api/src/config.rs
Normal file
@ -0,0 +1,97 @@
|
||||
//! Konfiguration aus Umgebungsvariablen (12-Factor-Stil).
|
||||
//!
|
||||
//! Für lokale Entwicklung lädt [`load`] zunächst eine optionale `.env`
|
||||
//! Datei und parst dann pro Bereich (Server, Database, Keycloak) mit
|
||||
//! Prefix-Filter über `envy`. So bleiben die Strukturen klar getrennt
|
||||
//! und Fehlermeldungen verraten den genauen Bereich.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub server: ServerConfig,
|
||||
pub database: DatabaseConfig,
|
||||
#[allow(dead_code)] // wird in der Keycloak-Phase verdrahtet
|
||||
pub keycloak: KeycloakConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DatabaseConfig {
|
||||
pub url: String,
|
||||
#[serde(default = "default_max_connections")]
|
||||
pub max_connections: u32,
|
||||
}
|
||||
|
||||
fn default_max_connections() -> u32 {
|
||||
10
|
||||
}
|
||||
|
||||
/// Felder werden in der Keycloak-Phase angefasst — bis dahin Dead-Code-
|
||||
/// Warnings unterdrücken.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct KeycloakConfig {
|
||||
pub issuer_url: String,
|
||||
pub audience: String,
|
||||
#[serde(default = "default_jwks_cache_ttl")]
|
||||
pub jwks_cache_ttl_seconds: u64,
|
||||
}
|
||||
|
||||
fn default_jwks_cache_ttl() -> u64 {
|
||||
3600
|
||||
}
|
||||
|
||||
/// Lädt die Konfiguration aus Umgebungsvariablen.
|
||||
///
|
||||
/// Reihenfolge:
|
||||
/// 1. Optionale `.env`-Datei im Arbeitsverzeichnis (für Local-Dev).
|
||||
/// 2. Pro Bereich werden Variablen mit passendem Prefix gelesen:
|
||||
/// * `SERVER_*` für [`ServerConfig`]
|
||||
/// * `DATABASE_*` für [`DatabaseConfig`]
|
||||
/// * `KEYCLOAK_*` für [`KeycloakConfig`]
|
||||
pub fn load() -> Result<Config, ConfigError> {
|
||||
// `.env` ist optional — in Produktion kommen die Werte aus dem
|
||||
// System-Environment.
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
let server = envy::prefixed("SERVER_")
|
||||
.from_env::<ServerConfig>()
|
||||
.map_err(|e| ConfigError::Section {
|
||||
section: "SERVER",
|
||||
source: e,
|
||||
})?;
|
||||
let database = envy::prefixed("DATABASE_")
|
||||
.from_env::<DatabaseConfig>()
|
||||
.map_err(|e| ConfigError::Section {
|
||||
section: "DATABASE",
|
||||
source: e,
|
||||
})?;
|
||||
let keycloak = envy::prefixed("KEYCLOAK_")
|
||||
.from_env::<KeycloakConfig>()
|
||||
.map_err(|e| ConfigError::Section {
|
||||
section: "KEYCLOAK",
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
Ok(Config {
|
||||
server,
|
||||
database,
|
||||
keycloak,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("missing or invalid env vars in section {section}: {source}")]
|
||||
Section {
|
||||
section: &'static str,
|
||||
#[source]
|
||||
source: envy::Error,
|
||||
},
|
||||
}
|
||||
51
crates/api/src/error.rs
Normal file
51
crates/api/src/error.rs
Normal file
@ -0,0 +1,51 @@
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::AuthError;
|
||||
use serde_json::json;
|
||||
|
||||
/// HTTP-Adapter für Application-Fehler. Mappt jede Variante auf
|
||||
/// einen Statuscode + JSON-Body. Verhindert, dass interne Details
|
||||
/// (z. B. SQL-Fehler) versehentlich in die Antwort durchschlagen —
|
||||
/// das eigentliche Detail wird über `tracing` geloggt.
|
||||
pub struct ApiError(pub ApplicationError);
|
||||
|
||||
impl From<ApplicationError> for ApiError {
|
||||
fn from(value: ApplicationError) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthError> for ApiError {
|
||||
fn from(err: AuthError) -> Self {
|
||||
// Auth-Detail für Debugging loggen, aber NICHT an den Client geben —
|
||||
// dort sehen wir nur „Unauthorized" / „internal error".
|
||||
tracing::debug!(error = %err, "auth verification failed");
|
||||
ApiError(err.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, code) = match &self.0 {
|
||||
ApplicationError::NotFound => (StatusCode::NOT_FOUND, "not_found"),
|
||||
ApplicationError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||
ApplicationError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
|
||||
ApplicationError::Validation(_) => (StatusCode::BAD_REQUEST, "validation"),
|
||||
ApplicationError::Repository(_)
|
||||
| ApplicationError::External(_)
|
||||
| ApplicationError::Unexpected(_) => {
|
||||
tracing::error!(error = %self.0, "internal error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
|
||||
}
|
||||
};
|
||||
|
||||
let body = Json(json!({
|
||||
"error": code,
|
||||
"message": self.0.to_string(),
|
||||
}));
|
||||
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
41
crates/api/src/extractors.rs
Normal file
41
crates/api/src/extractors.rs
Normal file
@ -0,0 +1,41 @@
|
||||
//! Eigene Axum-Extractoren für die API-Schicht.
|
||||
//!
|
||||
//! Da Axums Trait `FromRequestParts` und das fremde `Claims`-Struct
|
||||
//! aus `holzleitner_application` jeweils Fremd-Items sind (Rust-
|
||||
//! Orphan-Rule), wickeln wir `Claims` in einen lokalen Newtype
|
||||
//! `AuthenticatedUser`. Handler signaturen werden damit lesbarer als
|
||||
//! mit `axum::extract::Extension<Claims>` und liefern bei fehlender
|
||||
//! Authentifizierung einen konsistenten 401-Fehler.
|
||||
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use holzleitner_application::error::ApplicationError;
|
||||
use holzleitner_application::ports::Claims;
|
||||
|
||||
use crate::error::ApiError;
|
||||
|
||||
/// Wrapper-Extractor für `Claims` aus der JWT-Middleware.
|
||||
///
|
||||
/// Verwendung im Handler:
|
||||
/// ```ignore
|
||||
/// async fn handler(AuthenticatedUser(claims): AuthenticatedUser, ...) { ... }
|
||||
/// ```
|
||||
pub struct AuthenticatedUser(pub Claims);
|
||||
|
||||
impl<S> FromRequestParts<S> for AuthenticatedUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
parts
|
||||
.extensions
|
||||
.get::<Claims>()
|
||||
.cloned()
|
||||
.map(AuthenticatedUser)
|
||||
// Sollte nicht passieren wenn die Route hinter der Middleware
|
||||
// hängt — wir behandeln es als Programmier-/Konfig-Fehler.
|
||||
.ok_or(ApiError(ApplicationError::Unauthorized))
|
||||
}
|
||||
}
|
||||
158
crates/api/src/main.rs
Normal file
158
crates/api/src/main.rs
Normal file
@ -0,0 +1,158 @@
|
||||
//! Holzleitner-API — HTTP-Layer und Composition Root.
|
||||
//!
|
||||
//! Bootstrap-Reihenfolge:
|
||||
//! 1. Tracing/Logging initialisieren
|
||||
//! 2. Konfiguration aus Env-Variablen / `.env` laden
|
||||
//! 3. Postgres-Pool aufbauen und Migrations ausführen
|
||||
//! 4. Keycloak-AuthService instanziieren
|
||||
//! 5. Use Cases zusammenstellen und in `AppState` packen
|
||||
//! 6. Public + Protected Router komponieren und Server starten
|
||||
|
||||
mod config;
|
||||
mod error;
|
||||
mod extractors;
|
||||
mod middleware;
|
||||
mod openapi;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::Router;
|
||||
use axum::middleware::from_fn_with_state;
|
||||
use holzleitner_application::usecases::{
|
||||
ApplyDeliveryActionUseCase, ApplyScansUseCase, AssignCarToDeliveryUseCase,
|
||||
CreateDeliveryNoteUseCase, CreateMyCarUseCase, GetAccountUseCase, GetTourUseCase,
|
||||
ListMyCarsUseCase, ListMyToursTodayUseCase, SetDeliveryOrderUseCase, SyncTourUseCase,
|
||||
UpdateMyCarUseCase,
|
||||
};
|
||||
use holzleitner_infrastructure::auth::{KeycloakAdapterConfig, KeycloakAuthService};
|
||||
use holzleitner_infrastructure::persistence::{
|
||||
PgAccountRepository, PgCarRepository, PgDeliveryNoteRepository, PgDeliveryRepository,
|
||||
PgScanRepository, PgTourRepository, PoolConfig, connect_and_migrate,
|
||||
};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
use crate::middleware::jwt_middleware;
|
||||
use crate::openapi::ApiDoc;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
"holzleitner_api=info,holzleitner_infrastructure=info,tower_http=info".into()
|
||||
}),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cfg = config::load().context("config laden fehlgeschlagen")?;
|
||||
tracing::info!(host = %cfg.server.host, port = cfg.server.port, "starting up");
|
||||
|
||||
// --- Persistence ---------------------------------------------------
|
||||
let pool = connect_and_migrate(&PoolConfig {
|
||||
url: cfg.database.url.clone(),
|
||||
max_connections: cfg.database.max_connections,
|
||||
})
|
||||
.await
|
||||
.context("Postgres-Verbindung / Migrations fehlgeschlagen")?;
|
||||
tracing::info!("postgres connected, migrations applied");
|
||||
|
||||
let account_repository = Arc::new(PgAccountRepository::new(pool.clone()));
|
||||
let tour_repository = Arc::new(PgTourRepository::new(pool.clone()));
|
||||
let scan_repository = Arc::new(PgScanRepository::new(pool.clone()));
|
||||
let delivery_repository = Arc::new(PgDeliveryRepository::new(pool.clone()));
|
||||
let delivery_note_repository = Arc::new(PgDeliveryNoteRepository::new(pool.clone()));
|
||||
let car_repository = Arc::new(PgCarRepository::new(pool));
|
||||
|
||||
// --- Auth ----------------------------------------------------------
|
||||
let auth_service = Arc::new(KeycloakAuthService::new(KeycloakAdapterConfig {
|
||||
issuer_url: cfg.keycloak.issuer_url.clone(),
|
||||
audience: cfg.keycloak.audience.clone(),
|
||||
jwks_cache_ttl: Duration::from_secs(cfg.keycloak.jwks_cache_ttl_seconds),
|
||||
}));
|
||||
tracing::info!(
|
||||
issuer = %cfg.keycloak.issuer_url,
|
||||
audience = %cfg.keycloak.audience,
|
||||
"keycloak adapter ready"
|
||||
);
|
||||
|
||||
// --- Use Cases -----------------------------------------------------
|
||||
let get_account = Arc::new(GetAccountUseCase::new(account_repository));
|
||||
let get_tour = Arc::new(GetTourUseCase::new(tour_repository.clone()));
|
||||
let list_my_tours_today = Arc::new(ListMyToursTodayUseCase::new(tour_repository.clone()));
|
||||
let sync_tour = Arc::new(SyncTourUseCase::new(tour_repository.clone()));
|
||||
let set_delivery_order = Arc::new(SetDeliveryOrderUseCase::new(tour_repository));
|
||||
let apply_scans = Arc::new(ApplyScansUseCase::new(
|
||||
scan_repository,
|
||||
car_repository.clone(),
|
||||
));
|
||||
let apply_delivery_action =
|
||||
Arc::new(ApplyDeliveryActionUseCase::new(delivery_repository.clone()));
|
||||
let create_delivery_note = Arc::new(CreateDeliveryNoteUseCase::new(
|
||||
delivery_note_repository,
|
||||
car_repository.clone(),
|
||||
));
|
||||
let list_my_cars = Arc::new(ListMyCarsUseCase::new(car_repository.clone()));
|
||||
let create_my_car = Arc::new(CreateMyCarUseCase::new(car_repository.clone()));
|
||||
let update_my_car = Arc::new(UpdateMyCarUseCase::new(car_repository.clone()));
|
||||
let assign_car_to_delivery = Arc::new(AssignCarToDeliveryUseCase::new(
|
||||
car_repository,
|
||||
delivery_repository,
|
||||
));
|
||||
|
||||
let state = AppState {
|
||||
get_account,
|
||||
get_tour,
|
||||
list_my_tours_today,
|
||||
sync_tour,
|
||||
set_delivery_order,
|
||||
apply_scans,
|
||||
apply_delivery_action,
|
||||
create_delivery_note,
|
||||
list_my_cars,
|
||||
create_my_car,
|
||||
update_my_car,
|
||||
assign_car_to_delivery,
|
||||
auth_service,
|
||||
};
|
||||
|
||||
// --- Router --------------------------------------------------------
|
||||
//
|
||||
// Public-Routen (Health, OpenAPI-Spec, Swagger-UI) und Protected-
|
||||
// Routen werden getrennt gebaut; die JWT-Middleware liegt **nur**
|
||||
// auf dem protected-Subtree. `route_layer` greift nur für die jetzt
|
||||
// definierten Routen — neue Routen darunter müssten explizit
|
||||
// nochmal angehängt werden.
|
||||
let public = Router::new()
|
||||
.merge(routes::health::router())
|
||||
.merge(SwaggerUi::new("/swagger-ui").url("/openapi.json", ApiDoc::openapi()));
|
||||
let protected = Router::new()
|
||||
.merge(routes::accounts::router())
|
||||
.merge(routes::tours::router())
|
||||
.merge(routes::scans::router())
|
||||
.merge(routes::deliveries::router())
|
||||
.merge(routes::cars::router())
|
||||
.route_layer(from_fn_with_state(state.clone(), jwt_middleware));
|
||||
|
||||
let app = Router::new()
|
||||
.merge(public)
|
||||
.merge(protected)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", cfg.server.host, cfg.server.port)
|
||||
.parse()
|
||||
.with_context(|| format!("ungültige Adresse {}:{}", cfg.server.host, cfg.server.port))?;
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
tracing::info!("server läuft auf http://{}", addr);
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
46
crates/api/src/middleware/jwt.rs
Normal file
46
crates/api/src/middleware/jwt.rs
Normal file
@ -0,0 +1,46 @@
|
||||
//! JWT-Middleware: extrahiert das Bearer-Token, ruft den `AuthService`
|
||||
//! aus dem `AppState` und hängt die verifizierten `Claims` als Request-
|
||||
//! Extension an. Nachgelagerte Handler greifen darüber per
|
||||
//! `AuthenticatedUser`-Extractor zu.
|
||||
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use holzleitner_application::ports::AuthError;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn jwt_middleware(
|
||||
State(state): State<AppState>,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ApiError> {
|
||||
let bearer = extract_bearer(req.headers()).ok_or(ApiError::from(AuthError::MissingToken))?;
|
||||
|
||||
let claims = state
|
||||
.auth_service
|
||||
.verify_token(bearer)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
tracing::debug!(
|
||||
personalnummer = claims.personalnummer,
|
||||
subject = %claims.subject,
|
||||
roles = ?claims.roles,
|
||||
"auth ok",
|
||||
);
|
||||
req.extensions_mut().insert(claims);
|
||||
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
fn extract_bearer(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get(AUTHORIZATION)?
|
||||
.to_str()
|
||||
.ok()?
|
||||
.strip_prefix("Bearer ")
|
||||
}
|
||||
6
crates/api/src/middleware/mod.rs
Normal file
6
crates/api/src/middleware/mod.rs
Normal file
@ -0,0 +1,6 @@
|
||||
//! Axum-Middleware — z. B. JWT-Validierung gegen den
|
||||
//! `holzleitner_application::ports::AuthService`.
|
||||
|
||||
pub mod jwt;
|
||||
|
||||
pub use jwt::jwt_middleware;
|
||||
118
crates/api/src/openapi.rs
Normal file
118
crates/api/src/openapi.rs
Normal file
@ -0,0 +1,118 @@
|
||||
//! OpenAPI-Aggregation: zieht alle annotierten Handler und Schemata in
|
||||
//! ein einziges Dokument zusammen, das der `/openapi.json`-Endpoint
|
||||
//! serviert und Swagger-UI darstellt.
|
||||
//!
|
||||
//! Neue Endpoints werden hier in `paths(...)` registriert, neue Schemata
|
||||
//! in `components(schemas(...))`. Die Annotation am Handler (via
|
||||
//! `#[utoipa::path(...)]`) liefert die eigentliche Beschreibung.
|
||||
|
||||
use utoipa::Modify;
|
||||
use utoipa::OpenApi;
|
||||
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "Holzleitner Backend API",
|
||||
version = "0.1.0",
|
||||
description = "Backend für die Holzleitner-Lieferservice-App — Tour, Beladung, Ausführung."
|
||||
),
|
||||
paths(
|
||||
crate::routes::health::health,
|
||||
crate::routes::accounts::get_account,
|
||||
crate::routes::tours::list_my_tours_today,
|
||||
crate::routes::tours::get_tour,
|
||||
crate::routes::tours::set_delivery_order,
|
||||
crate::routes::tours::sync_tour,
|
||||
crate::routes::scans::apply_scans,
|
||||
crate::routes::deliveries::hold,
|
||||
crate::routes::deliveries::resume,
|
||||
crate::routes::deliveries::cancel,
|
||||
crate::routes::deliveries::complete,
|
||||
crate::routes::deliveries::create_note,
|
||||
crate::routes::deliveries::assign_car,
|
||||
crate::routes::cars::list_my_cars,
|
||||
crate::routes::cars::create_my_car,
|
||||
crate::routes::cars::update_my_car,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
holzleitner_domain::Account,
|
||||
holzleitner_domain::Address,
|
||||
holzleitner_domain::Article,
|
||||
holzleitner_domain::AuditAction,
|
||||
holzleitner_domain::Car,
|
||||
holzleitner_domain::Customer,
|
||||
holzleitner_domain::CustomerContact,
|
||||
holzleitner_domain::Delivery,
|
||||
holzleitner_domain::DeliveryItem,
|
||||
holzleitner_domain::DeliveryNote,
|
||||
holzleitner_domain::DeliveryState,
|
||||
holzleitner_domain::ScanState,
|
||||
holzleitner_domain::ScanStatus,
|
||||
holzleitner_domain::Tour,
|
||||
holzleitner_domain::Warehouse,
|
||||
holzleitner_application::dto::TourDetails,
|
||||
holzleitner_application::dto::DeliveryWithItems,
|
||||
holzleitner_application::dto::TourSummary,
|
||||
holzleitner_application::dto::SyncTourRequest,
|
||||
holzleitner_application::dto::SyncDelivery,
|
||||
holzleitner_application::dto::SyncDeliveryItem,
|
||||
holzleitner_application::dto::SetDeliveryOrderRequest,
|
||||
holzleitner_application::dto::SetDeliveryOrderResponse,
|
||||
holzleitner_application::dto::DeliveryOrderEntry,
|
||||
holzleitner_application::dto::ApplyScansRequest,
|
||||
holzleitner_application::dto::ApplyScansResponse,
|
||||
holzleitner_application::dto::ScanEvent,
|
||||
holzleitner_application::dto::ScanResult,
|
||||
holzleitner_application::dto::ScanResultStatus,
|
||||
holzleitner_application::dto::HoldDeliveryRequest,
|
||||
holzleitner_application::dto::CancelDeliveryRequest,
|
||||
holzleitner_application::dto::DeliveryResponse,
|
||||
holzleitner_application::dto::CreateDeliveryNoteRequest,
|
||||
holzleitner_application::dto::DeliveryNoteResponse,
|
||||
holzleitner_application::dto::CreateCarRequest,
|
||||
holzleitner_application::dto::UpdateCarRequest,
|
||||
holzleitner_application::dto::CarResponse,
|
||||
holzleitner_application::dto::CarsList,
|
||||
holzleitner_application::dto::AssignCarRequest,
|
||||
crate::routes::tours::TourSummaryList,
|
||||
crate::routes::tours::SyncTourResponse,
|
||||
)
|
||||
),
|
||||
modifiers(&SecurityAddon),
|
||||
tags(
|
||||
(name = "health", description = "Health- und Status-Endpoints"),
|
||||
(name = "accounts", description = "Account-Stammdaten"),
|
||||
(name = "tours", description = "Touren der Fahrer"),
|
||||
(name = "sync", description = "ERP-Sync-Endpunkte"),
|
||||
(name = "scans", description = "Scan-Events (Beladung & Auslieferung)"),
|
||||
(name = "deliveries", description = "Delivery-Lifecycle (hold / resume / cancel / complete)"),
|
||||
(name = "cars", description = "Fahrzeug-Stammdaten pro Fahrer"),
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
/// Hängt das `bearer_auth`-Security-Scheme nachträglich in die Spec ein —
|
||||
/// das geht nur über einen `Modify`-Hook, weil derive-Macros das nicht
|
||||
/// direkt erlauben.
|
||||
struct SecurityAddon;
|
||||
|
||||
impl Modify for SecurityAddon {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
if let Some(components) = openapi.components.as_mut() {
|
||||
components.add_security_scheme(
|
||||
"bearer_auth",
|
||||
SecurityScheme::Http(
|
||||
HttpBuilder::new()
|
||||
.scheme(HttpAuthScheme::Bearer)
|
||||
.bearer_format("JWT")
|
||||
.build(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
44
crates/api/src/routes/accounts.rs
Normal file
44
crates/api/src/routes/accounts.rs
Normal file
@ -0,0 +1,44 @@
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::get;
|
||||
use holzleitner_domain::Account;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/accounts/{personalnummer}", get(get_account))
|
||||
}
|
||||
|
||||
/// Liest den Account zu einer Personalnummer.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/accounts/{personalnummer}",
|
||||
tag = "accounts",
|
||||
params(
|
||||
("personalnummer" = i64, Path, description = "Personalnummer des Accounts")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Account gefunden", body = Account),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Kein Account zu dieser Personalnummer")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn get_account(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(personalnummer): Path<i64>,
|
||||
) -> Result<Json<Account>, ApiError> {
|
||||
tracing::debug!(
|
||||
caller = claims.personalnummer,
|
||||
target = personalnummer,
|
||||
"get_account",
|
||||
);
|
||||
let account = state.get_account.execute(personalnummer).await?;
|
||||
Ok(Json(account))
|
||||
}
|
||||
108
crates/api/src/routes/cars.rs
Normal file
108
crates/api/src/routes/cars.rs
Normal file
@ -0,0 +1,108 @@
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::routing::{get, patch};
|
||||
use holzleitner_application::dto::{
|
||||
CarResponse, CarsList, CreateCarRequest, UpdateCarRequest,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/me/cars", get(list_my_cars).post(create_my_car))
|
||||
.route("/me/cars/{car_id}", patch(update_my_car))
|
||||
}
|
||||
|
||||
/// Query-Parameter für `GET /me/cars`.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListCarsQuery {
|
||||
/// Default `false` — Endpoint liefert standardmäßig auch
|
||||
/// deaktivierte Fahrzeuge **nicht** mit.
|
||||
#[serde(default)]
|
||||
pub include_inactive: bool,
|
||||
}
|
||||
|
||||
/// Listet die Fahrzeuge des angemeldeten Fahrers.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/me/cars",
|
||||
tag = "cars",
|
||||
params(
|
||||
("includeInactive" = Option<bool>, Query, description = "Wenn true, werden inaktive Fahrzeuge mitgeliefert (default: false)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Fahrzeuge des Fahrers", body = CarsList),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_my_cars(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Query(query): Query<ListCarsQuery>,
|
||||
) -> Result<Json<CarsList>, ApiError> {
|
||||
let cars = state
|
||||
.list_my_cars
|
||||
.execute(claims.personalnummer, query.include_inactive)
|
||||
.await?;
|
||||
Ok(Json(CarsList { cars }))
|
||||
}
|
||||
|
||||
/// Legt ein neues Fahrzeug für den angemeldeten Fahrer an.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/me/cars",
|
||||
tag = "cars",
|
||||
request_body = CreateCarRequest,
|
||||
responses(
|
||||
(status = 200, description = "Fahrzeug angelegt", body = CarResponse),
|
||||
(status = 400, description = "Validierungsfehler (z. B. doppeltes Kennzeichen)"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_my_car(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Json(req): Json<CreateCarRequest>,
|
||||
) -> Result<Json<CarResponse>, ApiError> {
|
||||
let car = state
|
||||
.create_my_car
|
||||
.execute(claims.personalnummer, req)
|
||||
.await?;
|
||||
Ok(Json(CarResponse { car }))
|
||||
}
|
||||
|
||||
/// Aktualisiert ein Fahrzeug (Kennzeichen ändern / deaktivieren).
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/me/cars/{car_id}",
|
||||
tag = "cars",
|
||||
params(("car_id" = Uuid, Path)),
|
||||
request_body = UpdateCarRequest,
|
||||
responses(
|
||||
(status = 200, description = "Fahrzeug aktualisiert", body = CarResponse),
|
||||
(status = 400, description = "Validierungsfehler"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Fahrzeug nicht gefunden oder gehört nicht zu diesem Account")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn update_my_car(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(car_id): Path<Uuid>,
|
||||
Json(req): Json<UpdateCarRequest>,
|
||||
) -> Result<Json<CarResponse>, ApiError> {
|
||||
let car = state
|
||||
.update_my_car
|
||||
.execute(car_id, claims.personalnummer, req)
|
||||
.await?;
|
||||
Ok(Json(CarResponse { car }))
|
||||
}
|
||||
207
crates/api/src/routes/deliveries.rs
Normal file
207
crates/api/src/routes/deliveries.rs
Normal file
@ -0,0 +1,207 @@
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::{post, put};
|
||||
use holzleitner_application::dto::{
|
||||
AssignCarRequest, CancelDeliveryRequest, CreateDeliveryNoteRequest, DeliveryNoteResponse,
|
||||
DeliveryResponse, HoldDeliveryRequest,
|
||||
};
|
||||
use holzleitner_application::ports::DeliveryAction;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/deliveries/{delivery_id}/hold", post(hold))
|
||||
.route("/deliveries/{delivery_id}/resume", post(resume))
|
||||
.route("/deliveries/{delivery_id}/cancel", post(cancel))
|
||||
.route("/deliveries/{delivery_id}/complete", post(complete))
|
||||
.route("/deliveries/{delivery_id}/notes", post(create_note))
|
||||
.route("/deliveries/{delivery_id}/assigned-car", put(assign_car))
|
||||
}
|
||||
|
||||
/// Setzt die Lieferung auf `held`. Nur aus `active` zulässig.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/deliveries/{delivery_id}/hold",
|
||||
tag = "deliveries",
|
||||
params(("delivery_id" = Uuid, Path)),
|
||||
request_body = HoldDeliveryRequest,
|
||||
responses(
|
||||
(status = 200, description = "Lieferung geholdet", body = DeliveryResponse),
|
||||
(status = 400, description = "Invalider Statusübergang oder leerer Reason"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Lieferung nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn hold(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(delivery_id): Path<Uuid>,
|
||||
Json(req): Json<HoldDeliveryRequest>,
|
||||
) -> Result<Json<DeliveryResponse>, ApiError> {
|
||||
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.hold");
|
||||
let delivery = state
|
||||
.apply_delivery_action
|
||||
.execute(delivery_id, DeliveryAction::Hold { reason: req.reason })
|
||||
.await?;
|
||||
Ok(Json(DeliveryResponse { delivery }))
|
||||
}
|
||||
|
||||
/// Setzt die Lieferung zurück auf `active`. Nur aus `held` zulässig.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/deliveries/{delivery_id}/resume",
|
||||
tag = "deliveries",
|
||||
params(("delivery_id" = Uuid, Path)),
|
||||
responses(
|
||||
(status = 200, description = "Lieferung wieder aktiv", body = DeliveryResponse),
|
||||
(status = 400, description = "Invalider Statusübergang"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Lieferung nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn resume(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(delivery_id): Path<Uuid>,
|
||||
) -> Result<Json<DeliveryResponse>, ApiError> {
|
||||
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.resume");
|
||||
let delivery = state
|
||||
.apply_delivery_action
|
||||
.execute(delivery_id, DeliveryAction::Resume)
|
||||
.await?;
|
||||
Ok(Json(DeliveryResponse { delivery }))
|
||||
}
|
||||
|
||||
/// Setzt die Lieferung auf `canceled` — endgültig. Erlaubt aus
|
||||
/// `active` und `held`.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/deliveries/{delivery_id}/cancel",
|
||||
tag = "deliveries",
|
||||
params(("delivery_id" = Uuid, Path)),
|
||||
request_body = CancelDeliveryRequest,
|
||||
responses(
|
||||
(status = 200, description = "Lieferung storniert", body = DeliveryResponse),
|
||||
(status = 400, description = "Invalider Statusübergang oder leerer Reason"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Lieferung nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn cancel(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(delivery_id): Path<Uuid>,
|
||||
Json(req): Json<CancelDeliveryRequest>,
|
||||
) -> Result<Json<DeliveryResponse>, ApiError> {
|
||||
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.cancel");
|
||||
let delivery = state
|
||||
.apply_delivery_action
|
||||
.execute(
|
||||
delivery_id,
|
||||
DeliveryAction::Cancel { reason: req.reason },
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(DeliveryResponse { delivery }))
|
||||
}
|
||||
|
||||
/// Schließt die Lieferung ab — `state = completed`. Nur aus `active`.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/deliveries/{delivery_id}/complete",
|
||||
tag = "deliveries",
|
||||
params(("delivery_id" = Uuid, Path)),
|
||||
responses(
|
||||
(status = 200, description = "Lieferung abgeschlossen", body = DeliveryResponse),
|
||||
(status = 400, description = "Invalider Statusübergang"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Lieferung nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn complete(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(delivery_id): Path<Uuid>,
|
||||
) -> Result<Json<DeliveryResponse>, ApiError> {
|
||||
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.complete");
|
||||
let delivery = state
|
||||
.apply_delivery_action
|
||||
.execute(delivery_id, DeliveryAction::Complete)
|
||||
.await?;
|
||||
Ok(Json(DeliveryResponse { delivery }))
|
||||
}
|
||||
|
||||
/// Legt eine neue Notiz an einer Lieferung an. Mindestens eines von
|
||||
/// `text` und `imageAttachment` muss inhaltlich gefüllt sein
|
||||
/// (Leerstrings werden serverseitig getrimmt und als leer behandelt).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/deliveries/{delivery_id}/notes",
|
||||
tag = "deliveries",
|
||||
params(("delivery_id" = Uuid, Path)),
|
||||
request_body = CreateDeliveryNoteRequest,
|
||||
responses(
|
||||
(status = 200, description = "Notiz angelegt", body = DeliveryNoteResponse),
|
||||
(status = 400, description = "Notiz ohne Inhalt"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Lieferung nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_note(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(delivery_id): Path<Uuid>,
|
||||
Json(req): Json<CreateDeliveryNoteRequest>,
|
||||
) -> Result<Json<DeliveryNoteResponse>, ApiError> {
|
||||
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.create_note");
|
||||
let note = state
|
||||
.create_delivery_note
|
||||
.execute(delivery_id, claims.personalnummer, req)
|
||||
.await?;
|
||||
Ok(Json(DeliveryNoteResponse { note }))
|
||||
}
|
||||
|
||||
/// Setzt das `assigned_car_id` einer Lieferung. `carId: null` löst
|
||||
/// die Zuordnung wieder. Der Use Case stellt sicher, dass das Fahrzeug
|
||||
/// zum angemeldeten Account gehört.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/deliveries/{delivery_id}/assigned-car",
|
||||
tag = "deliveries",
|
||||
params(("delivery_id" = Uuid, Path)),
|
||||
request_body = AssignCarRequest,
|
||||
responses(
|
||||
(status = 200, description = "Fahrzeug zugewiesen / entfernt", body = DeliveryResponse),
|
||||
(status = 400, description = "Fahrzeug gehört nicht zum Account"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Lieferung nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn assign_car(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(delivery_id): Path<Uuid>,
|
||||
Json(req): Json<AssignCarRequest>,
|
||||
) -> Result<Json<DeliveryResponse>, ApiError> {
|
||||
tracing::info!(
|
||||
actor = claims.personalnummer,
|
||||
%delivery_id,
|
||||
car_id = ?req.car_id,
|
||||
"delivery.assign_car",
|
||||
);
|
||||
let delivery = state
|
||||
.assign_car_to_delivery
|
||||
.execute(delivery_id, claims.personalnummer, req.car_id)
|
||||
.await?;
|
||||
Ok(Json(DeliveryResponse { delivery }))
|
||||
}
|
||||
23
crates/api/src/routes/health.rs
Normal file
23
crates/api/src/routes/health.rs
Normal file
@ -0,0 +1,23 @@
|
||||
use axum::Router;
|
||||
use axum::routing::get;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/health", get(health))
|
||||
}
|
||||
|
||||
/// Health-Endpoint für Load-Balancer und Container-Probes. Bewusst
|
||||
/// kein Auth — eine `200 ok`-Antwort darf nicht von der Auth abhängen.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health",
|
||||
tag = "health",
|
||||
responses(
|
||||
(status = 200, description = "Service ist erreichbar", body = String)
|
||||
),
|
||||
security()
|
||||
)]
|
||||
pub async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
9
crates/api/src/routes/mod.rs
Normal file
9
crates/api/src/routes/mod.rs
Normal file
@ -0,0 +1,9 @@
|
||||
//! HTTP-Routen, gruppiert nach Domäne. Wird vom `main.rs`-Router
|
||||
//! zusammengesetzt.
|
||||
|
||||
pub mod accounts;
|
||||
pub mod cars;
|
||||
pub mod deliveries;
|
||||
pub mod health;
|
||||
pub mod scans;
|
||||
pub mod tours;
|
||||
49
crates/api/src/routes/scans.rs
Normal file
49
crates/api/src/routes/scans.rs
Normal file
@ -0,0 +1,49 @@
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::State;
|
||||
use axum::routing::post;
|
||||
use holzleitner_application::dto::{ApplyScansRequest, ApplyScansResponse};
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/scans", post(apply_scans))
|
||||
}
|
||||
|
||||
/// Wendet eine Liste von Scan-Events idempotent an.
|
||||
///
|
||||
/// Pro Event ein eigenes Resultat. Status `applied` schreibt einen
|
||||
/// frischen Audit-Eintrag, `duplicate` liefert den aktuellen Stand am
|
||||
/// Server, `rejected` enthält die Begründung. Reihenfolge der `results`
|
||||
/// entspricht der Reihenfolge der `scans` im Request.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/scans",
|
||||
tag = "scans",
|
||||
request_body = ApplyScansRequest,
|
||||
responses(
|
||||
(status = 200, description = "Bulk-Ergebnis pro Event", body = ApplyScansResponse),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn apply_scans(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Json(request): Json<ApplyScansRequest>,
|
||||
) -> Result<Json<ApplyScansResponse>, ApiError> {
|
||||
tracing::info!(
|
||||
actor = claims.personalnummer,
|
||||
count = request.scans.len(),
|
||||
"apply_scans",
|
||||
);
|
||||
let response = state
|
||||
.apply_scans
|
||||
.execute(request, claims.personalnummer)
|
||||
.await?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
158
crates/api/src/routes/tours.rs
Normal file
158
crates/api/src/routes/tours.rs
Normal file
@ -0,0 +1,158 @@
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::{get, post, put};
|
||||
use holzleitner_application::dto::{
|
||||
SetDeliveryOrderRequest, SetDeliveryOrderResponse, SyncTourRequest, TourDetails, TourSummary,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/me/tours/today", get(list_my_tours_today))
|
||||
.route("/tours/{tour_id}", get(get_tour))
|
||||
.route("/tours/{tour_id}/delivery-order", put(set_delivery_order))
|
||||
.route("/sync/tour", post(sync_tour))
|
||||
}
|
||||
|
||||
/// Antwort-Hülle für `GET /me/tours/today`. Eigenes Struct, weil
|
||||
/// utoipa für `Vec<T>` als Top-Level-Response keinen sauberen
|
||||
/// Schemanamen vergibt — und ein Wrapper macht die Erweiterbarkeit
|
||||
/// (z. B. Paginierung in Zukunft) zur Nicht-Breaking-Change.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TourSummaryList {
|
||||
pub tours: Vec<TourSummary>,
|
||||
}
|
||||
|
||||
/// Antwort-Hülle für `POST /sync/tour`.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncTourResponse {
|
||||
pub tour_id: Uuid,
|
||||
}
|
||||
|
||||
/// Listet heutige Touren des angemeldeten Fahrers (Filter aus dem JWT).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/me/tours/today",
|
||||
tag = "tours",
|
||||
responses(
|
||||
(status = 200, description = "Liste der heutigen Touren", body = TourSummaryList),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn list_my_tours_today(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
) -> Result<Json<TourSummaryList>, ApiError> {
|
||||
tracing::debug!(personalnummer = claims.personalnummer, "list_my_tours_today");
|
||||
let tours = state
|
||||
.list_my_tours_today
|
||||
.execute(claims.personalnummer)
|
||||
.await?;
|
||||
Ok(Json(TourSummaryList { tours }))
|
||||
}
|
||||
|
||||
/// Lädt eine Tour mit allen Lieferungen, Positionen und referenzierten
|
||||
/// Stammdaten — die App nutzt das als einzigen großen Read.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/tours/{tour_id}",
|
||||
tag = "tours",
|
||||
params(
|
||||
("tour_id" = Uuid, Path, description = "Eindeutige Tour-Id (UUID)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Tour-Aggregat gefunden", body = TourDetails),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Keine Tour mit dieser Id")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn get_tour(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(tour_id): Path<Uuid>,
|
||||
) -> Result<Json<TourDetails>, ApiError> {
|
||||
tracing::debug!(personalnummer = claims.personalnummer, %tour_id, "get_tour");
|
||||
let details = state.get_tour.execute(tour_id).await?;
|
||||
Ok(Json(details))
|
||||
}
|
||||
|
||||
/// Schreibt die Sortier-Reihenfolge aller Lieferungen einer Tour neu.
|
||||
/// Der Client schickt die **vollständige** neue Reihenfolge; fehlende
|
||||
/// oder fremde Lieferungs-Ids werden mit `400 validation` abgelehnt.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/tours/{tour_id}/delivery-order",
|
||||
tag = "tours",
|
||||
params(("tour_id" = Uuid, Path)),
|
||||
request_body = SetDeliveryOrderRequest,
|
||||
responses(
|
||||
(status = 200, description = "Neue Reihenfolge gespeichert", body = SetDeliveryOrderResponse),
|
||||
(status = 400, description = "Mengen-Mismatch oder Duplikate"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Tour nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn set_delivery_order(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(tour_id): Path<Uuid>,
|
||||
Json(request): Json<SetDeliveryOrderRequest>,
|
||||
) -> Result<Json<SetDeliveryOrderResponse>, ApiError> {
|
||||
tracing::info!(
|
||||
actor = claims.personalnummer,
|
||||
%tour_id,
|
||||
count = request.delivery_ids.len(),
|
||||
"set_delivery_order",
|
||||
);
|
||||
let response = state.set_delivery_order.execute(tour_id, request).await?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
/// Sync-Endpoint für das ERP: legt eine Tagestour samt Lieferungen und
|
||||
/// Positionen idempotent an. Identität pro Tour
|
||||
/// `(driver_personalnummer, tour_date)`, pro Lieferung
|
||||
/// `(belegart_id, belegnummer)`.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/sync/tour",
|
||||
tag = "sync",
|
||||
request_body = SyncTourRequest,
|
||||
responses(
|
||||
(status = 200, description = "Tour gespeichert", body = SyncTourResponse),
|
||||
(status = 400, description = "Validierungsfehler im Sync-Payload"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn sync_tour(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Json(request): Json<SyncTourRequest>,
|
||||
) -> Result<Json<SyncTourResponse>, ApiError> {
|
||||
tracing::info!(
|
||||
caller = claims.personalnummer,
|
||||
driver = request.driver_personalnummer,
|
||||
date = %request.tour_date,
|
||||
deliveries = request.deliveries.len(),
|
||||
"sync_tour",
|
||||
);
|
||||
let tour_id = state.sync_tour.execute(request).await?;
|
||||
Ok(Json(SyncTourResponse { tour_id }))
|
||||
}
|
||||
33
crates/api/src/state.rs
Normal file
33
crates/api/src/state.rs
Normal file
@ -0,0 +1,33 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use holzleitner_application::ports::AuthService;
|
||||
use holzleitner_application::usecases::{
|
||||
ApplyDeliveryActionUseCase, ApplyScansUseCase, AssignCarToDeliveryUseCase,
|
||||
CreateDeliveryNoteUseCase, CreateMyCarUseCase, GetAccountUseCase, GetTourUseCase,
|
||||
ListMyCarsUseCase, ListMyToursTodayUseCase, SetDeliveryOrderUseCase, SyncTourUseCase,
|
||||
UpdateMyCarUseCase,
|
||||
};
|
||||
|
||||
/// Shared application state, der per Axum's `State`-Extractor in alle
|
||||
/// Handler hineingegeben wird. Use Cases und Services liegen hinter
|
||||
/// `Arc`, damit `Clone` billig ist und Requests sich keine Locks teilen.
|
||||
///
|
||||
/// Das `AppState`-Klon wird zudem in die JWT-Middleware geschleust
|
||||
/// (`from_fn_with_state`), die den `auth_service` nutzt — derselbe
|
||||
/// Trait-Objekt-Arc wie die Handler.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub get_account: Arc<GetAccountUseCase>,
|
||||
pub get_tour: Arc<GetTourUseCase>,
|
||||
pub list_my_tours_today: Arc<ListMyToursTodayUseCase>,
|
||||
pub sync_tour: Arc<SyncTourUseCase>,
|
||||
pub set_delivery_order: Arc<SetDeliveryOrderUseCase>,
|
||||
pub apply_scans: Arc<ApplyScansUseCase>,
|
||||
pub apply_delivery_action: Arc<ApplyDeliveryActionUseCase>,
|
||||
pub create_delivery_note: Arc<CreateDeliveryNoteUseCase>,
|
||||
pub list_my_cars: Arc<ListMyCarsUseCase>,
|
||||
pub create_my_car: Arc<CreateMyCarUseCase>,
|
||||
pub update_my_car: Arc<UpdateMyCarUseCase>,
|
||||
pub assign_car_to_delivery: Arc<AssignCarToDeliveryUseCase>,
|
||||
pub auth_service: Arc<dyn AuthService>,
|
||||
}
|
||||
Reference in New Issue
Block a user