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

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