Backend-Arbeitsstand: ERP-Sync, Lieferlebenszyklus, Reports + config.toml

Bringt das Backend vom initialen Skeleton auf den aktuellen Arbeitsstand
(Clean Architecture: domain → application → infrastructure → api).

Wesentliche Bereiche:
- ERP-Anbindung (MSSQL-Pull der Touren, Import-Scheduler, Rückschreiben)
- Lieferlebenszyklus: Scan/Hold/Cancel/Complete, Gutschriften, Notizen,
  Bild-Anhänge, Unterschriften, PDF-Lieferreport → DOCUframe
- Stammdaten: Kunden, Artikel, Lager, Zahlungsarten, Services
- Keycloak-JWT-Gate + Fahrer-Provisionierung via Admin-API
- Admin-API-Key-Gate (X-Admin-Api-Key) für Maschinen-Endpunkte

Jüngste Änderungen dieser Session:
- Belegspezifische Kontaktdaten: alle ERP-Adressen (Beleg-/Liefer-/
  Rechnungsadresse, Ansprechpartner, Kundenstamm) mit Telefon/Mobil/
  E-Mail werden gesynct (Migration 0029, MSSQL-Query, TourDetails)
- Konfiguration von .env (envy/dotenvy) auf config.toml (toml/serde)
  umgestellt; Vorlage config.example.toml, Pfad via HOLZLEITNER_CONFIG

Nicht im Repo (per .gitignore): config.toml (Secrets), data/ (Laufzeit-/
Kundendaten), demo.mp4, .claude/, variocontrol-ai/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-06-01 17:52:58 +02:00
parent 438040acce
commit 6a9b5872e1
137 changed files with 13700 additions and 218 deletions

View File

@ -27,6 +27,6 @@ uuid.workspace = true
chrono.workspace = true
thiserror.workspace = true
anyhow.workspace = true
envy.workspace = true
dotenvy.workspace = true
toml.workspace = true
sqlx.workspace = true
tokio-cron-scheduler.workspace = true

View File

