commit 438040acce6d4c090c96463cb2d120eb13e3c64b Author: Dennis Nemec Date: Thu May 14 22:28:31 2026 +0200 Initial: Rust-Backend mit Clean Architecture (domain/application/infrastructure/api) Vier-Crate-Workspace mit: - Domain: Account, Car, Tour, Delivery, DeliveryItem, DeliveryNote, Customer, Article, Warehouse, ScanState, AuditAction — alle mit serde + feature-gated utoipa::ToSchema. - Application: Ports (TourRepository, DeliveryRepository, ScanRepository, DeliveryNoteRepository, CarRepository, AuthService) und Use Cases. - Infrastructure: Postgres-Adapter via sqlx (PgTourRepository etc.) + Keycloak-AuthService mit JWKS-Cache + OIDC-Discovery. - API: Axum 0.8, utoipa-OpenAPI + Swagger-UI, JWT-Bearer-Middleware, AuthenticatedUser-Extractor. Endpoints: - GET /me/tours/today, /tours/{id}, /accounts/{pn}, /me/cars, /health - POST /sync/tour, /scans (bulk + idempotent via clientScanId), /deliveries/{id}/{hold,resume,cancel,complete,notes}, /me/cars - PUT /tours/{id}/delivery-order, /deliveries/{id}/assigned-car, /me/cars/{id} - PATCH /me/cars/{id} Datenmodell: - 6 Migrationen (accounts, tours/deliveries/items + Stammdaten, scan_audit mit clientScanId-UNIQUE, state_reason refactor, delivery_notes, cars + FKs nachziehen). - Business-stabile Beleg-Keys (belegart_id, belegnummer) für ERP-Sync. - Append-only scan_audit + embedded scan_state als doppelte Wahrheit. Dev-Setup: - docker-compose mit Postgres 17 + Keycloak 26 - Keycloak-Realm 'holzleitner' mit Public-Client (PKCE), Testfahrer (PN 1001) + Audience-/Personalnummer-Mapper diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cdbcf26 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Vorlage für lokale Entwicklung — kopieren nach `.env` und nach Bedarf anpassen. +# Die `.env` selbst gehört nicht in Git. + +# --- HTTP-Server ---------------------------------------------------------- +SERVER_HOST=127.0.0.1 +SERVER_PORT=3000 + +# --- Postgres ------------------------------------------------------------- +# Passt zur docker-compose.yml (Service `postgres`). +DATABASE_URL=postgres://holzleitner:holzleitner_dev@localhost:5432/holzleitner +DATABASE_MAX_CONNECTIONS=10 + +# --- Keycloak (OIDC) ------------------------------------------------------ +# Passt zur docker-compose.yml (Service `keycloak`). +# Admin-UI: http://localhost:8080/admin/ (admin / admin) +# Realm: holzleitner +# Test-User: testfahrer / test (Personalnummer 1001, Rolle "driver") +KEYCLOAK_ISSUER_URL=http://localhost:8080/realms/holzleitner +KEYCLOAK_AUDIENCE=holzleitner-api +KEYCLOAK_JWKS_CACHE_TTL_SECONDS=3600 + +# --- Logging -------------------------------------------------------------- +# Standard-Filter; siehe tracing_subscriber::EnvFilter-Doku. +RUST_LOG=holzleitner_api=info,holzleitner_infrastructure=info,tower_http=info diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10a362e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +.env +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5130d83 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3258 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "envy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "holzleitner-api" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "dotenvy", + "envy", + "holzleitner-application", + "holzleitner-domain", + "holzleitner-infrastructure", + "serde", + "serde_json", + "sqlx", + "thiserror", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "utoipa", + "utoipa-swagger-ui", + "uuid", +] + +[[package]] +name = "holzleitner-application" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "holzleitner-domain", + "serde", + "thiserror", + "utoipa", + "uuid", +] + +[[package]] +name = "holzleitner-domain" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "utoipa", + "uuid", +] + +[[package]] +name = "holzleitner-infrastructure" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "holzleitner-application", + "holzleitner-domain", + "jsonwebtoken", + "reqwest", + "serde", + "serde_json", + "sqlx", + "thiserror", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.7", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", + "uuid", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" +dependencies = [ + "axum", + "base64", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "zip", +] + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c23e1f9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,42 @@ +[workspace] +resolver = "3" +members = [ + "crates/domain", + "crates/application", + "crates/infrastructure", + "crates/api", +] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "Proprietary" +authors = ["Holzleitner GmbH"] + +[workspace.dependencies] +# === Externe Crates (zentral gepinnt) ===================================== +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["serde", "v4"] } +chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] } +async-trait = "0.1" +thiserror = "2" +tokio = { version = "1", features = ["full"] } +axum = "0.8" +tower = "0.5" +tower-http = { version = "0.6", features = ["trace", "cors"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +jsonwebtoken = "9" +envy = "0.4" +dotenvy = "0.15" +anyhow = "1" +utoipa = { version = "5", features = ["axum_extras", "chrono", "uuid"] } +utoipa-swagger-ui = { version = "9", features = ["axum"] } + +# === Interne Crates ======================================================= +holzleitner-domain = { path = "crates/domain" } +holzleitner-application = { path = "crates/application" } +holzleitner-infrastructure = { path = "crates/infrastructure" } diff --git a/README.md b/README.md new file mode 100644 index 0000000..64fba77 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# Holzleitner-Backend + +Rust-Backend für die Holzleitner-Lieferservice-App. Workspace mit Clean +Architecture: `domain` → `application` → `infrastructure` → `api`. + +## Schnellstart (lokale Entwicklung) + +```bash +# 1) Postgres + Keycloak hochfahren +docker compose up -d +# Keycloak braucht ~30s bis "Listening on http://0.0.0.0:8080" im Log steht. + +# 2) Env-Datei vorbereiten +cp .env.example .env + +# 3) Backend starten +cargo run -p holzleitner-api +``` + +Migrations laufen beim Start automatisch über `sqlx::migrate!`. +Smoke-Test danach: + +```bash +curl http://127.0.0.1:3000/health +# → ok + +curl http://127.0.0.1:3000/accounts/1001 +# → {"personalnummer":1001,"name":"Müller Logistik GmbH","active":true} +``` + +## Keycloak (Dev) + +| Wo | URL / Credentials | +|---|---| +| Admin-Console | http://localhost:8080/admin/ (admin / admin) | +| Realm | `holzleitner` | +| Client | `holzleitner-app` (public, PKCE) | +| Test-User | `testfahrer` / `test` · Personalnummer 1001 · Rolle `driver` | +| Audience im Access-Token | `holzleitner-api` | + +Der Realm wird bei jedem `docker compose up` aus +`keycloak/import/realm-holzleitner.json` frisch importiert. Wer in der +Admin-UI Änderungen macht, sollte sie **in die JSON zurückspielen**, +sonst sind sie beim nächsten `docker compose down` weg. + +### Token für Dev-Tests holen + +```bash +curl -s -X POST \ + http://localhost:8080/realms/holzleitner/protocol/openid-connect/token \ + -d 'grant_type=password' \ + -d 'client_id=holzleitner-app' \ + -d 'username=testfahrer' \ + -d 'password=test' | jq -r .access_token +``` + +Den Token in den `Authorization`-Header packen, sobald die JWT-Middleware +in der API-Schicht aktiv ist: + +```bash +TOKEN=$(curl -s -X POST .../token -d ... | jq -r .access_token) +curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:3000/accounts/1001 +``` + +## Crate-Layout + +| Crate | Inhalt | Abhängigkeiten | +|---|---|---| +| `holzleitner-domain` | Reines Domänenmodell (serde + UUID + chrono) | — | +| `holzleitner-application` | Use Cases und Ports (Trait-Definitionen für Repository, AuthService) | `domain` | +| `holzleitner-infrastructure` | Konkrete Adapter (sqlx-Postgres, später Keycloak) | `domain`, `application` | +| `holzleitner-api` | Axum HTTP-Layer + Composition Root | alle | + +## Konfiguration + +Werte werden aus Umgebungsvariablen gelesen (siehe `.env.example`), +gruppiert nach Prefix: + +| Prefix | Bereich | +|---|---| +| `SERVER_*` | Bind-Host/Port | +| `DATABASE_*` | Postgres-URL, Pool-Größe | +| `KEYCLOAK_*` | OIDC-Issuer, Audience, JWKS-Cache (greift erst in der Keycloak-Phase) | + +## Migrations + +Migrations liegen im Workspace-Root `migrations/`. Eingebettet via +`sqlx::migrate!()` — kein zusätzlicher Laufzeit-Dateizugriff nötig. +Neue Migration anlegen: + +```bash +# Format: _.sql, z.B.: +touch migrations/0002_tour.sql +``` + +## Logging + +`tracing` + `tracing-subscriber` mit `EnvFilter`. Default: +`holzleitner_api=info,tower_http=info`. Override via `RUST_LOG`. diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml new file mode 100644 index 0000000..972edc1 --- /dev/null +++ b/crates/api/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "holzleitner-api" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[[bin]] +name = "holzleitner-server" +path = "src/main.rs" + +[dependencies] +holzleitner-domain = { workspace = true, features = ["openapi"] } +holzleitner-application = { workspace = true, features = ["openapi"] } +holzleitner-infrastructure.workspace = true +utoipa.workspace = true +utoipa-swagger-ui.workspace = true +axum.workspace = true +tokio.workspace = true +tower.workspace = true +tower-http.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true +thiserror.workspace = true +anyhow.workspace = true +envy.workspace = true +dotenvy.workspace = true +sqlx.workspace = true diff --git a/crates/api/src/config.rs b/crates/api/src/config.rs new file mode 100644 index 0000000..391dd79 --- /dev/null +++ b/crates/api/src/config.rs @@ -0,0 +1,97 @@ +//! Konfiguration aus Umgebungsvariablen (12-Factor-Stil). +//! +//! Für lokale Entwicklung lädt [`load`] zunächst eine optionale `.env` +//! Datei und parst dann pro Bereich (Server, Database, Keycloak) mit +//! Prefix-Filter über `envy`. So bleiben die Strukturen klar getrennt +//! und Fehlermeldungen verraten den genauen Bereich. + +use serde::Deserialize; + +#[derive(Debug, Clone)] +pub struct Config { + pub server: ServerConfig, + pub database: DatabaseConfig, + #[allow(dead_code)] // wird in der Keycloak-Phase verdrahtet + pub keycloak: KeycloakConfig, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ServerConfig { + pub host: String, + pub port: u16, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DatabaseConfig { + pub url: String, + #[serde(default = "default_max_connections")] + pub max_connections: u32, +} + +fn default_max_connections() -> u32 { + 10 +} + +/// Felder werden in der Keycloak-Phase angefasst — bis dahin Dead-Code- +/// Warnings unterdrücken. +#[allow(dead_code)] +#[derive(Debug, Clone, Deserialize)] +pub struct KeycloakConfig { + pub issuer_url: String, + pub audience: String, + #[serde(default = "default_jwks_cache_ttl")] + pub jwks_cache_ttl_seconds: u64, +} + +fn default_jwks_cache_ttl() -> u64 { + 3600 +} + +/// Lädt die Konfiguration aus Umgebungsvariablen. +/// +/// Reihenfolge: +/// 1. Optionale `.env`-Datei im Arbeitsverzeichnis (für Local-Dev). +/// 2. Pro Bereich werden Variablen mit passendem Prefix gelesen: +/// * `SERVER_*` für [`ServerConfig`] +/// * `DATABASE_*` für [`DatabaseConfig`] +/// * `KEYCLOAK_*` für [`KeycloakConfig`] +pub fn load() -> Result { + // `.env` ist optional — in Produktion kommen die Werte aus dem + // System-Environment. + let _ = dotenvy::dotenv(); + + let server = envy::prefixed("SERVER_") + .from_env::() + .map_err(|e| ConfigError::Section { + section: "SERVER", + source: e, + })?; + let database = envy::prefixed("DATABASE_") + .from_env::() + .map_err(|e| ConfigError::Section { + section: "DATABASE", + source: e, + })?; + let keycloak = envy::prefixed("KEYCLOAK_") + .from_env::() + .map_err(|e| ConfigError::Section { + section: "KEYCLOAK", + source: e, + })?; + + Ok(Config { + server, + database, + keycloak, + }) +} + +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("missing or invalid env vars in section {section}: {source}")] + Section { + section: &'static str, + #[source] + source: envy::Error, + }, +} diff --git a/crates/api/src/error.rs b/crates/api/src/error.rs new file mode 100644 index 0000000..ae2a87b --- /dev/null +++ b/crates/api/src/error.rs @@ -0,0 +1,51 @@ +use axum::Json; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::AuthError; +use serde_json::json; + +/// HTTP-Adapter für Application-Fehler. Mappt jede Variante auf +/// einen Statuscode + JSON-Body. Verhindert, dass interne Details +/// (z. B. SQL-Fehler) versehentlich in die Antwort durchschlagen — +/// das eigentliche Detail wird über `tracing` geloggt. +pub struct ApiError(pub ApplicationError); + +impl From for ApiError { + fn from(value: ApplicationError) -> Self { + Self(value) + } +} + +impl From for ApiError { + fn from(err: AuthError) -> Self { + // Auth-Detail für Debugging loggen, aber NICHT an den Client geben — + // dort sehen wir nur „Unauthorized" / „internal error". + tracing::debug!(error = %err, "auth verification failed"); + ApiError(err.into()) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let (status, code) = match &self.0 { + ApplicationError::NotFound => (StatusCode::NOT_FOUND, "not_found"), + ApplicationError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"), + ApplicationError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"), + ApplicationError::Validation(_) => (StatusCode::BAD_REQUEST, "validation"), + ApplicationError::Repository(_) + | ApplicationError::External(_) + | ApplicationError::Unexpected(_) => { + tracing::error!(error = %self.0, "internal error"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal_error") + } + }; + + let body = Json(json!({ + "error": code, + "message": self.0.to_string(), + })); + + (status, body).into_response() + } +} diff --git a/crates/api/src/extractors.rs b/crates/api/src/extractors.rs new file mode 100644 index 0000000..6fe1a61 --- /dev/null +++ b/crates/api/src/extractors.rs @@ -0,0 +1,41 @@ +//! Eigene Axum-Extractoren für die API-Schicht. +//! +//! Da Axums Trait `FromRequestParts` und das fremde `Claims`-Struct +//! aus `holzleitner_application` jeweils Fremd-Items sind (Rust- +//! Orphan-Rule), wickeln wir `Claims` in einen lokalen Newtype +//! `AuthenticatedUser`. Handler signaturen werden damit lesbarer als +//! mit `axum::extract::Extension` und liefern bei fehlender +//! Authentifizierung einen konsistenten 401-Fehler. + +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::Claims; + +use crate::error::ApiError; + +/// Wrapper-Extractor für `Claims` aus der JWT-Middleware. +/// +/// Verwendung im Handler: +/// ```ignore +/// async fn handler(AuthenticatedUser(claims): AuthenticatedUser, ...) { ... } +/// ``` +pub struct AuthenticatedUser(pub Claims); + +impl FromRequestParts for AuthenticatedUser +where + S: Send + Sync, +{ + type Rejection = ApiError; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + parts + .extensions + .get::() + .cloned() + .map(AuthenticatedUser) + // Sollte nicht passieren wenn die Route hinter der Middleware + // hängt — wir behandeln es als Programmier-/Konfig-Fehler. + .ok_or(ApiError(ApplicationError::Unauthorized)) + } +} diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs new file mode 100644 index 0000000..0e7281e --- /dev/null +++ b/crates/api/src/main.rs @@ -0,0 +1,158 @@ +//! Holzleitner-API — HTTP-Layer und Composition Root. +//! +//! Bootstrap-Reihenfolge: +//! 1. Tracing/Logging initialisieren +//! 2. Konfiguration aus Env-Variablen / `.env` laden +//! 3. Postgres-Pool aufbauen und Migrations ausführen +//! 4. Keycloak-AuthService instanziieren +//! 5. Use Cases zusammenstellen und in `AppState` packen +//! 6. Public + Protected Router komponieren und Server starten + +mod config; +mod error; +mod extractors; +mod middleware; +mod openapi; +mod routes; +mod state; + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context; +use axum::Router; +use axum::middleware::from_fn_with_state; +use holzleitner_application::usecases::{ + ApplyDeliveryActionUseCase, ApplyScansUseCase, AssignCarToDeliveryUseCase, + CreateDeliveryNoteUseCase, CreateMyCarUseCase, GetAccountUseCase, GetTourUseCase, + ListMyCarsUseCase, ListMyToursTodayUseCase, SetDeliveryOrderUseCase, SyncTourUseCase, + UpdateMyCarUseCase, +}; +use holzleitner_infrastructure::auth::{KeycloakAdapterConfig, KeycloakAuthService}; +use holzleitner_infrastructure::persistence::{ + PgAccountRepository, PgCarRepository, PgDeliveryNoteRepository, PgDeliveryRepository, + PgScanRepository, PgTourRepository, PoolConfig, connect_and_migrate, +}; +use tower_http::trace::TraceLayer; +use utoipa::OpenApi; +use utoipa_swagger_ui::SwaggerUi; + +use crate::middleware::jwt_middleware; +use crate::openapi::ApiDoc; +use crate::state::AppState; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { + "holzleitner_api=info,holzleitner_infrastructure=info,tower_http=info".into() + }), + ) + .init(); + + let cfg = config::load().context("config laden fehlgeschlagen")?; + tracing::info!(host = %cfg.server.host, port = cfg.server.port, "starting up"); + + // --- Persistence --------------------------------------------------- + let pool = connect_and_migrate(&PoolConfig { + url: cfg.database.url.clone(), + max_connections: cfg.database.max_connections, + }) + .await + .context("Postgres-Verbindung / Migrations fehlgeschlagen")?; + tracing::info!("postgres connected, migrations applied"); + + let account_repository = Arc::new(PgAccountRepository::new(pool.clone())); + let tour_repository = Arc::new(PgTourRepository::new(pool.clone())); + let scan_repository = Arc::new(PgScanRepository::new(pool.clone())); + let delivery_repository = Arc::new(PgDeliveryRepository::new(pool.clone())); + let delivery_note_repository = Arc::new(PgDeliveryNoteRepository::new(pool.clone())); + let car_repository = Arc::new(PgCarRepository::new(pool)); + + // --- Auth ---------------------------------------------------------- + let auth_service = Arc::new(KeycloakAuthService::new(KeycloakAdapterConfig { + issuer_url: cfg.keycloak.issuer_url.clone(), + audience: cfg.keycloak.audience.clone(), + jwks_cache_ttl: Duration::from_secs(cfg.keycloak.jwks_cache_ttl_seconds), + })); + tracing::info!( + issuer = %cfg.keycloak.issuer_url, + audience = %cfg.keycloak.audience, + "keycloak adapter ready" + ); + + // --- Use Cases ----------------------------------------------------- + let get_account = Arc::new(GetAccountUseCase::new(account_repository)); + let get_tour = Arc::new(GetTourUseCase::new(tour_repository.clone())); + let list_my_tours_today = Arc::new(ListMyToursTodayUseCase::new(tour_repository.clone())); + let sync_tour = Arc::new(SyncTourUseCase::new(tour_repository.clone())); + let set_delivery_order = Arc::new(SetDeliveryOrderUseCase::new(tour_repository)); + let apply_scans = Arc::new(ApplyScansUseCase::new( + scan_repository, + car_repository.clone(), + )); + let apply_delivery_action = + Arc::new(ApplyDeliveryActionUseCase::new(delivery_repository.clone())); + let create_delivery_note = Arc::new(CreateDeliveryNoteUseCase::new( + delivery_note_repository, + car_repository.clone(), + )); + let list_my_cars = Arc::new(ListMyCarsUseCase::new(car_repository.clone())); + let create_my_car = Arc::new(CreateMyCarUseCase::new(car_repository.clone())); + let update_my_car = Arc::new(UpdateMyCarUseCase::new(car_repository.clone())); + let assign_car_to_delivery = Arc::new(AssignCarToDeliveryUseCase::new( + car_repository, + delivery_repository, + )); + + let state = AppState { + get_account, + get_tour, + list_my_tours_today, + sync_tour, + set_delivery_order, + apply_scans, + apply_delivery_action, + create_delivery_note, + list_my_cars, + create_my_car, + update_my_car, + assign_car_to_delivery, + auth_service, + }; + + // --- Router -------------------------------------------------------- + // + // Public-Routen (Health, OpenAPI-Spec, Swagger-UI) und Protected- + // Routen werden getrennt gebaut; die JWT-Middleware liegt **nur** + // auf dem protected-Subtree. `route_layer` greift nur für die jetzt + // definierten Routen — neue Routen darunter müssten explizit + // nochmal angehängt werden. + let public = Router::new() + .merge(routes::health::router()) + .merge(SwaggerUi::new("/swagger-ui").url("/openapi.json", ApiDoc::openapi())); + let protected = Router::new() + .merge(routes::accounts::router()) + .merge(routes::tours::router()) + .merge(routes::scans::router()) + .merge(routes::deliveries::router()) + .merge(routes::cars::router()) + .route_layer(from_fn_with_state(state.clone(), jwt_middleware)); + + let app = Router::new() + .merge(public) + .merge(protected) + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let addr: SocketAddr = format!("{}:{}", cfg.server.host, cfg.server.port) + .parse() + .with_context(|| format!("ungültige Adresse {}:{}", cfg.server.host, cfg.server.port))?; + let listener = tokio::net::TcpListener::bind(addr).await?; + tracing::info!("server läuft auf http://{}", addr); + axum::serve(listener, app).await?; + + Ok(()) +} diff --git a/crates/api/src/middleware/jwt.rs b/crates/api/src/middleware/jwt.rs new file mode 100644 index 0000000..59e7c31 --- /dev/null +++ b/crates/api/src/middleware/jwt.rs @@ -0,0 +1,46 @@ +//! JWT-Middleware: extrahiert das Bearer-Token, ruft den `AuthService` +//! aus dem `AppState` und hängt die verifizierten `Claims` als Request- +//! Extension an. Nachgelagerte Handler greifen darüber per +//! `AuthenticatedUser`-Extractor zu. + +use axum::extract::{Request, State}; +use axum::http::HeaderMap; +use axum::http::header::AUTHORIZATION; +use axum::middleware::Next; +use axum::response::Response; +use holzleitner_application::ports::AuthError; + +use crate::error::ApiError; +use crate::state::AppState; + +pub async fn jwt_middleware( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let bearer = extract_bearer(req.headers()).ok_or(ApiError::from(AuthError::MissingToken))?; + + let claims = state + .auth_service + .verify_token(bearer) + .await + .map_err(ApiError::from)?; + + tracing::debug!( + personalnummer = claims.personalnummer, + subject = %claims.subject, + roles = ?claims.roles, + "auth ok", + ); + req.extensions_mut().insert(claims); + + Ok(next.run(req).await) +} + +fn extract_bearer(headers: &HeaderMap) -> Option<&str> { + headers + .get(AUTHORIZATION)? + .to_str() + .ok()? + .strip_prefix("Bearer ") +} diff --git a/crates/api/src/middleware/mod.rs b/crates/api/src/middleware/mod.rs new file mode 100644 index 0000000..8eaff25 --- /dev/null +++ b/crates/api/src/middleware/mod.rs @@ -0,0 +1,6 @@ +//! Axum-Middleware — z. B. JWT-Validierung gegen den +//! `holzleitner_application::ports::AuthService`. + +pub mod jwt; + +pub use jwt::jwt_middleware; diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs new file mode 100644 index 0000000..4163bf6 --- /dev/null +++ b/crates/api/src/openapi.rs @@ -0,0 +1,118 @@ +//! OpenAPI-Aggregation: zieht alle annotierten Handler und Schemata in +//! ein einziges Dokument zusammen, das der `/openapi.json`-Endpoint +//! serviert und Swagger-UI darstellt. +//! +//! Neue Endpoints werden hier in `paths(...)` registriert, neue Schemata +//! in `components(schemas(...))`. Die Annotation am Handler (via +//! `#[utoipa::path(...)]`) liefert die eigentliche Beschreibung. + +use utoipa::Modify; +use utoipa::OpenApi; +use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; + +#[derive(OpenApi)] +#[openapi( + info( + title = "Holzleitner Backend API", + version = "0.1.0", + description = "Backend für die Holzleitner-Lieferservice-App — Tour, Beladung, Ausführung." + ), + paths( + crate::routes::health::health, + crate::routes::accounts::get_account, + crate::routes::tours::list_my_tours_today, + crate::routes::tours::get_tour, + crate::routes::tours::set_delivery_order, + crate::routes::tours::sync_tour, + crate::routes::scans::apply_scans, + crate::routes::deliveries::hold, + crate::routes::deliveries::resume, + crate::routes::deliveries::cancel, + crate::routes::deliveries::complete, + crate::routes::deliveries::create_note, + crate::routes::deliveries::assign_car, + crate::routes::cars::list_my_cars, + crate::routes::cars::create_my_car, + crate::routes::cars::update_my_car, + ), + components( + schemas( + holzleitner_domain::Account, + holzleitner_domain::Address, + holzleitner_domain::Article, + holzleitner_domain::AuditAction, + holzleitner_domain::Car, + holzleitner_domain::Customer, + holzleitner_domain::CustomerContact, + holzleitner_domain::Delivery, + holzleitner_domain::DeliveryItem, + holzleitner_domain::DeliveryNote, + holzleitner_domain::DeliveryState, + holzleitner_domain::ScanState, + holzleitner_domain::ScanStatus, + holzleitner_domain::Tour, + holzleitner_domain::Warehouse, + holzleitner_application::dto::TourDetails, + holzleitner_application::dto::DeliveryWithItems, + holzleitner_application::dto::TourSummary, + holzleitner_application::dto::SyncTourRequest, + holzleitner_application::dto::SyncDelivery, + holzleitner_application::dto::SyncDeliveryItem, + holzleitner_application::dto::SetDeliveryOrderRequest, + holzleitner_application::dto::SetDeliveryOrderResponse, + holzleitner_application::dto::DeliveryOrderEntry, + holzleitner_application::dto::ApplyScansRequest, + holzleitner_application::dto::ApplyScansResponse, + holzleitner_application::dto::ScanEvent, + holzleitner_application::dto::ScanResult, + holzleitner_application::dto::ScanResultStatus, + holzleitner_application::dto::HoldDeliveryRequest, + holzleitner_application::dto::CancelDeliveryRequest, + holzleitner_application::dto::DeliveryResponse, + holzleitner_application::dto::CreateDeliveryNoteRequest, + holzleitner_application::dto::DeliveryNoteResponse, + holzleitner_application::dto::CreateCarRequest, + holzleitner_application::dto::UpdateCarRequest, + holzleitner_application::dto::CarResponse, + holzleitner_application::dto::CarsList, + holzleitner_application::dto::AssignCarRequest, + crate::routes::tours::TourSummaryList, + crate::routes::tours::SyncTourResponse, + ) + ), + modifiers(&SecurityAddon), + tags( + (name = "health", description = "Health- und Status-Endpoints"), + (name = "accounts", description = "Account-Stammdaten"), + (name = "tours", description = "Touren der Fahrer"), + (name = "sync", description = "ERP-Sync-Endpunkte"), + (name = "scans", description = "Scan-Events (Beladung & Auslieferung)"), + (name = "deliveries", description = "Delivery-Lifecycle (hold / resume / cancel / complete)"), + (name = "cars", description = "Fahrzeug-Stammdaten pro Fahrer"), + ), + security( + ("bearer_auth" = []) + ) +)] +pub struct ApiDoc; + +/// Hängt das `bearer_auth`-Security-Scheme nachträglich in die Spec ein — +/// das geht nur über einen `Modify`-Hook, weil derive-Macros das nicht +/// direkt erlauben. +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "bearer_auth", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .build(), + ), + ); + } + } +} diff --git a/crates/api/src/routes/accounts.rs b/crates/api/src/routes/accounts.rs new file mode 100644 index 0000000..f798fe4 --- /dev/null +++ b/crates/api/src/routes/accounts.rs @@ -0,0 +1,44 @@ +use axum::Json; +use axum::Router; +use axum::extract::{Path, State}; +use axum::routing::get; +use holzleitner_domain::Account; + +use crate::error::ApiError; +use crate::extractors::AuthenticatedUser; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new().route("/accounts/{personalnummer}", get(get_account)) +} + +/// Liest den Account zu einer Personalnummer. +#[utoipa::path( + get, + path = "/accounts/{personalnummer}", + tag = "accounts", + params( + ("personalnummer" = i64, Path, description = "Personalnummer des Accounts") + ), + responses( + (status = 200, description = "Account gefunden", body = Account), + (status = 401, description = "Authentifizierung fehlgeschlagen"), + (status = 404, description = "Kein Account zu dieser Personalnummer") + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn get_account( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Path(personalnummer): Path, +) -> Result, ApiError> { + tracing::debug!( + caller = claims.personalnummer, + target = personalnummer, + "get_account", + ); + let account = state.get_account.execute(personalnummer).await?; + Ok(Json(account)) +} diff --git a/crates/api/src/routes/cars.rs b/crates/api/src/routes/cars.rs new file mode 100644 index 0000000..2af4a36 --- /dev/null +++ b/crates/api/src/routes/cars.rs @@ -0,0 +1,108 @@ +use axum::Json; +use axum::Router; +use axum::extract::{Path, Query, State}; +use axum::routing::{get, patch}; +use holzleitner_application::dto::{ + CarResponse, CarsList, CreateCarRequest, UpdateCarRequest, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::error::ApiError; +use crate::extractors::AuthenticatedUser; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/me/cars", get(list_my_cars).post(create_my_car)) + .route("/me/cars/{car_id}", patch(update_my_car)) +} + +/// Query-Parameter für `GET /me/cars`. +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ListCarsQuery { + /// Default `false` — Endpoint liefert standardmäßig auch + /// deaktivierte Fahrzeuge **nicht** mit. + #[serde(default)] + pub include_inactive: bool, +} + +/// Listet die Fahrzeuge des angemeldeten Fahrers. +#[utoipa::path( + get, + path = "/me/cars", + tag = "cars", + params( + ("includeInactive" = Option, Query, description = "Wenn true, werden inaktive Fahrzeuge mitgeliefert (default: false)") + ), + responses( + (status = 200, description = "Fahrzeuge des Fahrers", body = CarsList), + (status = 401, description = "Authentifizierung fehlgeschlagen") + ), + security(("bearer_auth" = [])) +)] +pub async fn list_my_cars( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Query(query): Query, +) -> Result, ApiError> { + let cars = state + .list_my_cars + .execute(claims.personalnummer, query.include_inactive) + .await?; + Ok(Json(CarsList { cars })) +} + +/// Legt ein neues Fahrzeug für den angemeldeten Fahrer an. +#[utoipa::path( + post, + path = "/me/cars", + tag = "cars", + request_body = CreateCarRequest, + responses( + (status = 200, description = "Fahrzeug angelegt", body = CarResponse), + (status = 400, description = "Validierungsfehler (z. B. doppeltes Kennzeichen)"), + (status = 401, description = "Authentifizierung fehlgeschlagen") + ), + security(("bearer_auth" = [])) +)] +pub async fn create_my_car( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Json(req): Json, +) -> Result, ApiError> { + let car = state + .create_my_car + .execute(claims.personalnummer, req) + .await?; + Ok(Json(CarResponse { car })) +} + +/// Aktualisiert ein Fahrzeug (Kennzeichen ändern / deaktivieren). +#[utoipa::path( + patch, + path = "/me/cars/{car_id}", + tag = "cars", + params(("car_id" = Uuid, Path)), + request_body = UpdateCarRequest, + responses( + (status = 200, description = "Fahrzeug aktualisiert", body = CarResponse), + (status = 400, description = "Validierungsfehler"), + (status = 401, description = "Authentifizierung fehlgeschlagen"), + (status = 404, description = "Fahrzeug nicht gefunden oder gehört nicht zu diesem Account") + ), + security(("bearer_auth" = [])) +)] +pub async fn update_my_car( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Path(car_id): Path, + Json(req): Json, +) -> Result, ApiError> { + let car = state + .update_my_car + .execute(car_id, claims.personalnummer, req) + .await?; + Ok(Json(CarResponse { car })) +} diff --git a/crates/api/src/routes/deliveries.rs b/crates/api/src/routes/deliveries.rs new file mode 100644 index 0000000..d4bf8e5 --- /dev/null +++ b/crates/api/src/routes/deliveries.rs @@ -0,0 +1,207 @@ +use axum::Json; +use axum::Router; +use axum::extract::{Path, State}; +use axum::routing::{post, put}; +use holzleitner_application::dto::{ + AssignCarRequest, CancelDeliveryRequest, CreateDeliveryNoteRequest, DeliveryNoteResponse, + DeliveryResponse, HoldDeliveryRequest, +}; +use holzleitner_application::ports::DeliveryAction; +use uuid::Uuid; + +use crate::error::ApiError; +use crate::extractors::AuthenticatedUser; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/deliveries/{delivery_id}/hold", post(hold)) + .route("/deliveries/{delivery_id}/resume", post(resume)) + .route("/deliveries/{delivery_id}/cancel", post(cancel)) + .route("/deliveries/{delivery_id}/complete", post(complete)) + .route("/deliveries/{delivery_id}/notes", post(create_note)) + .route("/deliveries/{delivery_id}/assigned-car", put(assign_car)) +} + +/// Setzt die Lieferung auf `held`. Nur aus `active` zulässig. +#[utoipa::path( + post, + path = "/deliveries/{delivery_id}/hold", + tag = "deliveries", + params(("delivery_id" = Uuid, Path)), + request_body = HoldDeliveryRequest, + responses( + (status = 200, description = "Lieferung geholdet", body = DeliveryResponse), + (status = 400, description = "Invalider Statusübergang oder leerer Reason"), + (status = 401, description = "Authentifizierung fehlgeschlagen"), + (status = 404, description = "Lieferung nicht gefunden") + ), + security(("bearer_auth" = [])) +)] +pub async fn hold( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Path(delivery_id): Path, + Json(req): Json, +) -> Result, ApiError> { + tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.hold"); + let delivery = state + .apply_delivery_action + .execute(delivery_id, DeliveryAction::Hold { reason: req.reason }) + .await?; + Ok(Json(DeliveryResponse { delivery })) +} + +/// Setzt die Lieferung zurück auf `active`. Nur aus `held` zulässig. +#[utoipa::path( + post, + path = "/deliveries/{delivery_id}/resume", + tag = "deliveries", + params(("delivery_id" = Uuid, Path)), + responses( + (status = 200, description = "Lieferung wieder aktiv", body = DeliveryResponse), + (status = 400, description = "Invalider Statusübergang"), + (status = 401, description = "Authentifizierung fehlgeschlagen"), + (status = 404, description = "Lieferung nicht gefunden") + ), + security(("bearer_auth" = [])) +)] +pub async fn resume( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Path(delivery_id): Path, +) -> Result, ApiError> { + tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.resume"); + let delivery = state + .apply_delivery_action + .execute(delivery_id, DeliveryAction::Resume) + .await?; + Ok(Json(DeliveryResponse { delivery })) +} + +/// Setzt die Lieferung auf `canceled` — endgültig. Erlaubt aus +/// `active` und `held`. +#[utoipa::path( + post, + path = "/deliveries/{delivery_id}/cancel", + tag = "deliveries", + params(("delivery_id" = Uuid, Path)), + request_body = CancelDeliveryRequest, + responses( + (status = 200, description = "Lieferung storniert", body = DeliveryResponse), + (status = 400, description = "Invalider Statusübergang oder leerer Reason"), + (status = 401, description = "Authentifizierung fehlgeschlagen"), + (status = 404, description = "Lieferung nicht gefunden") + ), + security(("bearer_auth" = [])) +)] +pub async fn cancel( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Path(delivery_id): Path, + Json(req): Json, +) -> Result, ApiError> { + tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.cancel"); + let delivery = state + .apply_delivery_action + .execute( + delivery_id, + DeliveryAction::Cancel { reason: req.reason }, + ) + .await?; + Ok(Json(DeliveryResponse { delivery })) +} + +/// Schließt die Lieferung ab — `state = completed`. Nur aus `active`. +#[utoipa::path( + post, + path = "/deliveries/{delivery_id}/complete", + tag = "deliveries", + params(("delivery_id" = Uuid, Path)), + responses( + (status = 200, description = "Lieferung abgeschlossen", body = DeliveryResponse), + (status = 400, description = "Invalider Statusübergang"), + (status = 401, description = "Authentifizierung fehlgeschlagen"), + (status = 404, description = "Lieferung nicht gefunden") + ), + security(("bearer_auth" = [])) +)] +pub async fn complete( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Path(delivery_id): Path, +) -> Result, ApiError> { + tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.complete"); + let delivery = state + .apply_delivery_action + .execute(delivery_id, DeliveryAction::Complete) + .await?; + Ok(Json(DeliveryResponse { delivery })) +} + +/// Legt eine neue Notiz an einer Lieferung an. Mindestens eines von +/// `text` und `imageAttachment` muss inhaltlich gefüllt sein +/// (Leerstrings werden serverseitig getrimmt und als leer behandelt). +#[utoipa::path( + post, + path = "/deliveries/{delivery_id}/notes", + tag = "deliveries", + params(("delivery_id" = Uuid, Path)), + request_body = CreateDeliveryNoteRequest, + responses( + (status = 200, description = "Notiz angelegt", body = DeliveryNoteResponse), + (status = 400, description = "Notiz ohne Inhalt"), + (status = 401, description = "Authentifizierung fehlgeschlagen"), + (status = 404, description = "Lieferung nicht gefunden") + ), + security(("bearer_auth" = [])) +)] +pub async fn create_note( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Path(delivery_id): Path, + Json(req): Json, +) -> Result, ApiError> { + tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.create_note"); + let note = state + .create_delivery_note + .execute(delivery_id, claims.personalnummer, req) + .await?; + Ok(Json(DeliveryNoteResponse { note })) +} + +/// Setzt das `assigned_car_id` einer Lieferung. `carId: null` löst +/// die Zuordnung wieder. Der Use Case stellt sicher, dass das Fahrzeug +/// zum angemeldeten Account gehört. +#[utoipa::path( + put, + path = "/deliveries/{delivery_id}/assigned-car", + tag = "deliveries", + params(("delivery_id" = Uuid, Path)), + request_body = AssignCarRequest, + responses( + (status = 200, description = "Fahrzeug zugewiesen / entfernt", body = DeliveryResponse), + (status = 400, description = "Fahrzeug gehört nicht zum Account"), + (status = 401, description = "Authentifizierung fehlgeschlagen"), + (status = 404, description = "Lieferung nicht gefunden") + ), + security(("bearer_auth" = [])) +)] +pub async fn assign_car( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Path(delivery_id): Path, + Json(req): Json, +) -> Result, ApiError> { + tracing::info!( + actor = claims.personalnummer, + %delivery_id, + car_id = ?req.car_id, + "delivery.assign_car", + ); + let delivery = state + .assign_car_to_delivery + .execute(delivery_id, claims.personalnummer, req.car_id) + .await?; + Ok(Json(DeliveryResponse { delivery })) +} diff --git a/crates/api/src/routes/health.rs b/crates/api/src/routes/health.rs new file mode 100644 index 0000000..d3cc52a --- /dev/null +++ b/crates/api/src/routes/health.rs @@ -0,0 +1,23 @@ +use axum::Router; +use axum::routing::get; + +use crate::state::AppState; + +pub fn router() -> Router { + Router::new().route("/health", get(health)) +} + +/// Health-Endpoint für Load-Balancer und Container-Probes. Bewusst +/// kein Auth — eine `200 ok`-Antwort darf nicht von der Auth abhängen. +#[utoipa::path( + get, + path = "/health", + tag = "health", + responses( + (status = 200, description = "Service ist erreichbar", body = String) + ), + security() +)] +pub async fn health() -> &'static str { + "ok" +} diff --git a/crates/api/src/routes/mod.rs b/crates/api/src/routes/mod.rs new file mode 100644 index 0000000..95d5264 --- /dev/null +++ b/crates/api/src/routes/mod.rs @@ -0,0 +1,9 @@ +//! HTTP-Routen, gruppiert nach Domäne. Wird vom `main.rs`-Router +//! zusammengesetzt. + +pub mod accounts; +pub mod cars; +pub mod deliveries; +pub mod health; +pub mod scans; +pub mod tours; diff --git a/crates/api/src/routes/scans.rs b/crates/api/src/routes/scans.rs new file mode 100644 index 0000000..625d876 --- /dev/null +++ b/crates/api/src/routes/scans.rs @@ -0,0 +1,49 @@ +use axum::Json; +use axum::Router; +use axum::extract::State; +use axum::routing::post; +use holzleitner_application::dto::{ApplyScansRequest, ApplyScansResponse}; + +use crate::error::ApiError; +use crate::extractors::AuthenticatedUser; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new().route("/scans", post(apply_scans)) +} + +/// Wendet eine Liste von Scan-Events idempotent an. +/// +/// Pro Event ein eigenes Resultat. Status `applied` schreibt einen +/// frischen Audit-Eintrag, `duplicate` liefert den aktuellen Stand am +/// Server, `rejected` enthält die Begründung. Reihenfolge der `results` +/// entspricht der Reihenfolge der `scans` im Request. +#[utoipa::path( + post, + path = "/scans", + tag = "scans", + request_body = ApplyScansRequest, + responses( + (status = 200, description = "Bulk-Ergebnis pro Event", body = ApplyScansResponse), + (status = 401, description = "Authentifizierung fehlgeschlagen") + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn apply_scans( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Json(request): Json, +) -> Result, ApiError> { + tracing::info!( + actor = claims.personalnummer, + count = request.scans.len(), + "apply_scans", + ); + let response = state + .apply_scans + .execute(request, claims.personalnummer) + .await?; + Ok(Json(response)) +} diff --git a/crates/api/src/routes/tours.rs b/crates/api/src/routes/tours.rs new file mode 100644 index 0000000..f95c771 --- /dev/null +++ b/crates/api/src/routes/tours.rs @@ -0,0 +1,158 @@ +use axum::Json; +use axum::Router; +use axum::extract::{Path, State}; +use axum::routing::{get, post, put}; +use holzleitner_application::dto::{ + SetDeliveryOrderRequest, SetDeliveryOrderResponse, SyncTourRequest, TourDetails, TourSummary, +}; +use serde::Serialize; +use uuid::Uuid; + +use crate::error::ApiError; +use crate::extractors::AuthenticatedUser; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/me/tours/today", get(list_my_tours_today)) + .route("/tours/{tour_id}", get(get_tour)) + .route("/tours/{tour_id}/delivery-order", put(set_delivery_order)) + .route("/sync/tour", post(sync_tour)) +} + +/// Antwort-Hülle für `GET /me/tours/today`. Eigenes Struct, weil +/// utoipa für `Vec` als Top-Level-Response keinen sauberen +/// Schemanamen vergibt — und ein Wrapper macht die Erweiterbarkeit +/// (z. B. Paginierung in Zukunft) zur Nicht-Breaking-Change. +#[derive(Debug, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct TourSummaryList { + pub tours: Vec, +} + +/// Antwort-Hülle für `POST /sync/tour`. +#[derive(Debug, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SyncTourResponse { + pub tour_id: Uuid, +} + +/// Listet heutige Touren des angemeldeten Fahrers (Filter aus dem JWT). +#[utoipa::path( + get, + path = "/me/tours/today", + tag = "tours", + responses( + (status = 200, description = "Liste der heutigen Touren", body = TourSummaryList), + (status = 401, description = "Authentifizierung fehlgeschlagen") + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn list_my_tours_today( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, +) -> Result, ApiError> { + tracing::debug!(personalnummer = claims.personalnummer, "list_my_tours_today"); + let tours = state + .list_my_tours_today + .execute(claims.personalnummer) + .await?; + Ok(Json(TourSummaryList { tours })) +} + +/// Lädt eine Tour mit allen Lieferungen, Positionen und referenzierten +/// Stammdaten — die App nutzt das als einzigen großen Read. +#[utoipa::path( + get, + path = "/tours/{tour_id}", + tag = "tours", + params( + ("tour_id" = Uuid, Path, description = "Eindeutige Tour-Id (UUID)") + ), + responses( + (status = 200, description = "Tour-Aggregat gefunden", body = TourDetails), + (status = 401, description = "Authentifizierung fehlgeschlagen"), + (status = 404, description = "Keine Tour mit dieser Id") + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn get_tour( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Path(tour_id): Path, +) -> Result, ApiError> { + tracing::debug!(personalnummer = claims.personalnummer, %tour_id, "get_tour"); + let details = state.get_tour.execute(tour_id).await?; + Ok(Json(details)) +} + +/// Schreibt die Sortier-Reihenfolge aller Lieferungen einer Tour neu. +/// Der Client schickt die **vollständige** neue Reihenfolge; fehlende +/// oder fremde Lieferungs-Ids werden mit `400 validation` abgelehnt. +#[utoipa::path( + put, + path = "/tours/{tour_id}/delivery-order", + tag = "tours", + params(("tour_id" = Uuid, Path)), + request_body = SetDeliveryOrderRequest, + responses( + (status = 200, description = "Neue Reihenfolge gespeichert", body = SetDeliveryOrderResponse), + (status = 400, description = "Mengen-Mismatch oder Duplikate"), + (status = 401, description = "Authentifizierung fehlgeschlagen"), + (status = 404, description = "Tour nicht gefunden") + ), + security(("bearer_auth" = [])) +)] +pub async fn set_delivery_order( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Path(tour_id): Path, + Json(request): Json, +) -> Result, ApiError> { + tracing::info!( + actor = claims.personalnummer, + %tour_id, + count = request.delivery_ids.len(), + "set_delivery_order", + ); + let response = state.set_delivery_order.execute(tour_id, request).await?; + Ok(Json(response)) +} + +/// Sync-Endpoint für das ERP: legt eine Tagestour samt Lieferungen und +/// Positionen idempotent an. Identität pro Tour +/// `(driver_personalnummer, tour_date)`, pro Lieferung +/// `(belegart_id, belegnummer)`. +#[utoipa::path( + post, + path = "/sync/tour", + tag = "sync", + request_body = SyncTourRequest, + responses( + (status = 200, description = "Tour gespeichert", body = SyncTourResponse), + (status = 400, description = "Validierungsfehler im Sync-Payload"), + (status = 401, description = "Authentifizierung fehlgeschlagen") + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn sync_tour( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Json(request): Json, +) -> Result, ApiError> { + tracing::info!( + caller = claims.personalnummer, + driver = request.driver_personalnummer, + date = %request.tour_date, + deliveries = request.deliveries.len(), + "sync_tour", + ); + let tour_id = state.sync_tour.execute(request).await?; + Ok(Json(SyncTourResponse { tour_id })) +} diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs new file mode 100644 index 0000000..b6da606 --- /dev/null +++ b/crates/api/src/state.rs @@ -0,0 +1,33 @@ +use std::sync::Arc; + +use holzleitner_application::ports::AuthService; +use holzleitner_application::usecases::{ + ApplyDeliveryActionUseCase, ApplyScansUseCase, AssignCarToDeliveryUseCase, + CreateDeliveryNoteUseCase, CreateMyCarUseCase, GetAccountUseCase, GetTourUseCase, + ListMyCarsUseCase, ListMyToursTodayUseCase, SetDeliveryOrderUseCase, SyncTourUseCase, + UpdateMyCarUseCase, +}; + +/// Shared application state, der per Axum's `State`-Extractor in alle +/// Handler hineingegeben wird. Use Cases und Services liegen hinter +/// `Arc`, damit `Clone` billig ist und Requests sich keine Locks teilen. +/// +/// Das `AppState`-Klon wird zudem in die JWT-Middleware geschleust +/// (`from_fn_with_state`), die den `auth_service` nutzt — derselbe +/// Trait-Objekt-Arc wie die Handler. +#[derive(Clone)] +pub struct AppState { + pub get_account: Arc, + pub get_tour: Arc, + pub list_my_tours_today: Arc, + pub sync_tour: Arc, + pub set_delivery_order: Arc, + pub apply_scans: Arc, + pub apply_delivery_action: Arc, + pub create_delivery_note: Arc, + pub list_my_cars: Arc, + pub create_my_car: Arc, + pub update_my_car: Arc, + pub assign_car_to_delivery: Arc, + pub auth_service: Arc, +} diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml new file mode 100644 index 0000000..c76ead0 --- /dev/null +++ b/crates/application/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "holzleitner-application" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[features] +# Aktiviert utoipa::ToSchema-Derives auf DTOs + propagiert das Feature in +# das Domain-Crate, damit Antwort-Schemata vollständig generiert werden. +openapi = ["dep:utoipa", "holzleitner-domain/openapi"] + +[dependencies] +holzleitner-domain.workspace = true +serde.workspace = true +async-trait.workspace = true +thiserror.workspace = true +uuid.workspace = true +chrono.workspace = true +utoipa = { workspace = true, optional = true } diff --git a/crates/application/src/dto/car.rs b/crates/application/src/dto/car.rs new file mode 100644 index 0000000..83a7492 --- /dev/null +++ b/crates/application/src/dto/car.rs @@ -0,0 +1,50 @@ +//! Request- und Antwort-Typen für die Cars-Endpoints. +//! +//! Alle Cars-Endpoints sind aus Sicht des angemeldeten Fahrers ("/me/..."). +//! Der Account-Bezug kommt aus dem JWT, nicht aus dem Body. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use holzleitner_domain::Car; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct CreateCarRequest { + pub plate: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct UpdateCarRequest { + /// Wenn gesetzt: neues Kennzeichen. + pub plate: Option, + /// Wenn gesetzt: aktiv/inaktiv. Inaktive Fahrzeuge tauchen in + /// `GET /me/cars?activeOnly=true` (default) nicht auf. + pub active: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct CarResponse { + pub car: Car, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct CarsList { + pub cars: Vec, +} + +/// Setzt das `assigned_car_id` einer Lieferung. `None` (`carId: null`) +/// entfernt die Zuordnung. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AssignCarRequest { + pub car_id: Option, +} diff --git a/crates/application/src/dto/delivery_action.rs b/crates/application/src/dto/delivery_action.rs new file mode 100644 index 0000000..454b1cd --- /dev/null +++ b/crates/application/src/dto/delivery_action.rs @@ -0,0 +1,33 @@ +//! Eingaben und Antwort für die vier Delivery-Lifecycle-Endpunkte. +//! +//! Pro Endpoint genau ein eigener Request-Typ: +//! * Hold + Cancel brauchen einen Pflicht-Reason. +//! * Resume + Complete sind body-frei (App schickt leeres `{}`). +//! +//! Die Antwort liefert immer die frisch aktualisierte `Delivery` zurück, +//! damit die App ihre lokale Sicht in einem Schritt synchron halten kann. + +use serde::{Deserialize, Serialize}; + +use holzleitner_domain::Delivery; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct HoldDeliveryRequest { + pub reason: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct CancelDeliveryRequest { + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DeliveryResponse { + pub delivery: Delivery, +} diff --git a/crates/application/src/dto/delivery_order.rs b/crates/application/src/dto/delivery_order.rs new file mode 100644 index 0000000..4c02270 --- /dev/null +++ b/crates/application/src/dto/delivery_order.rs @@ -0,0 +1,35 @@ +//! Request/Response für `PUT /tours/{id}/delivery-order`. +//! +//! Der Client schickt **die vollständige neue Reihenfolge** aller +//! Lieferungen der Tour. Der Server validiert, dass die Menge der Ids +//! exakt zur Tour passt — fehlende oder fremde Ids werden hart abgelehnt. +//! Damit kann ein Client mit veralteten Daten nicht versehentlich +//! Lieferungen "verlieren". + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct SetDeliveryOrderRequest { + /// Reihenfolge: Position im Array (0-basiert) wird zu `sort_order` + /// (1-basiert) gemappt. + pub delivery_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct SetDeliveryOrderResponse { + pub tour_id: Uuid, + pub order: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DeliveryOrderEntry { + pub delivery_id: Uuid, + pub sort_order: i32, +} diff --git a/crates/application/src/dto/mod.rs b/crates/application/src/dto/mod.rs new file mode 100644 index 0000000..6be5f72 --- /dev/null +++ b/crates/application/src/dto/mod.rs @@ -0,0 +1,35 @@ +//! Data Transfer Objects der Application-Schicht. +//! +//! DTOs aggregieren Domänenobjekte zu **Use-Case-Antworten** und +//! beschreiben die Eingaben für Sync-Operationen. Sie bleiben bewusst +//! im Application-Layer (nicht im Domain-Layer), weil sie eine +//! Use-Case-Sicht auf die Daten sind, kein Stück Geschäftslogik — +//! ein anderer Use-Case darf eine andere Projektion liefern. +//! +//! Serde- und (optional) utoipa-Annotationen erlauben, dieselbe +//! Struktur direkt als HTTP-Antwort zu serialisieren — das spart eine +//! zweite Schicht handgeschriebener API-DTOs. + +pub mod car; +pub mod delivery_action; +pub mod delivery_order; +pub mod note; +pub mod scan; +pub mod tour_details; +pub mod tour_summary; +pub mod tour_sync; + +pub use car::{ + AssignCarRequest, CarResponse, CarsList, CreateCarRequest, UpdateCarRequest, +}; +pub use delivery_action::{CancelDeliveryRequest, DeliveryResponse, HoldDeliveryRequest}; +pub use delivery_order::{ + DeliveryOrderEntry, SetDeliveryOrderRequest, SetDeliveryOrderResponse, +}; +pub use note::{CreateDeliveryNoteRequest, DeliveryNoteResponse}; +pub use scan::{ + ApplyScansRequest, ApplyScansResponse, ScanEvent, ScanResult, ScanResultStatus, +}; +pub use tour_details::{DeliveryWithItems, TourDetails}; +pub use tour_summary::TourSummary; +pub use tour_sync::{SyncDelivery, SyncDeliveryItem, SyncTourRequest}; diff --git a/crates/application/src/dto/note.rs b/crates/application/src/dto/note.rs new file mode 100644 index 0000000..4b7cab8 --- /dev/null +++ b/crates/application/src/dto/note.rs @@ -0,0 +1,29 @@ +//! Request/Response für `POST /deliveries/{id}/notes`. +//! +//! Mindestens eines von `text` und `image_attachment` muss gesetzt +//! sein — die Constraint wird im Use Case geprüft und steht zusätzlich +//! als DB-CHECK. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use holzleitner_domain::DeliveryNote; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct CreateDeliveryNoteRequest { + pub text: Option, + /// Object-Storage-Key oder URL eines vorab hochgeladenen Bildes. + pub image_attachment: Option, + /// Fahrzeug, das die Notiz erzeugt hat. Muss zum angemeldeten + /// Account gehören. `None` ist erlaubt. + pub author_car_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DeliveryNoteResponse { + pub note: DeliveryNote, +} diff --git a/crates/application/src/dto/scan.rs b/crates/application/src/dto/scan.rs new file mode 100644 index 0000000..9c3a85a --- /dev/null +++ b/crates/application/src/dto/scan.rs @@ -0,0 +1,73 @@ +//! Bulk-Scan-Endpoint: Request, Response, pro-Event-Result. +//! +//! Idempotenz läuft über `client_scan_id`: ein UUID, das die App pro +//! erzeugtem Scan-Event genau einmal vergibt. Retry desselben Events +//! liefert `Duplicate` zurück. Pro Event ein eigener kleiner Vorgang — +//! ein einzelner Reject blockiert die anderen nicht. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use holzleitner_domain::{AuditAction, ScanState}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ApplyScansRequest { + pub scans: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ScanEvent { + pub client_scan_id: Uuid, + pub delivery_item_id: Uuid, + pub action: AuditAction, + /// Pflicht bei `Hold` und `Remove`. Sonst ignoriert. + pub reason: Option, + pub client_scanned_at: DateTime, + /// Fahrzeug, in dem der Scan gemacht wurde. Muss zum + /// angemeldeten Account gehören. `None` ist erlaubt, schwächt + /// aber den Audit-Trail. + pub actor_car_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ApplyScansResponse { + pub results: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "snake_case")] +pub enum ScanResultStatus { + /// Aktion wurde frisch angewendet; Audit-Eintrag geschrieben. + Applied, + /// `client_scan_id` war bereits bekannt. Item-State unverändert, + /// `scan_state` zeigt den aktuellen Stand am Server. + Duplicate, + /// Aktion wurde abgelehnt (z. B. unbekanntes Item, invalider + /// Statusübergang, fehlender Pflicht-Reason). `reason` füllt die + /// Begründung; `scan_state` ist `None`. + Rejected, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ScanResult { + pub client_scan_id: Uuid, + pub status: ScanResultStatus, + /// Bei `Rejected`: Begründung. Bei `Applied`/`Duplicate`: `None`. + pub reason: Option, + /// Aktueller `scan_state` der Position nach der Verarbeitung — + /// genau dann gesetzt, wenn der Server den Stand kennen konnte + /// (`Applied` oder `Duplicate`). Erlaubt der App, die UI ohne + /// Re-Fetch zu aktualisieren. + pub delivery_item_id: Option, + pub new_scan_state: Option, +} diff --git a/crates/application/src/dto/tour_details.rs b/crates/application/src/dto/tour_details.rs new file mode 100644 index 0000000..79ff8f2 --- /dev/null +++ b/crates/application/src/dto/tour_details.rs @@ -0,0 +1,41 @@ +//! Aggregat-Antwort für `GET /tours/{id}`. +//! +//! Vereint Tour, alle ihre Lieferungen mit Positionen sowie die +//! referenzierten Stammdaten (Kunden, Artikel, Lager) in einem +//! Payload. Die Stammdaten werden als deduplizierte Lookup-Listen +//! mitgeliefert — die App joint clientseitig per Id. Das spart +//! gegenüber vollständig denormalisierten Items spürbar Payload, +//! wenn die gleichen Artikel/Lager mehrfach vorkommen. + +use serde::Serialize; + +use holzleitner_domain::{ + Article, Customer, CustomerContact, Delivery, DeliveryItem, DeliveryNote, Tour, Warehouse, +}; + +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct TourDetails { + pub tour: Tour, + pub deliveries: Vec, + pub customers: Vec, + pub customer_contacts: Vec, + pub articles: Vec
, + pub warehouses: Vec, + /// Alle Notizen aller Lieferungen dieser Tour, in einer Liste. + /// Die App joint clientseitig per `delivery_id`. Reihenfolge: + /// pro Lieferung aufsteigend nach `created_at`. + pub notes: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DeliveryWithItems { + #[serde(flatten)] + pub delivery: Delivery, + /// Sortier-Reihenfolge innerhalb der Tour (1-basiert). + pub sort_order: i32, + pub items: Vec, +} diff --git a/crates/application/src/dto/tour_summary.rs b/crates/application/src/dto/tour_summary.rs new file mode 100644 index 0000000..b59f933 --- /dev/null +++ b/crates/application/src/dto/tour_summary.rs @@ -0,0 +1,17 @@ +//! Liste-Antwort für `GET /me/tours/today` — leichtgewichtig, ohne +//! Lieferpositionen. Die App nutzt diese Liste für die +//! Fahrzeug/Tour-Auswahl-Page und lädt erst nach der Auswahl die +//! vollständige Tour über `GET /tours/{id}`. + +use chrono::NaiveDate; +use serde::Serialize; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct TourSummary { + pub tour_id: Uuid, + pub tour_date: NaiveDate, + pub delivery_count: i64, +} diff --git a/crates/application/src/dto/tour_sync.rs b/crates/application/src/dto/tour_sync.rs new file mode 100644 index 0000000..b4a1827 --- /dev/null +++ b/crates/application/src/dto/tour_sync.rs @@ -0,0 +1,68 @@ +//! Request-Body für `POST /sync/tour`. +//! +//! Diese Struktur ist die Kontaktfläche zum ERP. Sie beschreibt eine +//! Tagestour eines Fahrers inklusive aller Lieferungen und Positionen. +//! Identität: +//! * Tour: `(driver_personalnummer, tour_date)` — Upsert. +//! * Delivery: `(belegart_id, belegnummer)` — Upsert. +//! * DeliveryItem: `(delivery, belegzeilen_nr, komponenten_artikel_nr)`. +//! +//! Bewusst keine UUIDs vom Sender erwartet — die ERP-Welt arbeitet mit +//! ihren eigenen business-stabilen Keys, wir generieren UUIDs intern. + +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; + +use holzleitner_domain::Address; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct SyncTourRequest { + pub driver_personalnummer: i64, + pub tour_date: NaiveDate, + pub deliveries: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct SyncDelivery { + pub belegart_id: i64, + pub belegnummer: String, + + pub erp_customer_id: i64, + pub customer_name: String, + pub customer_address: Address, + + /// Snapshot der Lieferadresse (kann von der Stammadresse abweichen). + pub delivery_address: Address, + + /// 1-basiert, definiert die initiale Reihenfolge in der App. + pub sort_order: i32, + + pub desired_time: Option, + pub special_agreements: Option, + + pub items: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct SyncDeliveryItem { + pub belegzeilen_nr: i32, + /// Komponenten-Artikelnummer bei aufgelösten Stücklisten, sonst leer. + pub komponenten_artikel_nr: Option, + + pub article_number: String, + pub article_name: String, + /// Default-Lager-Code für den Artikel (Anlage neuer Artikel). + pub article_default_warehouse_code: Option, + pub article_scannable: bool, + + pub warehouse_code: String, + pub warehouse_name: String, + + pub required_quantity: i32, +} diff --git a/crates/application/src/error.rs b/crates/application/src/error.rs new file mode 100644 index 0000000..67fa540 --- /dev/null +++ b/crates/application/src/error.rs @@ -0,0 +1,31 @@ +use thiserror::Error; + +/// Fehler-Hierarchie der Application-Schicht. +/// +/// Use Cases geben diesen Typ zurück. Die API-Schicht mappt ihn auf +/// HTTP-Statuscodes (`NotFound` → 404, `Unauthorized` → 401, …), +/// Persistence-Adapter mappen ihre konkreten Fehler nach +/// `Repository(...)` hinein. +#[derive(Debug, Error)] +pub enum ApplicationError { + #[error("not found")] + NotFound, + + #[error("unauthorized")] + Unauthorized, + + #[error("forbidden")] + Forbidden, + + #[error("validation: {0}")] + Validation(String), + + #[error("repository: {0}")] + Repository(String), + + #[error("external service: {0}")] + External(String), + + #[error("unexpected: {0}")] + Unexpected(String), +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs new file mode 100644 index 0000000..83f3dfb --- /dev/null +++ b/crates/application/src/lib.rs @@ -0,0 +1,17 @@ +//! Application Layer — Use Cases und Ports. +//! +//! Diese Crate kennt das [`holzleitner_domain`]-Modell und definiert +//! gegen welche **Ports** (Repository-Traits, Service-Traits) die Use +//! Cases programmieren. Konkrete Implementierungen leben in +//! `holzleitner-infrastructure` (Postgres, Keycloak, …). +//! +//! Diese Crate hat bewusst **keine** Abhängigkeit auf eine konkrete +//! Datenbank, ein HTTP-Framework oder einen Auth-Provider — sie soll +//! testbar und framework-unabhängig bleiben. + +pub mod dto; +pub mod error; +pub mod ports; +pub mod usecases; + +pub use error::ApplicationError; diff --git a/crates/application/src/ports/account_repository.rs b/crates/application/src/ports/account_repository.rs new file mode 100644 index 0000000..57c1082 --- /dev/null +++ b/crates/application/src/ports/account_repository.rs @@ -0,0 +1,17 @@ +use async_trait::async_trait; +use holzleitner_domain::Account; + +use crate::error::ApplicationError; + +/// Repository für [`Account`]-Lesezugriffe. Schreibzugriffe folgen +/// später, sobald sie fachlich gebraucht werden (Accounts werden in der +/// Regel aus dem ERP gespiegelt, nicht durch die App erzeugt). +#[async_trait] +pub trait AccountRepository: Send + Sync { + /// Liest einen Account anhand seiner Personalnummer. + /// Liefert `None`, wenn kein Datensatz existiert. + async fn find_by_personalnummer( + &self, + personalnummer: i64, + ) -> Result, ApplicationError>; +} diff --git a/crates/application/src/ports/auth_service.rs b/crates/application/src/ports/auth_service.rs new file mode 100644 index 0000000..b6d351f --- /dev/null +++ b/crates/application/src/ports/auth_service.rs @@ -0,0 +1,80 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use thiserror::Error; + +use crate::error::ApplicationError; + +/// Verifizierte Claims eines OAuth2/OIDC-Access-Tokens. +/// +/// Bewusst sparsam — nur das, was die Anwendung wirklich braucht. +/// Identität und Autorisierung leiten sich aus `personalnummer` + +/// `roles` ab; `subject` ist die Keycloak-User-UUID für Audit-/Log- +/// Zwecke. +#[derive(Debug, Clone)] +pub struct Claims { + /// Personalnummer aus dem Custom-Claim (User-Attribut). Identifiziert + /// den Account in unserer Welt (siehe [`holzleitner_domain::Account`]). + pub personalnummer: i64, + + /// Keycloak-User-UUID (`sub`). Stabil, opak — eignet sich für Logs, + /// nicht für fachliche Aussagen. + pub subject: String, + + /// Realm-Rollen (z. B. `driver`). Quelle für RBAC-Checks in Use Cases. + pub roles: Vec, + + /// Ablaufzeitpunkt aus `exp`. Nur informativ — die Signatur-Validierung + /// im Adapter prüft das bereits. + pub expires_at: DateTime, +} + +impl Claims { + /// Convenience-Check, ob der Anwender eine bestimmte Rolle trägt. + pub fn has_role(&self, role: &str) -> bool { + self.roles.iter().any(|r| r == role) + } +} + +/// Fehler des [`AuthService`]. Adapter-spezifische Details bleiben in den +/// Varianten, die API-Schicht mappt sie pauschal auf 401 (Unauthorized) — +/// außer `External`, das auf 500/502 mappt. +#[derive(Debug, Error)] +pub enum AuthError { + #[error("missing bearer token")] + MissingToken, + + #[error("token expired")] + Expired, + + #[error("invalid token: {0}")] + Invalid(String), + + #[error("audience mismatch")] + InvalidAudience, + + #[error("missing claim: {0}")] + MissingClaim(&'static str), + + /// Auth-Provider nicht erreichbar oder antwortet nicht plausibel. + /// Aus Anwendersicht ein 5xx, nicht ein 401. + #[error("external auth service: {0}")] + External(String), +} + +impl From for ApplicationError { + fn from(err: AuthError) -> Self { + match err { + AuthError::External(msg) => ApplicationError::External(msg), + _ => ApplicationError::Unauthorized, + } + } +} + +/// Verifiziert OAuth2/OIDC-Access-Tokens. Konkrete Implementierung in +/// `holzleitner-infrastructure` (Keycloak via OIDC-Discovery + JWKS). +#[async_trait] +pub trait AuthService: Send + Sync { + /// Validiert das übergebene Bearer-Token (ohne `Bearer `-Prefix) und + /// gibt die extrahierten Claims zurück. + async fn verify_token(&self, bearer_token: &str) -> Result; +} diff --git a/crates/application/src/ports/car_repository.rs b/crates/application/src/ports/car_repository.rs new file mode 100644 index 0000000..71a31f9 --- /dev/null +++ b/crates/application/src/ports/car_repository.rs @@ -0,0 +1,58 @@ +//! Port für Fahrzeug-Stammdaten. +//! +//! Alle Lese- und Schreibzugriffe gehen über `account_id` (= +//! Personalnummer aus dem JWT). Es gibt **keine** Methode, die +//! Fahrzeuge ohne Account-Filter zurückgibt — so ist die Isolation +//! zwischen Subunternehmer-Accounts strukturell garantiert. + +use async_trait::async_trait; +use uuid::Uuid; + +use holzleitner_domain::Car; + +use crate::error::ApplicationError; + +#[async_trait] +pub trait CarRepository: Send + Sync { + /// Liste der Fahrzeuge eines Accounts. `include_inactive = false` + /// blendet deaktivierte Fahrzeuge aus (Default für die App). + async fn find_by_account( + &self, + personalnummer: i64, + include_inactive: bool, + ) -> Result, ApplicationError>; + + /// Sucht ein Fahrzeug, das einem Account gehört. `None` wenn es + /// das Fahrzeug nicht gibt **oder** zu einem anderen Account + /// gehört — beides sieht für den Caller gleich aus, das ist + /// gewollt (kein Account-Probing). + async fn find_by_id_for_account( + &self, + car_id: Uuid, + personalnummer: i64, + ) -> Result, ApplicationError>; + + async fn create( + &self, + personalnummer: i64, + plate: &str, + ) -> Result; + + /// Optional-Patch. `None` heißt "unverändert". + async fn update( + &self, + car_id: Uuid, + personalnummer: i64, + plate: Option<&str>, + active: Option, + ) -> Result; + + /// Validiert, dass alle übergebenen IDs zum Account gehören. Nutzt + /// der Use-Case beim Bulk-Scan, um die `actor_car_id`-Werte + /// einmalig vor dem Verarbeiten zu prüfen. + async fn assert_owned_by_account( + &self, + car_ids: &[Uuid], + personalnummer: i64, + ) -> Result<(), ApplicationError>; +} diff --git a/crates/application/src/ports/delivery_note_repository.rs b/crates/application/src/ports/delivery_note_repository.rs new file mode 100644 index 0000000..084fc73 --- /dev/null +++ b/crates/application/src/ports/delivery_note_repository.rs @@ -0,0 +1,24 @@ +//! Port für Delivery-Notizen. +//! +//! Aktuell nur das Anlegen — der Read-Pfad läuft als Teil des Tour- +//! Aggregats (`TourDetails.notes`). Sollten irgendwann Listen-Reads +//! oder Updates an einzelnen Notizen nötig werden, kommen die hier rein. + +use async_trait::async_trait; +use uuid::Uuid; + +use holzleitner_domain::DeliveryNote; + +use crate::error::ApplicationError; + +#[async_trait] +pub trait DeliveryNoteRepository: Send + Sync { + async fn create( + &self, + delivery_id: Uuid, + author_personalnummer: i64, + author_car_id: Option, + text: Option, + image_attachment: Option, + ) -> Result; +} diff --git a/crates/application/src/ports/delivery_repository.rs b/crates/application/src/ports/delivery_repository.rs new file mode 100644 index 0000000..64ab289 --- /dev/null +++ b/crates/application/src/ports/delivery_repository.rs @@ -0,0 +1,44 @@ +//! Port für Delivery-Lifecycle-Übergänge. +//! +//! Eine schmale, aktionsbasierte Schnittstelle: der Use Case übergibt +//! eine [`DeliveryAction`], die Persistence führt SELECT-FOR-UPDATE, +//! validiert den Statusübergang und schreibt fort — alles in einer +//! Transaktion. Liefert die frisch aktualisierte [`Delivery`] zurück, +//! damit die API direkt antworten kann. + +use async_trait::async_trait; +use uuid::Uuid; + +use holzleitner_domain::Delivery; + +use crate::error::ApplicationError; + +#[derive(Debug, Clone)] +pub enum DeliveryAction { + /// State → `Held`, `state_reason` = `reason`. + Hold { reason: String }, + /// State → `Active`, `state_reason` = `NULL`. Nur aus `Held`. + Resume, + /// State → `Canceled`, `state_reason` = `reason`. Endgültig. + Cancel { reason: String }, + /// State → `Completed`, `state_reason` = `NULL`. Nur aus `Active`. + Complete, +} + +#[async_trait] +pub trait DeliveryRepository: Send + Sync { + async fn apply_action( + &self, + delivery_id: Uuid, + action: DeliveryAction, + ) -> Result; + + /// Setzt das `assigned_car_id` einer Lieferung. `None` löst die + /// Zuordnung. Der Use Case muss vorher prüfen, dass das + /// Fahrzeug zum angemeldeten Account gehört. + async fn assign_car( + &self, + delivery_id: Uuid, + car_id: Option, + ) -> Result; +} diff --git a/crates/application/src/ports/mod.rs b/crates/application/src/ports/mod.rs new file mode 100644 index 0000000..0334e45 --- /dev/null +++ b/crates/application/src/ports/mod.rs @@ -0,0 +1,22 @@ +//! Ports — die Trait-Definitionen, gegen die Use Cases programmieren. +//! +//! Hier landen Repository-Traits (z. B. `TourRepository`, +//! `DeliveryRepository`) sowie Service-Traits (z. B. `AuthService` für +//! Keycloak). Konkrete Implementierungen leben in +//! `holzleitner-infrastructure`. + +pub mod account_repository; +pub mod auth_service; +pub mod car_repository; +pub mod delivery_note_repository; +pub mod delivery_repository; +pub mod scan_repository; +pub mod tour_repository; + +pub use account_repository::AccountRepository; +pub use auth_service::{AuthError, AuthService, Claims}; +pub use car_repository::CarRepository; +pub use delivery_note_repository::DeliveryNoteRepository; +pub use delivery_repository::{DeliveryAction, DeliveryRepository}; +pub use scan_repository::{ApplyScanOutcome, ScanRepository}; +pub use tour_repository::TourRepository; diff --git a/crates/application/src/ports/scan_repository.rs b/crates/application/src/ports/scan_repository.rs new file mode 100644 index 0000000..293f384 --- /dev/null +++ b/crates/application/src/ports/scan_repository.rs @@ -0,0 +1,46 @@ +//! Port für das Anwenden von Scan-Events. +//! +//! Bewusst eine schmale Schnittstelle: ein einziger Aufruf +//! `apply_one` pro Event. Die Bulk-Logik (Iteration, Sammlung der +//! Ergebnisse) lebt im Use Case — die Persistence kennt nur die +//! atomare Operation und kann sie isoliert testen. +//! +//! `apply_one` MUSS idempotent über `client_scan_id` sein und darf +//! niemals den `scan_state` ändern, wenn der Audit-Eintrag schon +//! existiert. + +use async_trait::async_trait; +use uuid::Uuid; + +use holzleitner_domain::ScanState; + +use crate::dto::ScanEvent; +use crate::error::ApplicationError; + +/// Ergebnis einer einzelnen Scan-Operation aus Sicht der Persistence. +/// Der Use Case mappt das auf die `ScanResult`-Antwort der API. +#[derive(Debug, Clone)] +pub enum ApplyScanOutcome { + /// Frisch angewendet — neuer State der Position. + Applied { + delivery_item_id: Uuid, + new_state: ScanState, + }, + /// `client_scan_id` war bereits da. State unverändert, hier der + /// aktuelle Stand am Server für die App. + Duplicate { + delivery_item_id: Uuid, + current_state: ScanState, + }, + /// Abgelehnt (unbekanntes Item, invalider Statusübergang, …). + Rejected { reason: String }, +} + +#[async_trait] +pub trait ScanRepository: Send + Sync { + async fn apply_one( + &self, + event: &ScanEvent, + actor_personalnummer: i64, + ) -> Result; +} diff --git a/crates/application/src/ports/tour_repository.rs b/crates/application/src/ports/tour_repository.rs new file mode 100644 index 0000000..22fe377 --- /dev/null +++ b/crates/application/src/ports/tour_repository.rs @@ -0,0 +1,52 @@ +//! Repository-Port für Touren. +//! +//! Drei Operationen, die direkt drei Use Cases bedienen: +//! * `find_today_for_driver` — leichtgewichtige Liste (Auswahl-Page) +//! * `find_details_by_id` — fettes Aggregat (Sortier-/Beladen-/Auslieferung) +//! * `upsert_from_sync` — idempotente Übernahme aus dem ERP +//! +//! Das fette Aggregat hängen wir bewusst NICHT an die Sync-Operation, +//! weil das ERP nicht den aktuellen Scan-Status kennt und nichts darüber +//! aussagen darf. + +use async_trait::async_trait; +use chrono::NaiveDate; +use uuid::Uuid; + +use crate::dto::{ + DeliveryOrderEntry, SyncTourRequest, TourDetails, TourSummary, +}; +use crate::error::ApplicationError; + +#[async_trait] +pub trait TourRepository: Send + Sync { + async fn find_today_for_driver( + &self, + personalnummer: i64, + today: NaiveDate, + ) -> Result, ApplicationError>; + + async fn find_details_by_id( + &self, + tour_id: Uuid, + ) -> Result, ApplicationError>; + + /// Legt eine Tour samt Lieferungen und Positionen idempotent an + /// bzw. aktualisiert sie. Gibt die `tour_id` zurück. + async fn upsert_from_sync( + &self, + request: &SyncTourRequest, + ) -> Result; + + /// Schreibt die `sort_order` aller Lieferungen einer Tour neu. + /// + /// Validiert in einer Transaktion, dass die übergebene Id-Menge + /// **exakt** der aktuellen Lieferungs-Menge der Tour entspricht. + /// Fehlende, fremde oder doppelte Ids → `Validation`-Fehler. + /// Gibt die finale Reihenfolge zurück. + async fn set_delivery_order( + &self, + tour_id: Uuid, + delivery_ids: &[Uuid], + ) -> Result, ApplicationError>; +} diff --git a/crates/application/src/usecases/apply_delivery_action.rs b/crates/application/src/usecases/apply_delivery_action.rs new file mode 100644 index 0000000..ef67363 --- /dev/null +++ b/crates/application/src/usecases/apply_delivery_action.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; + +use uuid::Uuid; + +use holzleitner_domain::Delivery; + +use crate::error::ApplicationError; +use crate::ports::{DeliveryAction, DeliveryRepository}; + +/// Vier Lifecycle-Übergänge an einer Lieferung — `Hold`, `Resume`, +/// `Cancel`, `Complete`. Die Statusmaschine läuft im Repository unter +/// Lock; dieser Use Case validiert die Begründungs-Pflichten vorab. +pub struct ApplyDeliveryActionUseCase { + repository: Arc, +} + +impl ApplyDeliveryActionUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute( + &self, + delivery_id: Uuid, + action: DeliveryAction, + ) -> Result { + match &action { + DeliveryAction::Hold { reason } | DeliveryAction::Cancel { reason } => { + if reason.trim().is_empty() { + return Err(ApplicationError::Validation( + "reason darf nicht leer sein".into(), + )); + } + } + DeliveryAction::Resume | DeliveryAction::Complete => {} + } + + self.repository.apply_action(delivery_id, action).await + } +} diff --git a/crates/application/src/usecases/apply_scans.rs b/crates/application/src/usecases/apply_scans.rs new file mode 100644 index 0000000..0e8ab62 --- /dev/null +++ b/crates/application/src/usecases/apply_scans.rs @@ -0,0 +1,118 @@ +use std::collections::BTreeSet; +use std::sync::Arc; + +use holzleitner_domain::AuditAction; + +use crate::dto::{ + ApplyScansRequest, ApplyScansResponse, ScanEvent, ScanResult, ScanResultStatus, +}; +use crate::error::ApplicationError; +use crate::ports::{ApplyScanOutcome, CarRepository, ScanRepository}; + +/// Wendet eine Liste von Scan-Events idempotent an. +/// +/// Pro Event ein eigener Vorgang in der Persistence — ein einzelner +/// Reject blockiert die anderen nicht. Pflicht-Reasons (`Hold` / +/// `Remove`) werden hier vorab geprüft, ebenso die Ownership aller +/// in der Batch enthaltenen `actor_car_id`-Werte. +pub struct ApplyScansUseCase { + repository: Arc, + cars: Arc, +} + +impl ApplyScansUseCase { + pub fn new(repository: Arc, cars: Arc) -> Self { + Self { repository, cars } + } + + pub async fn execute( + &self, + request: ApplyScansRequest, + actor_personalnummer: i64, + ) -> Result { + // Distinct car_ids auf einmal validieren — eine Query statt + // pro-Event-Roundtrip. + let distinct_cars: BTreeSet = request + .scans + .iter() + .filter_map(|e| e.actor_car_id) + .collect(); + if !distinct_cars.is_empty() { + let ids: Vec = distinct_cars.into_iter().collect(); + self.cars + .assert_owned_by_account(&ids, actor_personalnummer) + .await?; + } + + let mut results = Vec::with_capacity(request.scans.len()); + + for event in &request.scans { + if let Some(reason) = pre_validate(event) { + results.push(ScanResult { + client_scan_id: event.client_scan_id, + status: ScanResultStatus::Rejected, + reason: Some(reason), + delivery_item_id: None, + new_scan_state: None, + }); + continue; + } + + let outcome = self + .repository + .apply_one(event, actor_personalnummer) + .await?; + + results.push(match outcome { + ApplyScanOutcome::Applied { + delivery_item_id, + new_state, + } => ScanResult { + client_scan_id: event.client_scan_id, + status: ScanResultStatus::Applied, + reason: None, + delivery_item_id: Some(delivery_item_id), + new_scan_state: Some(new_state), + }, + ApplyScanOutcome::Duplicate { + delivery_item_id, + current_state, + } => ScanResult { + client_scan_id: event.client_scan_id, + status: ScanResultStatus::Duplicate, + reason: None, + delivery_item_id: Some(delivery_item_id), + new_scan_state: Some(current_state), + }, + ApplyScanOutcome::Rejected { reason } => ScanResult { + client_scan_id: event.client_scan_id, + status: ScanResultStatus::Rejected, + reason: Some(reason), + delivery_item_id: None, + new_scan_state: None, + }, + }); + } + + Ok(ApplyScansResponse { results }) + } +} + +/// Validiert Pflichtfelder ohne DB-Aufruf. Liefert `Some(reason)`, +/// wenn das Event verworfen werden soll. +fn pre_validate(event: &ScanEvent) -> Option { + match event.action { + AuditAction::Hold | AuditAction::Remove => { + let trimmed = event.reason.as_deref().map(str::trim).unwrap_or(""); + if trimmed.is_empty() { + Some(format!( + "reason required for action {:?}", + event.action + )) + } else { + None + } + } + AuditAction::Scan | AuditAction::Unscan | AuditAction::Unhold => None, + } +} diff --git a/crates/application/src/usecases/cars.rs b/crates/application/src/usecases/cars.rs new file mode 100644 index 0000000..e5b14e3 --- /dev/null +++ b/crates/application/src/usecases/cars.rs @@ -0,0 +1,124 @@ +//! Use Cases rund um die Fahrzeug-Stammdaten eines Fahrers. +//! +//! Vier kleine Operationen — alle leichtgewichtig, weil die +//! Account-Isolation strukturell im Repository sitzt. + +use std::sync::Arc; + +use uuid::Uuid; + +use holzleitner_domain::Car; + +use crate::dto::{CreateCarRequest, UpdateCarRequest}; +use crate::error::ApplicationError; +use crate::ports::CarRepository; + +pub struct ListMyCarsUseCase { + repository: Arc, +} + +impl ListMyCarsUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute( + &self, + personalnummer: i64, + include_inactive: bool, + ) -> Result, ApplicationError> { + self.repository + .find_by_account(personalnummer, include_inactive) + .await + } +} + +pub struct CreateMyCarUseCase { + repository: Arc, +} + +impl CreateMyCarUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute( + &self, + personalnummer: i64, + request: CreateCarRequest, + ) -> Result { + let plate = request.plate.trim(); + if plate.is_empty() { + return Err(ApplicationError::Validation( + "plate darf nicht leer sein".into(), + )); + } + self.repository.create(personalnummer, plate).await + } +} + +pub struct UpdateMyCarUseCase { + repository: Arc, +} + +impl UpdateMyCarUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute( + &self, + car_id: Uuid, + personalnummer: i64, + request: UpdateCarRequest, + ) -> Result { + // Leerer Body ist erlaubt (idempotenter PATCH), aber wenn + // plate gesetzt ist, darf es nicht nur Whitespace sein. + let plate = match request.plate { + Some(p) => { + let trimmed = p.trim(); + if trimmed.is_empty() { + return Err(ApplicationError::Validation( + "plate darf nicht leer sein".into(), + )); + } + Some(trimmed.to_owned()) + } + None => None, + }; + + self.repository + .update(car_id, personalnummer, plate.as_deref(), request.active) + .await + } +} + +/// Setzt das `assigned_car_id` einer Lieferung. Validiert die +/// Fahrzeug-Ownership und delegiert dann an `DeliveryRepository`. +pub struct AssignCarToDeliveryUseCase { + cars: Arc, + deliveries: Arc, +} + +impl AssignCarToDeliveryUseCase { + pub fn new( + cars: Arc, + deliveries: Arc, + ) -> Self { + Self { cars, deliveries } + } + + pub async fn execute( + &self, + delivery_id: Uuid, + personalnummer: i64, + car_id: Option, + ) -> Result { + if let Some(id) = car_id { + self.cars + .assert_owned_by_account(&[id], personalnummer) + .await?; + } + self.deliveries.assign_car(delivery_id, car_id).await + } +} diff --git a/crates/application/src/usecases/create_delivery_note.rs b/crates/application/src/usecases/create_delivery_note.rs new file mode 100644 index 0000000..b805ee4 --- /dev/null +++ b/crates/application/src/usecases/create_delivery_note.rs @@ -0,0 +1,73 @@ +use std::sync::Arc; + +use uuid::Uuid; + +use holzleitner_domain::DeliveryNote; + +use crate::dto::CreateDeliveryNoteRequest; +use crate::error::ApplicationError; +use crate::ports::{CarRepository, DeliveryNoteRepository}; + +/// Legt eine neue Notiz an einer Lieferung an. +/// +/// Validierung: +/// * mindestens eines von `text` (nicht-leer nach trim) und +/// `image_attachment` (nicht-leer nach trim) muss gesetzt sein. +/// * `author_car_id` muss — falls gesetzt — zum angemeldeten Account gehören. +pub struct CreateDeliveryNoteUseCase { + repository: Arc, + cars: Arc, +} + +impl CreateDeliveryNoteUseCase { + pub fn new( + repository: Arc, + cars: Arc, + ) -> Self { + Self { repository, cars } + } + + pub async fn execute( + &self, + delivery_id: Uuid, + author_personalnummer: i64, + request: CreateDeliveryNoteRequest, + ) -> Result { + let text = clean(request.text); + let image = clean(request.image_attachment); + + if text.is_none() && image.is_none() { + return Err(ApplicationError::Validation( + "notiz braucht text oder image_attachment".into(), + )); + } + + if let Some(car_id) = request.author_car_id { + self.cars + .assert_owned_by_account(&[car_id], author_personalnummer) + .await?; + } + + self.repository + .create( + delivery_id, + author_personalnummer, + request.author_car_id, + text, + image, + ) + .await + } +} + +/// Trim + leerer-String → None. +fn clean(input: Option) -> Option { + input.and_then(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } + }) +} diff --git a/crates/application/src/usecases/get_account.rs b/crates/application/src/usecases/get_account.rs new file mode 100644 index 0000000..14c4ae5 --- /dev/null +++ b/crates/application/src/usecases/get_account.rs @@ -0,0 +1,28 @@ +use std::sync::Arc; + +use holzleitner_domain::Account; + +use crate::error::ApplicationError; +use crate::ports::AccountRepository; + +/// Liefert den Account zu einer Personalnummer oder einen +/// [`ApplicationError::NotFound`], wenn nichts gefunden wurde. +/// +/// Bewusst trivial — dient erstmal als End-to-End-Smoke-Test, damit +/// alle Schichten zusammen laufen. +pub struct GetAccountUseCase { + repository: Arc, +} + +impl GetAccountUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute(&self, personalnummer: i64) -> Result { + self.repository + .find_by_personalnummer(personalnummer) + .await? + .ok_or(ApplicationError::NotFound) + } +} diff --git a/crates/application/src/usecases/get_tour.rs b/crates/application/src/usecases/get_tour.rs new file mode 100644 index 0000000..9ed30b0 --- /dev/null +++ b/crates/application/src/usecases/get_tour.rs @@ -0,0 +1,26 @@ +use std::sync::Arc; + +use uuid::Uuid; + +use crate::dto::TourDetails; +use crate::error::ApplicationError; +use crate::ports::TourRepository; + +/// Liefert das vollständige Tour-Aggregat (Lieferungen, Positionen, +/// Stammdaten) für eine Tour-Id. Genau ein Round-Trip für die App. +pub struct GetTourUseCase { + repository: Arc, +} + +impl GetTourUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute(&self, tour_id: Uuid) -> Result { + self.repository + .find_details_by_id(tour_id) + .await? + .ok_or(ApplicationError::NotFound) + } +} diff --git a/crates/application/src/usecases/list_my_tours_today.rs b/crates/application/src/usecases/list_my_tours_today.rs new file mode 100644 index 0000000..e656757 --- /dev/null +++ b/crates/application/src/usecases/list_my_tours_today.rs @@ -0,0 +1,27 @@ +use std::sync::Arc; + +use chrono::Utc; + +use crate::dto::TourSummary; +use crate::error::ApplicationError; +use crate::ports::TourRepository; + +/// Liste der heutigen Touren des angemeldeten Fahrers. Das "heute" +/// liegt **bewusst im Backend**: die App-Uhr ist nicht autoritativ +/// (Zeitzone, Falsch-Stand, Manipulation). +pub struct ListMyToursTodayUseCase { + repository: Arc, +} + +impl ListMyToursTodayUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute(&self, personalnummer: i64) -> Result, ApplicationError> { + let today = Utc::now().date_naive(); + self.repository + .find_today_for_driver(personalnummer, today) + .await + } +} diff --git a/crates/application/src/usecases/mod.rs b/crates/application/src/usecases/mod.rs new file mode 100644 index 0000000..a37ec82 --- /dev/null +++ b/crates/application/src/usecases/mod.rs @@ -0,0 +1,28 @@ +//! Use Cases — Geschäftslogik-Operationen. +//! +//! Jeder Use Case kapselt **eine** Operation aus Sicht des Anwenders +//! (z. B. „Tour des Tages laden", „Artikel scannen", „Lieferung +//! abbrechen"). Use Cases nehmen Ports (Trait-Objekte) per Konstruktor +//! entgegen und orchestrieren damit das Domänenmodell. + +pub mod apply_delivery_action; +pub mod apply_scans; +pub mod cars; +pub mod create_delivery_note; +pub mod get_account; +pub mod get_tour; +pub mod list_my_tours_today; +pub mod set_delivery_order; +pub mod sync_tour; + +pub use apply_delivery_action::ApplyDeliveryActionUseCase; +pub use apply_scans::ApplyScansUseCase; +pub use cars::{ + AssignCarToDeliveryUseCase, CreateMyCarUseCase, ListMyCarsUseCase, UpdateMyCarUseCase, +}; +pub use create_delivery_note::CreateDeliveryNoteUseCase; +pub use get_account::GetAccountUseCase; +pub use get_tour::GetTourUseCase; +pub use list_my_tours_today::ListMyToursTodayUseCase; +pub use set_delivery_order::SetDeliveryOrderUseCase; +pub use sync_tour::SyncTourUseCase; diff --git a/crates/application/src/usecases/set_delivery_order.rs b/crates/application/src/usecases/set_delivery_order.rs new file mode 100644 index 0000000..5b98461 --- /dev/null +++ b/crates/application/src/usecases/set_delivery_order.rs @@ -0,0 +1,53 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use uuid::Uuid; + +use crate::dto::{SetDeliveryOrderRequest, SetDeliveryOrderResponse}; +use crate::error::ApplicationError; +use crate::ports::TourRepository; + +/// Schreibt die Sortier-Reihenfolge aller Lieferungen einer Tour neu. +/// +/// Eingabe-Validierung (vor DB-Aufruf): +/// * mindestens eine Id +/// * keine Duplikate +/// +/// Die Mengen-Übereinstimmung mit der Tour wird in der Persistence +/// geprüft (braucht DB-Kontext). +pub struct SetDeliveryOrderUseCase { + repository: Arc, +} + +impl SetDeliveryOrderUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute( + &self, + tour_id: Uuid, + request: SetDeliveryOrderRequest, + ) -> Result { + if request.delivery_ids.is_empty() { + return Err(ApplicationError::Validation( + "delivery_ids darf nicht leer sein".into(), + )); + } + let mut seen = HashSet::with_capacity(request.delivery_ids.len()); + for id in &request.delivery_ids { + if !seen.insert(*id) { + return Err(ApplicationError::Validation(format!( + "delivery_id {id} kommt mehrfach vor" + ))); + } + } + + let order = self + .repository + .set_delivery_order(tour_id, &request.delivery_ids) + .await?; + + Ok(SetDeliveryOrderResponse { tour_id, order }) + } +} diff --git a/crates/application/src/usecases/sync_tour.rs b/crates/application/src/usecases/sync_tour.rs new file mode 100644 index 0000000..f828472 --- /dev/null +++ b/crates/application/src/usecases/sync_tour.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; + +use uuid::Uuid; + +use crate::dto::SyncTourRequest; +use crate::error::ApplicationError; +use crate::ports::TourRepository; + +/// Übernimmt eine Tagestour aus dem ERP. Idempotent: wiederholte Aufrufe +/// mit gleichem `(driver_personalnummer, tour_date)` aktualisieren die +/// bestehende Tour, statt eine neue anzulegen. +/// +/// Validierung läuft hier in der Application-Schicht — die Repository- +/// Schicht darf einen sauberen Datensatz erwarten. +pub struct SyncTourUseCase { + repository: Arc, +} + +impl SyncTourUseCase { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn execute(&self, request: SyncTourRequest) -> Result { + if request.deliveries.is_empty() { + return Err(ApplicationError::Validation( + "tour ohne lieferungen abgelehnt".into(), + )); + } + for delivery in &request.deliveries { + if delivery.belegnummer.trim().is_empty() { + return Err(ApplicationError::Validation( + "leere belegnummer".into(), + )); + } + if delivery.items.is_empty() { + return Err(ApplicationError::Validation(format!( + "lieferung {} ohne positionen", + delivery.belegnummer + ))); + } + for item in &delivery.items { + if item.required_quantity <= 0 { + return Err(ApplicationError::Validation(format!( + "lieferung {}, position {}: required_quantity muss > 0 sein", + delivery.belegnummer, item.belegzeilen_nr + ))); + } + } + } + + self.repository.upsert_from_sync(&request).await + } +} diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml new file mode 100644 index 0000000..5f88001 --- /dev/null +++ b/crates/domain/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "holzleitner-domain" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[features] +# Opt-in: schaltet `utoipa::ToSchema`-Ableitungen für die OpenAPI- +# Generierung in der API-Schicht frei. Konsumenten ohne OpenAPI-Bedarf +# (z. B. CLI-Tools, Tests) bleiben utoipa-frei. +openapi = ["dep:utoipa"] + +[dependencies] +serde.workspace = true +uuid.workspace = true +chrono.workspace = true +utoipa = { workspace = true, optional = true } diff --git a/crates/domain/src/account.rs b/crates/domain/src/account.rs new file mode 100644 index 0000000..f54c8d4 --- /dev/null +++ b/crates/domain/src/account.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +/// Account eines Liefer-Unternehmens oder Einzel-Lieferfahrers. +/// +/// Die Personalnummer ist sowohl Primärschlüssel als auch Login-ID. Sie +/// stammt aus dem ERP-Stamm — entweder ein Unternehmen (juristische +/// Person, eigener Personalnummern-Kreis) oder eine natürliche Person. +/// +/// Mehrere physische Fahrer können denselben Account benutzen; das Modell +/// unterscheidet sie nicht, sondern loggt die Aktivität auf [`crate::domain::Car`]- +/// Ebene (siehe Audit-Log). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct Account { + pub personalnummer: i64, + pub name: String, + pub active: bool, +} diff --git a/crates/domain/src/article.rs b/crates/domain/src/article.rs new file mode 100644 index 0000000..4e15afb --- /dev/null +++ b/crates/domain/src/article.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Artikel. ERP-Mirror; die `article_number` ist die business-stabile +/// Artikelnummer aus dem ERP-Stamm und dient gleichzeitig als Brücke. +/// +/// `scannable = false` markiert nicht-physische Positionen wie +/// Dienstleistungen, Versandpauschalen o.ä. — sie tauchen zwar als +/// `DeliveryItem` auf, blockieren aber den Beladen-Fortschritt nicht. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct Article { + pub id: Uuid, + pub article_number: String, + pub name: String, + pub scannable: bool, + pub default_warehouse_id: Option, +} diff --git a/crates/domain/src/audit.rs b/crates/domain/src/audit.rs new file mode 100644 index 0000000..1d9f986 --- /dev/null +++ b/crates/domain/src/audit.rs @@ -0,0 +1,67 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::delivery::ScanStatus; + +/// Aktion-Typen im Scan-Audit-Log. +/// +/// * `Scan` / `Unscan` verändern die `scanned_quantity` (+1 / -1). +/// * `Hold` / `Unhold` ändern nur den Status, keine Menge. +/// * `Remove` markiert die Position als entfernt (Status `Removed`, +/// z. B. weil der Kunde sie nicht annimmt). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "snake_case")] +pub enum AuditAction { + Scan, + Unscan, + Hold, + Unhold, + Remove, +} + +/// Append-only Audit-Log-Eintrag: jedes Ereignis am Scan-Zustand einer +/// Position bekommt eine eigene Zeile. Nie geupdated, nie gelöscht. +/// +/// Beleg-Bezug wird denormalisiert mitgeführt, damit der Audit-Trail +/// auch dann auflösbar bleibt, wenn das zugehörige `DeliveryItem` +/// irgendwann archiviert oder bereinigt wird. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ScanAuditEntry { + pub id: Uuid, + /// Vom Client vergebene UUID — Idempotenz-Schlüssel beim Retry. + pub client_scan_id: Uuid, + pub delivery_item_id: Uuid, + + pub action: AuditAction, + + /// Signed Δ in `scanned_quantity`: +1 bei SCAN, -1 bei UNSCAN, 0 sonst. + pub delta: i32, + + /// Snapshot der `scanned_quantity` direkt NACH dieser Aktion. + /// Vermeidet teure Aggregat-Queries bei Reports. + pub resulting_quantity: i32, + + /// Status der Position NACH dieser Aktion. + pub resulting_status: ScanStatus, + + /// Grund bei HOLD / REMOVE (jeweils Pflicht). + pub reason: Option, + + /// Akteur — Personalnummer aus dem JWT. + pub actor_personalnummer: i64, + /// Akteur-Fahrzeug, sofern bekannt (cars werden später verwaltet). + pub actor_car_id: Option, + + pub client_scanned_at: DateTime, + pub server_recorded_at: DateTime, + + // ── Denormalisierter ERP-Beleg-Bezug (Archiv-stabil) ─────────────── + pub erp_belegart_id: i64, + pub erp_belegnummer: String, + pub erp_belegzeilen_nr: i32, + pub erp_komponenten_artikel_nr: Option, +} diff --git a/crates/domain/src/car.rs b/crates/domain/src/car.rs new file mode 100644 index 0000000..25c79cf --- /dev/null +++ b/crates/domain/src/car.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Fahrzeug eines [`crate::domain::Account`]. Wird in der App selbst +/// gepflegt — kein ERP-Spiegel. Eindeutig per UUID. +/// +/// Im Audit-Log ist der `Car` der „Akteur": die Personalnummer-Ebene +/// (Account) ist gröber und unterscheidet nicht zwischen mehreren +/// gleichzeitig aktiven Fahrern desselben Subunternehmens. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct Car { + pub id: Uuid, + /// Verweis auf [`crate::domain::Account::personalnummer`]. + pub account_id: i64, + pub plate: String, + pub active: bool, +} diff --git a/crates/domain/src/common.rs b/crates/domain/src/common.rs new file mode 100644 index 0000000..a741b18 --- /dev/null +++ b/crates/domain/src/common.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; + +/// Postanschrift — wird sowohl als aktuelle Kundenanschrift in [`Customer`] +/// als auch als unveränderlicher Snapshot in [`crate::domain::Delivery`] +/// verwendet (`delivery_address_snapshot`). +/// +/// Bewusst als Value Object modelliert: gleiche Adresse = gleicher Wert. +/// Strikte Equality erleichtert Sync-Diffs zwischen ERP und Backend. +/// +/// [`Customer`]: crate::domain::Customer +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct Address { + pub street: String, + pub house_number: String, + pub postal_code: String, + pub city: String, + pub country: String, +} diff --git a/crates/domain/src/customer.rs b/crates/domain/src/customer.rs new file mode 100644 index 0000000..38da9cb --- /dev/null +++ b/crates/domain/src/customer.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::common::Address; + +/// Kunde. ERP-Mirror: die Stammdaten gehören dem ERP, wir spiegeln sie +/// für die App. Die `erp_customer_id` ist die Brücke zurück (in der +/// Regel die `Kunde.row_id` aus ERPframe). +/// +/// Die `Customer.address` ist die *aktuelle* Anschrift. Für historische +/// Stabilität führt [`crate::domain::Delivery`] zusätzlich einen +/// `delivery_address_snapshot` — Adress-Änderungen wirken nicht +/// rückwirkend auf bereits zugestellte oder geplante Lieferungen. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct Customer { + pub id: Uuid, + pub erp_customer_id: i64, + pub name: String, + pub address: Address, +} + +/// Ansprechpartner eines Kunden. Ein Kunde kann mehrere Kontaktpersonen +/// haben (z. B. Empfang vor Ort + Geschäftsführung). Eine Lieferung wählt +/// 0..N davon als aktive Kontakte aus (siehe +/// `Delivery::contact_person_ids`). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct CustomerContact { + pub id: Uuid, + pub customer_id: Uuid, + pub name: String, + pub phone: Option, + pub email: Option, +} diff --git a/crates/domain/src/delivery.rs b/crates/domain/src/delivery.rs new file mode 100644 index 0000000..0b7b0d5 --- /dev/null +++ b/crates/domain/src/delivery.rs @@ -0,0 +1,135 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::common::Address; + +/// Lebenszyklus einer Lieferung. +/// +/// `Held` ist für „heute nicht zustellbar, aber nicht endgültig abgesagt" +/// reserviert; `Canceled` ist endgültig. `Completed` setzt der +/// Abschluss-Flow am Ende der Auslieferung. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "snake_case")] +pub enum DeliveryState { + Active, + Held, + Canceled, + Completed, +} + +/// Eine einzelne Lieferung an einen Kunden. Aggregat-Wurzel für die +/// Liefer-Items, Notizen und das ggf. zugeordnete Fahrzeug. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct Delivery { + pub id: Uuid, + pub tour_id: Uuid, + + /// ERP-Beleg-Bezug: business-stabiles Paar `(Belegart, Belegnummer)`. + /// Überlebt den Belegkopf-Archivübergang. + pub erp_belegart_id: i64, + pub erp_belegnummer: String, + + pub customer_id: Uuid, + + /// Eingefrorene Liefer-Adresse zum Zeitpunkt des Tour-Syncs. + /// Schützt vor rückwirkenden Kunden-Adressänderungen. + pub delivery_address_snapshot: Address, + + /// Fahrzeug-Zuordnung, gesetzt in der Auswählen-Phase. + /// Bei Ein-Auto-Teams beim Sync automatisch gefüllt. + pub assigned_car_id: Option, + + /// Ausgewählte Ansprechpartner für genau diese Lieferung (Auswahl + /// aus `Customer.contacts`). Kann leer sein. + pub contact_person_ids: Vec, + + /// Wunsch-Lieferzeit als Freitext (z. B. "vormittags", "ab 14:00"). + pub desired_time: Option, + + /// Sondervereinbarungen (z. B. „Türklingel defekt, hintenrum klopfen"). + pub special_agreements: Option, + + pub state: DeliveryState, + + /// Begründung bei `state == Held` oder `state == Canceled`. Beim + /// Resume / Complete wieder `None`. + pub state_reason: Option, +} + +/// Status einer einzelnen Scan-Position innerhalb eines Items. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "snake_case")] +pub enum ScanStatus { + InProgress, + Done, + Held, + Removed, +} + +/// Eingebetteter Scan-Zustand pro [`DeliveryItem`]. Wird durch +/// `ScanAuditEntry`-Events fortgeschrieben — das Audit-Log ist die +/// Wahrheit über das WIE und WANN, dieses Embedded-VO ist die schnelle +/// Wahrheit über das WIEVIEL. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ScanState { + pub scanned_quantity: i32, + pub status: ScanStatus, + /// Grund bei `status == Held` oder `status == Removed`. + pub held_reason: Option, + pub last_updated_at: DateTime, +} + +/// Einzelposition einer Lieferung. Vereint reguläre Belegzeilen und +/// Stücklisten-Komponenten zu einer flachen Liste — die Stücklisten- +/// Hierarchie ist ein ERP-Konstrukt und wird beim Sync aufgelöst. +/// +/// Über die Felder `belegzeilen_nr` und `komponenten_artikel_nr` bleibt +/// die ERP-Herkunft auflösbar. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DeliveryItem { + pub id: Uuid, + pub delivery_id: Uuid, + + pub article_id: Uuid, + pub required_quantity: i32, + pub warehouse_id: Uuid, + + /// ERP-Belegzeilen-Nr (Position innerhalb des Belegs). + pub belegzeilen_nr: i32, + + /// Bei Items aus einer Stückliste: Artikelnummer der Komponente. + /// Bei regulären Belegzeilen: `None`. + pub komponenten_artikel_nr: Option, + + pub scan_state: ScanState, +} + +/// Notiz an einer Lieferung — frei eingegeben durch den Fahrer. +/// +/// Mindestens eines von `text` oder `image_attachment` muss gesetzt +/// sein. Die Constraint sitzt sowohl im DB-Schema (CHECK) als auch +/// in der Application-Schicht. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DeliveryNote { + pub id: Uuid, + pub delivery_id: Uuid, + pub text: Option, + /// Referenz auf einen Bild-Anhang (z. B. Object-Storage-Key/URL). + pub image_attachment: Option, + /// Personalnummer des Akteurs (aus dem JWT). Pflicht. + pub author_personalnummer: i64, + /// Fahrzeug, falls bekannt — nullable bis das Backend Cars verwaltet. + pub author_car_id: Option, + pub created_at: DateTime, +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs new file mode 100644 index 0000000..4ccbe33 --- /dev/null +++ b/crates/domain/src/lib.rs @@ -0,0 +1,38 @@ +//! Domänenmodell des Lieferservice-Backends. +//! +//! Aufbau pro Aggregat-Wurzel bzw. zusammengehöriger Begriffsgruppe. +//! Reihenfolge der Module entspricht inhaltlich der Datenfluss-Logik: +//! Stamm- und Strukturdaten zuerst, danach transaktionale Aggregate +//! (Tour → Delivery → Audit), zuletzt der prozessuale Zustand. +//! +//! **Konventionen** +//! * Rust-Felder in `snake_case`, JSON via `serde` in `camelCase`. +//! * Enum-Varianten in `PascalCase`, JSON via `serde` in `snake_case`. +//! * Identifier vom ERP (Personalnummer, Belegart/-nummer, Artikelnummer) +//! behalten ihre fachlichen Namen, weil sie als Brücken im Datenmodell +//! erkennbar bleiben sollen. +//! * Eigene IDs sind UUIDs — entkoppelt vom ERP, generieren wir selbst. + +#![allow(dead_code)] // Modelle werden später von Service-Schicht genutzt. + +mod account; +mod article; +mod audit; +mod car; +mod common; +mod customer; +mod delivery; +mod process_state; +mod tour; +mod warehouse; + +pub use account::Account; +pub use article::Article; +pub use audit::{AuditAction, ScanAuditEntry}; +pub use car::Car; +pub use common::Address; +pub use customer::{Customer, CustomerContact}; +pub use delivery::{Delivery, DeliveryItem, DeliveryNote, DeliveryState, ScanState, ScanStatus}; +pub use process_state::{DeliveryPhase, DeliveryProcessState}; +pub use tour::Tour; +pub use warehouse::Warehouse; diff --git a/crates/domain/src/process_state.rs b/crates/domain/src/process_state.rs new file mode 100644 index 0000000..205692e --- /dev/null +++ b/crates/domain/src/process_state.rs @@ -0,0 +1,50 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Phasen des geführten Tagesablaufs eines Fahrers. +/// +/// Die Reihenfolge der Varianten entspricht dem natürlichen Fortschritt; +/// `PartialOrd`/`Ord` werden derived, sodass `current < max_reached` +/// als „besucht"-Check funktioniert. +/// +/// `Auswaehlen` ist optional und nur sichtbar bei Accounts mit mehreren +/// Fahrzeugen — die Eintrittsphase bei einem Ein-Auto-Account ist +/// `Sortieren`. +#[derive( + Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord, +)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "snake_case")] +pub enum DeliveryPhase { + Auswaehlen, + Sortieren, + Beladen, + Ausliefern, +} + +/// Prozess-State pro Fahrzeug pro Tag. Trägt sowohl die aktuelle Phase +/// als auch die höchste je erreichte Phase — damit Rück- und +/// Vorwärtssprünge zwischen besuchten Phasen erlaubt sind. +/// +/// `sorting_order` ist die in der Sortier-Phase festgelegte +/// Auslieferungsreihenfolge (Liste von [`crate::domain::Delivery`]-IDs). +/// Bei der Beladung wird sie umgekehrt benutzt (letzte Lieferung zuerst +/// ins Auto). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DeliveryProcessState { + pub id: Uuid, + pub car_id: Uuid, + pub date: NaiveDate, + + pub current_phase: DeliveryPhase, + pub max_reached_phase: DeliveryPhase, + + pub sorting_order: Vec, + + /// Gesetzt sobald die Sortierung bestätigt wurde — markiert den + /// Übergang von `Sortieren` nach `Beladen` als „offiziell". + pub confirmed_at: Option>, +} diff --git a/crates/domain/src/tour.rs b/crates/domain/src/tour.rs new file mode 100644 index 0000000..dde03c3 --- /dev/null +++ b/crates/domain/src/tour.rs @@ -0,0 +1,20 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Tour eines Tages, pro [`crate::domain::Account`]. Aggregat-Wurzel +/// für die Lieferungen dieses Tages — die einzelnen [`crate::domain::Delivery`] +/// referenzieren ihre Tour per FK. +/// +/// Der Sync vom ERP läuft in der Regel einmal am Vortag und füllt eine +/// neue Tour-Zeile inklusive Delivery- und DeliveryItem-Strukturen. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct Tour { + pub id: Uuid, + pub account_id: i64, + pub date: NaiveDate, + /// Zeitpunkt des letzten ERP-Sync — für Drift-Erkennung. + pub synced_at: DateTime, +} diff --git a/crates/domain/src/warehouse.rs b/crates/domain/src/warehouse.rs new file mode 100644 index 0000000..b5cda20 --- /dev/null +++ b/crates/domain/src/warehouse.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Lager. ERP-Mirror; `code` ist die ERP-Lager-Nr (z. B. `"0"` für das +/// Standardlager). Das `is_standard`-Flag ist der schnelle Filter für +/// die Beladen-Logik („nur Standardlager-Artikel zählen für Fertig"). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct Warehouse { + pub id: Uuid, + pub code: String, + pub name: String, + pub is_standard: bool, +} diff --git a/crates/infrastructure/Cargo.toml b/crates/infrastructure/Cargo.toml new file mode 100644 index 0000000..3faa587 --- /dev/null +++ b/crates/infrastructure/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "holzleitner-infrastructure" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +holzleitner-domain.workspace = true +holzleitner-application.workspace = true +async-trait.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true +tokio.workspace = true +sqlx.workspace = true +reqwest.workspace = true +jsonwebtoken.workspace = true +tracing.workspace = true diff --git a/crates/infrastructure/src/auth/keycloak.rs b/crates/infrastructure/src/auth/keycloak.rs new file mode 100644 index 0000000..0a0efe1 --- /dev/null +++ b/crates/infrastructure/src/auth/keycloak.rs @@ -0,0 +1,241 @@ +//! Keycloak-Implementierung von [`AuthService`]. +//! +//! ## Verifikations-Flow +//! +//! 1. OIDC-Discovery (`{issuer}/.well-known/openid-configuration`) holt +//! einmalig den `jwks_uri`. Wird gecached. +//! 2. JWKS (`jwks_uri`) liefert die aktuellen Signatur-Schlüssel. Cache +//! läuft nach `jwks_cache_ttl` ab, oder wird beim ersten unbekannten +//! `kid` proaktiv aktualisiert (Key-Rotation). +//! 3. JWT wird mit `jsonwebtoken` validiert: Signatur, `exp`, `iss`, +//! `aud`. Anschließend werden die fachlichen Claims extrahiert. +//! +//! ## Concurrency +//! +//! Der Cache hängt hinter `Arc>` (Tokio). Lese-Zugriffe +//! laufen lock-free auf Snapshot-Daten, Schreibzugriffe sind selten +//! (nur beim Refresh). + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use chrono::{TimeZone, Utc}; +use holzleitner_application::ports::{AuthError, AuthService, Claims}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; +use serde::Deserialize; +use tokio::sync::RwLock; + +/// Konfiguration für den Keycloak-Adapter. Befüllt typischerweise aus +/// der API-Konfig (`KEYCLOAK_*`-Env-Vars). +#[derive(Debug, Clone)] +pub struct KeycloakAdapterConfig { + /// Voll-qualifizierter Issuer (Realm-URL), z. B. + /// `http://localhost:8080/realms/holzleitner`. **Ohne** trailing slash. + pub issuer_url: String, + /// Erwartete Audience im Access-Token (= Client/Resource-Name). + pub audience: String, + /// Zeit, bevor der lokale JWKS-Cache zwangsweise neu gezogen wird. + pub jwks_cache_ttl: Duration, +} + +pub struct KeycloakAuthService { + config: KeycloakAdapterConfig, + http: reqwest::Client, + cache: Arc>, +} + +#[derive(Default)] +struct JwksCache { + /// `kid` → fertige DecodingKey zur Verifikation. + keys: HashMap, + /// Zeitpunkt des letzten erfolgreichen Refresh. + fetched_at: Option, + /// Aus OIDC-Discovery aufgelöste JWKS-URL — wird gemerkt. + jwks_uri: Option, +} + +impl KeycloakAuthService { + pub fn new(config: KeycloakAdapterConfig) -> Self { + Self { + config, + http: reqwest::Client::new(), + cache: Arc::new(RwLock::new(JwksCache::default())), + } + } + + async fn discover_jwks_uri(&self) -> Result { + let url = format!( + "{}/.well-known/openid-configuration", + self.config.issuer_url.trim_end_matches('/') + ); + let doc: OidcDiscovery = self + .http + .get(&url) + .send() + .await + .map_err(|e| AuthError::External(format!("OIDC discovery request: {e}")))? + .error_for_status() + .map_err(|e| AuthError::External(format!("OIDC discovery status: {e}")))? + .json() + .await + .map_err(|e| AuthError::External(format!("OIDC discovery body: {e}")))?; + Ok(doc.jwks_uri) + } + + async fn refresh_jwks(&self) -> Result<(), AuthError> { + // jwks_uri einmal auflösen und merken. + let jwks_uri = { + let read = self.cache.read().await; + read.jwks_uri.clone() + }; + let jwks_uri = match jwks_uri { + Some(u) => u, + None => { + let u = self.discover_jwks_uri().await?; + self.cache.write().await.jwks_uri = Some(u.clone()); + u + } + }; + + let jwks: Jwks = self + .http + .get(&jwks_uri) + .send() + .await + .map_err(|e| AuthError::External(format!("JWKS request: {e}")))? + .error_for_status() + .map_err(|e| AuthError::External(format!("JWKS status: {e}")))? + .json() + .await + .map_err(|e| AuthError::External(format!("JWKS body: {e}")))?; + + let mut next = HashMap::new(); + for k in jwks.keys { + // Wir unterstützen erstmal nur RSA (Keycloak-Default). + if k.kty != "RSA" { + continue; + } + let Some(kid) = k.kid else { continue }; + let key = DecodingKey::from_rsa_components(&k.n, &k.e) + .map_err(|e| AuthError::External(format!("RSA key decode: {e}")))?; + next.insert(kid, key); + } + + let mut write = self.cache.write().await; + write.keys = next; + write.fetched_at = Some(Instant::now()); + tracing::debug!(count = write.keys.len(), "jwks cache refreshed"); + Ok(()) + } + + async fn key_for_kid(&self, kid: &str) -> Result { + // 1) Lese-Pfad: wenn Cache frisch und kid bekannt → lock-free zurück. + { + let read = self.cache.read().await; + let expired = match read.fetched_at { + Some(t) => t.elapsed() > self.config.jwks_cache_ttl, + None => true, + }; + if !expired { + if let Some(k) = read.keys.get(kid) { + return Ok(k.clone()); + } + // Cache frisch, aber kid nicht drin → Key-Rotation? + // Trotzdem refreshen, könnte ein neuer Key sein. + } + } + + // 2) Refresh und nochmals nachsehen. + self.refresh_jwks().await?; + let read = self.cache.read().await; + read.keys + .get(kid) + .cloned() + .ok_or_else(|| AuthError::Invalid(format!("unknown kid: {kid}"))) + } +} + +#[async_trait] +impl AuthService for KeycloakAuthService { + async fn verify_token(&self, bearer_token: &str) -> Result { + let header = decode_header(bearer_token) + .map_err(|e| AuthError::Invalid(format!("header parse: {e}")))?; + let kid = header + .kid + .ok_or_else(|| AuthError::Invalid("missing kid".into()))?; + + let key = self.key_for_kid(&kid).await?; + + let mut validation = Validation::new(Algorithm::RS256); + validation.set_audience(&[&self.config.audience]); + validation.set_issuer(&[&self.config.issuer_url]); + validation.validate_exp = true; + + let token = decode::(bearer_token, &key, &validation).map_err(|e| { + match e.kind() { + jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::Expired, + jsonwebtoken::errors::ErrorKind::InvalidAudience => AuthError::InvalidAudience, + _ => AuthError::Invalid(format!("verify: {e}")), + } + })?; + + let raw = token.claims; + let personalnummer = raw + .personalnummer + .ok_or(AuthError::MissingClaim("personalnummer"))?; + let roles = raw + .realm_access + .map(|r| r.roles) + .unwrap_or_default(); + let expires_at = Utc + .timestamp_opt(raw.exp, 0) + .single() + .ok_or_else(|| AuthError::Invalid("invalid exp".into()))?; + + Ok(Claims { + personalnummer, + subject: raw.sub, + roles, + expires_at, + }) + } +} + +// ============================================================================= +// JSON-Hilfstypen +// ============================================================================= + +#[derive(Deserialize)] +struct OidcDiscovery { + jwks_uri: String, +} + +#[derive(Deserialize)] +struct Jwks { + keys: Vec, +} + +#[derive(Deserialize)] +struct JwksKey { + kid: Option, + kty: String, + n: String, + e: String, +} + +/// Rohformat der Token-Claims direkt aus dem JWT-Body. Wird unmittelbar +/// danach in das fachliche [`Claims`]-Struct übersetzt. +#[derive(Deserialize)] +struct RawClaims { + sub: String, + exp: i64, + personalnummer: Option, + realm_access: Option, +} + +#[derive(Deserialize)] +struct RealmAccess { + roles: Vec, +} diff --git a/crates/infrastructure/src/auth/mod.rs b/crates/infrastructure/src/auth/mod.rs new file mode 100644 index 0000000..d6296bd --- /dev/null +++ b/crates/infrastructure/src/auth/mod.rs @@ -0,0 +1,10 @@ +//! Auth-Adapter: Keycloak OAuth2/OIDC. +//! +//! Implementiert den `AuthService`-Port aus +//! `holzleitner_application::ports`. Discovery via OIDC-`/.well-known/`- +//! Endpunkt, JWT-Verifikation gegen den JWKS-Endpunkt, Claims-Mapping +//! auf das Domänenmodell (Personalnummer, Rollen). + +pub mod keycloak; + +pub use keycloak::{KeycloakAdapterConfig, KeycloakAuthService}; diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs new file mode 100644 index 0000000..10727c9 --- /dev/null +++ b/crates/infrastructure/src/lib.rs @@ -0,0 +1,13 @@ +//! Infrastructure Layer — konkrete Adapter zu externen Systemen. +//! +//! Implementiert die in `holzleitner_application::ports` definierten +//! Traits gegen reale Technologien: +//! * [`persistence`] — Postgres via sqlx +//! * [`auth`] — Keycloak OAuth2/OIDC via reqwest + jsonwebtoken +//! +//! Wer hier was Neues anhängt (z. B. einen Object-Storage-Adapter für +//! Note-Bilder), legt ein neues Submodul an und implementiert dort die +//! passenden Application-Ports. + +pub mod auth; +pub mod persistence; diff --git a/crates/infrastructure/src/persistence/account_repository.rs b/crates/infrastructure/src/persistence/account_repository.rs new file mode 100644 index 0000000..6103a7e --- /dev/null +++ b/crates/infrastructure/src/persistence/account_repository.rs @@ -0,0 +1,54 @@ +use async_trait::async_trait; +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::AccountRepository; +use holzleitner_domain::Account; +use sqlx::PgPool; + +/// SQLx-Postgres-Implementierung von [`AccountRepository`]. +pub struct PgAccountRepository { + pool: PgPool, +} + +impl PgAccountRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +/// Tabellen-Zeile aus `accounts`. Eigener Typ in der Infrastructure- +/// Schicht, damit der Domain-Typ [`Account`] frei von `sqlx`-Traits +/// bleibt. +#[derive(sqlx::FromRow)] +struct AccountRow { + personalnummer: i64, + name: String, + active: bool, +} + +impl From for Account { + fn from(row: AccountRow) -> Self { + Account { + personalnummer: row.personalnummer, + name: row.name, + active: row.active, + } + } +} + +#[async_trait] +impl AccountRepository for PgAccountRepository { + async fn find_by_personalnummer( + &self, + personalnummer: i64, + ) -> Result, ApplicationError> { + let row = sqlx::query_as::<_, AccountRow>( + "SELECT personalnummer, name, active FROM accounts WHERE personalnummer = $1", + ) + .bind(personalnummer) + .fetch_optional(&self.pool) + .await + .map_err(|e| ApplicationError::Repository(e.to_string()))?; + + Ok(row.map(Account::from)) + } +} diff --git a/crates/infrastructure/src/persistence/car_repository.rs b/crates/infrastructure/src/persistence/car_repository.rs new file mode 100644 index 0000000..a97ee64 --- /dev/null +++ b/crates/infrastructure/src/persistence/car_repository.rs @@ -0,0 +1,191 @@ +//! Postgres-Implementierung des `CarRepository`-Ports. +//! +//! Account-Isolation ist strukturell: jede SQL-Query filtert auf +//! `account_id`. Ein Fahrzeug, das einem anderen Account gehört, ist +//! aus Sicht dieses Repositories nicht existent (`NotFound`). + +use async_trait::async_trait; +use sqlx::PgPool; +use uuid::Uuid; + +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::CarRepository; +use holzleitner_domain::Car; + +pub struct PgCarRepository { + pool: PgPool, +} + +impl PgCarRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[derive(sqlx::FromRow)] +struct CarRow { + id: Uuid, + account_id: i64, + plate: String, + active: bool, +} + +impl From for Car { + fn from(r: CarRow) -> Self { + Car { + id: r.id, + account_id: r.account_id, + plate: r.plate, + active: r.active, + } + } +} + +fn db(e: E) -> ApplicationError { + ApplicationError::Repository(e.to_string()) +} + +/// Postgres-Fehlercode für UNIQUE-Verletzungen — wir mappen sie auf +/// einen lesbaren `Validation`-Fehler. +fn is_unique_violation(err: &sqlx::Error) -> bool { + matches!( + err, + sqlx::Error::Database(db_err) if db_err.code().as_deref() == Some("23505") + ) +} + +#[async_trait] +impl CarRepository for PgCarRepository { + async fn find_by_account( + &self, + personalnummer: i64, + include_inactive: bool, + ) -> Result, ApplicationError> { + let rows = sqlx::query_as::<_, CarRow>( + r#" + SELECT id, account_id, plate, active + FROM cars + WHERE account_id = $1 + AND (active = TRUE OR $2) + ORDER BY plate + "#, + ) + .bind(personalnummer) + .bind(include_inactive) + .fetch_all(&self.pool) + .await + .map_err(db)?; + Ok(rows.into_iter().map(Car::from).collect()) + } + + async fn find_by_id_for_account( + &self, + car_id: Uuid, + personalnummer: i64, + ) -> Result, ApplicationError> { + let row = sqlx::query_as::<_, CarRow>( + "SELECT id, account_id, plate, active FROM cars WHERE id = $1 AND account_id = $2", + ) + .bind(car_id) + .bind(personalnummer) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + Ok(row.map(Car::from)) + } + + async fn create( + &self, + personalnummer: i64, + plate: &str, + ) -> Result { + let result = sqlx::query_as::<_, CarRow>( + r#" + INSERT INTO cars (account_id, plate) + VALUES ($1, $2) + RETURNING id, account_id, plate, active + "#, + ) + .bind(personalnummer) + .bind(plate) + .fetch_one(&self.pool) + .await; + + match result { + Ok(row) => Ok(Car::from(row)), + Err(err) if is_unique_violation(&err) => Err(ApplicationError::Validation(format!( + "kennzeichen '{plate}' existiert bereits" + ))), + Err(err) => Err(db(err)), + } + } + + async fn update( + &self, + car_id: Uuid, + personalnummer: i64, + plate: Option<&str>, + active: Option, + ) -> Result { + // COALESCE-Pattern: NULL-Argumente lassen die Spalte unverändert. + let result = sqlx::query_as::<_, CarRow>( + r#" + UPDATE cars + SET plate = COALESCE($3, plate), + active = COALESCE($4, active) + WHERE id = $1 AND account_id = $2 + RETURNING id, account_id, plate, active + "#, + ) + .bind(car_id) + .bind(personalnummer) + .bind(plate) + .bind(active) + .fetch_optional(&self.pool) + .await; + + match result { + Ok(Some(row)) => Ok(Car::from(row)), + Ok(None) => Err(ApplicationError::NotFound), + Err(err) if is_unique_violation(&err) => Err(ApplicationError::Validation( + "kennzeichen existiert bereits".into(), + )), + Err(err) => Err(db(err)), + } + } + + async fn assert_owned_by_account( + &self, + car_ids: &[Uuid], + personalnummer: i64, + ) -> Result<(), ApplicationError> { + if car_ids.is_empty() { + return Ok(()); + } + let owned: Vec = sqlx::query_scalar( + r#" + SELECT id FROM cars + WHERE id = ANY($1) AND account_id = $2 + "#, + ) + .bind(car_ids) + .bind(personalnummer) + .fetch_all(&self.pool) + .await + .map_err(db)?; + + if owned.len() == car_ids.len() { + Ok(()) + } else { + let owned_set: std::collections::HashSet = owned.into_iter().collect(); + let foreign: Vec = car_ids + .iter() + .copied() + .filter(|id| !owned_set.contains(id)) + .collect(); + Err(ApplicationError::Validation(format!( + "fahrzeug(e) {foreign:?} gehören nicht zu diesem account" + ))) + } + } +} diff --git a/crates/infrastructure/src/persistence/delivery_note_repository.rs b/crates/infrastructure/src/persistence/delivery_note_repository.rs new file mode 100644 index 0000000..4a94f42 --- /dev/null +++ b/crates/infrastructure/src/persistence/delivery_note_repository.rs @@ -0,0 +1,76 @@ +//! Postgres-Implementierung des `DeliveryNoteRepository`-Ports. +//! +//! Existenz-Check der Lieferung vor dem Insert: liefert einen sauberen +//! `NotFound`, statt den FK-Verletzungs-Fehlertext durchzureichen. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use uuid::Uuid; + +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::DeliveryNoteRepository; +use holzleitner_domain::DeliveryNote; + +pub struct PgDeliveryNoteRepository { + pool: PgPool, +} + +impl PgDeliveryNoteRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +fn db(e: E) -> ApplicationError { + ApplicationError::Repository(e.to_string()) +} + +#[async_trait] +impl DeliveryNoteRepository for PgDeliveryNoteRepository { + async fn create( + &self, + delivery_id: Uuid, + author_personalnummer: i64, + author_car_id: Option, + text: Option, + image_attachment: Option, + ) -> Result { + let exists: Option = + sqlx::query_scalar("SELECT id FROM deliveries WHERE id = $1") + .bind(delivery_id) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + if exists.is_none() { + return Err(ApplicationError::NotFound); + } + + let (id, created_at): (Uuid, DateTime) = sqlx::query_as( + r#" + INSERT INTO delivery_notes ( + delivery_id, text, image_attachment, author_personalnummer, author_car_id + ) VALUES ($1, $2, $3, $4, $5) + RETURNING id, created_at + "#, + ) + .bind(delivery_id) + .bind(text.as_deref()) + .bind(image_attachment.as_deref()) + .bind(author_personalnummer) + .bind(author_car_id) + .fetch_one(&self.pool) + .await + .map_err(db)?; + + Ok(DeliveryNote { + id, + delivery_id, + text, + image_attachment, + author_personalnummer, + author_car_id, + created_at, + }) + } +} diff --git a/crates/infrastructure/src/persistence/delivery_repository.rs b/crates/infrastructure/src/persistence/delivery_repository.rs new file mode 100644 index 0000000..50c01ee --- /dev/null +++ b/crates/infrastructure/src/persistence/delivery_repository.rs @@ -0,0 +1,250 @@ +//! Postgres-Implementierung des `DeliveryRepository`-Ports. +//! +//! Ein Aktionsaufruf entspricht genau einer Transaktion. Ablauf: +//! 1. `SELECT … FOR UPDATE` auf die Lieferung (Lock + aktueller State). +//! 2. Übergang validieren — bei invalider Quelle `Validation`-Fehler. +//! 3. `UPDATE deliveries SET state = …, state_reason = …`. +//! 4. Kontaktpersonen-Verknüpfungen laden, frische `Delivery` bauen. +//! +//! Die Validierung lebt **hier**, weil sie den aktuellen DB-State +//! braucht. Im Use Case sitzt nur die Pflicht-Reason-Prüfung. + +use async_trait::async_trait; +use sqlx::{PgPool, Postgres, Transaction}; +use uuid::Uuid; + +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::{DeliveryAction, DeliveryRepository}; +use holzleitner_domain::{Address, Delivery, DeliveryState}; + +pub struct PgDeliveryRepository { + pool: PgPool, +} + +impl PgDeliveryRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[derive(sqlx::FromRow)] +struct DeliveryRow { + id: Uuid, + tour_id: Uuid, + erp_belegart_id: i64, + erp_belegnummer: String, + customer_id: Uuid, + snap_street: String, + snap_house_number: String, + snap_postal_code: String, + snap_city: String, + snap_country: String, + assigned_car_id: Option, + desired_time: Option, + special_agreements: Option, + state: String, +} + +fn db(e: E) -> ApplicationError { + ApplicationError::Repository(e.to_string()) +} + +fn parse_state(value: &str) -> Result { + match value { + "active" => Ok(DeliveryState::Active), + "held" => Ok(DeliveryState::Held), + "canceled" => Ok(DeliveryState::Canceled), + "completed" => Ok(DeliveryState::Completed), + other => Err(ApplicationError::Repository(format!( + "unknown delivery state '{other}'" + ))), + } +} + +fn state_str(s: DeliveryState) -> &'static str { + match s { + DeliveryState::Active => "active", + DeliveryState::Held => "held", + DeliveryState::Canceled => "canceled", + DeliveryState::Completed => "completed", + } +} + +/// Berechnet Ziel-State + neuen Reason aus aktueller Quelle und Aktion. +/// `Err(reason)` bei invalidem Übergang — wird vom Use Case als +/// `ApplicationError::Validation` durchgereicht. +fn next_state( + current: DeliveryState, + action: &DeliveryAction, +) -> Result<(DeliveryState, Option), String> { + use DeliveryAction as A; + use DeliveryState as S; + match (current, action) { + (S::Active, A::Hold { reason }) => Ok((S::Held, Some(reason.clone()))), + (S::Held, A::Resume) => Ok((S::Active, None)), + (S::Active | S::Held, A::Cancel { reason }) => Ok((S::Canceled, Some(reason.clone()))), + (S::Active, A::Complete) => Ok((S::Completed, None)), + + (S::Completed, _) => Err("delivery ist completed".into()), + (S::Canceled, _) => Err("delivery ist canceled".into()), + (s, a) => Err(format!( + "invalider übergang {:?} aus state {:?}", + a, s + )), + } +} + +async fn lock_delivery( + tx: &mut Transaction<'_, Postgres>, + delivery_id: Uuid, +) -> Result, ApplicationError> { + sqlx::query_as::<_, DeliveryRow>( + r#" + SELECT + id, tour_id, erp_belegart_id, erp_belegnummer, customer_id, + snap_street, snap_house_number, snap_postal_code, snap_city, snap_country, + assigned_car_id, desired_time, special_agreements, + state + FROM deliveries + WHERE id = $1 + FOR UPDATE + "#, + ) + .bind(delivery_id) + .fetch_optional(&mut **tx) + .await + .map_err(db) +} + +async fn load_contacts( + tx: &mut Transaction<'_, Postgres>, + delivery_id: Uuid, +) -> Result, ApplicationError> { + let rows: Vec<(Uuid,)> = sqlx::query_as( + "SELECT customer_contact_id FROM delivery_contact_persons WHERE delivery_id = $1", + ) + .bind(delivery_id) + .fetch_all(&mut **tx) + .await + .map_err(db)?; + Ok(rows.into_iter().map(|(id,)| id).collect()) +} + +#[async_trait] +impl DeliveryRepository for PgDeliveryRepository { + async fn apply_action( + &self, + delivery_id: Uuid, + action: DeliveryAction, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(db)?; + + let Some(row) = lock_delivery(&mut tx, delivery_id).await? else { + tx.rollback().await.map_err(db)?; + return Err(ApplicationError::NotFound); + }; + + let current = parse_state(&row.state)?; + let (target, new_reason) = match next_state(current, &action) { + Ok(v) => v, + Err(reason) => { + tx.rollback().await.map_err(db)?; + return Err(ApplicationError::Validation(reason)); + } + }; + + sqlx::query( + r#" + UPDATE deliveries + SET state = $1, + state_reason = $2 + WHERE id = $3 + "#, + ) + .bind(state_str(target)) + .bind(new_reason.as_deref()) + .bind(delivery_id) + .execute(&mut *tx) + .await + .map_err(db)?; + + let contact_person_ids = load_contacts(&mut tx, delivery_id).await?; + tx.commit().await.map_err(db)?; + + Ok(Delivery { + id: row.id, + tour_id: row.tour_id, + erp_belegart_id: row.erp_belegart_id, + erp_belegnummer: row.erp_belegnummer, + customer_id: row.customer_id, + delivery_address_snapshot: Address { + street: row.snap_street, + house_number: row.snap_house_number, + postal_code: row.snap_postal_code, + city: row.snap_city, + country: row.snap_country, + }, + assigned_car_id: row.assigned_car_id, + contact_person_ids, + desired_time: row.desired_time, + special_agreements: row.special_agreements, + state: target, + state_reason: new_reason, + }) + } + + async fn assign_car( + &self, + delivery_id: Uuid, + car_id: Option, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(db)?; + + let Some(row) = lock_delivery(&mut tx, delivery_id).await? else { + tx.rollback().await.map_err(db)?; + return Err(ApplicationError::NotFound); + }; + + let current = parse_state(&row.state)?; + + sqlx::query("UPDATE deliveries SET assigned_car_id = $1 WHERE id = $2") + .bind(car_id) + .bind(delivery_id) + .execute(&mut *tx) + .await + .map_err(db)?; + + // state_reason holen wir aus dem Lock-Row — die Aktion ändert + // nichts daran. + let state_reason: Option = + sqlx::query_scalar("SELECT state_reason FROM deliveries WHERE id = $1") + .bind(delivery_id) + .fetch_one(&mut *tx) + .await + .map_err(db)?; + + let contact_person_ids = load_contacts(&mut tx, delivery_id).await?; + tx.commit().await.map_err(db)?; + + Ok(Delivery { + id: row.id, + tour_id: row.tour_id, + erp_belegart_id: row.erp_belegart_id, + erp_belegnummer: row.erp_belegnummer, + customer_id: row.customer_id, + delivery_address_snapshot: Address { + street: row.snap_street, + house_number: row.snap_house_number, + postal_code: row.snap_postal_code, + city: row.snap_city, + country: row.snap_country, + }, + assigned_car_id: car_id, + contact_person_ids, + desired_time: row.desired_time, + special_agreements: row.special_agreements, + state: current, + state_reason, + }) + } +} diff --git a/crates/infrastructure/src/persistence/mod.rs b/crates/infrastructure/src/persistence/mod.rs new file mode 100644 index 0000000..45660bd --- /dev/null +++ b/crates/infrastructure/src/persistence/mod.rs @@ -0,0 +1,21 @@ +//! Persistence-Adapter: Postgres via sqlx. +//! +//! Jede Repository-Trait aus `holzleitner_application::ports` bekommt +//! hier eine konkrete `Pg…Repository`-Implementierung. Connection-Pool +//! und Migrations werden ebenfalls hier verwaltet. + +pub mod account_repository; +pub mod car_repository; +pub mod delivery_note_repository; +pub mod delivery_repository; +pub mod pool; +pub mod scan_repository; +pub mod tour_repository; + +pub use account_repository::PgAccountRepository; +pub use car_repository::PgCarRepository; +pub use delivery_note_repository::PgDeliveryNoteRepository; +pub use delivery_repository::PgDeliveryRepository; +pub use pool::{connect_and_migrate, PoolConfig}; +pub use scan_repository::PgScanRepository; +pub use tour_repository::PgTourRepository; diff --git a/crates/infrastructure/src/persistence/pool.rs b/crates/infrastructure/src/persistence/pool.rs new file mode 100644 index 0000000..e25b3d8 --- /dev/null +++ b/crates/infrastructure/src/persistence/pool.rs @@ -0,0 +1,26 @@ +use sqlx::PgPool; +use sqlx::postgres::PgPoolOptions; + +/// Konfigurationsparameter für den Postgres-Connection-Pool. Wird +/// typischerweise aus der API-Schicht (Composition Root) befüllt. +#[derive(Debug, Clone)] +pub struct PoolConfig { + pub url: String, + pub max_connections: u32, +} + +/// Baut den Postgres-Pool auf und führt alle ausstehenden Migrations +/// aus dem Workspace-Migrations-Verzeichnis aus. +/// +/// Die Migration-Definition ist über `sqlx::migrate!()` zur Compile-Zeit +/// eingebettet — kein zusätzlicher Dateizugriff zur Laufzeit nötig. +pub async fn connect_and_migrate(config: &PoolConfig) -> Result { + let pool = PgPoolOptions::new() + .max_connections(config.max_connections) + .connect(&config.url) + .await?; + + sqlx::migrate!("../../migrations").run(&pool).await?; + + Ok(pool) +} diff --git a/crates/infrastructure/src/persistence/scan_repository.rs b/crates/infrastructure/src/persistence/scan_repository.rs new file mode 100644 index 0000000..8427df7 --- /dev/null +++ b/crates/infrastructure/src/persistence/scan_repository.rs @@ -0,0 +1,319 @@ +//! Postgres-Implementierung des `ScanRepository`-Ports. +//! +//! Pro Event eine eigene Transaktion. Ablauf: +//! 1. `SELECT … FOR UPDATE` auf das Item (Lock + aktueller State). +//! 2. Zustandsübergang berechnen oder mit `Rejected` aussteigen. +//! 3. `INSERT INTO scan_audit … ON CONFLICT DO NOTHING RETURNING id` +//! * Hat die Insert eine Zeile geliefert → Audit ist frisch, +//! `UPDATE delivery_items` mit neuem State, commit, `Applied`. +//! * Sonst → `client_scan_id` war schon da, rollback der Tx mit dem +//! unveränderten Item, `Duplicate` mit dem unter (1) gelesenen State. +//! +//! Race-frei: zwei parallele Requests mit identischem `client_scan_id` +//! kollidieren an der UNIQUE-Constraint und bekommen sauber genau einen +//! `Applied` und einen `Duplicate`. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::{PgPool, Postgres, Transaction}; +use uuid::Uuid; + +use holzleitner_application::dto::ScanEvent; +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::{ApplyScanOutcome, ScanRepository}; +use holzleitner_domain::{AuditAction, ScanState, ScanStatus}; + +pub struct PgScanRepository { + pool: PgPool, +} + +impl PgScanRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +/// Komplette Sicht auf eine Position inklusive ERP-Bezug — was wir +/// für Zustandsübergang und Audit-Insert brauchen, in einer Query. +#[derive(sqlx::FromRow)] +struct ItemLockRow { + id: Uuid, + required_quantity: i32, + scanned_quantity: i32, + scan_status: String, + held_reason: Option, + scan_last_updated_at: DateTime, + belegzeilen_nr: i32, + komponenten_artikel_nr: Option, + erp_belegart_id: i64, + erp_belegnummer: String, +} + +fn db(e: E) -> ApplicationError { + ApplicationError::Repository(e.to_string()) +} + +fn parse_status(value: &str) -> Result { + match value { + "in_progress" => Ok(ScanStatus::InProgress), + "done" => Ok(ScanStatus::Done), + "held" => Ok(ScanStatus::Held), + "removed" => Ok(ScanStatus::Removed), + other => Err(ApplicationError::Repository(format!( + "unknown scan status '{other}'" + ))), + } +} + +fn status_str(s: ScanStatus) -> &'static str { + match s { + ScanStatus::InProgress => "in_progress", + ScanStatus::Done => "done", + ScanStatus::Held => "held", + ScanStatus::Removed => "removed", + } +} + +fn action_str(a: AuditAction) -> &'static str { + match a { + AuditAction::Scan => "scan", + AuditAction::Unscan => "unscan", + AuditAction::Hold => "hold", + AuditAction::Unhold => "unhold", + AuditAction::Remove => "remove", + } +} + +/// Ergebnis einer reinen Zustandsübergangs-Rechnung (ohne DB). +struct Transition { + delta: i32, + new_quantity: i32, + new_status: ScanStatus, + new_held_reason: Option, +} + +/// Berechnet den nächsten Zustand. Bei `Err` enthält der String die +/// fachliche Ablehnungs-Begründung, die 1:1 an die App geht. +fn apply_transition( + action: AuditAction, + current_qty: i32, + current_status: ScanStatus, + required_qty: i32, + reason: Option<&str>, +) -> Result { + match action { + AuditAction::Scan => match current_status { + ScanStatus::InProgress | ScanStatus::Done => { + let new_qty = current_qty + 1; + let new_status = if new_qty >= required_qty { + ScanStatus::Done + } else { + ScanStatus::InProgress + }; + Ok(Transition { + delta: 1, + new_quantity: new_qty, + new_status, + new_held_reason: None, + }) + } + ScanStatus::Held => Err("item is on hold; unhold before scanning".into()), + ScanStatus::Removed => Err("item is removed; cannot scan".into()), + }, + AuditAction::Unscan => match current_status { + ScanStatus::InProgress | ScanStatus::Done => { + if current_qty == 0 { + return Err("nothing scanned yet".into()); + } + let new_qty = current_qty - 1; + Ok(Transition { + delta: -1, + new_quantity: new_qty, + new_status: ScanStatus::InProgress, + new_held_reason: None, + }) + } + ScanStatus::Held => Err("item is on hold".into()), + ScanStatus::Removed => Err("item is removed".into()), + }, + AuditAction::Hold => match current_status { + ScanStatus::InProgress | ScanStatus::Done => Ok(Transition { + delta: 0, + new_quantity: current_qty, + new_status: ScanStatus::Held, + new_held_reason: reason.map(str::to_owned), + }), + ScanStatus::Held => Err("item is already held".into()), + ScanStatus::Removed => Err("item is removed".into()), + }, + AuditAction::Unhold => match current_status { + ScanStatus::Held => { + let new_status = if current_qty >= required_qty { + ScanStatus::Done + } else { + ScanStatus::InProgress + }; + Ok(Transition { + delta: 0, + new_quantity: current_qty, + new_status, + new_held_reason: None, + }) + } + _ => Err("item is not held".into()), + }, + AuditAction::Remove => match current_status { + ScanStatus::Removed => Err("item is already removed".into()), + _ => Ok(Transition { + delta: 0, + new_quantity: current_qty, + new_status: ScanStatus::Removed, + new_held_reason: reason.map(str::to_owned), + }), + }, + } +} + +async fn lock_item( + tx: &mut Transaction<'_, Postgres>, + delivery_item_id: Uuid, +) -> Result, ApplicationError> { + sqlx::query_as::<_, ItemLockRow>( + r#" + SELECT + di.id, + di.required_quantity, + di.scanned_quantity, + di.scan_status, + di.held_reason, + di.scan_last_updated_at, + di.belegzeilen_nr, + di.komponenten_artikel_nr, + d.erp_belegart_id, + d.erp_belegnummer + FROM delivery_items di + JOIN deliveries d ON d.id = di.delivery_id + WHERE di.id = $1 + FOR UPDATE OF di + "#, + ) + .bind(delivery_item_id) + .fetch_optional(&mut **tx) + .await + .map_err(db) +} + +#[async_trait] +impl ScanRepository for PgScanRepository { + async fn apply_one( + &self, + event: &ScanEvent, + actor_personalnummer: i64, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(db)?; + + let Some(item) = lock_item(&mut tx, event.delivery_item_id).await? else { + tx.rollback().await.map_err(db)?; + return Ok(ApplyScanOutcome::Rejected { + reason: format!("unknown delivery_item_id {}", event.delivery_item_id), + }); + }; + + let current_status = parse_status(&item.scan_status)?; + let current_state = ScanState { + scanned_quantity: item.scanned_quantity, + status: current_status, + held_reason: item.held_reason.clone(), + last_updated_at: item.scan_last_updated_at, + }; + + let transition = match apply_transition( + event.action, + item.scanned_quantity, + current_status, + item.required_quantity, + event.reason.as_deref(), + ) { + Ok(t) => t, + Err(reason) => { + tx.rollback().await.map_err(db)?; + return Ok(ApplyScanOutcome::Rejected { reason }); + } + }; + + // Audit-Insert. ON CONFLICT DO NOTHING macht die Idempotenz: ist + // die client_scan_id schon da, returnen wir keine Zeile. + let inserted_id: Option = sqlx::query_scalar( + r#" + INSERT INTO scan_audit ( + client_scan_id, delivery_item_id, action, + delta, resulting_quantity, resulting_status, + reason, actor_personalnummer, actor_car_id, client_scanned_at, + erp_belegart_id, erp_belegnummer, erp_belegzeilen_nr, + erp_komponenten_artikel_nr + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + ON CONFLICT (client_scan_id) DO NOTHING + RETURNING id + "#, + ) + .bind(event.client_scan_id) + .bind(item.id) + .bind(action_str(event.action)) + .bind(transition.delta) + .bind(transition.new_quantity) + .bind(status_str(transition.new_status)) + .bind(transition.new_held_reason.as_deref()) + .bind(actor_personalnummer) + .bind(event.actor_car_id) + .bind(event.client_scanned_at) + .bind(item.erp_belegart_id) + .bind(&item.erp_belegnummer) + .bind(item.belegzeilen_nr) + .bind(item.komponenten_artikel_nr.as_deref()) + .fetch_optional(&mut *tx) + .await + .map_err(db)?; + + if inserted_id.is_none() { + // Duplicate: alles zurück, aktueller State (vor diesem Versuch) + // an die App. + tx.rollback().await.map_err(db)?; + return Ok(ApplyScanOutcome::Duplicate { + delivery_item_id: item.id, + current_state, + }); + } + + // Applied: Item-State fortschreiben. + let new_last_updated: DateTime = sqlx::query_scalar( + r#" + UPDATE delivery_items + SET scanned_quantity = $1, + scan_status = $2, + held_reason = $3, + scan_last_updated_at = now() + WHERE id = $4 + RETURNING scan_last_updated_at + "#, + ) + .bind(transition.new_quantity) + .bind(status_str(transition.new_status)) + .bind(transition.new_held_reason.as_deref()) + .bind(item.id) + .fetch_one(&mut *tx) + .await + .map_err(db)?; + + tx.commit().await.map_err(db)?; + + Ok(ApplyScanOutcome::Applied { + delivery_item_id: item.id, + new_state: ScanState { + scanned_quantity: transition.new_quantity, + status: transition.new_status, + held_reason: transition.new_held_reason, + last_updated_at: new_last_updated, + }, + }) + } +} diff --git a/crates/infrastructure/src/persistence/tour_repository.rs b/crates/infrastructure/src/persistence/tour_repository.rs new file mode 100644 index 0000000..b68a755 --- /dev/null +++ b/crates/infrastructure/src/persistence/tour_repository.rs @@ -0,0 +1,815 @@ +//! Postgres-Implementierung des `TourRepository`-Ports. +//! +//! Drei Operationen, getrennt umgesetzt: +//! * `find_today_for_driver` — eine Query (Tour + Lieferzahl pro Tour). +//! * `find_details_by_id` — pro Aggregat ein paar gezielte Queries, +//! anschließend in-memory zusammenbauen. Bewusst keine eine-Big-Join- +//! Query: das vervielfacht Daten über die Leitung, die wir clientseitig +//! wieder deduplizieren müssten — und Postgres handhabt 5–7 kleine +//! Prepared Statements im selben Pool effizient genug. +//! * `upsert_from_sync` — eine Transaktion, idempotent per UPSERT auf +//! den fachlichen Keys. Bestehende `scan_state`-Werte bleiben +//! unangetastet: das ERP weiß nichts davon und darf sie nicht +//! überschreiben. + +use std::collections::HashMap; + +use async_trait::async_trait; +use chrono::{DateTime, NaiveDate, Utc}; +use sqlx::{PgPool, Postgres, Transaction}; +use uuid::Uuid; + +use holzleitner_application::dto::{ + DeliveryOrderEntry, DeliveryWithItems, SyncDelivery, SyncDeliveryItem, SyncTourRequest, + TourDetails, TourSummary, +}; +use holzleitner_application::error::ApplicationError; +use holzleitner_application::ports::TourRepository; +use holzleitner_domain::{ + Address, Article, Customer, CustomerContact, Delivery, DeliveryItem, DeliveryNote, + DeliveryState, ScanState, ScanStatus, Tour, Warehouse, +}; + +pub struct PgTourRepository { + pool: PgPool, +} + +impl PgTourRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +// ===== Row-Typen ========================================================= +// +// Eigene FromRow-Strukturen in der Infrastructure halten die Domain-Typen +// frei von `sqlx`-Traits. Mapping in private Helfer. + +#[derive(sqlx::FromRow)] +struct TourRow { + id: Uuid, + account_id: i64, + tour_date: NaiveDate, + synced_at: DateTime, +} + +#[derive(sqlx::FromRow)] +struct TourSummaryRow { + id: Uuid, + tour_date: NaiveDate, + delivery_count: i64, +} + +#[derive(sqlx::FromRow)] +struct DeliveryRow { + id: Uuid, + tour_id: Uuid, + erp_belegart_id: i64, + erp_belegnummer: String, + customer_id: Uuid, + snap_street: String, + snap_house_number: String, + snap_postal_code: String, + snap_city: String, + snap_country: String, + assigned_car_id: Option, + desired_time: Option, + special_agreements: Option, + state: String, + state_reason: Option, + sort_order: i32, +} + +#[derive(sqlx::FromRow)] +struct DeliveryItemRow { + id: Uuid, + delivery_id: Uuid, + article_id: Uuid, + required_quantity: i32, + warehouse_id: Uuid, + belegzeilen_nr: i32, + komponenten_artikel_nr: Option, + scanned_quantity: i32, + scan_status: String, + held_reason: Option, + scan_last_updated_at: DateTime, +} + +#[derive(sqlx::FromRow)] +struct CustomerRow { + id: Uuid, + erp_customer_id: i64, + name: String, + street: String, + house_number: String, + postal_code: String, + city: String, + country: String, +} + +#[derive(sqlx::FromRow)] +struct CustomerContactRow { + id: Uuid, + customer_id: Uuid, + name: String, + phone: Option, + email: Option, +} + +#[derive(sqlx::FromRow)] +struct ArticleRow { + id: Uuid, + article_number: String, + name: String, + scannable: bool, + default_warehouse_id: Option, +} + +#[derive(sqlx::FromRow)] +struct WarehouseRow { + id: Uuid, + code: String, + name: String, + is_standard: bool, +} + +#[derive(sqlx::FromRow)] +struct ContactLinkRow { + delivery_id: Uuid, + customer_contact_id: Uuid, +} + +#[derive(sqlx::FromRow)] +struct DeliveryNoteRow { + id: Uuid, + delivery_id: Uuid, + text: Option, + image_attachment: Option, + author_personalnummer: i64, + author_car_id: Option, + created_at: DateTime, +} + +// ===== Mapping Row -> Domain ============================================= + +fn map_tour(row: TourRow) -> Tour { + Tour { + id: row.id, + account_id: row.account_id, + date: row.tour_date, + synced_at: row.synced_at, + } +} + +fn parse_delivery_state(value: &str) -> Result { + match value { + "active" => Ok(DeliveryState::Active), + "held" => Ok(DeliveryState::Held), + "canceled" => Ok(DeliveryState::Canceled), + "completed" => Ok(DeliveryState::Completed), + other => Err(ApplicationError::Repository(format!( + "unknown delivery state '{other}'" + ))), + } +} + +fn parse_scan_status(value: &str) -> Result { + match value { + "in_progress" => Ok(ScanStatus::InProgress), + "done" => Ok(ScanStatus::Done), + "held" => Ok(ScanStatus::Held), + "removed" => Ok(ScanStatus::Removed), + other => Err(ApplicationError::Repository(format!( + "unknown scan status '{other}'" + ))), + } +} + +fn map_item(row: DeliveryItemRow) -> Result { + Ok(DeliveryItem { + id: row.id, + delivery_id: row.delivery_id, + article_id: row.article_id, + required_quantity: row.required_quantity, + warehouse_id: row.warehouse_id, + belegzeilen_nr: row.belegzeilen_nr, + komponenten_artikel_nr: row.komponenten_artikel_nr, + scan_state: ScanState { + scanned_quantity: row.scanned_quantity, + status: parse_scan_status(&row.scan_status)?, + held_reason: row.held_reason, + last_updated_at: row.scan_last_updated_at, + }, + }) +} + +fn map_customer(row: CustomerRow) -> Customer { + Customer { + id: row.id, + erp_customer_id: row.erp_customer_id, + name: row.name, + address: Address { + street: row.street, + house_number: row.house_number, + postal_code: row.postal_code, + city: row.city, + country: row.country, + }, + } +} + +fn map_contact(row: CustomerContactRow) -> CustomerContact { + CustomerContact { + id: row.id, + customer_id: row.customer_id, + name: row.name, + phone: row.phone, + email: row.email, + } +} + +fn map_article(row: ArticleRow) -> Article { + Article { + id: row.id, + article_number: row.article_number, + name: row.name, + scannable: row.scannable, + default_warehouse_id: row.default_warehouse_id, + } +} + +fn map_note(row: DeliveryNoteRow) -> DeliveryNote { + DeliveryNote { + id: row.id, + delivery_id: row.delivery_id, + text: row.text, + image_attachment: row.image_attachment, + author_personalnummer: row.author_personalnummer, + author_car_id: row.author_car_id, + created_at: row.created_at, + } +} + +fn map_warehouse(row: WarehouseRow) -> Warehouse { + Warehouse { + id: row.id, + code: row.code, + name: row.name, + is_standard: row.is_standard, + } +} + +fn map_delivery( + row: DeliveryRow, + contact_person_ids: Vec, +) -> Result<(Delivery, i32), ApplicationError> { + let state = parse_delivery_state(&row.state)?; + let delivery = Delivery { + id: row.id, + tour_id: row.tour_id, + erp_belegart_id: row.erp_belegart_id, + erp_belegnummer: row.erp_belegnummer, + customer_id: row.customer_id, + delivery_address_snapshot: Address { + street: row.snap_street, + house_number: row.snap_house_number, + postal_code: row.snap_postal_code, + city: row.snap_city, + country: row.snap_country, + }, + assigned_car_id: row.assigned_car_id, + contact_person_ids, + desired_time: row.desired_time, + special_agreements: row.special_agreements, + state, + state_reason: row.state_reason, + }; + Ok((delivery, row.sort_order)) +} + +// ===== Helfer: Error-Mapping ============================================= + +fn db(e: E) -> ApplicationError { + ApplicationError::Repository(e.to_string()) +} + +// ===== Trait-Implementierung ============================================= + +#[async_trait] +impl TourRepository for PgTourRepository { + async fn find_today_for_driver( + &self, + personalnummer: i64, + today: NaiveDate, + ) -> Result, ApplicationError> { + let rows = sqlx::query_as::<_, TourSummaryRow>( + r#" + SELECT t.id, t.tour_date, COUNT(d.id) AS delivery_count + FROM tours t + LEFT JOIN deliveries d ON d.tour_id = t.id + WHERE t.account_id = $1 AND t.tour_date = $2 + GROUP BY t.id, t.tour_date + ORDER BY t.tour_date + "#, + ) + .bind(personalnummer) + .bind(today) + .fetch_all(&self.pool) + .await + .map_err(db)?; + + Ok(rows + .into_iter() + .map(|r| TourSummary { + tour_id: r.id, + tour_date: r.tour_date, + delivery_count: r.delivery_count, + }) + .collect()) + } + + async fn find_details_by_id( + &self, + tour_id: Uuid, + ) -> Result, ApplicationError> { + // 1. Tour selbst + let Some(tour_row) = sqlx::query_as::<_, TourRow>( + "SELECT id, account_id, tour_date, synced_at FROM tours WHERE id = $1", + ) + .bind(tour_id) + .fetch_optional(&self.pool) + .await + .map_err(db)? + else { + return Ok(None); + }; + let tour = map_tour(tour_row); + + // 2. Lieferungen + let delivery_rows = sqlx::query_as::<_, DeliveryRow>( + r#" + SELECT + id, tour_id, erp_belegart_id, erp_belegnummer, customer_id, + snap_street, snap_house_number, snap_postal_code, snap_city, snap_country, + assigned_car_id, desired_time, special_agreements, + state, state_reason, sort_order + FROM deliveries + WHERE tour_id = $1 + ORDER BY sort_order, erp_belegnummer + "#, + ) + .bind(tour_id) + .fetch_all(&self.pool) + .await + .map_err(db)?; + + let delivery_ids: Vec = delivery_rows.iter().map(|d| d.id).collect(); + + // 3. Kontakt-Person-Verknüpfungen + let contact_links = sqlx::query_as::<_, ContactLinkRow>( + r#" + SELECT delivery_id, customer_contact_id + FROM delivery_contact_persons + WHERE delivery_id = ANY($1) + "#, + ) + .bind(&delivery_ids) + .fetch_all(&self.pool) + .await + .map_err(db)?; + + let mut contacts_per_delivery: HashMap> = HashMap::new(); + for link in contact_links { + contacts_per_delivery + .entry(link.delivery_id) + .or_default() + .push(link.customer_contact_id); + } + + // 4. Positionen + let item_rows = sqlx::query_as::<_, DeliveryItemRow>( + r#" + SELECT + id, delivery_id, article_id, required_quantity, warehouse_id, + belegzeilen_nr, komponenten_artikel_nr, + scanned_quantity, scan_status, held_reason, scan_last_updated_at + FROM delivery_items + WHERE delivery_id = ANY($1) + ORDER BY delivery_id, belegzeilen_nr, komponenten_artikel_nr NULLS FIRST + "#, + ) + .bind(&delivery_ids) + .fetch_all(&self.pool) + .await + .map_err(db)?; + + let mut items_per_delivery: HashMap> = HashMap::new(); + let mut article_ids = std::collections::BTreeSet::new(); + let mut warehouse_ids = std::collections::BTreeSet::new(); + for row in item_rows { + article_ids.insert(row.article_id); + warehouse_ids.insert(row.warehouse_id); + let delivery_id = row.delivery_id; + let item = map_item(row)?; + items_per_delivery + .entry(delivery_id) + .or_default() + .push(item); + } + + // 5. Lieferungen + Items kombinieren + let mut customer_ids = std::collections::BTreeSet::new(); + let mut deliveries = Vec::with_capacity(delivery_rows.len()); + for row in delivery_rows { + customer_ids.insert(row.customer_id); + let delivery_id = row.id; + let contact_ids = contacts_per_delivery.remove(&delivery_id).unwrap_or_default(); + let items = items_per_delivery.remove(&delivery_id).unwrap_or_default(); + let (delivery, sort_order) = map_delivery(row, contact_ids)?; + deliveries.push(DeliveryWithItems { + delivery, + sort_order, + items, + }); + } + + // 6. Lookup-Stammdaten + let customer_ids_vec: Vec = customer_ids.into_iter().collect(); + let customers = sqlx::query_as::<_, CustomerRow>( + r#" + SELECT id, erp_customer_id, name, street, house_number, postal_code, city, country + FROM customers + WHERE id = ANY($1) + ORDER BY name + "#, + ) + .bind(&customer_ids_vec) + .fetch_all(&self.pool) + .await + .map_err(db)? + .into_iter() + .map(map_customer) + .collect::>(); + + let customer_contacts = sqlx::query_as::<_, CustomerContactRow>( + r#" + SELECT id, customer_id, name, phone, email + FROM customer_contacts + WHERE customer_id = ANY($1) + ORDER BY name + "#, + ) + .bind(&customer_ids_vec) + .fetch_all(&self.pool) + .await + .map_err(db)? + .into_iter() + .map(map_contact) + .collect::>(); + + let article_ids_vec: Vec = article_ids.into_iter().collect(); + let articles = sqlx::query_as::<_, ArticleRow>( + r#" + SELECT id, article_number, name, scannable, default_warehouse_id + FROM articles + WHERE id = ANY($1) + ORDER BY article_number + "#, + ) + .bind(&article_ids_vec) + .fetch_all(&self.pool) + .await + .map_err(db)? + .into_iter() + .map(map_article) + .collect::>(); + + let warehouse_ids_vec: Vec = warehouse_ids.into_iter().collect(); + let warehouses = sqlx::query_as::<_, WarehouseRow>( + r#" + SELECT id, code, name, is_standard + FROM warehouses + WHERE id = ANY($1) + ORDER BY code + "#, + ) + .bind(&warehouse_ids_vec) + .fetch_all(&self.pool) + .await + .map_err(db)? + .into_iter() + .map(map_warehouse) + .collect::>(); + + // 7. Notizen aller Lieferungen dieser Tour. + let notes = sqlx::query_as::<_, DeliveryNoteRow>( + r#" + SELECT id, delivery_id, text, image_attachment, + author_personalnummer, author_car_id, created_at + FROM delivery_notes + WHERE delivery_id = ANY($1) + ORDER BY delivery_id, created_at + "#, + ) + .bind(&delivery_ids) + .fetch_all(&self.pool) + .await + .map_err(db)? + .into_iter() + .map(map_note) + .collect::>(); + + Ok(Some(TourDetails { + tour, + deliveries, + customers, + customer_contacts, + articles, + warehouses, + notes, + })) + } + + async fn set_delivery_order( + &self, + tour_id: Uuid, + delivery_ids: &[Uuid], + ) -> Result, ApplicationError> { + let mut tx = self.pool.begin().await.map_err(db)?; + + // 1. Lock alle Lieferungen dieser Tour. Liefert leer, wenn Tour + // nicht existiert oder keine Lieferungen hat. + let existing: Vec = sqlx::query_scalar( + "SELECT id FROM deliveries WHERE tour_id = $1 FOR UPDATE", + ) + .bind(tour_id) + .fetch_all(&mut *tx) + .await + .map_err(db)?; + + if existing.is_empty() { + tx.rollback().await.map_err(db)?; + return Err(ApplicationError::NotFound); + } + + // 2. Mengen-Match: Input muss exakt der Tour entsprechen. + let existing_set: std::collections::HashSet = existing.iter().copied().collect(); + let input_set: std::collections::HashSet = delivery_ids.iter().copied().collect(); + if existing_set != input_set { + tx.rollback().await.map_err(db)?; + let fremde: Vec = + input_set.difference(&existing_set).copied().collect(); + let fehlende: Vec = + existing_set.difference(&input_set).copied().collect(); + return Err(ApplicationError::Validation(format!( + "delivery_ids match nicht zur tour (fehlende: {fehlende:?}, fremde: {fremde:?})" + ))); + } + + // 3. Bulk-Update via UNNEST. + let positions: Vec = (1..=delivery_ids.len() as i32).collect(); + sqlx::query( + r#" + UPDATE deliveries AS d + SET sort_order = data.new_order + FROM ( + SELECT UNNEST($1::uuid[]) AS id, + UNNEST($2::int[]) AS new_order + ) AS data + WHERE d.id = data.id + "#, + ) + .bind(delivery_ids) + .bind(&positions) + .execute(&mut *tx) + .await + .map_err(db)?; + + tx.commit().await.map_err(db)?; + + Ok(delivery_ids + .iter() + .zip(positions.iter()) + .map(|(id, pos)| DeliveryOrderEntry { + delivery_id: *id, + sort_order: *pos, + }) + .collect()) + } + + async fn upsert_from_sync( + &self, + request: &SyncTourRequest, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(db)?; + + // 1. Tour upserten — Identität: (account_id, tour_date) + let tour_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO tours (account_id, tour_date) + VALUES ($1, $2) + ON CONFLICT (account_id, tour_date) DO UPDATE + SET synced_at = now() + RETURNING id + "#, + ) + .bind(request.driver_personalnummer) + .bind(request.tour_date) + .fetch_one(&mut *tx) + .await + .map_err(db)?; + + for delivery in &request.deliveries { + // 2. Kunde upserten — Identität: erp_customer_id + let customer_id = upsert_customer(&mut tx, delivery).await?; + + // 3. Lieferung upserten — Identität: (belegart_id, belegnummer). + // Bestehende Lieferung bleibt mit ihrem state/cancellation + // erhalten; nur Stammdaten + sort_order werden refresht. + let delivery_id = upsert_delivery(&mut tx, tour_id, customer_id, delivery).await?; + + for item in &delivery.items { + let warehouse_id = upsert_warehouse(&mut tx, item).await?; + let article_id = upsert_article(&mut tx, item, warehouse_id).await?; + upsert_delivery_item(&mut tx, delivery_id, article_id, warehouse_id, item).await?; + } + } + + tx.commit().await.map_err(db)?; + Ok(tour_id) + } +} + +// ===== Upsert-Helfer ===================================================== + +async fn upsert_warehouse( + tx: &mut Transaction<'_, Postgres>, + item: &SyncDeliveryItem, +) -> Result { + let id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO warehouses (code, name) + VALUES ($1, $2) + ON CONFLICT (code) DO UPDATE + SET name = EXCLUDED.name + RETURNING id + "#, + ) + .bind(&item.warehouse_code) + .bind(&item.warehouse_name) + .fetch_one(&mut **tx) + .await + .map_err(db)?; + Ok(id) +} + +async fn upsert_article( + tx: &mut Transaction<'_, Postgres>, + item: &SyncDeliveryItem, + fallback_warehouse_id: Uuid, +) -> Result { + // Optional: explizites Default-Lager aus dem ERP. Wenn nicht + // geliefert, nehmen wir das Lager dieser Position als Default — das + // ist eine pragmatische Wahl, die wir später korrigieren können. + let default_warehouse_id = if let Some(code) = &item.article_default_warehouse_code { + sqlx::query_scalar::<_, Uuid>("SELECT id FROM warehouses WHERE code = $1") + .bind(code) + .fetch_optional(&mut **tx) + .await + .map_err(db)? + .unwrap_or(fallback_warehouse_id) + } else { + fallback_warehouse_id + }; + + let id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO articles (article_number, name, scannable, default_warehouse_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (article_number) DO UPDATE + SET name = EXCLUDED.name, + scannable = EXCLUDED.scannable, + default_warehouse_id = EXCLUDED.default_warehouse_id + RETURNING id + "#, + ) + .bind(&item.article_number) + .bind(&item.article_name) + .bind(item.article_scannable) + .bind(default_warehouse_id) + .fetch_one(&mut **tx) + .await + .map_err(db)?; + Ok(id) +} + +async fn upsert_customer( + tx: &mut Transaction<'_, Postgres>, + delivery: &SyncDelivery, +) -> Result { + let id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO customers ( + erp_customer_id, name, street, house_number, postal_code, city, country + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (erp_customer_id) DO UPDATE SET + name = EXCLUDED.name, + street = EXCLUDED.street, + house_number = EXCLUDED.house_number, + postal_code = EXCLUDED.postal_code, + city = EXCLUDED.city, + country = EXCLUDED.country + RETURNING id + "#, + ) + .bind(delivery.erp_customer_id) + .bind(&delivery.customer_name) + .bind(&delivery.customer_address.street) + .bind(&delivery.customer_address.house_number) + .bind(&delivery.customer_address.postal_code) + .bind(&delivery.customer_address.city) + .bind(&delivery.customer_address.country) + .fetch_one(&mut **tx) + .await + .map_err(db)?; + Ok(id) +} + +async fn upsert_delivery( + tx: &mut Transaction<'_, Postgres>, + tour_id: Uuid, + customer_id: Uuid, + delivery: &SyncDelivery, +) -> Result { + let id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO deliveries ( + tour_id, erp_belegart_id, erp_belegnummer, customer_id, + snap_street, snap_house_number, snap_postal_code, snap_city, snap_country, + sort_order, desired_time, special_agreements + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (erp_belegart_id, erp_belegnummer) DO UPDATE SET + tour_id = EXCLUDED.tour_id, + customer_id = EXCLUDED.customer_id, + snap_street = EXCLUDED.snap_street, + snap_house_number = EXCLUDED.snap_house_number, + snap_postal_code = EXCLUDED.snap_postal_code, + snap_city = EXCLUDED.snap_city, + snap_country = EXCLUDED.snap_country, + sort_order = EXCLUDED.sort_order, + desired_time = EXCLUDED.desired_time, + special_agreements = EXCLUDED.special_agreements + RETURNING id + "#, + ) + .bind(tour_id) + .bind(delivery.belegart_id) + .bind(&delivery.belegnummer) + .bind(customer_id) + .bind(&delivery.delivery_address.street) + .bind(&delivery.delivery_address.house_number) + .bind(&delivery.delivery_address.postal_code) + .bind(&delivery.delivery_address.city) + .bind(&delivery.delivery_address.country) + .bind(delivery.sort_order) + .bind(delivery.desired_time.as_deref()) + .bind(delivery.special_agreements.as_deref()) + .fetch_one(&mut **tx) + .await + .map_err(db)?; + Ok(id) +} + +async fn upsert_delivery_item( + tx: &mut Transaction<'_, Postgres>, + delivery_id: Uuid, + article_id: Uuid, + warehouse_id: Uuid, + item: &SyncDeliveryItem, +) -> Result<(), ApplicationError> { + // Identität: (delivery_id, belegzeilen_nr, komponenten_artikel_nr). + // scan_state-Felder bleiben beim UPDATE bewusst unberührt — das ERP + // weiß nichts über Scans. + sqlx::query( + r#" + INSERT INTO delivery_items ( + delivery_id, article_id, required_quantity, warehouse_id, + belegzeilen_nr, komponenten_artikel_nr + ) VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (delivery_id, belegzeilen_nr, komponenten_artikel_nr) DO UPDATE SET + article_id = EXCLUDED.article_id, + required_quantity = EXCLUDED.required_quantity, + warehouse_id = EXCLUDED.warehouse_id + "#, + ) + .bind(delivery_id) + .bind(article_id) + .bind(item.required_quantity) + .bind(warehouse_id) + .bind(item.belegzeilen_nr) + .bind(item.komponenten_artikel_nr.as_deref()) + .execute(&mut **tx) + .await + .map_err(db)?; + Ok(()) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c3c698c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +# Lokales Entwicklungs-Setup für das Holzleitner-Backend. +# Startbefehl: docker compose up -d +# +# Postgres-Daten landen im benannten Volume `postgres-data` und überleben +# Container-Neustarts. Keycloak nutzt bewusst keinen persistenten Volume — +# der Realm wird bei jedem Start frisch aus `keycloak/import/` importiert, +# damit die Quellen-of-truth Versionierte Dateien bleiben. +# Komplettes Reset: `docker compose down -v`. + +services: + postgres: + image: postgres:17-alpine + container_name: holzleitner-postgres + restart: unless-stopped + environment: + POSTGRES_DB: holzleitner + POSTGRES_USER: holzleitner + POSTGRES_PASSWORD: holzleitner_dev + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U holzleitner -d holzleitner"] + interval: 5s + timeout: 5s + retries: 5 + + keycloak: + image: quay.io/keycloak/keycloak:26.0 + container_name: holzleitner-keycloak + restart: unless-stopped + command: ["start-dev", "--import-realm"] + environment: + # Bootstrap-Admin (Keycloak 26+ neue Env-Vars). + # Admin-Console: http://localhost:8080/admin/ (admin / admin) + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + # Health-Endpoints für externe Checks aktivieren. + KC_HEALTH_ENABLED: "true" + ports: + - "8080:8080" + volumes: + - ./keycloak/import:/opt/keycloak/data/import:ro + +volumes: + postgres-data: diff --git a/keycloak/import/realm-holzleitner.json b/keycloak/import/realm-holzleitner.json new file mode 100644 index 0000000..50ce6df --- /dev/null +++ b/keycloak/import/realm-holzleitner.json @@ -0,0 +1,92 @@ +{ + "realm": "holzleitner", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "accessTokenLifespan": 1800, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "roles": { + "realm": [ + { + "name": "driver", + "description": "Lieferfahrer — darf Touren laden, scannen und abschließen." + } + ] + }, + "users": [ + { + "username": "testfahrer", + "enabled": true, + "emailVerified": true, + "firstName": "Test", + "lastName": "Fahrer", + "email": "test@example.com", + "credentials": [ + { + "type": "password", + "value": "test", + "temporary": false + } + ], + "realmRoles": ["driver"], + "attributes": { + "personalnummer": ["1001"] + } + } + ], + "clients": [ + { + "clientId": "holzleitner-app", + "name": "Holzleitner Mobile App", + "description": "Public Client für die Flutter-App (Authorization Code + PKCE und Direct Access Grants im Dev).", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "implicitFlowEnabled": false, + "redirectUris": [ + "http://localhost:*", + "holzleitner://*" + ], + "webOrigins": ["+"], + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "protocolMappers": [ + { + "name": "audience-holzleitner-api", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.client.audience": "holzleitner-api", + "id.token.claim": "false", + "access.token.claim": "true", + "introspection.token.claim": "true" + } + }, + { + "name": "personalnummer", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "config": { + "user.attribute": "personalnummer", + "claim.name": "personalnummer", + "jsonType.label": "long", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true", + "introspection.token.claim": "true" + } + } + ] + } + ] +} diff --git a/migrations/0001_accounts.sql b/migrations/0001_accounts.sql new file mode 100644 index 0000000..0a7e212 --- /dev/null +++ b/migrations/0001_accounts.sql @@ -0,0 +1,13 @@ +-- Erste Tabelle: Account. Wird zunächst nur als Smoke-Test-Ziel für die +-- End-to-End-Achse benutzt; weitere Tabellen folgen pro Migration. + +CREATE TABLE accounts ( + personalnummer BIGINT PRIMARY KEY, + name TEXT NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE +); + +-- Seed-Daten für Smoke-Tests. +INSERT INTO accounts (personalnummer, name, active) VALUES + (1001, 'Müller Logistik GmbH', TRUE), + (1002, 'Schmidt Transporte', TRUE); diff --git a/migrations/0002_tours.sql b/migrations/0002_tours.sql new file mode 100644 index 0000000..9c0accc --- /dev/null +++ b/migrations/0002_tours.sql @@ -0,0 +1,185 @@ +-- Tour-Domäne: Stammdaten (customers, customer_contacts, articles, warehouses) +-- plus die transaktionalen Tabellen tours, deliveries, delivery_items. +-- +-- Datenfluss: das ERP pusht eine Tour via POST /sync/tour. Dieser Sync +-- legt fehlende Kunden/Artikel/Lager an oder aktualisiert sie und schreibt +-- danach die transaktionalen Zeilen. + +-- ---------- Stamm: Lager ------------------------------------------------- +CREATE TABLE warehouses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + is_standard BOOLEAN NOT NULL DEFAULT FALSE +); + +-- Genau ein Lager darf als "Standardlager" markiert sein. Reduziert die +-- Fertig-Logik auf der App auf einen Bool-Check. +CREATE UNIQUE INDEX warehouses_one_standard + ON warehouses ((is_standard)) + WHERE is_standard; + +-- ---------- Stamm: Artikel ------------------------------------------------ +CREATE TABLE articles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + article_number TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + scannable BOOLEAN NOT NULL DEFAULT TRUE, + default_warehouse_id UUID REFERENCES warehouses(id) +); + +-- ---------- Stamm: Kunde -------------------------------------------------- +CREATE TABLE customers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + erp_customer_id BIGINT NOT NULL UNIQUE, + name TEXT NOT NULL, + -- Aktuelle Anschrift (für Snapshot in deliveries gesondert geführt) + street TEXT NOT NULL, + house_number TEXT NOT NULL, + postal_code TEXT NOT NULL, + city TEXT NOT NULL, + country TEXT NOT NULL +); + +CREATE TABLE customer_contacts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + name TEXT NOT NULL, + phone TEXT, + email TEXT +); +CREATE INDEX customer_contacts_customer ON customer_contacts(customer_id); + +-- ---------- Transaktional: Tour ------------------------------------------ +CREATE TABLE tours ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- account_id = Personalnummer des Subunternehmers + account_id BIGINT NOT NULL REFERENCES accounts(personalnummer), + tour_date DATE NOT NULL, + synced_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- Eine Tour pro Account und Tag. Der Sync läuft als Upsert auf diese + -- Constraint. + UNIQUE (account_id, tour_date) +); +CREATE INDEX tours_account_date ON tours(account_id, tour_date); + +-- ---------- Transaktional: Delivery -------------------------------------- +CREATE TABLE deliveries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tour_id UUID NOT NULL REFERENCES tours(id) ON DELETE CASCADE, + + -- Business-stabiles Beleg-Paar aus dem ERP, überlebt Archivübergang + erp_belegart_id BIGINT NOT NULL, + erp_belegnummer TEXT NOT NULL, + + customer_id UUID NOT NULL REFERENCES customers(id), + + -- Snapshot der Adresse zum Zeitpunkt des Tour-Syncs + snap_street TEXT NOT NULL, + snap_house_number TEXT NOT NULL, + snap_postal_code TEXT NOT NULL, + snap_city TEXT NOT NULL, + snap_country TEXT NOT NULL, + + assigned_car_id UUID, -- noch keine cars-Tabelle, FK später + desired_time TEXT, + special_agreements TEXT, + state TEXT NOT NULL DEFAULT 'active' + CHECK (state IN ('active', 'held', 'canceled', 'completed')), + cancellation_reason TEXT, + + -- Sortier-Reihenfolge innerhalb der Tour. Beim Sync mit dichter + -- Reihenfolge initialisiert, später durch PUT /tours/{id}/delivery-order + -- überschrieben. + sort_order INT NOT NULL DEFAULT 0, + + UNIQUE (erp_belegart_id, erp_belegnummer) +); +CREATE INDEX deliveries_tour ON deliveries(tour_id); +CREATE INDEX deliveries_customer ON deliveries(customer_id); + +-- N:M-Tabelle Delivery → ausgewählte Ansprechpartner +CREATE TABLE delivery_contact_persons ( + delivery_id UUID NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE, + customer_contact_id UUID NOT NULL REFERENCES customer_contacts(id) ON DELETE CASCADE, + PRIMARY KEY (delivery_id, customer_contact_id) +); + +-- ---------- Transaktional: DeliveryItem ---------------------------------- +CREATE TABLE delivery_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + delivery_id UUID NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE, + + article_id UUID NOT NULL REFERENCES articles(id), + required_quantity INT NOT NULL CHECK (required_quantity > 0), + warehouse_id UUID NOT NULL REFERENCES warehouses(id), + + -- ERP-Position innerhalb des Belegs + belegzeilen_nr INT NOT NULL, + -- Stücklistenkomponente: ArtNr der Komponente, sonst NULL + komponenten_artikel_nr TEXT, + + -- Embedded ScanState (siehe Domain::ScanState) + scanned_quantity INT NOT NULL DEFAULT 0 CHECK (scanned_quantity >= 0), + scan_status TEXT NOT NULL DEFAULT 'in_progress' + CHECK (scan_status IN ('in_progress','done','held','removed')), + held_reason TEXT, + scan_last_updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- NULLS NOT DISTINCT: zwei Items mit (delivery, belegzeilenNr) und + -- NULL-Komponente kollidieren — sonst würde der UPSERT eine zweite + -- Zeile anlegen statt zu aktualisieren. (Postgres 15+) + UNIQUE NULLS NOT DISTINCT (delivery_id, belegzeilen_nr, komponenten_artikel_nr) +); +CREATE INDEX delivery_items_delivery ON delivery_items(delivery_id); + +-- ---------- Seed: Smoke-Test-Daten ---------------------------------------- +INSERT INTO warehouses (id, code, name, is_standard) VALUES + ('11111111-1111-1111-1111-111111111111', '0', 'Standardlager', TRUE), + ('11111111-1111-1111-1111-111111111112', 'A', 'Außenlager A', FALSE); + +INSERT INTO articles (id, article_number, name, scannable, default_warehouse_id) VALUES + ('22222222-2222-2222-2222-222222222221', 'BRETT-200', 'Holzbrett 200cm', TRUE, '11111111-1111-1111-1111-111111111111'), + ('22222222-2222-2222-2222-222222222222', 'PALETTE-EUR', 'Europalette', TRUE, '11111111-1111-1111-1111-111111111111'), + ('22222222-2222-2222-2222-222222222223', 'FRACHT-PAUSCH', 'Fracht', FALSE, NULL); + +INSERT INTO customers (id, erp_customer_id, name, street, house_number, postal_code, city, country) VALUES + ('33333333-3333-3333-3333-333333333331', 4711, 'Bauernhof Huber', 'Dorfstraße', '12', '83410', 'Laufen', 'DE'), + ('33333333-3333-3333-3333-333333333332', 4712, 'Sägewerk Müller', 'Industriering', '5', '83395', 'Freilassing', 'DE'); + +INSERT INTO customer_contacts (id, customer_id, name, phone, email) VALUES + ('44444444-4444-4444-4444-444444444441', '33333333-3333-3333-3333-333333333331', 'Sepp Huber', '+49 8682 12345', NULL), + ('44444444-4444-4444-4444-444444444442', '33333333-3333-3333-3333-333333333332', 'Anna Müller', NULL, 'anna@muellersaege.de'); + +-- Eine Beispiel-Tour für Personalnummer 1001 am heutigen Tag +INSERT INTO tours (id, account_id, tour_date) VALUES + ('55555555-5555-5555-5555-555555555555', 1001, CURRENT_DATE); + +INSERT INTO deliveries ( + id, tour_id, erp_belegart_id, erp_belegnummer, customer_id, + snap_street, snap_house_number, snap_postal_code, snap_city, snap_country, + sort_order +) VALUES + ('66666666-6666-6666-6666-666666666661', '55555555-5555-5555-5555-555555555555', + 1, 'AB-2026-0001', '33333333-3333-3333-3333-333333333331', + 'Dorfstraße', '12', '83410', 'Laufen', 'DE', 1), + ('66666666-6666-6666-6666-666666666662', '55555555-5555-5555-5555-555555555555', + 1, 'AB-2026-0002', '33333333-3333-3333-3333-333333333332', + 'Industriering', '5', '83395', 'Freilassing', 'DE', 2); + +INSERT INTO delivery_contact_persons (delivery_id, customer_contact_id) VALUES + ('66666666-6666-6666-6666-666666666661', '44444444-4444-4444-4444-444444444441'), + ('66666666-6666-6666-6666-666666666662', '44444444-4444-4444-4444-444444444442'); + +INSERT INTO delivery_items ( + delivery_id, article_id, required_quantity, warehouse_id, + belegzeilen_nr, komponenten_artikel_nr +) VALUES + ('66666666-6666-6666-6666-666666666661', '22222222-2222-2222-2222-222222222221', + 20, '11111111-1111-1111-1111-111111111111', 1, NULL), + ('66666666-6666-6666-6666-666666666661', '22222222-2222-2222-2222-222222222222', + 2, '11111111-1111-1111-1111-111111111111', 2, NULL), + ('66666666-6666-6666-6666-666666666662', '22222222-2222-2222-2222-222222222221', + 10, '11111111-1111-1111-1111-111111111112', 1, NULL), + ('66666666-6666-6666-6666-666666666662', '22222222-2222-2222-2222-222222222223', + 1, '11111111-1111-1111-1111-111111111111', 2, NULL); diff --git a/migrations/0003_scan_audit.sql b/migrations/0003_scan_audit.sql new file mode 100644 index 0000000..3dcaf0e --- /dev/null +++ b/migrations/0003_scan_audit.sql @@ -0,0 +1,52 @@ +-- Append-only Audit-Log für jede Scan-Aktion an einer Lieferposition. +-- +-- Doppelter Wahrheits-Ansatz: +-- * delivery_items.scan_* = "schnelle Wahrheit" über WIEVIEL (Embedded) +-- * scan_audit = "lange Wahrheit" über WIE und WANN +-- +-- Idempotenz: client_scan_id ist UNIQUE. Schickt die App einen Scan +-- nach Netzfehler erneut, kollidiert sie auf diesem Index — der Server +-- liefert "duplicate" zurück, ohne den embedded scan_state zu verändern. +-- +-- Beleg-Bezug wird denormalisiert mitgespeichert: bleibt nachvollziehbar, +-- auch wenn delivery_items irgendwann archiviert oder bereinigt wird. + +CREATE TABLE scan_audit ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Idempotenz-Schlüssel vom Client. + client_scan_id UUID NOT NULL UNIQUE, + + delivery_item_id UUID NOT NULL REFERENCES delivery_items(id) ON DELETE CASCADE, + + action TEXT NOT NULL + CHECK (action IN ('scan','unscan','hold','unhold','remove')), + + -- Signed Delta auf scanned_quantity. +1 / -1 / 0 abhängig vom action. + delta INT NOT NULL, + -- Snapshot scanned_quantity nach dieser Aktion (Reports brauchen keinen Replay). + resulting_quantity INT NOT NULL CHECK (resulting_quantity >= 0), + resulting_status TEXT NOT NULL + CHECK (resulting_status IN ('in_progress','done','held','removed')), + + reason TEXT, + + -- Akteur: Personalnummer aus dem JWT (Pflicht). + -- car_id NULL bis Cars im Backend gemanagt werden. + actor_personalnummer BIGINT NOT NULL, + actor_car_id UUID, + + -- Zeitpunkt am Client (offline-fähig) + Eingang am Server. + client_scanned_at TIMESTAMPTZ NOT NULL, + server_recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- Denormalisierter ERP-Beleg-Bezug. + erp_belegart_id BIGINT NOT NULL, + erp_belegnummer TEXT NOT NULL, + erp_belegzeilen_nr INT NOT NULL, + erp_komponenten_artikel_nr TEXT +); + +CREATE INDEX scan_audit_item ON scan_audit(delivery_item_id); +CREATE INDEX scan_audit_recorded ON scan_audit(server_recorded_at); +CREATE INDEX scan_audit_actor ON scan_audit(actor_personalnummer, server_recorded_at); diff --git a/migrations/0004_delivery_state_reason.sql b/migrations/0004_delivery_state_reason.sql new file mode 100644 index 0000000..035391e --- /dev/null +++ b/migrations/0004_delivery_state_reason.sql @@ -0,0 +1,10 @@ +-- Ein Reason-Feld für beide Nicht-Active-Status (Hold + Cancel). +-- +-- Bisher hieß die Spalte `cancellation_reason` und passte nur zum +-- Cancel-Pfad. Mit dem Hold-Flow brauchen wir ebenfalls einen +-- begründenden Text — semantisch ist es immer "warum ist die +-- Lieferung gerade nicht 'active'". `state_reason` ist der bessere +-- Name; beim Resume / Complete wird die Spalte auf NULL gesetzt. + +ALTER TABLE deliveries + RENAME COLUMN cancellation_reason TO state_reason; diff --git a/migrations/0005_delivery_notes.sql b/migrations/0005_delivery_notes.sql new file mode 100644 index 0000000..482fc52 --- /dev/null +++ b/migrations/0005_delivery_notes.sql @@ -0,0 +1,21 @@ +-- Notizen pro Lieferung. Eine Notiz ist entweder Text, ein Bild-Anhang +-- (Object-Storage-Key/URL) oder beides — aber nicht NULL/NULL. +-- +-- Akteur: actor_personalnummer ist Pflicht (aus JWT). Das fachlich +-- gewünschte author_car_id bleibt optional, bis das Backend Fahrzeuge +-- selbst verwaltet. + +CREATE TABLE delivery_notes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + delivery_id UUID NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE, + text TEXT, + image_attachment TEXT, + author_personalnummer BIGINT NOT NULL, + author_car_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CHECK (text IS NOT NULL OR image_attachment IS NOT NULL) +); + +CREATE INDEX delivery_notes_delivery + ON delivery_notes (delivery_id, created_at); diff --git a/migrations/0006_cars.sql b/migrations/0006_cars.sql new file mode 100644 index 0000000..10d8a09 --- /dev/null +++ b/migrations/0006_cars.sql @@ -0,0 +1,39 @@ +-- Fahrzeuge eines Subunternehmer-Accounts. Kein ERP-Spiegel — die App +-- pflegt die Fahrzeuge selbst. +-- +-- Im Audit-Log und an Lieferungen war `car_id` bislang ohne FK +-- geführt (Spalten existieren in 0002/0003/0005). Diese Migration +-- zieht die FKs nach. Bestehende NULL-Werte bleiben gültig. +-- +-- `(account_id, plate)` ist fachlich eindeutig — pro Account keine +-- doppelten Kennzeichen. + +CREATE TABLE cars ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id BIGINT NOT NULL REFERENCES accounts(personalnummer), + plate TEXT NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE UNIQUE INDEX cars_account_plate ON cars(account_id, plate); +CREATE INDEX cars_account ON cars(account_id); + +-- FKs nachziehen — alle Spalten bleiben NULLABLE, weil +-- Audit-Einträge der Vor-Cars-Phase keinen car_id haben. +ALTER TABLE deliveries + ADD CONSTRAINT deliveries_assigned_car_fk + FOREIGN KEY (assigned_car_id) REFERENCES cars(id); + +ALTER TABLE delivery_notes + ADD CONSTRAINT delivery_notes_author_car_fk + FOREIGN KEY (author_car_id) REFERENCES cars(id); + +ALTER TABLE scan_audit + ADD CONSTRAINT scan_audit_actor_car_fk + FOREIGN KEY (actor_car_id) REFERENCES cars(id); + +-- Seed: zwei Fahrzeuge für Testfahrer (PN 1001) — bewusst zwei, +-- damit der Flow "Auswählen ab 2 Autos" testbar ist. +INSERT INTO cars (id, account_id, plate, active) VALUES + ('77777777-7777-7777-7777-777777777771', 1001, 'BGL-HZ 100', TRUE), + ('77777777-7777-7777-7777-777777777772', 1001, 'BGL-HZ 200', TRUE);