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:
Dennis Nemec
2026-05-14 22:28:31 +02:00
commit 438040acce
83 changed files with 8922 additions and 0 deletions

32
crates/api/Cargo.toml Normal file
View 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
View 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
View 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()
}
}

View 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
View 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(())
}

View 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 ")
}

View 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
View 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(),
),
);
}
}
}

View 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))
}

View 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 }))
}

View 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 }))
}

View 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"
}

View 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;

View 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))
}

View 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
View 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>,
}