@ -1,27 +1,351 @@
//! Konfiguration aus Umgebungsvariablen (12-Factor-Stil).
//! Konfiguration aus einer `config.toml`-Datei.
//!
//! 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.
//! [`load`] liest die TOML-Datei (Pfad via `HOLZLEITNER_CONFIG`-Env
//! überschreibbar, sonst `config.toml` im Arbeitsverzeichnis) und
//! deserialisiert sie über `serde` in [`Config`]. Die Struktur ist nach
//! Bereichen in TOML-Sections gruppiert (`[server]`, `[database]`,
//! `[keycloak]`, …); fehlende optionale Sections/Felder fallen auf die
//! `#[serde(default = …)]`-Werte zurück.
//!
//! Die Vorlage liegt in `config.example.toml`; für lokale Entwicklung
//! `cp config.example.toml config.toml` und Werte anpassen. Die echte
//! `config.toml` enthält Secrets und gehört nicht in Git.
use std::path::PathBuf;
use chrono::NaiveDate;
use serde::Deserialize;
#[derive(Debug, Clone)]
/// Env-Variable, mit der der Pfad zur Config-Datei überschrieben werden
/// kann (z. B. im Deployment). Ohne sie wird `config.toml` im
/// Arbeitsverzeichnis erwartet.
const CONFIG_PATH_ENV: &str = "HOLZLEITNER_CONFIG";
const DEFAULT_CONFIG_PATH: &str = "config.toml";
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub server: ServerConfig,
pub database: DatabaseConfig,
#[allow(dead_code)] // wird in der Keycloak-Phase verdrahtet
pub keycloak: KeycloakConfig,
pub gsd: GsdConfig,
#[serde(default)]
pub signature: SignatureConfig,
#[serde(default)]
pub attachment: AttachmentConfig,
#[serde(default)]
pub report: ReportConfig,
#[serde(default)]
pub erp: ErpConfig,
#[serde(default)]
pub import: ImportConfig,
/// DEV-ONLY-Schalter (`today_override`, `sync_enabled`).
#[serde(default)]
pub dev: DevConfig,
/// Admin-API-Key-Gate auf `/admin`.
#[serde(default)]
pub admin: AdminConfig,
/// Log-Filter (Override via `RUST_LOG`-Env hat Vorrang, siehe main.rs).
#[serde(default)]
pub logging: LoggingConfig,
}
impl Config {
/// Loggt eine Zusammenfassung der geladenen Konfiguration beim Start.
/// Secrets (Passwörter, Client-Secrets, Hashes) werden **maskiert** —
/// nur „gesetzt/leer" ist sichtbar, nie der Klartext. Hilft, beim
/// Binary-Start auf einen Blick Fehlkonfigurationen zu erkennen (z. B.
/// einen aktiven `today_override`).
pub fn log_summary(&self) {
tracing::info!("════════════ Konfiguration (Start) ════════════");
tracing::info!(host = %self.server.host, port = self.server.port, "cfg.server");
tracing::info!(
url = %mask_db_url(&self.database.url),
max_connections = self.database.max_connections,
"cfg.database"
);
tracing::info!(
issuer_url = %self.keycloak.issuer_url,
audience = %self.keycloak.audience,
realm = %self.keycloak.realm,
admin_url = %display_or_unset(&self.keycloak.admin_url),
provisioning_enabled = self.keycloak.provisioning_enabled,
provisioner_client_id = %display_or_unset(&self.keycloak.provisioner_client_id),
provisioner_client_secret = %mask(&self.keycloak.provisioner_client_secret),
driver_role = %self.keycloak.driver_role,
driver_default_password = %mask(&self.keycloak.driver_default_password),
jwks_cache_ttl_seconds = self.keycloak.jwks_cache_ttl_seconds,
"cfg.keycloak"
);
tracing::info!(
host = %display_or_unset(&self.erp.host),
port = self.erp.port,
database = %display_or_unset(&self.erp.database),
user = %display_or_unset(&self.erp.user),
password = %mask(&self.erp.password),
trust_cert = self.erp.trust_cert,
writeback_enabled = self.erp.writeback_enabled,
"cfg.erp"
);
tracing::info!(
enabled = self.import.enabled,
cron = %self.import.cron,
date_offset_days = self.import.date_offset_days,
"cfg.import (scheduler)"
);
tracing::info!(
rest_url = %self.gsd.rest_url,
user = %self.gsd.user,
app_key = %mask(&self.gsd.app_key),
password_md5 = %mask(&self.gsd.password_md5),
app_names = ?self.gsd.app_names,
"cfg.gsd"
);
tracing::info!(storage_dir = %self.signature.storage_dir, "cfg.signature");
tracing::info!(storage_dir = %self.attachment.storage_dir, "cfg.attachment (Bild-Notizen lokal)");
tracing::info!(
storage_dir = %self.report.storage_dir,
upload_enabled = self.report.upload_enabled,
retry_cron = %self.report.retry_cron,
"cfg.report (PDF-Report → DOCUframe)"
);
if self.report.upload_enabled {
tracing::warn!("report.upload_enabled=true: Reports werden an DOCUframe übertragen + lokale Dateien danach gelöscht");
}
match &self.dev.today_override {
Some(date) => tracing::warn!(
today_override = %date,
"cfg.DEV: dev.today_override AKTIV — 'heute' ist überschrieben! \
In Produktion entfernen."
),
None => tracing::info!("cfg.dev.today_override = (echte Uhr)"),
}
if self.dev.sync_enabled {
tracing::warn!(
"cfg.DEV: dev.sync_enabled AKTIV — ungeschützter Endpoint \
POST /dev/resync löscht Postgres-Tourdaten! In Produktion aus."
);
}
tracing::info!(
admin_api_key = %mask(&self.admin.api_key),
"cfg.admin (X-Admin-Api-Key Gate auf /admin)"
);
if self.admin.api_key.is_empty() {
tracing::warn!(
"cfg.admin: admin.api_key LEER — alle /admin-Endpunkte sind \
GESPERRT (fail-closed). Zum Aktivieren admin.api_key setzen."
);
}
tracing::info!("═══════════════════════════════════════════════");
}
}
/// Maskiert ein Secret: zeigt nur, ob gesetzt — nie den Klartext.
fn mask(secret: &str) -> &'static str {
if secret.is_empty() {
"(leer)"
} else {
"***gesetzt***"
}
}
/// Zeigt den Wert oder `(leer)` für optionale, nicht-geheime Felder.
fn display_or_unset(value: &str) -> String {
if value.is_empty() {
"(leer)".to_string()
} else {
value.to_string()
}
}
/// Maskiert das Passwort in einer DB-URL: `scheme://user:pass@host` →
/// `scheme://user:***@host`. Lässt alles andere unverändert.
fn mask_db_url(url: &str) -> String {
let Some(scheme_end) = url.find("://") else {
return url.to_string();
};
let (scheme, rest) = url.split_at(scheme_end + 3);
let Some(at) = rest.find('@') else {
return url.to_string();
};
let (creds, host) = rest.split_at(at); // host beginnt mit '@'
let Some(colon) = creds.find(':') else {
return url.to_string();
};
let user = &creds[..colon];
format!("{scheme}{user}:***{host}")
}
/// Lese-Zugang zur ERPframe-MS-SQL-DB (für den täglichen Touren-Pull).
/// Alle Felder optional defaultet — der Server bootet auch ohne ERP-Config,
/// solange `import.enabled = false` (Default) ist.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ErpConfig {
#[serde(default)]
pub host: String,
#[serde(default = "default_mssql_port")]
pub port: u16,
#[serde(default)]
pub database: String,
#[serde(default)]
pub user: String,
#[serde(default)]
pub password: String,
/// Selbstsigniertes Server-Zertifikat akzeptieren (Intranet-DB).
#[serde(default = "default_true")]
pub trust_cert: bool,
/// ERP-**Rückschreiben** beim Lieferabschluss aktiv? Default `false` —
/// dann bleibt der Abschluss rein lokal (Dev/Seed ohne ERP-Beleg).
#[serde(default)]
pub writeback_enabled: bool,
}
impl Default for ErpConfig {
fn default() -> Self {
Self {
host: String::new(),
port: default_mssql_port(),
database: String::new(),
user: String::new(),
password: String::new(),
trust_cert: default_true(),
writeback_enabled: false,
}
}
}
fn default_mssql_port() -> u16 {
1433
}
fn default_true() -> bool {
true
}
/// Steuert den geplanten ERP-Import.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ImportConfig {
/// Default `false` — der Scheduler startet nur, wenn explizit aktiviert.
#[serde(default)]
pub enabled: bool,
/// Cron-Ausdruck (tokio-cron-scheduler, 6-stellig inkl. Sekunden).
/// Default: täglich 03:00:00.
#[serde(default = "default_import_cron")]
pub cron: String,
/// Versatz in Tagen relativ zu „heute" für das Ziel-Tourdatum.
/// Default `1` = morgen.
#[serde(default = "default_import_offset")]
pub date_offset_days: i64,
}
impl Default for ImportConfig {
fn default() -> Self {
Self {
enabled: false,
cron: default_import_cron(),
date_offset_days: default_import_offset(),
}
}
}
fn default_import_cron() -> String {
"0 0 3 * * *".to_string()
}
fn default_import_offset() -> i64 {
1
}
/// Lokaler Speicher für Unterschriften-PNGs (Kunde + Fahrer). Bewusst NICHT
/// DOCUframe — die Bilder bleiben auf der Backend-Maschine.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SignatureConfig {
/// Basis-Verzeichnis für die PNG-Dateien (wird beim Start angelegt).
#[serde(default = "default_signature_dir")]
pub storage_dir: String,
}
impl Default for SignatureConfig {
fn default() -> Self {
Self {
storage_dir: default_signature_dir(),
}
}
}
fn default_signature_dir() -> String {
"./data/signatures".to_string()
}
/// Lokaler Speicher für hochgeladene Bild-Notizen. Statt DOCUframe landen die
/// Bilder pro Belegnummer in einem Unterordner (`<dir>/<Belegnummer>/…`).
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AttachmentConfig {
/// Basis-Verzeichnis (wird beim Start angelegt).
#[serde(default = "default_attachment_dir")]
pub storage_dir: String,
}
impl Default for AttachmentConfig {
fn default() -> Self {
Self {
storage_dir: default_attachment_dir(),
}
}
}
fn default_attachment_dir() -> String {
"./data/attachments".to_string()
}
/// Lokaler (temporärer) Speicher für die PDF-Lieferreports. Später wird der
/// Blob direkt an ein DOCUframe-Makro gesendet (Sink-Stub vorhanden).
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReportConfig {
/// Basis-Verzeichnis (wird beim Start angelegt).
#[serde(default = "default_report_dir")]
pub storage_dir: String,
/// Schaltet die DOCUframe-Übertragung des Reports beim Abschluss + den
/// Retry-Cron frei. Aus = nur lokale Ablage (Dev / DOCUframe nicht erreichbar).
#[serde(default)]
pub upload_enabled: bool,
/// Cron-Ausdruck (6-stellig, mit Sekunden) für den Retry der offenen
/// Report-Jobs. Default: alle 5 Minuten.
#[serde(default = "default_report_retry_cron")]
pub retry_cron: String,
}
impl Default for ReportConfig {
fn default() -> Self {
Self {
storage_dir: default_report_dir(),
upload_enabled: false,
retry_cron: default_report_retry_cron(),
}
}
}
fn default_report_dir() -> String {
"./data/reports".to_string()
}
fn default_report_retry_cron() -> String {
"0 */5 * * * *".to_string()
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DatabaseConfig {
pub url: String,
#[serde(default = "default_max_connections")]
@ -36,62 +360,269 @@ fn default_max_connections() -> u32 {
/// Warnings unterdrücken.
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeycloakConfig {
pub issuer_url: String,
pub audience: String,
#[serde(default = "default_jwks_cache_ttl")]
pub jwks_cache_ttl_seconds: u64,
// --- Provisioning via Admin-API (ERP-Sync legt Fahrer-Konten an) ------
/// Basis-URL der Keycloak-Instanz **ohne** `/realms/...`, z. B.
/// `http://localhost:8080`. Leer ⇒ Provisionierung nicht möglich.
#[serde(default)]
pub admin_url: String,
/// Realm-Name. Default `holzleitner`.
#[serde(default = "default_realm")]
pub realm: String,
/// Service-Account-Client (confidential) für die Admin-API.
#[serde(default)]
pub provisioner_client_id: String,
#[serde(default)]
pub provisioner_client_secret: String,
/// Temporäres Default-Passwort für neu angelegte Fahrer-Konten (muss beim
/// ersten Login geändert werden).
#[serde(default = "default_driver_password")]
pub driver_default_password: String,
/// Realm-Rolle, die jedem Fahrer zugewiesen wird. Default `driver`.
#[serde(default = "default_driver_role")]
pub driver_role: String,
/// Konto-Provisionierung beim Sync aktiv? Default `false`.
#[serde(default)]
pub provisioning_enabled: bool,
}
fn default_jwks_cache_ttl() -> u64 {
3600
}
fn default_realm() -> String {
"holzleitner".to_string()
}
fn default_driver_password() -> String {
"Holzleitner-Start1!".to_string()
}
fn default_driver_role() -> String {
"driver".to_string()
}
/// Lädt die Konfiguration aus Umgebungsvariablen.
/// Anbindung an die GSD/DOCUframe-REST-API (für Datei-Uploads).
///
/// Login läuft über einen technischen Service-Account; `password_md5` ist
/// bereits der MD5-Hash des Passworts (GSD erwartet `pass` als MD5) — so
/// liegt kein Klartext-Secret in der Konfiguration.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GsdConfig {
/// Basis-URL der GSD-REST-API (ohne abschließenden Slash), z. B.
/// `http://192.168.1.9:8334`.
pub rest_url: String,
pub app_key: String,
pub user: String,
/// MD5-Hash des Service-Account-Passworts.
pub password_md5: String,
/// App-Namen als TOML-Array, z. B. `["GSD-RestApi"]`.
pub app_names: Vec<String>,
}
/// DEV-ONLY-Schalter. Beide Felder defaulten auf „aus".
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct DevConfig {
/// Überschreibt das serverseitige „heute" für `GET /me/tours/today`.
/// Als **quotierter** String `"YYYY-MM-DD"` angeben — ein bloßes
/// `2026-06-01` würde TOML als nativen Datums-Typ lesen, den chrono
/// nicht direkt akzeptiert. `None`/weglassen = echte Uhr. Nur zum
/// Testen mit historischen/importierten Touren.
#[serde(default)]
pub today_override: Option<NaiveDate>,
/// Schaltet den ungeschützten Dev-Resync-Endpoint (`POST /dev/resync`)
/// frei, der Postgres platt macht und neu importiert. Default `false`
/// → Endpoint wird gar nicht erst gemountet.
#[serde(default)]
pub sync_enabled: bool,
}
/// Admin-API-Key-Gate auf `/admin`.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct AdminConfig {
/// Statischer API-Key für die `/admin`-Endpunkte (Maschinen-Zugang per
/// Header `X-Admin-Api-Key`, KEIN Keycloak/JWT). **Leer ⇒ alle
/// Admin-Routen sind gesperrt** (fail-closed). In Produktion einen
/// hochentropischen Zufallswert setzen, nie committen.
#[serde(default)]
pub api_key: String,
}
/// Logging-Filter (tracing-subscriber `EnvFilter`-Syntax).
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LoggingConfig {
/// Default-Filter. Die `RUST_LOG`-Env-Variable hat Vorrang (siehe
/// `main.rs`), damit Ad-hoc-Debugging ohne Datei-Edit möglich bleibt.
#[serde(default = "default_log_filter")]
pub filter: String,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
filter: default_log_filter(),
}
}
}
fn default_log_filter() -> String {
// WICHTIG: Der Binary-Crate heißt `holzleitner_server` (vom `[[bin]]
// name`), NICHT `holzleitner_api` — sonst werden alle Logs aus
// main.rs/config.rs (Startup + Config-Übersicht) herausgefiltert.
"holzleitner_server=info,holzleitner_api=info,\
holzleitner_application=info,holzleitner_infrastructure=info,\
tower_http=info"
.to_string()
}
/// Pfad zur Config-Datei: `HOLZLEITNER_CONFIG`-Env, sonst `config.toml`.
fn config_path() -> PathBuf {
match std::env::var(CONFIG_PATH_ENV) {
Ok(p) if !p.trim().is_empty() => PathBuf::from(p.trim()),
_ => PathBuf::from(DEFAULT_CONFIG_PATH),
}
}
/// Lädt die Konfiguration aus der TOML-Datei.
///
/// 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`]
/// 1. Pfad bestimmen (`HOLZLEITNER_CONFIG`-Env oder `config.toml`).
/// 2. Datei lesen und mit `toml`/`serde` in [`Config`] deserialisieren.
/// Fehlende optionale Sections/Felder fallen auf die `serde`-Defaults.
pub fn load() -> Result<Config, ConfigError> {
// `.env` ist optional — in Produktion kommen die Werte aus dem
// System-Environment.
let _ = dotenvy::dotenv();
let path = config_path();
let raw = std::fs::read_to_string(&path).map_err(|source| ConfigError::Read {
path: path.clone(),
source,
})?;
parse(&raw).map_err(|source| ConfigError::Parse { path, source })
}
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,
})
/// Deserialisiert TOML-Text in [`Config`]. Ausgelagert, damit er in Tests
/// ohne Dateizugriff aufgerufen werden kann.
fn parse(raw: &str) -> Result<Config, toml::de::Error> {
toml::from_str::<Config>(raw)
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("missing or invalid env vars in section {section}: {source}")]
Section {
section: &'static str,
// PathBuf implementiert kein Display → Debug-Format `{path:?}`.
#[error("config-Datei {path:?} konnte nicht gelesen werden: {source}")]
Read {
path: PathBuf,
#[source]
source: envy::Error,
source: std::io::Error,
},
#[error("config-Datei {path:?} ist ungültig: {source}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
}
#[cfg(test)]
mod tests {
use super::*;
/// Minimal-Config mit nur den Pflicht-Sections — optionale Sections
/// fallen auf Defaults zurück.
#[test]
fn parses_minimal_config() {
let raw = r#"
[server]
host = "0.0.0.0"
port = 3000
[database]
url = "postgres://u:p@localhost/db"
[keycloak]
issuer_url = "http://localhost:8080/realms/holzleitner"
audience = "holzleitner-api"
[gsd]
rest_url = "http://localhost:8334"
app_key = "k"
user = "u"
password_md5 = "abc"
app_names = ["GSD-RestApi"]
"#;
let cfg = parse(raw).expect("minimal config should parse");
assert_eq!(cfg.server.port, 3000);
assert_eq!(cfg.database.max_connections, 10); // default
assert_eq!(cfg.report.storage_dir, "./data/reports"); // default section
assert!(!cfg.dev.sync_enabled); // default
assert!(cfg.dev.today_override.is_none());
assert!(cfg.admin.api_key.is_empty());
assert_eq!(cfg.erp.port, 1433); // default
}
/// `today_override` wird als `YYYY-MM-DD` geparst.
#[test]
fn parses_today_override_and_arrays() {
let raw = r#"
[server]
host = "0.0.0.0"
port = 3000
[database]
url = "postgres://u:p@localhost/db"
[keycloak]
issuer_url = "iss"
audience = "aud"
[gsd]
rest_url = "http://localhost:8334"
app_key = "k"
user = "u"
password_md5 = "abc"
app_names = ["A", "B"]
[dev]
today_override = "2026-06-01"
sync_enabled = true
"#;
let cfg = parse(raw).expect("config should parse");
assert_eq!(
cfg.dev.today_override,
Some(NaiveDate::from_ymd_opt(2026, 6, 1).unwrap())
);
assert!(cfg.dev.sync_enabled);
assert_eq!(cfg.gsd.app_names, vec!["A".to_string(), "B".to_string()]);
}
/// Unbekannte Schlüssel werden abgewiesen — schützt vor Tippfehlern.
#[test]
fn rejects_unknown_keys() {
let raw = r#"
[server]
host = "0.0.0.0"
port = 3000
bogus = true
[database]
url = "postgres://u:p@localhost/db"
[keycloak]
issuer_url = "iss"
audience = "aud"
[gsd]
rest_url = "r"
app_key = "k"
user = "u"
password_md5 = "abc"
app_names = []
"#;
assert!(parse(raw).is_err());
}
}

View File

@ -33,6 +33,7 @@ impl IntoResponse for ApiError {
ApplicationError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
ApplicationError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
ApplicationError::Validation(_) => (StatusCode::BAD_REQUEST, "validation"),
ApplicationError::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
ApplicationError::Repository(_)
| ApplicationError::External(_)
| ApplicationError::Unexpected(_) => {

View File

@ -1,8 +1,8 @@
//! Holzleitner-API — HTTP-Layer und Composition Root.
//!
//! Bootstrap-Reihenfolge:
//! 1. Tracing/Logging initialisieren
//! 2. Konfiguration aus Env-Variablen / `.env` laden
//! 1. Konfiguration aus `config.toml` laden (liefert u. a. den Log-Filter)
//! 2. Tracing/Logging initialisieren
//! 3. Postgres-Pool aufbauen und Migrations ausführen
//! 4. Keycloak-AuthService instanziieren
//! 5. Use Cases zusammenstellen und in `AppState` packen
@ -24,36 +24,73 @@ 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,
ApplyDeliveryActionUseCase, ApplyDeliveryCreditEventUseCase, ApplyScansUseCase,
AssignCarToDeliveryUseCase, CompleteDeliveryUseCase, CreateDeliveryNoteUseCase,
CreateMyCarUseCase, CreatePaymentMethodUseCase, CreateServiceUseCase,
DeleteDeliveryNoteUseCase, DeleteDeliveryServiceUseCase, DeletePaymentMethodUseCase,
DeleteServiceUseCase, DevResyncToursUseCase, GenerateDeliveryReportUseCase, GetAccountUseCase,
GetAttachmentPreviewUseCase, GetTourUseCase,
ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase,
ListMyToursTodayUseCase, ListPaymentMethodsUseCase,
ListServicesUseCase, MarkMailSentUseCase, PushCompletionToErpUseCase,
SetDeliveryOrderUseCase, SetDeliveryServiceUseCase, SyncTourUseCase, UpdateDeliveryNoteUseCase,
ProcessDeliveryReportUseCase, UpdateMyCarUseCase, UpdatePaymentMethodUseCase,
UpdateServiceUseCase, UploadDeliveryNoteImageUseCase,
};
use holzleitner_application::ports::DeliveryReportJobRepository;
use holzleitner_infrastructure::auth::{
KeycloakAdapterConfig, KeycloakAdminClient, KeycloakAdminConfig, KeycloakAuthService,
};
use holzleitner_infrastructure::erp::{
MssqlErpConfig, MssqlErpDeliverySource, MssqlErpDeliveryWriteback,
};
use holzleitner_infrastructure::gsd::{GsdConfig, GsdService};
use holzleitner_infrastructure::report::{
LocalReportSink, PdfDeliveryReportRenderer, PgDeliveryReportRepository,
};
use holzleitner_infrastructure::auth::{KeycloakAdapterConfig, KeycloakAuthService};
use holzleitner_infrastructure::persistence::{
PgAccountRepository, PgCarRepository, PgDeliveryNoteRepository, PgDeliveryRepository,
PgScanRepository, PgTourRepository, PoolConfig, connect_and_migrate,
PgAccountRepository, PgAttachmentRepository, PgCarRepository, PgDeliveryCompletionRepository,
PgDeliveryCreditRepository, PgDeliveryNoteRepository, PgDeliveryReportJobRepository,
PgDeliveryRepository, PgDeliveryServiceRepository, PgPaymentMethodRepository, PgScanRepository,
PgServiceRepository, PgTourRepository, PoolConfig, connect_and_migrate,
};
use holzleitner_infrastructure::storage::{LocalAttachmentStorage, LocalSignatureStorage};
use tokio_cron_scheduler::{Job, JobScheduler};
use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use crate::middleware::jwt_middleware;
use crate::middleware::{admin_api_key_middleware, jwt_middleware};
use crate::openapi::ApiDoc;
use crate::state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Config ZUERST laden — der Log-Filter steht jetzt in `config.toml`
// (`[logging] filter`), also brauchen wir die Config, bevor der
// Subscriber initialisiert wird. Fehler hier werden von anyhow auch
// ohne aktiven Subscriber sichtbar (stderr).
let cfg = config::load().context("config laden fehlgeschlagen")?;
tracing_subscriber::fmt()
// Auf stderr loggen statt stdout: stderr ist unbuffered und erscheint
// damit sofort — auch wenn die Ausgabe in eine Datei/Pipe/ein
// IDE-Terminal läuft (stdout wäre dort blockgepuffert → Logs würden
// erst verspätet/gar nicht sichtbar).
.with_writer(std::io::stderr)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
"holzleitner_api=info,holzleitner_infrastructure=info,tower_http=info".into()
}),
// `RUST_LOG`-Env hat Vorrang (Ad-hoc-Debugging ohne Datei-Edit),
// sonst der Filter aus `config.toml` (`[logging] filter`).
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| cfg.logging.filter.clone().into()),
)
.init();
let cfg = config::load().context("config laden fehlgeschlagen")?;
tracing::info!(host = %cfg.server.host, port = cfg.server.port, "starting up");
tracing::info!("starting up");
// Vollständige (secret-maskierte) Konfig-Übersicht beim Start — damit
// Fehlkonfigurationen (z. B. ein aktiver dev.today_override) sofort
// im Log sichtbar sind.
cfg.log_summary();
// --- Persistence ---------------------------------------------------
let pool = connect_and_migrate(&PoolConfig {
@ -69,7 +106,74 @@ async fn main() -> anyhow::Result<()> {
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));
let attachment_repository = Arc::new(PgAttachmentRepository::new(pool.clone()));
let car_repository = Arc::new(PgCarRepository::new(pool.clone()));
let payment_method_repository = Arc::new(PgPaymentMethodRepository::new(pool.clone()));
let delivery_credit_repository = Arc::new(PgDeliveryCreditRepository::new(pool.clone()));
let delivery_completion_repository =
Arc::new(PgDeliveryCompletionRepository::new(pool.clone()));
let service_repository = Arc::new(PgServiceRepository::new(pool.clone()));
let delivery_service_repository = Arc::new(PgDeliveryServiceRepository::new(pool.clone()));
let report_repository = Arc::new(PgDeliveryReportRepository::new(pool.clone()));
let report_job_repository = Arc::new(PgDeliveryReportJobRepository::new(pool.clone()));
// --- Lokaler Unterschriften-Speicher (Dateisystem) -----------------
let signature_storage = Arc::new(
LocalSignatureStorage::new(&cfg.signature.storage_dir)
.context("Signatur-Verzeichnis konnte nicht angelegt werden")?,
);
tracing::info!(dir = %cfg.signature.storage_dir, "signature storage ready");
// --- Lokaler Bild-Speicher (Bild-Notizen, Ordner = Belegnummer) ----
// Ersetzt den DOCUframe-Upload für Bild-Notizen. Der GSD-Service bleibt
// erhalten (s. u.), wird aber nicht mehr für Attachments verdrahtet.
let attachment_storage = Arc::new(
LocalAttachmentStorage::new(&cfg.attachment.storage_dir)
.context("Attachment-Verzeichnis konnte nicht angelegt werden")?,
);
tracing::info!(dir = %cfg.attachment.storage_dir, "attachment storage ready (lokal)");
// --- PDF-Lieferreport (lokaler/temporärer Sink; DOCUframe später) --
let report_sink = Arc::new(
LocalReportSink::new(&cfg.report.storage_dir)
.context("Report-Verzeichnis konnte nicht angelegt werden")?,
);
let generate_delivery_report = Arc::new(GenerateDeliveryReportUseCase::new(
report_repository,
Arc::new(PdfDeliveryReportRenderer),
report_sink.clone(),
signature_storage.clone(),
attachment_storage.clone(),
));
tracing::info!(dir = %cfg.report.storage_dir, "delivery report renderer ready (printpdf, lokal)");
// --- GSD/DOCUframe (Datei-Upload) ----------------------------------
// Bleibt für den Lizenz-Release beim Shutdown konstruiert. Der frühere
// Attachment-Upload/-Download über DOCUframe ist hier bewusst nicht mehr
// verdrahtet (Code in `gsd::GsdService` bleibt für später erhalten).
let gsd_service = Arc::new(GsdService::new(
pool,
GsdConfig {
rest_url: cfg.gsd.rest_url.clone(),
app_key: cfg.gsd.app_key.clone(),
user: cfg.gsd.user.clone(),
password_md5: cfg.gsd.password_md5.clone(),
app_names: cfg.gsd.app_names.clone(),
},
));
// --- Report-Upload-Pipeline (Report → DOCUframe, mit Retry-Job) ----
// Nutzt GsdService als DocuframeReportGateway (Upload + Makro). Räumt
// nach Erfolg lokale Dateien (Report, Unterschriften, Bild-Notizen) auf.
let process_delivery_report = Arc::new(ProcessDeliveryReportUseCase::new(
generate_delivery_report.clone(),
report_job_repository.clone(),
gsd_service.clone(),
attachment_repository.clone(),
attachment_storage.clone(),
signature_storage.clone(),
report_sink.clone(),
));
// --- Auth ----------------------------------------------------------
let auth_service = Arc::new(KeycloakAuthService::new(KeycloakAdapterConfig {
@ -86,8 +190,74 @@ async fn main() -> anyhow::Result<()> {
// --- 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 list_my_tours_today = Arc::new(ListMyToursTodayUseCase::new(
tour_repository.clone(),
cfg.dev.today_override,
));
let sync_tour = Arc::new(SyncTourUseCase::new(tour_repository.clone()));
// ERP-Import (täglicher Pull aus ERPframe-MSSQL) — nutzt denselben
// Sync-Pfad wie der HTTP-Endpoint. Geht NICHT in den AppState (kein
// HTTP-Endpoint), sondern nur in den Scheduler.
let erp_mssql_config = MssqlErpConfig {
host: cfg.erp.host.clone(),
port: cfg.erp.port,
database: cfg.erp.database.clone(),
user: cfg.erp.user.clone(),
password: cfg.erp.password.clone(),
trust_cert: cfg.erp.trust_cert,
};
let erp_source = Arc::new(MssqlErpDeliverySource::new(erp_mssql_config.clone()));
// Optionaler Keycloak-Provisioner: legt beim Sync fehlende Fahrer-Konten
// an (Username = Fahrer-/Account-Nummer, temporäres Passwort). Nur aktiv,
// wenn `KEYCLOAK_PROVISIONING_ENABLED=true`.
let driver_provisioner: Option<
Arc<dyn holzleitner_application::ports::DriverIdentityProvisioner>,
> = if cfg.keycloak.provisioning_enabled {
tracing::info!(
admin_url = %cfg.keycloak.admin_url,
realm = %cfg.keycloak.realm,
role = %cfg.keycloak.driver_role,
"keycloak driver provisioning ENABLED"
);
Some(Arc::new(KeycloakAdminClient::new(KeycloakAdminConfig {
base_url: cfg.keycloak.admin_url.clone(),
realm: cfg.keycloak.realm.clone(),
client_id: cfg.keycloak.provisioner_client_id.clone(),
client_secret: cfg.keycloak.provisioner_client_secret.clone(),
default_password: cfg.keycloak.driver_default_password.clone(),
driver_role: cfg.keycloak.driver_role.clone(),
})))
} else {
tracing::info!("keycloak driver provisioning disabled");
None
};
let import_erp_tours = Arc::new(ImportErpToursUseCase::new(
erp_source,
sync_tour.clone(),
driver_provisioner,
));
// DEV-ONLY: überschreibender Resync (löscht Postgres-Tourdaten + Import).
// Wird immer gebaut, der Endpoint aber nur bei dev.sync_enabled gemountet.
let dev_resync_tours = Arc::new(DevResyncToursUseCase::new(
tour_repository.clone(),
import_erp_tours.clone(),
));
// ERP-Rückschreiben beim Lieferabschluss. Der Push-Use-Case wird IMMER
// gebaut (Admin-Retry-Endpunkt nutzt ihn manuell). Ob der normale
// Abschluss-Pfad automatisch pusht, steuert `ERP_WRITEBACK_ENABLED`.
let erp_writeback = Arc::new(MssqlErpDeliveryWriteback::new(erp_mssql_config));
let push_completion_to_erp = Arc::new(PushCompletionToErpUseCase::new(
delivery_completion_repository.clone(),
erp_writeback,
));
// Admin-Lese-Use-Case: Belegnummern der an einem Tag ausgelieferten
// Lieferungen. `.clone()` VOR dem späteren Move in `complete_delivery`.
let list_delivered_belegnummern = Arc::new(ListDeliveredBelegnummernUseCase::new(
delivery_completion_repository.clone(),
));
let mark_mail_sent = Arc::new(MarkMailSentUseCase::new(
delivery_completion_repository.clone(),
));
let set_delivery_order = Arc::new(SetDeliveryOrderUseCase::new(tour_repository));
let apply_scans = Arc::new(ApplyScansUseCase::new(
scan_repository,
@ -95,10 +265,39 @@ async fn main() -> anyhow::Result<()> {
));
let apply_delivery_action =
Arc::new(ApplyDeliveryActionUseCase::new(delivery_repository.clone()));
let complete_delivery = Arc::new(CompleteDeliveryUseCase::new(
delivery_completion_repository,
signature_storage,
car_repository.clone(),
if cfg.erp.writeback_enabled {
Some(push_completion_to_erp.clone())
} else {
None
},
));
let apply_delivery_credit_event = Arc::new(ApplyDeliveryCreditEventUseCase::new(
delivery_credit_repository,
car_repository.clone(),
));
let create_delivery_note = Arc::new(CreateDeliveryNoteUseCase::new(
delivery_note_repository.clone(),
car_repository.clone(),
));
let update_delivery_note = Arc::new(UpdateDeliveryNoteUseCase::new(
delivery_note_repository.clone(),
));
let delete_delivery_note =
Arc::new(DeleteDeliveryNoteUseCase::new(delivery_note_repository.clone()));
let upload_delivery_note_image = Arc::new(UploadDeliveryNoteImageUseCase::new(
attachment_storage.clone(),
attachment_repository.clone(),
delivery_note_repository,
car_repository.clone(),
));
let get_attachment_preview = Arc::new(GetAttachmentPreviewUseCase::new(
attachment_repository,
attachment_storage.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()));
@ -106,6 +305,28 @@ async fn main() -> anyhow::Result<()> {
car_repository,
delivery_repository,
));
let list_payment_methods = Arc::new(ListPaymentMethodsUseCase::new(
payment_method_repository.clone(),
));
let create_payment_method = Arc::new(CreatePaymentMethodUseCase::new(
payment_method_repository.clone(),
));
let update_payment_method = Arc::new(UpdatePaymentMethodUseCase::new(
payment_method_repository.clone(),
));
let delete_payment_method =
Arc::new(DeletePaymentMethodUseCase::new(payment_method_repository));
let list_services = Arc::new(ListServicesUseCase::new(service_repository.clone()));
let create_service = Arc::new(CreateServiceUseCase::new(service_repository.clone()));
let update_service = Arc::new(UpdateServiceUseCase::new(service_repository.clone()));
let delete_service = Arc::new(DeleteServiceUseCase::new(service_repository.clone()));
let set_delivery_service = Arc::new(SetDeliveryServiceUseCase::new(
service_repository,
delivery_service_repository.clone(),
));
let delete_delivery_service = Arc::new(DeleteDeliveryServiceUseCase::new(
delivery_service_repository,
));
let state = AppState {
get_account,
@ -113,14 +334,39 @@ async fn main() -> anyhow::Result<()> {
list_my_tours_today,
sync_tour,
set_delivery_order,
import_erp_tours: import_erp_tours.clone(),
dev_resync_tours,
generate_delivery_report,
process_delivery_report: process_delivery_report.clone(),
report_upload_enabled: cfg.report.upload_enabled,
apply_scans,
apply_delivery_action,
complete_delivery,
push_completion_to_erp,
list_delivered_belegnummern,
mark_mail_sent,
apply_delivery_credit_event,
create_delivery_note,
update_delivery_note,
delete_delivery_note,
upload_delivery_note_image,
get_attachment_preview,
list_my_cars,
create_my_car,
update_my_car,
assign_car_to_delivery,
list_payment_methods,
create_payment_method,
update_payment_method,
delete_payment_method,
list_services,
create_service,
update_service,
delete_service,
set_delivery_service,
delete_delivery_service,
auth_service,
admin_api_key: cfg.admin.api_key.clone().into(),
};
// --- Router --------------------------------------------------------
@ -130,29 +376,156 @@ async fn main() -> anyhow::Result<()> {
// 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()
let mut public = Router::new()
.merge(routes::health::router())
.merge(SwaggerUi::new("/swagger-ui").url("/openapi.json", ApiDoc::openapi()));
// DEV-ONLY: ungeschützten Resync-Endpoint NUR bei explizit gesetztem Flag
// mounten — in Produktion existiert er nicht.
if cfg.dev.sync_enabled {
public = public.merge(routes::dev::router());
tracing::warn!("DEV: POST /dev/resync gemountet (unauthentifiziert, dev.sync_enabled=true)");
}
let protected = Router::new()
.merge(routes::accounts::router())
.merge(routes::tours::router())
.merge(routes::scans::router())
.merge(routes::deliveries::router())
.merge(routes::attachments::router())
.merge(routes::cars::router())
.merge(routes::payment_methods::router())
.merge(routes::services::router())
.route_layer(from_fn_with_state(state.clone(), jwt_middleware));
// `/admin`-Routen sind Maschinen-Endpunkte (Cron/ERPframe/Ops) und laufen
// bewusst NICHT über die JWT-Middleware, sondern über ein eigenes
// statisches-API-Key-Gate (Header `X-Admin-Api-Key`). Eigener Subtree mit
// eigener `route_layer`.
let admin = routes::admin::router()
.route_layer(from_fn_with_state(state.clone(), admin_api_key_middleware));
let app = Router::new()
.merge(public)
.merge(protected)
.merge(admin)
.layer(TraceLayer::new_for_http())
.with_state(state);
// --- ERP-Import-Scheduler (optional) -------------------------------
// Läuft im selben Prozess. Nur wenn IMPORT_ENABLED=true. Hält den
// JobScheduler am Leben, indem die Variable bis Programmende gebunden
// bleibt (Drop würde die Jobs stoppen).
let _erp_scheduler = if cfg.import.enabled {
let scheduler = JobScheduler::new()
.await
.context("JobScheduler konnte nicht erstellt werden")?;
let import = import_erp_tours.clone();
let offset = cfg.import.date_offset_days;
let job = Job::new_async(cfg.import.cron.as_str(), move |_uuid, _lock| {
let import = import.clone();
Box::pin(async move {
let date = (chrono::Utc::now() + chrono::Duration::days(offset)).date_naive();
match import.execute(date).await {
Ok(summary) => tracing::info!(
date = %summary.date,
total = summary.tours_total,
ok = summary.tours_ok,
failed = summary.tours_failed,
"erp_import.done"
),
Err(e) => tracing::error!(%date, error = %e, "erp_import.failed"),
}
})
})
.context("ERP-Import-Job konnte nicht erstellt werden")?;
scheduler.add(job).await.context("ERP-Import-Job add fehlgeschlagen")?;
scheduler.start().await.context("Scheduler-Start fehlgeschlagen")?;
tracing::info!(
cron = %cfg.import.cron,
offset_days = cfg.import.date_offset_days,
"erp_import scheduler gestartet"
);
Some(scheduler)
} else {
tracing::info!("erp_import deaktiviert (IMPORT_ENABLED!=true)");
None
};
// --- Report-Retry-Scheduler (offene DOCUframe-Übertragungen) -------
// Greift alle offenen `delivery_report_jobs` periodisch erneut auf
// (Upload/Makro, die zuvor fehlschlugen — z. B. DOCUframe nicht erreichbar).
let _report_scheduler = if cfg.report.upload_enabled {
let scheduler = JobScheduler::new()
.await
.context("Report-JobScheduler konnte nicht erstellt werden")?;
let process = process_delivery_report.clone();
let jobs = report_job_repository.clone();
let job = Job::new_async(cfg.report.retry_cron.as_str(), move |_uuid, _lock| {
let process = process.clone();
let jobs = jobs.clone();
Box::pin(async move {
match jobs.list_open().await {
Ok(open) => {
if !open.is_empty() {
tracing::info!(count = open.len(), "report_retry: offene Jobs werden erneut versucht");
}
for j in open {
if let Err(e) = process.execute(j.delivery_id).await {
tracing::warn!(delivery_id = %j.delivery_id, error = %e, "report_retry: Job weiterhin offen");
}
}
}
Err(e) => tracing::error!(error = %e, "report_retry: offene Jobs konnten nicht geladen werden"),
}
})
})
.context("Report-Retry-Job konnte nicht erstellt werden")?;
scheduler.add(job).await.context("Report-Retry-Job add fehlgeschlagen")?;
scheduler.start().await.context("Report-Scheduler-Start fehlgeschlagen")?;
tracing::info!(cron = %cfg.report.retry_cron, "report_retry scheduler gestartet");
Some(scheduler)
} else {
tracing::info!("report_upload deaktiviert (REPORT_UPLOAD_ENABLED!=true) — Reports nur lokal");
None
};
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?;
// Graceful Shutdown: bei Ctrl-C / SIGTERM die GSD-Lizenz aktiv freigeben,
// damit der Seat nicht bis zum Session-Ablauf geblockt bleibt.
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal(gsd_service))
.await?;
Ok(())
}
/// Wartet auf Ctrl-C bzw. SIGTERM und gibt davor die GSD-Lizenz frei.
async fn shutdown_signal(gsd_service: Arc<GsdService>) {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("Ctrl-C-Handler konnte nicht installiert werden");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("SIGTERM-Handler konnte nicht installiert werden")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutdown signal empfangen — gebe GSD-Lizenz frei");
gsd_service.release_license().await;
}

View File

@ -0,0 +1,102 @@
//! Admin-API-Key-Middleware: schützt die `/admin`-Routen mit einem
//! statischen Schlüssel (Maschinen-Zugang aus Cron/ERPframe-Makro/Skripten),
//! **ohne** Keycloak/JWT.
//!
//! Der Aufrufer schickt den Schlüssel im Header `X-Admin-Api-Key`. Verglichen
//! wird gegen `AppState::admin_api_key` (aus `[admin] api_key` in config.toml).
//!
//! Sicherheits-Eigenschaften:
//! * **Fail-closed**: ist kein Schlüssel konfiguriert (leer), wird *jede*
//! Admin-Anfrage abgelehnt — niemals „offen, weil unkonfiguriert".
//! * **Konstant-zeitlicher Vergleich**: kein früher Abbruch beim ersten
//! abweichenden Byte (reduziert Timing-Seitenkanäle).
//! * Bei fehlendem/falschem Schlüssel: `401 Unauthorized`, ohne den Grund
//! an den Client zu verraten (Detail nur ins Log).
use axum::extract::{Request, State};
use axum::http::HeaderMap;
use axum::middleware::Next;
use axum::response::Response;
use holzleitner_application::error::ApplicationError;
use crate::error::ApiError;
use crate::state::AppState;
/// Header-Name für den Admin-API-Key.
pub const ADMIN_API_KEY_HEADER: &str = "x-admin-api-key";
pub async fn admin_api_key_middleware(
State(state): State<AppState>,
req: Request,
next: Next,
) -> Result<Response, ApiError> {
let configured = state.admin_api_key.as_ref();
// Fail-closed: ohne konfigurierten Schlüssel ist /admin komplett dicht.
if configured.is_empty() {
tracing::warn!(
"admin-Zugriff abgelehnt: admin.api_key ist nicht gesetzt (fail-closed)"
);
return Err(ApiError(ApplicationError::Unauthorized));
}
let presented = extract_key(req.headers());
match presented {
Some(key) if constant_time_eq(key.as_bytes(), configured.as_bytes()) => {
tracing::debug!("admin-api-key ok");
Ok(next.run(req).await)
}
Some(_) => {
tracing::warn!("admin-Zugriff abgelehnt: falscher X-Admin-Api-Key");
Err(ApiError(ApplicationError::Unauthorized))
}
None => {
tracing::warn!("admin-Zugriff abgelehnt: Header X-Admin-Api-Key fehlt");
Err(ApiError(ApplicationError::Unauthorized))
}
}
}
fn extract_key(headers: &HeaderMap) -> Option<&str> {
headers.get(ADMIN_API_KEY_HEADER)?.to_str().ok()
}
/// Konstant-zeitlicher Byte-Vergleich. Die Längen-Vorprüfung verrät nur die
/// Schlüssellänge (unkritisch); der Inhalt wird ohne früh­zeitigen Abbruch
/// verglichen.
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::constant_time_eq;
#[test]
fn eq_matches_identical() {
assert!(constant_time_eq(b"secret-key", b"secret-key"));
}
#[test]
fn eq_rejects_different_content_same_len() {
assert!(!constant_time_eq(b"secret-key", b"secret-kex"));
}
#[test]
fn eq_rejects_different_len() {
assert!(!constant_time_eq(b"short", b"longer-key"));
}
#[test]
fn eq_rejects_empty_vs_nonempty() {
assert!(!constant_time_eq(b"", b"x"));
}
}

View File

@ -1,6 +1,8 @@
//! Axum-Middleware — z. B. JWT-Validierung gegen den
//! `holzleitner_application::ports::AuthService`.
pub mod admin_key;
pub mod jwt;
pub use admin_key::admin_api_key_middleware;
pub use jwt::jwt_middleware;

View File

@ -8,7 +8,9 @@
use utoipa::Modify;
use utoipa::OpenApi;
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
use utoipa::openapi::security::{
ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme,
};
#[derive(OpenApi)]
#[openapi(
@ -30,10 +32,29 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
crate::routes::deliveries::cancel,
crate::routes::deliveries::complete,
crate::routes::deliveries::create_note,
crate::routes::deliveries::update_note,
crate::routes::deliveries::delete_note,
crate::routes::deliveries::upload_note_image,
crate::routes::deliveries::apply_credit,
crate::routes::attachments::get_attachment,
crate::routes::deliveries::assign_car,
crate::routes::deliveries::set_service,
crate::routes::deliveries::delete_service_value,
crate::routes::services::list_services,
crate::routes::services::create_service,
crate::routes::services::update_service,
crate::routes::services::delete_service,
crate::routes::cars::list_my_cars,
crate::routes::cars::create_my_car,
crate::routes::cars::update_my_car,
crate::routes::payment_methods::list_payment_methods,
crate::routes::payment_methods::create_payment_method,
crate::routes::payment_methods::update_payment_method,
crate::routes::payment_methods::delete_payment_method,
crate::routes::admin::import_erp,
crate::routes::admin::push_completion,
crate::routes::admin::delivered_belegnummern,
crate::routes::admin::mark_mail_sent,
),
components(
schemas(
@ -42,20 +63,31 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
holzleitner_domain::Article,
holzleitner_domain::AuditAction,
holzleitner_domain::Car,
holzleitner_domain::ContactChannel,
holzleitner_domain::ContactKind,
holzleitner_domain::ContactRole,
holzleitner_domain::ContactSource,
holzleitner_domain::Customer,
holzleitner_domain::CustomerContact,
holzleitner_domain::Delivery,
holzleitner_domain::DeliveryItem,
holzleitner_domain::DeliveryNote,
holzleitner_domain::DeliveryCredit,
holzleitner_domain::DeliveryState,
holzleitner_domain::ScanState,
holzleitner_domain::ScanStatus,
holzleitner_domain::Tour,
holzleitner_domain::PaymentMethod,
holzleitner_domain::Service,
holzleitner_domain::ServiceKind,
holzleitner_domain::DeliveryServiceValue,
holzleitner_domain::Warehouse,
holzleitner_application::dto::TourDetails,
holzleitner_application::dto::DeliveryWithItems,
holzleitner_application::dto::TourSummary,
holzleitner_application::dto::SyncTourRequest,
holzleitner_application::dto::SyncContactChannel,
holzleitner_application::dto::SyncContactSource,
holzleitner_application::dto::SyncDelivery,
holzleitner_application::dto::SyncDeliveryItem,
holzleitner_application::dto::SetDeliveryOrderRequest,
@ -68,14 +100,33 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
holzleitner_application::dto::ScanResultStatus,
holzleitner_application::dto::HoldDeliveryRequest,
holzleitner_application::dto::CancelDeliveryRequest,
holzleitner_application::dto::CompleteDeliveryAcknowledgements,
holzleitner_application::dto::DeliveryResponse,
holzleitner_application::dto::CreateDeliveryNoteRequest,
holzleitner_application::dto::UpdateDeliveryNoteRequest,
holzleitner_application::dto::DeliveryNoteResponse,
holzleitner_application::dto::CreditAction,
holzleitner_application::dto::DeliveryCreditEventRequest,
holzleitner_application::dto::DeliveryCreditResponse,
holzleitner_application::dto::CreateCarRequest,
holzleitner_application::dto::UpdateCarRequest,
holzleitner_application::dto::CarResponse,
holzleitner_application::dto::CarsList,
holzleitner_application::dto::AssignCarRequest,
holzleitner_application::dto::CreatePaymentMethodRequest,
holzleitner_application::dto::UpdatePaymentMethodRequest,
holzleitner_application::dto::PaymentMethodResponse,
holzleitner_application::dto::PaymentMethodsList,
holzleitner_application::dto::CreateServiceRequest,
holzleitner_application::dto::UpdateServiceRequest,
holzleitner_application::dto::ServiceResponse,
holzleitner_application::dto::ServicesList,
holzleitner_application::dto::SetDeliveryServiceRequest,
holzleitner_application::dto::DeliveryServiceResponse,
holzleitner_application::usecases::ImportSummary,
crate::routes::admin::DeliveredBelegnummernResponse,
crate::routes::admin::MarkMailSentRequest,
crate::routes::admin::MarkMailSentResponse,
crate::routes::tours::TourSummaryList,
crate::routes::tours::SyncTourResponse,
)
@ -89,6 +140,9 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
(name = "scans", description = "Scan-Events (Beladung & Auslieferung)"),
(name = "deliveries", description = "Delivery-Lifecycle (hold / resume / cancel / complete)"),
(name = "cars", description = "Fahrzeug-Stammdaten pro Fahrer"),
(name = "payment-methods", description = "Zahlungsmethoden — globale Stammdaten"),
(name = "services", description = "Services / Lieferoptionen — globale Stammdaten"),
(name = "admin", description = "Betriebs-/Admin-Endpunkte (Maschinen-Zugang via X-Admin-Api-Key, kein JWT)"),
),
security(
("bearer_auth" = [])
@ -113,6 +167,11 @@ impl Modify for SecurityAddon {
.build(),
),
);
// Statischer API-Key für die /admin-Routen (Maschinen-Zugang).
components.add_security_scheme(
"admin_api_key",
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("X-Admin-Api-Key"))),
);
}
}
}

View File

@ -0,0 +1,224 @@
//! Admin-/Betriebs-Endpunkte.
//!
//! Aktuell: manueller ERP-Import-Trigger. Derselbe Use Case, den auch der
//! tägliche Scheduler ruft — hier on-demand für ein konkretes Datum
//! (Testen + manuelle Nachläufe im Betrieb). JWT-geschützt wie alle
//! protected Routen.
use axum::Json;
use axum::Router;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::usecases::ImportSummary;
use crate::error::ApiError;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/admin/import-erp", post(import_erp))
.route("/admin/push-completion", post(push_completion))
.route(
"/admin/delivered-belegnummern",
get(delivered_belegnummern),
)
.route("/admin/mark-mail-sent", post(mark_mail_sent))
}
#[derive(Debug, Deserialize)]
pub struct ImportErpQuery {
/// Ziel-Tourdatum `YYYY-MM-DD`. Fehlt der Parameter, wird **heute**
/// verwendet.
#[serde(default)]
pub date: Option<String>,
}
/// Stößt den ERP-Import für ein Datum an und liefert die Zusammenfassung.
#[utoipa::path(
post,
path = "/admin/import-erp",
tag = "admin",
params(
("date" = Option<String>, Query, description = "Ziel-Tourdatum YYYY-MM-DD (Default: heute)")
),
responses(
(status = 200, description = "Import durchgeführt", body = ImportSummary),
(status = 400, description = "Ungültiges Datum"),
(status = 401, description = "Admin-API-Key fehlt/ungültig"),
(status = 502, description = "ERP nicht erreichbar / Lesefehler")
),
security(("admin_api_key" = []))
)]
pub async fn import_erp(
State(state): State<AppState>,
Query(query): Query<ImportErpQuery>,
) -> Result<Json<ImportSummary>, ApiError> {
let date = match query.date {
Some(s) => NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"ungültiges Datum '{s}' (erwartet YYYY-MM-DD): {e}"
)))
})?,
None => chrono::Utc::now().date_naive(),
};
tracing::info!(%date, "admin.import_erp");
let summary = state.import_erp_tours.execute(date).await?;
tracing::info!(
%date,
total = summary.tours_total,
ok = summary.tours_ok,
failed = summary.tours_failed,
"admin.import_erp.done"
);
Ok(Json(summary))
}
#[derive(Debug, Deserialize)]
pub struct PushCompletionQuery {
/// UUID der bereits abgeschlossenen Lieferung.
pub delivery_id: String,
}
/// Stößt das ERP-Rückschreiben eines bereits lokal abgeschlossenen
/// Lieferabschlusses erneut an (idempotenter Retry, falls der automatische
/// Push beim Abschluss fehlschlug).
#[utoipa::path(
post,
path = "/admin/push-completion",
tag = "admin",
params(
("delivery_id" = String, Query, description = "UUID der abgeschlossenen Lieferung")
),
responses(
(status = 204, description = "Rückschreiben erfolgreich"),
(status = 400, description = "Ungültige delivery_id"),
(status = 401, description = "Admin-API-Key fehlt/ungültig"),
(status = 404, description = "Lieferung nicht gefunden / nicht abgeschlossen"),
(status = 502, description = "ERP nicht erreichbar / Schreibfehler")
),
security(("admin_api_key" = []))
)]
pub async fn push_completion(
State(state): State<AppState>,
Query(query): Query<PushCompletionQuery>,
) -> Result<StatusCode, ApiError> {
let delivery_id = Uuid::parse_str(query.delivery_id.trim()).map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"ungültige delivery_id '{}': {e}",
query.delivery_id
)))
})?;
tracing::info!(%delivery_id, "admin.push_completion");
state.push_completion_to_erp.execute(delivery_id).await?;
tracing::info!(%delivery_id, "admin.push_completion.done");
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Deserialize)]
pub struct DeliveredBelegnummernQuery {
/// Ziel-Tag im Format `DD-MM-YYYY`. **Fehlt der Parameter, werden ALLE**
/// offenen (noch nicht versendeten) Belege über alle Tage geliefert — das
/// ist der Modus des Mailclients.
#[serde(default)]
pub day: Option<String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct DeliveredBelegnummernResponse {
/// Tag, nach dem gefiltert wurde (ISO `YYYY-MM-DD`), oder `"all"` wenn kein
/// `day` angegeben war.
pub day: String,
/// Anzahl der offenen (noch nicht versendeten) Belege.
pub count: usize,
/// Belegnummern aller **ausgelieferten** (abgeschlossenen) Lieferungen,
/// deren Liefermail noch **nicht versendet** wurde, aufsteigend nach
/// Abschluss-Zeitpunkt.
pub belegnummern: Vec<String>,
}
/// Liefert die Belegnummern ausgelieferter (abgeschlossener) Lieferungen,
/// **deren Liefermail noch nicht versendet wurde** (`mail_sent_at IS NULL`).
/// „Ausgeliefert" = es existiert ein Abschluss. Mit `day` (DD-MM-YYYY) nur
/// Abschlüsse dieses Berliner Kalendertages; **ohne `day` alle offenen** (über
/// alle Tage) — so bleiben Belege über Mitternacht nicht hängen.
#[utoipa::path(
get,
path = "/admin/delivered-belegnummern",
tag = "admin",
params(
("day" = Option<String>, Query, description = "Tag DD-MM-YYYY; ohne Angabe ALLE offenen Belege")
),
responses(
(status = 200, description = "Offene (nicht versendete) Belegnummern", body = DeliveredBelegnummernResponse),
(status = 400, description = "Ungültiger Tag"),
(status = 401, description = "Admin-API-Key fehlt/ungültig")
),
security(("admin_api_key" = []))
)]
pub async fn delivered_belegnummern(
State(state): State<AppState>,
Query(query): Query<DeliveredBelegnummernQuery>,
) -> Result<Json<DeliveredBelegnummernResponse>, ApiError> {
let day: Option<NaiveDate> = match query.day {
Some(s) => Some(NaiveDate::parse_from_str(s.trim(), "%d-%m-%Y").map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"ungültiger Tag '{s}' (erwartet DD-MM-YYYY): {e}"
)))
})?),
None => None,
};
tracing::info!(?day, "admin.delivered_belegnummern");
let belegnummern = state.list_delivered_belegnummern.execute(day).await?;
tracing::info!(?day, count = belegnummern.len(), "admin.delivered_belegnummern.done");
Ok(Json(DeliveredBelegnummernResponse {
day: day.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_else(|| "all".into()),
count: belegnummern.len(),
belegnummern,
}))
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct MarkMailSentRequest {
/// Belegnummern, deren Liefermail erfolgreich versendet wurde und die als
/// versendet markiert werden sollen.
pub belegnummern: Vec<String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct MarkMailSentResponse {
/// Anzahl frisch markierter (vorher offener) Belege. Bereits markierte
/// zählen nicht mit (idempotent).
pub marked: u64,
}
/// Markiert die Liefermails der angegebenen Belegnummern als **versendet**
/// (`mail_sent_at = now()`, nur wo noch offen). Vom Mailclient aufzurufen,
/// NACHDEM ERPframe die Mails erfolgreich verschickt hat — danach erscheinen
/// die Belege nicht mehr in `GET /admin/delivered-belegnummern`.
#[utoipa::path(
post,
path = "/admin/mark-mail-sent",
tag = "admin",
request_body = MarkMailSentRequest,
responses(
(status = 200, description = "Markierung durchgeführt", body = MarkMailSentResponse),
(status = 401, description = "Admin-API-Key fehlt/ungültig")
),
security(("admin_api_key" = []))
)]
pub async fn mark_mail_sent(
State(state): State<AppState>,
Json(body): Json<MarkMailSentRequest>,
) -> Result<Json<MarkMailSentResponse>, ApiError> {
tracing::info!(count = body.belegnummern.len(), "admin.mark_mail_sent");
let marked = state.mark_mail_sent.execute(body.belegnummern).await?;
tracing::info!(marked, "admin.mark_mail_sent.done");
Ok(Json(MarkMailSentResponse { marked }))
}

View File

@ -0,0 +1,88 @@
use axum::Router;
use axum::extract::{Path, Query, State};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
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("/attachments/{id}", get(get_attachment))
}
/// Größen-/Format-Parameter für das gerenderte Vorschaubild. Alle optional
/// mit sinnvollen Defaults — die App kann pro Anwendungsfall (Thumbnail vs.
/// Vollbild) abweichende Werte anfragen.
#[derive(Debug, Deserialize)]
pub struct PreviewQuery {
#[serde(default = "default_dimension")]
pub w: u32,
#[serde(default = "default_dimension")]
pub h: u32,
#[serde(default = "default_quality")]
pub q: u32,
#[serde(default = "default_ext")]
pub ext: String,
#[serde(default = "default_page")]
pub page: String,
}
fn default_dimension() -> u32 {
1024
}
fn default_quality() -> u32 {
85
}
fn default_ext() -> String {
"jpeg".to_string()
}
fn default_page() -> String {
"1".to_string()
}
/// Liefert ein gerendertes Vorschaubild des Attachments (Bytes), geladen
/// aus DOCUframe. Auflösung/Format über Query-Parameter steuerbar
/// (`?w=&h=&q=&ext=&page=`).
#[utoipa::path(
get,
path = "/attachments/{id}",
tag = "attachments",
params(
("id" = Uuid, Path, description = "Attachment-Id (unsere UUID)"),
("w" = Option<u32>, Query, description = "Breite in Pixeln (Default 1024)"),
("h" = Option<u32>, Query, description = "Höhe in Pixeln (Default 1024)"),
("q" = Option<u32>, Query, description = "Qualität 0100 (Default 85)"),
("ext" = Option<String>, Query, description = "png|jpeg|jpg|webp|tiff (Default jpeg)"),
("page" = Option<String>, Query, description = "Seitennummer (Default 1)"),
),
responses(
(status = 200, description = "Vorschaubild (Bytes)", content_type = "image/jpeg"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Attachment nicht gefunden")
),
security(("bearer_auth" = []))
)]
pub async fn get_attachment(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path(id): Path<Uuid>,
Query(query): Query<PreviewQuery>,
) -> Result<Response, ApiError> {
tracing::info!(actor = claims.personalnummer, %id, "attachment.preview");
// DOCUframe-Parameterschema: width_height_quality_extension.
let parameters = format!("{}_{}_{}_{}", query.w, query.h, query.q, query.ext);
let preview = state
.get_attachment_preview
.execute(id, parameters, query.page)
.await?;
Ok((
[(header::CONTENT_TYPE, preview.content_type)],
preview.bytes,
)
.into_response())
}

View File

@ -1,11 +1,15 @@
use axum::Json;
use axum::Router;
use axum::extract::{Path, State};
use axum::routing::{post, put};
use axum::extract::{DefaultBodyLimit, Multipart, Path, State};
use axum::http::StatusCode;
use axum::routing::{patch, post, put};
use holzleitner_application::dto::{
AssignCarRequest, CancelDeliveryRequest, CreateDeliveryNoteRequest, DeliveryNoteResponse,
DeliveryResponse, HoldDeliveryRequest,
AssignCarRequest, CancelDeliveryRequest, CompleteDeliveryAcknowledgements,
CreateDeliveryNoteRequest, DeliveryCreditEventRequest, DeliveryCreditResponse,
DeliveryNoteResponse, DeliveryResponse, DeliveryServiceResponse, HoldDeliveryRequest,
SetDeliveryServiceRequest, UpdateDeliveryNoteRequest,
};
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::DeliveryAction;
use uuid::Uuid;
@ -13,14 +17,40 @@ use crate::error::ApiError;
use crate::extractors::AuthenticatedUser;
use crate::state::AppState;
/// Maximale Größe eines multipart-Uploads. Axums Default-Body-Limit liegt
/// bei 2 MiB — Handy-Kamerafotos sprengen das regelmäßig, der
/// multipart-Stream wird dann abgeschnitten und multer wirft „Error parsing
/// multipart…". Daher heben wir das Limit **nur** für die multipart-Routen
/// an (Bild-Upload + Abschluss mit Signaturen); die JSON-Routen behalten ihr
/// sicheres Default.
const MULTIPART_BODY_LIMIT: usize = 25 * 1024 * 1024;
pub fn router() -> Router<AppState> {
// Eigener Sub-Router für multipart-Uploads mit angehobenem Body-Limit.
let multipart = Router::new()
.route(
"/deliveries/{delivery_id}/notes/image",
post(upload_note_image),
)
.route("/deliveries/{delivery_id}/complete", post(complete))
.layer(DefaultBodyLimit::max(MULTIPART_BODY_LIMIT));
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}/notes/{note_id}",
patch(update_note).delete(delete_note),
)
.route("/deliveries/{delivery_id}/credit", post(apply_credit))
.route(
"/deliveries/{delivery_id}/services/{service_id}",
put(set_service).delete(delete_service_value),
)
.route("/deliveries/{delivery_id}/assigned-car", put(assign_car))
.merge(multipart)
}
/// Setzt die Lieferung auf `held`. Nur aus `active` zulässig.
@ -113,14 +143,30 @@ pub async fn cancel(
}
/// Schließt die Lieferung ab — `state = completed`. Nur aus `active`.
///
/// `multipart/form-data` mit drei Feldern:
/// * `customer_signature` — PNG der Kunden-Unterschrift (Pflicht)
/// * `driver_signature` — PNG der Fahrer-Unterschrift (Pflicht)
/// * `acknowledgements` — JSON (`CompleteDeliveryAcknowledgements`):
/// `receiptConfirmed` (Pflicht true), `notesAcknowledged`,
/// `acknowledgedNoteIds`, `authorCarId`.
///
/// Atomar: Signaturen werden lokal gespeichert, die Abschluss-Zeile
/// geschrieben und der Status auf `completed` gesetzt — alles oder nichts.
/// Gates: Lieferung aktiv, alle scanbaren Positionen fertig, Notizen
/// bestätigt (falls vorhanden).
#[utoipa::path(
post,
path = "/deliveries/{delivery_id}/complete",
tag = "deliveries",
params(("delivery_id" = Uuid, Path)),
request_body(
content_type = "multipart/form-data",
description = "Felder `customer_signature`, `driver_signature` (PNG) + `acknowledgements` (JSON)"
),
responses(
(status = 200, description = "Lieferung abgeschlossen", body = DeliveryResponse),
(status = 400, description = "Invalider Statusübergang"),
(status = 400, description = "Invalider Statusübergang / fehlende Signatur / offene Scans / Notizen unbestätigt"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Lieferung nicht gefunden")
),
@ -130,15 +176,107 @@ pub async fn complete(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path(delivery_id): Path<Uuid>,
mut multipart: Multipart,
) -> Result<Json<DeliveryResponse>, ApiError> {
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.complete");
let mut customer_png: Option<Vec<u8>> = None;
let mut driver_png: Option<Vec<u8>> = None;
let mut acknowledgements: Option<CompleteDeliveryAcknowledgements> = None;
while let Some(field) = multipart.next_field().await.map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"multipart konnte nicht gelesen werden: {e}"
)))
})? {
match field.name() {
Some("customer_signature") => {
let data = field.bytes().await.map_err(read_err)?;
customer_png = Some(data.to_vec());
}
Some("driver_signature") => {
let data = field.bytes().await.map_err(read_err)?;
driver_png = Some(data.to_vec());
}
Some("acknowledgements") => {
let text = field.text().await.map_err(read_err)?;
let parsed: CompleteDeliveryAcknowledgements =
serde_json::from_str(&text).map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"`acknowledgements` ist kein gültiges JSON: {e}"
)))
})?;
acknowledgements = Some(parsed);
}
_ => {}
}
}
let customer_png = customer_png.ok_or_else(|| {
ApiError(ApplicationError::Validation(
"Feld `customer_signature` fehlt".into(),
))
})?;
let driver_png = driver_png.ok_or_else(|| {
ApiError(ApplicationError::Validation(
"Feld `driver_signature` fehlt".into(),
))
})?;
let acknowledgements = acknowledgements.ok_or_else(|| {
ApiError(ApplicationError::Validation(
"Feld `acknowledgements` fehlt".into(),
))
})?;
let delivery = state
.apply_delivery_action
.execute(delivery_id, DeliveryAction::Complete)
.complete_delivery
.execute(
delivery_id,
claims.personalnummer,
acknowledgements,
customer_png,
driver_png,
)
.await?;
// PDF-Report (best-effort, NACH erfolgreichem Abschluss): ein Fehler hier
// darf die Abschluss-Antwort NIE kippen.
if state.report_upload_enabled {
// An DOCUframe übertragen — im Hintergrund, damit die Antwort schnell
// bleibt. Schlägt etwas fehl, bleibt ein Job in PG offen und der
// Retry-Cron versucht es erneut.
let process = state.process_delivery_report.clone();
tokio::spawn(async move {
match process.execute(delivery_id).await {
Ok(()) => tracing::info!(%delivery_id, "delivery.complete.report_uploaded"),
Err(e) => tracing::warn!(
%delivery_id, error = %e,
"delivery.complete.report_upload_failed (Retry-Cron übernimmt)"
),
}
});
} else {
// DOCUframe-Upload aus (Dev): Report nur lokal erzeugen.
match state.generate_delivery_report.execute(delivery_id).await {
Ok(reference) => {
tracing::info!(%delivery_id, reference, "delivery.complete.report_generated_local")
}
Err(e) => {
tracing::error!(%delivery_id, error = %e, "delivery.complete.report_failed")
}
}
}
Ok(Json(DeliveryResponse { delivery }))
}
/// Helfer: multipart-Feld-Lesefehler → `Validation`.
fn read_err(e: axum::extract::multipart::MultipartError) -> ApiError {
ApiError(ApplicationError::Validation(format!(
"feld konnte nicht gelesen werden: {e}"
)))
}
/// 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).
@ -170,6 +308,238 @@ pub async fn create_note(
Ok(Json(DeliveryNoteResponse { note }))
}
/// Ändert Text/Bild einer Notiz. Innerhalb des (geteilten) Accounts darf
/// jeder Fahrer Notizen pflegen — kein Autor-Check. `delivery_id` ist Teil
/// des Pfads (REST-Konsistenz), die Notiz wird über `note_id` adressiert.
#[utoipa::path(
patch,
path = "/deliveries/{delivery_id}/notes/{note_id}",
tag = "deliveries",
params(
("delivery_id" = Uuid, Path),
("note_id" = Uuid, Path),
),
request_body = UpdateDeliveryNoteRequest,
responses(
(status = 200, description = "Notiz aktualisiert", body = DeliveryNoteResponse),
(status = 400, description = "Notiz ohne Inhalt"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Notiz nicht gefunden")
),
security(("bearer_auth" = []))
)]
pub async fn update_note(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path((delivery_id, note_id)): Path<(Uuid, Uuid)>,
Json(req): Json<UpdateDeliveryNoteRequest>,
) -> Result<Json<DeliveryNoteResponse>, ApiError> {
tracing::info!(
actor = claims.personalnummer,
%delivery_id,
%note_id,
"delivery.update_note"
);
let note = state.update_delivery_note.execute(note_id, req).await?;
Ok(Json(DeliveryNoteResponse { note }))
}
/// Löscht eine Notiz. Antwortet mit `204 No Content`.
#[utoipa::path(
delete,
path = "/deliveries/{delivery_id}/notes/{note_id}",
tag = "deliveries",
params(
("delivery_id" = Uuid, Path),
("note_id" = Uuid, Path),
),
responses(
(status = 204, description = "Notiz gelöscht"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Notiz nicht gefunden")
),
security(("bearer_auth" = []))
)]
pub async fn delete_note(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path((delivery_id, note_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, ApiError> {
tracing::info!(
actor = claims.personalnummer,
%delivery_id,
%note_id,
"delivery.delete_note"
);
state.delete_delivery_note.execute(note_id).await?;
Ok(StatusCode::NO_CONTENT)
}
/// Lädt ein Bild zu einer Lieferung hoch (multipart/form-data, Feld `file`)
/// und legt dafür eine Bild-Notiz an. Das Bild geht in den
/// DOCUframe-Dokumentenspeicher; gespeichert wird die zurückgelieferte
/// Referenz (`~ObjectID`) als `image_attachment` der Notiz.
#[utoipa::path(
post,
path = "/deliveries/{delivery_id}/notes/image",
tag = "deliveries",
params(("delivery_id" = Uuid, Path)),
request_body(
content_type = "multipart/form-data",
description = "Formularfeld `file` mit den Bilddaten"
),
responses(
(status = 200, description = "Bild hochgeladen, Notiz angelegt", body = DeliveryNoteResponse),
(status = 400, description = "Kein/leeres Datei-Feld"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Lieferung nicht gefunden"),
(status = 500, description = "Upload zu DOCUframe fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn upload_note_image(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path(delivery_id): Path<Uuid>,
mut multipart: Multipart,
) -> Result<Json<DeliveryNoteResponse>, ApiError> {
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.upload_note_image");
let mut bytes: Option<Vec<u8>> = None;
let mut filename = String::from("upload");
let mut mime = String::from("application/octet-stream");
while let Some(field) = multipart.next_field().await.map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"multipart konnte nicht gelesen werden: {e}"
)))
})? {
if field.name() == Some("file") {
if let Some(fname) = field.file_name() {
filename = fname.to_owned();
}
if let Some(ct) = field.content_type() {
mime = ct.to_owned();
}
let data = field.bytes().await.map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"datei konnte nicht gelesen werden: {e}"
)))
})?;
bytes = Some(data.to_vec());
}
}
let bytes = bytes.ok_or_else(|| {
ApiError(ApplicationError::Validation(
"kein `file`-Feld im multipart-Body".into(),
))
})?;
let note = state
.upload_delivery_note_image
.execute(delivery_id, claims.personalnummer, None, filename, mime, bytes)
.await?;
Ok(Json(DeliveryNoteResponse { note }))
}
/// Wendet ein Betrags-Gutschrift-Ereignis an (`set`/`remove`). Append-only,
/// idempotent über `clientEventId`. Nur bei aktiver Lieferung; bei `set` sind
/// Betrag (0 < x ≤ 150 €, 10-€-Schritte) und Grund Pflicht. Antwort: der
/// aktuelle Gutschrift-Stand (`null`, wenn entfernt).
#[utoipa::path(
post,
path = "/deliveries/{delivery_id}/credit",
tag = "deliveries",
params(("delivery_id" = Uuid, Path)),
request_body = DeliveryCreditEventRequest,
responses(
(status = 200, description = "Gutschrift gesetzt/entfernt", body = DeliveryCreditResponse),
(status = 400, description = "Ungültiger Betrag/Grund oder Lieferung nicht aktiv"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Lieferung nicht gefunden")
),
security(("bearer_auth" = []))
)]
pub async fn apply_credit(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path(delivery_id): Path<Uuid>,
Json(req): Json<DeliveryCreditEventRequest>,
) -> Result<Json<DeliveryCreditResponse>, ApiError> {
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.apply_credit");
let credit = state
.apply_delivery_credit_event
.execute(delivery_id, claims.personalnummer, req)
.await?;
Ok(Json(DeliveryCreditResponse { credit }))
}
/// Setzt (Upsert) den Wert eines Service für eine Lieferung. Genau das zum
/// Service-Typ passende Feld (`boolValue`/`numericValue`) muss gesetzt sein;
/// numerische Werte werden gegen min/max geprüft. Nur bei aktiver Lieferung.
#[utoipa::path(
put,
path = "/deliveries/{delivery_id}/services/{service_id}",
tag = "deliveries",
params(
("delivery_id" = Uuid, Path),
("service_id" = Uuid, Path),
),
request_body = SetDeliveryServiceRequest,
responses(
(status = 200, description = "Wert gesetzt", body = DeliveryServiceResponse),
(status = 400, description = "Wert passt nicht zum Service-Typ / außerhalb min-max / Lieferung nicht aktiv"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Service oder Lieferung nicht gefunden")
),
security(("bearer_auth" = []))
)]
pub async fn set_service(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path((delivery_id, service_id)): Path<(Uuid, Uuid)>,
Json(req): Json<SetDeliveryServiceRequest>,
) -> Result<Json<DeliveryServiceResponse>, ApiError> {
tracing::info!(actor = claims.personalnummer, %delivery_id, %service_id, "delivery.set_service");
let value = state
.set_delivery_service
.execute(delivery_id, service_id, claims.personalnummer, req)
.await?;
Ok(Json(DeliveryServiceResponse { value }))
}
/// Entfernt den Service-Wert einer Lieferung (Service „nicht gesetzt").
/// Nur bei aktiver Lieferung. Antwort `204`.
#[utoipa::path(
delete,
path = "/deliveries/{delivery_id}/services/{service_id}",
tag = "deliveries",
params(
("delivery_id" = Uuid, Path),
("service_id" = Uuid, Path),
),
responses(
(status = 204, description = "Wert entfernt"),
(status = 400, description = "Lieferung nicht aktiv"),
(status = 401, description = "Authentifizierung fehlgeschlagen"),
(status = 404, description = "Lieferung nicht gefunden")
),
security(("bearer_auth" = []))
)]
pub async fn delete_service_value(
State(state): State<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path((delivery_id, service_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, ApiError> {
tracing::info!(actor = claims.personalnummer, %delivery_id, %service_id, "delivery.delete_service_value");
state
.delete_delivery_service
.execute(delivery_id, service_id)
.await?;
Ok(StatusCode::NO_CONTENT)
}
/// 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.

View File

@ -0,0 +1,160 @@
//! DEV-ONLY Endpunkte. Werden in `main.rs` **nur** gemountet, wenn
//! `dev.sync_enabled = true` (config.toml) — in Produktion existieren sie nicht.
//!
//! `POST /dev/resync` ist bewusst **unauthentifiziert** (liegt auf dem
//! public Router), damit man ihn ohne JWT per `curl` triggern kann. Er macht
//! die Postgres-Tourdaten platt und importiert frisch aus dem ERP — der
//! „überschreibende" Sync für die lokale Entwicklung. NIEMALS in Produktion
//! aktivieren.
use axum::Json;
use axum::Router;
use axum::extract::{Query, State};
use axum::routing::post;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::usecases::ImportSummary;
use crate::error::ApiError;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/dev/resync", post(dev_resync))
.route("/dev/generate-report", post(dev_generate_report))
.route("/dev/process-report", post(dev_process_report))
.route("/dev/unmark-mail-sent", post(dev_unmark_mail_sent))
}
#[derive(Debug, Deserialize)]
pub struct DevResyncQuery {
/// Ziel-Tourdatum `YYYY-MM-DD`. Fehlt der Parameter, wird **heute**
/// (echte Uhr) verwendet.
#[serde(default)]
pub date: Option<String>,
}
/// DEV-ONLY, UNAUTHENTIFIZIERT: löscht alle Postgres-Tourdaten und importiert
/// das Datum frisch aus dem ERP. Liefert die Import-Zusammenfassung.
pub async fn dev_resync(
State(state): State<AppState>,
Query(query): Query<DevResyncQuery>,
) -> Result<Json<ImportSummary>, ApiError> {
let date = match query.date {
Some(s) => NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"ungültiges Datum '{s}' (erwartet YYYY-MM-DD): {e}"
)))
})?,
None => chrono::Utc::now().date_naive(),
};
tracing::warn!(%date, "dev.resync: Postgres wird überschrieben + neu importiert");
let summary = state.dev_resync_tours.execute(date).await?;
tracing::info!(
%date,
total = summary.tours_total,
ok = summary.tours_ok,
failed = summary.tours_failed,
provisioned = summary.drivers_provisioned,
"dev.resync.done"
);
Ok(Json(summary))
}
#[derive(Debug, Deserialize)]
pub struct DevReportQuery {
/// UUID der Lieferung, für die der Report erzeugt werden soll.
pub delivery_id: String,
}
#[derive(Debug, Serialize)]
pub struct DevReportResponse {
pub reference: String,
}
/// DEV-ONLY: erzeugt den PDF-Report für eine Lieferung (ohne echten Abschluss)
/// und gibt die Sink-Referenz (Dateipfad) zurück. Zum Iterieren am Layout.
pub async fn dev_generate_report(
State(state): State<AppState>,
Query(query): Query<DevReportQuery>,
) -> Result<Json<DevReportResponse>, ApiError> {
let delivery_id = Uuid::parse_str(query.delivery_id.trim()).map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"ungültige delivery_id '{}': {e}",
query.delivery_id
)))
})?;
tracing::warn!(%delivery_id, "dev.generate_report angestoßen");
let reference = state.generate_delivery_report.execute(delivery_id).await?;
tracing::info!(%delivery_id, reference, "dev.generate_report.done");
Ok(Json(DevReportResponse { reference }))
}
/// DEV-ONLY: stößt die volle DOCUframe-Übertragungs-Pipeline für eine Lieferung
/// an (Render → Upload → Makro → Cleanup). Solange das Makro fehlt, schlägt der
/// Makro-Schritt erwartungsgemäß fehl — der Job bleibt dann in PG offen und der
/// Retry-Cron versucht es erneut. Liefert eine kurze Status-Meldung.
pub async fn dev_process_report(
State(state): State<AppState>,
Query(query): Query<DevReportQuery>,
) -> Result<Json<DevProcessResponse>, ApiError> {
let delivery_id = Uuid::parse_str(query.delivery_id.trim()).map_err(|e| {
ApiError(ApplicationError::Validation(format!(
"ungültige delivery_id '{}': {e}",
query.delivery_id
)))
})?;
tracing::warn!(%delivery_id, "dev.process_report angestoßen");
match state.process_delivery_report.execute(delivery_id).await {
Ok(()) => {
tracing::info!(%delivery_id, "dev.process_report.done");
Ok(Json(DevProcessResponse {
ok: true,
message: "Report an DOCUframe übertragen + lokale Dateien aufgeräumt".into(),
}))
}
Err(e) => {
tracing::warn!(%delivery_id, error = %e, "dev.process_report.failed (Job bleibt offen)");
Ok(Json(DevProcessResponse {
ok: false,
message: format!("fehlgeschlagen (Job in PG offen, Cron retried): {e}"),
}))
}
}
}
#[derive(Debug, Serialize)]
pub struct DevProcessResponse {
pub ok: bool,
pub message: String,
}
#[derive(Debug, Deserialize)]
pub struct DevUnmarkRequest {
/// Belegnummern, deren Mail-Versendet-Markierung wieder aufgehoben werden
/// soll (für erneutes Testen).
pub belegnummern: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct DevUnmarkResponse {
/// Anzahl tatsächlich zurückgesetzter (vorher markierter) Belege.
pub unmarked: u64,
}
/// DEV-ONLY, UNAUTHENTIFIZIERT: setzt `mail_sent_at` der angegebenen
/// Belegnummern wieder auf NULL, sodass sie erneut als offen in
/// `GET /admin/delivered-belegnummern` erscheinen. Zum wiederholten Testen
/// des Mailclients.
pub async fn dev_unmark_mail_sent(
State(state): State<AppState>,
Json(body): Json<DevUnmarkRequest>,
) -> Result<Json<DevUnmarkResponse>, ApiError> {
tracing::warn!(count = body.belegnummern.len(), "dev.unmark_mail_sent");
let unmarked = state.mark_mail_sent.unmark(body.belegnummern).await?;
tracing::info!(unmarked, "dev.unmark_mail_sent.done");
Ok(Json(DevUnmarkResponse { unmarked }))
}

View File

@ -2,8 +2,13 @@
//! zusammengesetzt.
pub mod accounts;
pub mod admin;
pub mod attachments;
pub mod cars;
pub mod deliveries;
pub mod dev;
pub mod health;
pub mod payment_methods;
pub mod scans;
pub mod services;
pub mod tours;

View File

@ -0,0 +1,142 @@
//! `/payment-methods` — globale Zahlungs-Stammdaten.
//!
//! Lese-Endpoint ist von der App frei nutzbar (Liste für die Auswahl in
//! der Auslieferungs-Phase). Schreib-Endpoints (POST/PATCH/DELETE) sind
//! Admin-Operationen — Authentifizierung schützt sie über die globale
//! Middleware, eine Rollen-Trennung kommt später (Phase H).
use axum::Json;
use axum::Router;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::get;
// `Path<Uuid>` für PATCH/DELETE — direkt aus axum::extract verwendet.
use holzleitner_application::dto::{
CreatePaymentMethodRequest, PaymentMethodResponse, PaymentMethodsList,
UpdatePaymentMethodRequest,
};
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(
"/payment-methods",
get(list_payment_methods).post(create_payment_method),
)
.route(
"/payment-methods/{id}",
axum::routing::patch(update_payment_method).delete(delete_payment_method),
)
}
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ListPaymentMethodsQuery {
/// Default `false` — Endpoint liefert nur aktive Methoden.
#[serde(default)]
pub include_inactive: bool,
}
/// Listet die Zahlungsmethoden.
#[utoipa::path(
get,
path = "/payment-methods",
tag = "payment-methods",
params(
("includeInactive" = Option<bool>, Query,
description = "Wenn true, werden inaktive Methoden mitgeliefert (default: false)")
),
responses(
(status = 200, description = "Zahlungsmethoden", body = PaymentMethodsList),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn list_payment_methods(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Query(query): Query<ListPaymentMethodsQuery>,
) -> Result<Json<PaymentMethodsList>, ApiError> {
let methods = state
.list_payment_methods
.execute(query.include_inactive)
.await?;
Ok(Json(PaymentMethodsList { methods }))
}
/// Legt eine neue Zahlungsmethode an.
#[utoipa::path(
post,
path = "/payment-methods",
tag = "payment-methods",
request_body = CreatePaymentMethodRequest,
responses(
(status = 200, body = PaymentMethodResponse),
(status = 400, description = "Validierungsfehler (z. B. doppelter code)"),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn create_payment_method(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Json(req): Json<CreatePaymentMethodRequest>,
) -> Result<Json<PaymentMethodResponse>, ApiError> {
let method = state.create_payment_method.execute(req).await?;
Ok(Json(PaymentMethodResponse { method }))
}
/// Patcht Anzeige-Name und/oder Aktiv-Flag.
#[utoipa::path(
patch,
path = "/payment-methods/{id}",
tag = "payment-methods",
params(("id" = Uuid, Path, description = "Zahlungsmethoden-Id")),
request_body = UpdatePaymentMethodRequest,
responses(
(status = 200, body = PaymentMethodResponse),
(status = 404, description = "Methode nicht gefunden"),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn update_payment_method(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Path(id): Path<Uuid>,
Json(req): Json<UpdatePaymentMethodRequest>,
) -> Result<Json<PaymentMethodResponse>, ApiError> {
let method = state.update_payment_method.execute(id, req).await?;
Ok(Json(PaymentMethodResponse { method }))
}
/// Hartes Löschen. `409 Conflict`, wenn die Methode von einer Lieferung
/// referenziert wird — der Admin soll dann den `active = false`-Pfad
/// nutzen.
#[utoipa::path(
delete,
path = "/payment-methods/{id}",
tag = "payment-methods",
params(("id" = Uuid, Path, description = "Zahlungsmethoden-Id")),
responses(
(status = 204, description = "Methode gelöscht"),
(status = 404, description = "Methode nicht gefunden"),
(status = 409, description = "Methode ist noch von Lieferungen referenziert"),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn delete_payment_method(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
state.delete_payment_method.execute(id).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@ -0,0 +1,133 @@
//! `/services` — admin-konfigurierbare Service-Stammdaten (früher
//! „Lieferoptionen").
//!
//! Lese-Endpoint nutzt die App (Phase 4). Schreib-Endpoints (POST/PATCH/
//! DELETE) sind Admin-Operationen — geschützt durch die globale JWT-
//! Middleware; Rollen-Trennung kommt später (Phase H).
use axum::Json;
use axum::Router;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::get;
use holzleitner_application::dto::{
CreateServiceRequest, ServiceResponse, ServicesList, UpdateServiceRequest,
};
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("/services", get(list_services).post(create_service))
.route(
"/services/{id}",
axum::routing::patch(update_service).delete(delete_service),
)
}
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ListServicesQuery {
#[serde(default)]
pub include_inactive: bool,
}
/// Listet die Services (sortiert nach `sortOrder`).
#[utoipa::path(
get,
path = "/services",
tag = "services",
params(
("includeInactive" = Option<bool>, Query,
description = "Wenn true, werden inaktive Services mitgeliefert (default: false)")
),
responses(
(status = 200, description = "Services", body = ServicesList),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn list_services(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Query(query): Query<ListServicesQuery>,
) -> Result<Json<ServicesList>, ApiError> {
let services = state.list_services.execute(query.include_inactive).await?;
Ok(Json(ServicesList { services }))
}
/// Legt einen neuen Service an.
#[utoipa::path(
post,
path = "/services",
tag = "services",
request_body = CreateServiceRequest,
responses(
(status = 200, body = ServiceResponse),
(status = 400, description = "Validierungsfehler (z. B. kind/min/max inkonsistent)"),
(status = 409, description = "key existiert bereits"),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn create_service(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Json(req): Json<CreateServiceRequest>,
) -> Result<Json<ServiceResponse>, ApiError> {
let service = state.create_service.execute(req).await?;
Ok(Json(ServiceResponse { service }))
}
/// Patcht Name/Grenzen/Aktiv-Flag/Sortierung. `kind` ist nicht änderbar.
#[utoipa::path(
patch,
path = "/services/{id}",
tag = "services",
params(("id" = Uuid, Path, description = "Service-Id")),
request_body = UpdateServiceRequest,
responses(
(status = 200, body = ServiceResponse),
(status = 404, description = "Service nicht gefunden"),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn update_service(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Path(id): Path<Uuid>,
Json(req): Json<UpdateServiceRequest>,
) -> Result<Json<ServiceResponse>, ApiError> {
let service = state.update_service.execute(id, req).await?;
Ok(Json(ServiceResponse { service }))
}
/// Hartes Löschen. `409 Conflict`, wenn der Service noch von einer Lieferung
/// referenziert wird — dann stattdessen deaktivieren.
#[utoipa::path(
delete,
path = "/services/{id}",
tag = "services",
params(("id" = Uuid, Path, description = "Service-Id")),
responses(
(status = 204, description = "Service gelöscht"),
(status = 404, description = "Service nicht gefunden"),
(status = 409, description = "Service ist noch referenziert"),
(status = 401, description = "Authentifizierung fehlgeschlagen")
),
security(("bearer_auth" = []))
)]
pub async fn delete_service(
State(state): State<AppState>,
AuthenticatedUser(_claims): AuthenticatedUser,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
state.delete_service.execute(id).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@ -2,10 +2,19 @@ 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,
ApplyDeliveryActionUseCase, ApplyDeliveryCreditEventUseCase, ApplyScansUseCase,
AssignCarToDeliveryUseCase, CompleteDeliveryUseCase, CreateDeliveryNoteUseCase,
CreateMyCarUseCase, CreatePaymentMethodUseCase, CreateServiceUseCase,
DeleteDeliveryNoteUseCase, DeleteDeliveryServiceUseCase, DeletePaymentMethodUseCase,
DeleteServiceUseCase, DevResyncToursUseCase, GenerateDeliveryReportUseCase, GetAccountUseCase,
GetAttachmentPreviewUseCase, GetTourUseCase,
ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase,
ListMyToursTodayUseCase, ListPaymentMethodsUseCase,
ListServicesUseCase, MarkMailSentUseCase, ProcessDeliveryReportUseCase,
PushCompletionToErpUseCase,
SetDeliveryOrderUseCase, SetDeliveryServiceUseCase, SyncTourUseCase, UpdateDeliveryNoteUseCase,
UpdateMyCarUseCase, UpdatePaymentMethodUseCase, UpdateServiceUseCase,
UploadDeliveryNoteImageUseCase,
};
/// Shared application state, der per Axum's `State`-Extractor in alle
@ -21,13 +30,49 @@ pub struct AppState {
pub get_tour: Arc<GetTourUseCase>,
pub list_my_tours_today: Arc<ListMyToursTodayUseCase>,
pub sync_tour: Arc<SyncTourUseCase>,
pub import_erp_tours: Arc<ImportErpToursUseCase>,
/// DEV-ONLY: überschreibender Resync (löscht Postgres + importiert neu).
pub dev_resync_tours: Arc<DevResyncToursUseCase>,
/// Erzeugt den PDF-Lieferreport (lokal — Dev-Endpoint + Fallback ohne Upload).
pub generate_delivery_report: Arc<GenerateDeliveryReportUseCase>,
/// Überträgt den Report an DOCUframe (Upload → Makro → Cleanup) — beim
/// Abschluss (Hintergrund) + Retry-Cron + Dev-Endpoint.
pub process_delivery_report: Arc<ProcessDeliveryReportUseCase>,
/// Spiegelt `REPORT_UPLOAD_ENABLED`: steuert, ob beim Abschluss die
/// DOCUframe-Übertragung läuft (an) oder nur lokal erzeugt wird (aus).
pub report_upload_enabled: bool,
pub set_delivery_order: Arc<SetDeliveryOrderUseCase>,
pub apply_scans: Arc<ApplyScansUseCase>,
pub apply_delivery_action: Arc<ApplyDeliveryActionUseCase>,
pub complete_delivery: Arc<CompleteDeliveryUseCase>,
pub push_completion_to_erp: Arc<PushCompletionToErpUseCase>,
/// Admin: Belegnummern offener (noch nicht versendeter) Lieferungen.
pub list_delivered_belegnummern: Arc<ListDeliveredBelegnummernUseCase>,
/// Admin: Liefermails von Belegnummern als versendet markieren (Dedup).
pub mark_mail_sent: Arc<MarkMailSentUseCase>,
pub apply_delivery_credit_event: Arc<ApplyDeliveryCreditEventUseCase>,
pub create_delivery_note: Arc<CreateDeliveryNoteUseCase>,
pub update_delivery_note: Arc<UpdateDeliveryNoteUseCase>,
pub delete_delivery_note: Arc<DeleteDeliveryNoteUseCase>,
pub upload_delivery_note_image: Arc<UploadDeliveryNoteImageUseCase>,
pub get_attachment_preview: Arc<GetAttachmentPreviewUseCase>,
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 list_payment_methods: Arc<ListPaymentMethodsUseCase>,
pub create_payment_method: Arc<CreatePaymentMethodUseCase>,
pub update_payment_method: Arc<UpdatePaymentMethodUseCase>,
pub delete_payment_method: Arc<DeletePaymentMethodUseCase>,
pub list_services: Arc<ListServicesUseCase>,
pub create_service: Arc<CreateServiceUseCase>,
pub update_service: Arc<UpdateServiceUseCase>,
pub delete_service: Arc<DeleteServiceUseCase>,
pub set_delivery_service: Arc<SetDeliveryServiceUseCase>,
pub delete_delivery_service: Arc<DeleteDeliveryServiceUseCase>,
pub auth_service: Arc<dyn AuthService>,
/// Statischer API-Key-Gate für die `/admin`-Routen (Header
/// `X-Admin-Api-Key`). Leer ⇒ alle Admin-Routen gesperrt (fail-closed).
/// Wird von der `admin_api_key`-Middleware konstant-zeitlich verglichen.
pub admin_api_key: Arc<str>,
}