commit 0e539710c0d9cb268e4a1526dc8b80a5e69d591c Author: Torsten Schulz (local) Date: Tue Jun 2 15:28:38 2026 +0200 feat: Add password reset functionality with request and reset forms feat: Implement price list import feature with preview and apply options feat: Create price rules management page with CRUD operations feat: Develop quotes management page with itemized quotes and status tracking feat: Introduce organization registration page for new users feat: Build suppliers management page with detailed supplier information feat: Create users management page for inviting and managing roles chore: Add TypeScript configuration for improved type checking chore: Set up Vite configuration for development server and API proxy chore: Add Vite environment type definitions for better TypeScript support diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cc0bf05 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +DATABASE_URL=postgres://companytool:companytool@localhost:5432/companytool +BACKEND_BIND=127.0.0.1:8080 +VITE_WS_URL=ws://localhost:8080/ws +COMPANYTOOL_DATA_KEY_ID=dev-data-key-v1 +COMPANYTOOL_DATA_KEY_BASE64=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc= +COMPANYTOOL_EMAIL_TRANSPORT=outbox +COMPANYTOOL_DOCUMENT_STORAGE_DIR=storage/documents diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ecdc26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target/ +/web-frontend/node_modules/ +/web-frontend/dist/ +.env + diff --git a/BETRIEB.md b/BETRIEB.md new file mode 100644 index 0000000..94dcec4 --- /dev/null +++ b/BETRIEB.md @@ -0,0 +1,97 @@ +# Betrieb + +## Authentifizierung + +- Passwörter werden mit Argon2id gehasht. +- Initialpasswörter erzwingen `must_change_password`. +- Passwort-Reset erfolgt über kurzlebige Tokens aus `password_reset_tokens`. +- Firmen-Einladungen verwenden Tokens aus `user_invitations.token_hash`. +- Nach Passwort-Reset werden bestehende Sessions widerrufen. + +## E-Mail + +E-Mail-Inhalte werden in `email_outbox` verschlüsselt gespeichert. Im +Entwicklungsmodus werden Tokens zusätzlich in API-Antworten ausgegeben. Im +Produktivbetrieb werden keine Passwörter oder Tokens in API-Antworten geliefert. + +Transportmodi: + +- `COMPANYTOOL_EMAIL_TRANSPORT=outbox`: nur verschlüsselte Ablage in PostgreSQL. +- `COMPANYTOOL_EMAIL_TRANSPORT=file`: zusätzliche Zustellung als JSON-Datei in + `COMPANYTOOL_EMAIL_FILE_DIR`. + +SMTP kann später als weiterer Transport hinter demselben Outbox-Modell ergänzt +werden. + +## Verschlüsselungsschlüssel + +Produktiv müssen gesetzt sein: + +```env +COMPANYTOOL_DATA_KEY_ID=prod-data-key-v1 +COMPANYTOOL_DATA_KEY_BASE64=<32-byte-key-base64> +``` + +Der Key muss außerhalb der Datenbank gesichert werden. Ohne diesen Key können +verschlüsselte Firmen-, Dokument- und Kommunikationsdaten nicht wiederhergestellt +werden. + +Key-Rotation ist vorbereitet über `*_key_id`-Spalten. Ablauf: + +1. neuen Key als `COMPANYTOOL_DATA_KEY_ID` bereitstellen +2. neue Schreibvorgänge mit neuem Key speichern +3. Re-Encryption-Job für alte Datensätze implementieren und ausführen +4. alten Key erst nach verifiziertem Backup entfernen + +## Backup und Restore + +PostgreSQL: + +```bash +pg_dump --format=custom --file=companytool.dump "$DATABASE_URL" +pg_restore --clean --if-exists --dbname "$DATABASE_URL" companytool.dump +``` + +Dokumente: + +```bash +tar -C storage -czf companytool-documents.tar.gz documents +tar -C storage -xzf companytool-documents.tar.gz +``` + +Für einzelne Firmen müssen das jeweilige `company_*`-Schema und der passende +Ordner unter `storage/documents/` gemeinsam gesichert werden. + +## Docker + +Nur PostgreSQL: + +```bash +docker compose up -d postgres +``` + +PostgreSQL und Backend: + +```bash +docker compose --profile backend up -d +``` + +## TLS und Reverse Proxy + +Das Backend bleibt intern auf HTTP/WSS hinter einem Reverse Proxy. Öffentlich +muss ausschließlich HTTPS/WSS erreichbar sein. Ein nginx-Beispiel liegt unter: + +```text +deploy/nginx-companytool.conf +``` + +## Lokale Einzelkunden-Installation + +Für lokale Installationen bleibt `dev_bootstrap-local` als Entwicklungs- und +Installationshelfer vorgesehen. Ein späteres Installationsprogramm soll: + +- PostgreSQL-Verbindung prüfen +- lokale Firma anlegen +- ersten Besitzer anlegen +- Backend- und Client-Konfiguration schreiben +- Dokumentenordner und Schlüsseldatei vorbereiten diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f18cab2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,6042 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99b76d84ee70e30a4a7e39ab9018e2b17a6a09e31084176cc7c0b2dec036ba45" + +[[package]] +name = "accesskit_atspi_common" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5393c75d4666f580f4cac0a968bc97c36076bb536a129f28210dac54ee127ed" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "serde", + "thiserror 1.0.69", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a12dc159d52233c43d9fe5415969433cbdd52c3d6e0df51bda7d447427b9986" +dependencies = [ + "accesskit", + "immutable-chunkmap", +] + +[[package]] +name = "accesskit_macos" +version = "0.17.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfc6c1ecd82053d127961ad80a8beaa6004fb851a3a5b96506d7a6bd462403f6" +dependencies = [ + "accesskit", + "accesskit_consumer", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "once_cell", +] + +[[package]] +name = "accesskit_unix" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be7f5cf6165be10a54b2655fa2e0e12b2509f38ed6fc43e11c31fdb7ee6230bb" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974e96c347384d9133427167fb8a58c340cb0496988dacceebdc1ed27071023b" +dependencies = [ + "accesskit", + "accesskit_consumer", + "paste", + "static_assertions", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "accesskit_winit" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aea3522719f1c44564d03e9469a8e2f3a98b3a8a880bd66d0789c6b9c4a669dd" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[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-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.11.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[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 = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[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 2.0.117", +] + +[[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 = "atspi" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be534b16650e35237bb1ed189ba2aab86ce65e88cc84c66f4935ba38575cecbf" +dependencies = [ + "atspi-common", + "atspi-connection", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1909ed2dc01d0a17505d89311d192518507e8a056a48148e3598fef5e7bb6ba7" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-connection" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "430c5960624a4baaa511c9c0fcc2218e3b58f5dbcc47e6190cafee344b873333" +dependencies = [ + "atspi-common", + "atspi-proxies", + "futures-lite", + "zbus", +] + +[[package]] +name = "atspi-proxies" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e6c5de3e524cf967569722446bcd458d5032348554d9a17d7d72b041ab7496" +dependencies = [ + "atspi-common", + "serde", + "zbus", + "zvariant", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "base64", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "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 = "bit-set" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0481a0e032742109b1133a095184ee93d88f3dc9e0d28a5d033dc77a073f44f" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c54ff287cfc0a34f38a6b832ea1bd8e448a330b3e40a50859e6488bee07f22" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[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 = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.11.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.11.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "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.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "com" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" +dependencies = [ + "com_macros", +] + +[[package]] +name = "com_macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" +dependencies = [ + "com_macros_support", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "com_macros_support" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "companytool-backend" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "axum", + "base64", + "chrono", + "companytool-shared-protocol", + "dotenvy", + "futures-util", + "rand_core 0.6.4", + "serde", + "serde_json", + "sha2", + "sqlx", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "companytool-desktop-client" +version = "0.1.0" +dependencies = [ + "anyhow", + "companytool-shared-protocol", + "eframe", + "egui", + "futures-util", + "image", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", + "toml", + "url", +] + +[[package]] +name = "companytool-shared-protocol" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "base64", + "chrono", + "rand_core 0.6.4", + "serde", + "serde_json", + "uuid", +] + +[[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" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[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", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[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 = "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 = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775cfde491852059e386c4e1deb4aef381c617dc364184c6f6afee99b87c402b" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ac2645a9bf4826eb4e91488b1f17b8eaddeef09396706b2f14066461338e24f" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glow 0.14.2", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "winapi", + "windows-sys 0.52.0", + "winit", +] + +[[package]] +name = "egui" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53eafabcce0cb2325a59a98736efe0bf060585b437763f8c476957fb274bb974" +dependencies = [ + "accesskit", + "ahash", + "emath", + "epaint", + "log", + "nohash-hasher", +] + +[[package]] +name = "egui-wgpu" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d00fd5d06d8405397e64a928fa0ef3934b3c30273ea7603e3dc4627b1f7a1a82" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "thiserror 1.0.69", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a9c430f4f816340e8e8c1b20eec274186b1be6bc4c7dfc467ed50d57abc36c6" +dependencies = [ + "accesskit_winit", + "ahash", + "arboard", + "egui", + "log", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e39bccc683cd43adab530d8f21a13eb91e80de10bcc38c3f1c16601b6f62b26" +dependencies = [ + "ahash", + "bytemuck", + "egui", + "glow 0.14.2", + "log", + "memoffset", + "wasm-bindgen", + "web-sys", + "winit", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "emath" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1fe0049ce51d0fb414d029e668dd72eb30bc2b739bf34296ed97bd33df544f3" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "epaint" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a32af8da821bd4f43f2c137e295459ee2e1661d87ca8779dfa0eaf45d870e20f" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "log", + "nohash-hasher", + "parking_lot", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "483440db0b7993cf77a20314f08311dbe95675092405518c0677aa08c151a3ea" + +[[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 = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[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 = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[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 = [ + "crc32fast", + "miniz_oxide", +] + +[[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 = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[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-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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-macro", + "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 = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[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 = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glow" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d51fa363f025f5c111e03f13eda21162faeacb6911fe8caa0c0349f9cf0c4483" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.11.1", + "cfg_aliases 0.2.1", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases 0.2.1", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.11.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd4240fc91d3433d5e5b0fc5b67672d771850dc19bbee03c1381e19322803d7" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "winapi", + "windows 0.52.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.11.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.11.1", +] + +[[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 = "hassle-rs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" +dependencies = [ + "bitflags 2.11.1", + "com", + "libc", + "libloading", + "thiserror 1.0.69", + "widestring", + "winapi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[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 = "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 0.62.2", +] + +[[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 = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + +[[package]] +name = "immutable-chunkmap" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3e98b1520e49e252237edc238a39869da9f3241f2ec19dc788c1d24694d1e4" +dependencies = [ + "arrayvec", +] + +[[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 = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[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 = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[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 = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[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 = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[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 2.11.1", + "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 = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[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 = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[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.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[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 = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.11.1", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[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 = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bd5a652b6faf21496f2cfd88fc49989c8db0825d1f6746b1a71a6ede24a63ad" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.11.1", + "cfg_aliases 0.1.1", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "rustc-hash 1.1.0", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", + "memoffset", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[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-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-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 = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.1", + "block2", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "orbclient" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a570f6bca41d29acb2139229a7c873ec99bc9a313bd10804081d89bfac8ff329" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[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 = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[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" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[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 = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[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 = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-xml" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.2", + "rustls", + "socket2", + "thiserror 2.0.18", + "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 2.1.2", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "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 0.2.1", + "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 = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags 2.11.1", +] + +[[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 = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[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 = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[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 = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[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 2.0.117", +] + +[[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_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[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 = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.11.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.11.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.18", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +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 = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.11.1", +] + +[[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 2.0.18", + "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 2.0.117", +] + +[[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 2.0.117", + "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 2.11.1", + "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 2.0.18", + "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 2.11.1", + "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 2.0.18", + "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 2.0.18", + "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 = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[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 = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[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 2.0.117", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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 2.0.117", +] + +[[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 = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[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", + "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 2.0.117", +] + +[[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 = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.1", + "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 2.0.117", +] + +[[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 = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.6", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.2", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[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-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[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 = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[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 2.0.117", + "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 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.11.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.4", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[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 = "webbrowser" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +dependencies = [ + "core-foundation 0.10.1", + "jni", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[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 = "wgpu" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d1c4ba43f80542cf63a0a6ed3134629ae73e8ab51e4b765a67f3aa062eb433" +dependencies = [ + "arrayvec", + "cfg_aliases 0.1.1", + "document-features", + "js-sys", + "log", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348c840d1051b8e86c3bcd31206080c5e71e5933dabd79be1ce732b0b2f089a" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.11.1", + "cfg_aliases 0.1.1", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6bbf4b4de8b2a83c0401d9e5ae0080a2792055f25859a02bf9be97952bbed4f" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bitflags 2.11.1", + "cfg_aliases 0.1.1", + "core-graphics-types", + "glow 0.13.1", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winapi", +] + +[[package]] +name = "wgpu-types" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9d91f0e2c4b51434dfa6db77846f2793149d8e73f800fa2e41f52b8eac3c5d" +dependencies = [ + "bitflags 2.11.1", + "js-sys", + "web-sys", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[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 = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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 2.0.117", +] + +[[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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[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.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +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 = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.11.1", + "block2", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases 0.2.1", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[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 2.0.117", + "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 2.0.117", + "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 2.11.1", + "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 = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.11.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[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 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca2c5dceb099bddaade154055c926bb8ae507a18756ba1d8963fd7b51d8ed1d" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709ab20fc57cb22af85be7b360239563209258430bccf38d8b979c5a2ae3ecce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f374552b954f6abb4bd6ce979e6c9b38fb9d0cd7cc68a7d796e70c9f3a233" +dependencies = [ + "quick-xml 0.30.0", + "serde", + "static_assertions", + "zbus_names", + "zvariant", +] + +[[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 2.0.117", +] + +[[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 2.0.117", + "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 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7e06ce1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[workspace] +members = [ + "backend", + "desktop-client", + "shared-protocol", +] +resolver = "2" + +[workspace.package] +edition = "2021" +license = "UNLICENSED" + diff --git a/FENSTERKONZEPT.md b/FENSTERKONZEPT.md new file mode 100644 index 0000000..423b427 --- /dev/null +++ b/FENSTERKONZEPT.md @@ -0,0 +1,58 @@ +# Fensterkonzept + +Die Anwendung ist nach dem Login fensterbasiert. Das Dashboard bleibt die +Hauptfläche und zeigt Status, Live-Verbindung und zusammenfassende Daten. Alle +fachlichen Arbeitsbereiche öffnen als eigene Fenster. + +## Grundregeln + +- Vor dem Login sind nur Login und Registrierung sichtbar. +- Nach dem Login bleibt das Dashboard die Basisansicht. +- Navigationseinträge außer Dashboard öffnen ein Fenster. +- Ein bereits geöffnetes Fenster wird fokussiert, nicht doppelt geöffnet. +- Fenster können geschlossen und später erneut geöffnet werden. +- Fenster behalten pro Benutzer Position, Größe und Reihenfolge, soweit der + jeweilige Client das technisch unterstützt. +- Offene Listenfenster müssen auf Live-Events reagieren und ihre Daten + aktualisieren oder als veraltet markieren. + +## Einheitliche Fensteraktionen + +- `Öffnen`: Fenster wird erzeugt oder, falls vorhanden, fokussiert. +- `Fokussieren`: Fenster erhält die höchste Z-Reihenfolge. +- `Schließen`: Fenster wird aus der Arbeitsfläche entfernt. +- `Verschieben`: Fensterposition wird geändert. +- `Größe ändern`: Fensterabmessungen werden geändert. +- `Aktualisieren`: Fenster lädt seine Daten neu oder reagiert auf ein + Backend-Event. + +## Webclient + +- Fenster werden über der Dashboard-Fläche dargestellt. +- Position und Größe sind per Maus oder Pointer veränderbar. +- Fensterstatus wird im `localStorage` pro Benutzer gespeichert. +- Wiederherstellung erfolgt nach Login oder Reload. + +## Desktopclient + +- Fenster verwenden native `egui::Window`-Instanzen. +- Benutzerrechte, Firmendaten und Freischaltungen sind als Fenster umgesetzt. +- Weitere fachliche Module folgen demselben Muster. + +## Live-Updates + +Backend-Änderungen erzeugen Events über die bestehende Socket-Verbindung. Clients +verwenden diese Events, um offene Fenster zu aktualisieren oder deren Status auf +`Aktualisiert` zu setzen. + +Für Phase 2 genügt ein gemeinsamer Live-Event-Store pro Client. Fachliche +Module können später gezielt nach Entity-Typ filtern. + +## Parallelbearbeitung und Konflikte + +- Schreibvorgänge laufen immer über das Backend. +- Das Backend entscheidet final über Berechtigungen und Datenstand. +- Bei konkurrierenden Änderungen wird zunächst eine frische Liste geladen. +- Für spätere fachliche Datensätze wird ein Versionsfeld oder `updated_at` + benötigt, damit Konflikte vor dem Speichern erkennbar sind. + diff --git a/IMPLEMENTIERUNGSPLAN.md b/IMPLEMENTIERUNGSPLAN.md new file mode 100644 index 0000000..2f2faf7 --- /dev/null +++ b/IMPLEMENTIERUNGSPLAN.md @@ -0,0 +1,181 @@ +# Implementierungsplan + +Dieser Plan beschreibt die konkrete technische Umsetzung. Die fachlichen +Grundentscheidungen stehen in `PLANUNG.md`; Installations- und Betriebsdetails +stehen in `INSTALL.md`. + +## Leitlinien + +- Backend zuerst dort stabilisieren, wo mehrere Clients dieselben Funktionen + nutzen. +- Gemeinsame Datenverträge und Rechte werden vor UI-Komfortfunktionen umgesetzt. +- Webclient und Desktopclient sollen dieselben Backend-Endpunkte verwenden. +- Alles außer dem Dashboard wird in Fenstern bearbeitet. +- Neue fachliche Funktionen bekommen eigene atomare Rechte. +- Bestehende Firmenschemas müssen bei Backend-Start idempotent nachgezogen + werden. +- Benutzer sichtbare Texte verwenden echte Umlaute. + +## Phase 1: Fundament Stabilisieren + +Ziel: Login, Firma, Benutzerrechte, Fensterkonzept und Live-Aktualisierung sind +verlässlich testbar. + +- [x] PostgreSQL-Grundschema und Firmenschema-Migrationen anlegen +- [x] Dev-Bootstrap für lokale Benutzer/Firma ohne E-Mail-Versand +- [x] Webclient auf Vue umstellen +- [x] Desktopclient-Konfiguration für Backend-URL +- [x] Logo in Webclient und Desktopclient verwenden +- [x] Benutzerrechte-Fenster im Webclient +- [x] Benutzerrechte-Fenster im Desktopclient +- [x] Rollenänderungen über Backend-Endpunkt speichern +- [x] Atomare Rechte initial anlegen +- [x] Bestehende aktive Firmenschemas beim Backend-Start nachziehen +- [x] Echte Session-/Auth-Tokens einführen statt temporärer User-ID im Header +- [x] Aktuelle Firma explizit auswählen und in Requests mitsenden +- [x] Backend-Rechteprüfung für jeden geschützten Endpunkt zentralisieren +- [x] Rollen auf Rechte abbilden und nicht nur Rollen anzeigen +- [x] Live-Events für Benutzer-/Rollenänderungen an alle Clients senden +- [x] Automatisierten API-Test für Rollenänderung ergänzen +- [x] Webclient-Build/Typprüfung für Benutzerrechte-Fenster ergänzen +- [x] Desktopclient-Build/Typprüfung für Benutzerrechte-Fenster ergänzen + +## Phase 2: Fenster- und Clientmodell + +Ziel: Die Anwendung verhält sich konsequent fensterbasiert und aktualisiert +offene Fenster bei Änderungen. + +- [x] Webclient öffnet angemeldete Arbeitsbereiche als Fenster +- [x] Desktopclient öffnet Benutzerrechte als Fenster +- [x] Gemeinsames Fensterkonzept dokumentieren +- [x] Webclient-Fenster verschiebbar machen +- [x] Webclient-Fenstergröße änderbar machen +- [x] Webclient-Fensterstatus pro Benutzer lokal speichern +- [x] Desktopclient-Fenster für Firmendaten ergänzen +- [x] Desktopclient-Fenster für Freischaltung ergänzen +- [x] Einheitliche Fensteraktionen definieren: Öffnen, Schließen, Fokussieren, + Aktualisieren +- [x] Live-Update-Store im Webclient für Stammdaten einführen +- [x] Live-Update-Store im Desktopclient für Stammdaten einführen +- [x] Konfliktverhalten bei paralleler Bearbeitung definieren + +## Phase 3: Stammdaten + +Ziel: Kunden, Lieferanten, Artikel und Aktivitäten sind als erste fachliche +Objekte vollständig nutzbar. + +- [x] Datenmodell Kunden finalisieren +- [x] Migration Kunden erstellen +- [x] Backend-CRUD Kunden implementieren +- [x] Web-Fenster Kundenliste und Kundendetail implementieren +- [x] Desktop-Fenster Kundenliste und Kundendetail implementieren +- [x] Kundenrabatt und Skonto beim Kunden ablegen +- [x] Datenmodell Lieferanten finalisieren +- [x] Migration Lieferanten erstellen +- [x] Backend-CRUD Lieferanten implementieren +- [x] Lieferanten-Skonto ablegen +- [x] Datenmodell Artikel finalisieren +- [x] Migration Artikel erstellen +- [x] Backend-CRUD Artikel implementieren +- [x] Artikelpreise historisieren +- [x] Datenmodell Aktivitäten finalisieren +- [x] Migration Aktivitäten erstellen +- [x] Backend-CRUD Aktivitäten implementieren +- [x] Web-Fenster für Lieferanten, Artikel und Aktivitäten implementieren +- [x] Desktop-Fenster für Lieferanten, Artikel und Aktivitäten implementieren +- [x] Live-Events für Stammdatenänderungen senden + +## Phase 4: Angebote und Rechnungen + +Ziel: Angebote und Rechnungen bilden den ersten produktiven Arbeitsablauf. + +- [x] Nummernkreise für Angebote und Rechnungen produktionsreif machen +- [x] Nummernkreise für Kunden, Lieferanten, Artikel und Aktivitäten anbinden +- [x] Nummernkreis-Verwaltung im Webclient und Desktopclient bereitstellen +- [x] Datenmodell Angebote finalisieren +- [x] Backend-CRUD Angebote implementieren +- [x] Angebotspositionen nur aus vorhandenen Artikeln erlauben +- [x] Positionspreis pro Angebot individuell überschreibbar machen +- [x] Web-Fenster Angebot erstellen +- [x] Desktop-Fenster Angebot erstellen +- [x] Angebot zu Ausgangsrechnung umwandeln +- [x] Datenmodell Ausgangsrechnungen finalisieren +- [x] Rechnungspositionen nur aus vorhandenen Artikeln erlauben +- [x] Positionspreis pro Rechnung individuell überschreibbar machen +- [x] Kundenrabatt und Skonto automatisch vorschlagen +- [x] Rechnung revisionssicher abschließen +- [x] Storno-/Korrekturrechnung vorbereiten +- [x] Datenmodell Eingangsrechnungen finalisieren +- [x] Eingangsrechnungen Lieferanten zuordnen +- [x] Lieferanten-Skonto berücksichtigen +- [x] Web-Fenster Ausgangs- und Eingangsrechnungen erstellen +- [x] Desktop-Fenster Ausgangs- und Eingangsrechnungen erstellen + +## Phase 5: Import und Preisaktualisierung + +Ziel: Artikellisten und externe APIs aktualisieren Preise nachvollziehbar. + +- [x] Importformat CSV definieren +- [x] Importformat Excel prüfen und als spätere Erweiterung zurückstellen +- [x] Importvorschau im Backend vorbereiten +- [x] Preislistenimport mit Mapping speichern +- [x] Preisänderungen historisieren +- [x] Preisregeln je Lieferant/Quelle definieren +- [x] API-Connector-Grundstruktur anlegen +- [x] Externe Preis-API-Konfiguration verschlüsselt speichern +- [x] Manueller Preisabgleich +- [x] Geplanter Preisabgleich vorbereiten: Intervall und letzter Abgleich werden gespeichert +- [x] Live-Update an offene Angebots-/Rechnungsfenster senden +- [x] Native-Client-Fenster für Preislisten, Preis-APIs und Preisregeln anbinden + +## Phase 6: Kommunikation und Dokumente + +Ziel: Kommunikation, Dokumente und Historie werden je Firma verwaltet. + +- [x] Datenmodell Kommunikation finalisieren +- [x] Kommunikation Kunden/Lieferanten/Vorgängen/Rechnungen zuordnen +- [x] Dokumentenspeicher-Layout festlegen +- [x] Dokumenten-Metadaten verschlüsselt speichern +- [x] Upload-Endpunkt implementieren +- [x] Download-Endpunkt implementieren +- [x] Rechteprüfung für Dokumentzugriff +- [x] Audit-Log für Dokumentzugriffe +- [x] Web-Fenster für Kommunikation und Dokumente anbinden + +## Phase 7: Sicherheit und Betrieb + +Ziel: Öffentlicher Server und lokale Installation sind trennbar und sicher +betreibbar. + +- [x] Produktives Authentifizierungskonzept implementieren +- [x] Passwort-Reset implementieren +- [x] Einladung mit sicherem Token statt Passwortanzeige implementieren +- [x] E-Mail-Outbox und produktiven Datei-Transport anbinden +- [x] Dev-Ausgabe für E-Mail-Inhalte klar vom Produktivbetrieb trennen +- [x] Mandantenschema-Erzeugung transaktional absichern +- [x] Verschlüsselungsschlüssel-Konzept für Betrieb dokumentieren +- [x] Schlüsselrotation planen +- [x] Backup/Restore je Firma dokumentieren +- [x] Docker-Setup für Backend erweitern +- [x] Reverse-Proxy/TLS-Beispiel bereitstellen +- [x] Installationsprogramm für lokale Einzelkunden-Version planen + +## Phase 8: Qualitätssicherung + +Ziel: Kernabläufe sind reproduzierbar testbar. + +- [x] API-Onboarding-Test erweitern: Registrierung, Freischaltung, Login, + Rechteänderung +- [x] Kommunikationstest um Live-Events für fachliche Daten erweitern +- [x] Migrationstest für bestehende Firmenschemas +- [x] Rechteprüfung negativ testen +- [x] Webclient-Build und Typprüfung in Standardcheck aufnehmen +- [x] Desktopclient-Headless-Tests erweitern +- [x] Datenbank-Testsetup dokumentieren +- [x] Testdaten-Seed für lokale Entwicklung anlegen + +## Aktueller Nächster Schritt + +1. Optimierungen und Fehlerbehebungen priorisieren. +2. Benutzereinstellungen für die Navigation sind umgesetzt: scrollbar, + oder einklappbare Gruppen je Benutzer. diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..1d61b54 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,395 @@ +# Installation + +Diese Anleitung beschreibt die Installation einer lokalen Company-Tool-Instanz +mit PostgreSQL und Backend. Sie ist so aufgebaut, dass sie später als Grundlage +für Installationen bei Organisationen verwendet werden kann. + +## Voraussetzungen + +Für eine lokale Installation werden benötigt: + +- PostgreSQL 16 oder neuer +- Rust/Cargo +- Node.js 24 oder neuer, für Webfrontend und Kommunikationstests + +Optional: + +- Docker oder Podman, wenn PostgreSQL als Container laufen soll + +## PostgreSQL per Docker Compose + +Im Projekt liegt eine vorbereitete PostgreSQL-Konfiguration. + +```bash +docker compose up -d postgres +``` + +Die Standardwerte sind: + +- Host: `localhost` +- Port: `5432` +- Datenbank: `companytool` +- Benutzer: `companytool` +- Passwort: `companytool` + +Danach `.env` anlegen: + +```bash +cp .env.example .env +``` + +Verbindung prüfen: + +```bash +psql "postgres://companytool:companytool@localhost:5432/companytool" -c "select 1;" +``` + +## PostgreSQL manuell anlegen + +Falls PostgreSQL bereits auf einem Server installiert ist, kann die Datenbank +manuell angelegt werden. + +Als PostgreSQL-Admin ausführen: + +```sql +create user companytool with password 'companytool'; +create database companytool owner companytool; +``` + +Empfohlene produktive Anpassungen: + +- eigenes starkes Passwort verwenden +- Datenbank nur aus dem internen Netz erreichbar machen +- PostgreSQL-Backups einrichten +- Zugriff über Firewall einschränken + +Beispiel mit sicherem Passwort: + +```sql +create user companytool with password ''; +create database companytool owner companytool; +``` + +Dann `.env` anpassen: + +```env +DATABASE_URL=postgres://companytool:@localhost:5432/companytool +BACKEND_BIND=127.0.0.1:8080 +VITE_WS_URL=ws://localhost:8080/ws +``` + +## Backend starten + +```bash +cargo run -p companytool-backend +``` + +Beim Start führt das Backend aktuell die Basistabelle für den Testdatensatz +automatisch aus und legt bei Bedarf einen ersten Datensatz an. Die SQLx- +Migrationen unter `backend/migrations/` werden beim Backend-Start automatisch +ausgeführt. + +Health-Check: + +```bash +curl http://127.0.0.1:8080/health +``` + +Erwartete Antwort: + +```json +{"status":"ok"} +``` + +## Verschlüsselte Kommunikation testen + +Backend in einem Terminal starten: + +```bash +cargo run -p companytool-backend +``` + +In einem zweiten Terminal: + +```bash +node scripts/communication-test.mjs +``` + +Der Test prüft: + +- zwei echte WebSocket-Clients +- `hello`/`hello_ack`-Handshake +- eigener AES-256-GCM-Session-Key pro Client +- verschlüsselter Snapshot vom Backend +- verschlüsselte `subscribe`- und `ping`-Nachrichten +- verschlüsseltes `pong`-Event an beide Clients +- keine fachlichen Klartextmarker in den Rohframes + +Erwartetes Ergebnis: + +```text +client-a handshake, encrypted snapshot and subscribe ok +client-b handshake, encrypted snapshot and subscribe ok +encrypted multi-client communication test ok +``` + +## Onboarding-API testen + +Mit laufendem Backend: + +```bash +node scripts/api-onboarding-test.mjs http://127.0.0.1:8080 +``` + +Der Test legt eine Organization-Registrierung an, lädt Liste und Detail, +schaltet die Organization frei, testet den Login mit Initialpasswort, speichert +Firmendaten, prüft Rechte-Negativfälle, lädt einen weiteren User ein und prüft +die Rechteänderung. + +## Schema-Migration testen + +Mit laufendem Backend und PostgreSQL: + +```bash +node scripts/schema-migration-test.mjs http://127.0.0.1:8080 +``` + +Der Test legt eine Firma an, führt die Mandantenschema-Provisionierung erneut +aus und prüft, dass Nummernkreise, Rollen und spätere Tabellen wie +Kommunikation weiterhin funktionieren. Dadurch werden idempotente +Mandantenmigrationen früh erkannt. + +## Live-Events testen + +Mit laufendem Backend und PostgreSQL kann der Kommunikationstest zusätzlich +eine fachliche Änderung über die REST-API auslösen: + +```bash +node scripts/communication-test.mjs ws://127.0.0.1:8080/ws http://127.0.0.1:8080 +``` + +Der Test prüft dann neben Handshake, Verschlüsselung und Ping/Pong auch, dass +eine neu angelegte Aktivität als verschlüsseltes `record_changed`-Event bei +zwei verbundenen Clients ankommt. + +## Standardcheck + +Der Standardcheck bündelt die schnellen Prüfungen ohne laufendes Backend: + +```bash +bash scripts/standard-check.sh +``` + +Enthalten sind: + +- Rust-Formatprüfung +- `cargo check --workspace` +- Headless-Unit-Tests des Desktopclients +- Syntaxprüfung der Node-Testskripte +- Webfrontend-Build inklusive Vue/TypeScript-Prüfung + +## Lokale Testdaten anlegen + +Mit laufendem Backend kann ein reproduzierbarer Entwicklungsdatensatz erzeugt +werden: + +```bash +node scripts/dev-seed.mjs http://127.0.0.1:8080 +``` + +Das Skript nutzt den Dev-Bootstrap, legt eine Firma, einen Admin-Zugang, +Skonto, Kunde, Lieferant, Artikel und Aktivität an und gibt die Zugangsdaten +als JSON aus. Der Endpunkt ist nur im Entwicklungsbetrieb vorgesehen. + +## Kommunikation ohne PostgreSQL testen + +Falls PostgreSQL noch nicht eingerichtet ist, kann nur die +Kommunikationsschicht getestet werden. + +Terminal 1: + +```bash +COMMUNICATION_TEST_MODE=1 cargo run -p companytool-backend +``` + +Terminal 2: + +```bash +node scripts/communication-test.mjs +``` + +Dieser Modus ist nur für Entwicklung und Tests gedacht. + +## Webfrontend starten + +```bash +cd web-frontend +npm install +npm run dev +``` + +Standard-URL: + +```text +http://localhost:5175/ +``` + +Im Entwicklungsbetrieb leitet Vite `/api` und `/ws` standardmäßig an +`http://127.0.0.1:8080` weiter. Wenn das Backend auf einem anderen Port läuft, +wird die Zieladresse so gesetzt: + +```bash +VITE_BACKEND_ORIGIN=http://127.0.0.1:18084 npm run dev +``` + +### Lokalen Testzugang ohne E-Mail anlegen + +Im Entwicklungsbuild ist auf der Login-Seite ein Dev-Bootstrap verfügbar. Damit +wird eine lokale Testfirma mit erstem User angelegt. Das Initialpasswort wird +direkt in der Oberfläche angezeigt, weil auf Entwicklungsrechnern kein +E-Mail-Versand vorausgesetzt wird. + +Alternativ per API: + +```bash +curl -s -X POST http://127.0.0.1:8080/api/v1/dev/bootstrap-local \ + -H 'Content-Type: application/json' \ + -d '{"organization_name":"Lokale Testfirma","email":"admin@example.test"}' +``` + +Produktiv ist dieser Endpunkt nicht vorgesehen. Er ist nur im Debug-Build oder +mit `COMPANYTOOL_DEV_MODE=1` aktiv. + +## Desktopclient starten + +Der native Client liest seine Server-Konfiguration aus: + +```text +desktop-client/companytool-client.toml +``` + +Minimaler Inhalt: + +```toml +[server] +api_base_url = "http://localhost:8080" +ws_url = "ws://localhost:8080/ws" +``` + +API- und WebSocket-URL können überschrieben werden: + +```bash +cargo run -p companytool-desktop-client -- --api-url http://127.0.0.1:8080 --ws-url ws://127.0.0.1:8080/ws +``` + +Oder mit expliziter Config-Datei: + +```bash +cargo run -p companytool-desktop-client -- --config /pfad/companytool-client.toml +``` + +Alternativ per Umgebungsvariablen: + +```bash +COMPANYTOOL_CLIENT_CONFIG=/pfad/companytool-client.toml cargo run -p companytool-desktop-client +COMPANYTOOL_API_BASE_URL=http://127.0.0.1:8080 cargo run -p companytool-desktop-client +COMPANYTOOL_WS_URL=ws://127.0.0.1:8080/ws cargo run -p companytool-desktop-client +``` + +Start mit Standardkonfiguration: + +```bash +cargo run -p companytool-desktop-client +``` + +## Nativen Client testen + +Der native Client hat Headless-Tests. Dabei wird kein Fenster geöffnet. + +### Kommunikation + +Backend starten: + +```bash +COMMUNICATION_TEST_MODE=1 cargo run -p companytool-backend +``` + +Test ausführen: + +```bash +cargo run -p companytool-desktop-client -- --communication-test +``` + +Optional gegen eine andere WebSocket-URL oder Config-Datei: + +```bash +cargo run -p companytool-desktop-client -- --communication-test --ws-url ws://127.0.0.1:18081/ws +cargo run -p companytool-desktop-client -- --communication-test --config /pfad/companytool-client.toml +``` + +Erwartetes Ergebnis: + +```text +native client communication test ok +``` + +### Registrierung + +Für den Registrierungstest muss das Backend mit PostgreSQL laufen: + +```bash +cargo run -p companytool-backend +``` + +Test ausführen: + +```bash +cargo run -p companytool-desktop-client -- --registration-test --api-url http://127.0.0.1:8080 +``` + +Optional können Firmenname und E-Mail gesetzt werden: + +```bash +cargo run -p companytool-desktop-client -- --registration-test --api-url http://127.0.0.1:8080 --organization-name "Beispiel GmbH" --email admin@example.org +``` + +Erwartetes Ergebnis: + +```text +native client registration test ok +``` + +## Hinweise für Organisations-Installationen + +Für Installationen bei Organisationen gelten zusätzlich: + +- produktiv nur HTTPS/WSS verwenden +- PostgreSQL-Passwort individuell vergeben +- `.env` nicht an Dritte weitergeben +- regelmäßige Datenbank-Backups einrichten +- Zugriff auf PostgreSQL nicht öffentlich freigeben +- Backend hinter Reverse Proxy betreiben +- Zertifikate für lokale oder öffentliche Nutzung sauber einrichten +- spätere Versionen werden genau eine Organisation pro lokaler Installation + unterstützen + +## Fehlerbehebung + +PostgreSQL nicht erreichbar: + +```bash +pg_isready -h localhost -p 5432 +``` + +Backend kann Datenbank nicht öffnen: + +- `DATABASE_URL` in `.env` prüfen +- PostgreSQL-Dienst prüfen +- Benutzer, Passwort und Datenbanknamen prüfen + +Port bereits belegt: + +```bash +BACKEND_BIND=127.0.0.1:18080 cargo run -p companytool-backend +node scripts/communication-test.mjs ws://127.0.0.1:18080/ws +``` diff --git a/PLANUNG.md b/PLANUNG.md new file mode 100644 index 0000000..e4e1fa4 --- /dev/null +++ b/PLANUNG.md @@ -0,0 +1,2334 @@ +# Planung Firmen-Software + +Stand: 2026-05-22 + +## Zielbild + +Die Software wird als mehrplatzfähiges Firmensystem geplant: + +- lokaler Betrieb mit eigenem Server bei einer Firma +- öffentlicher SaaS-Betrieb mit vielen Firmen auf einer Instanz +- Backend in Rust +- PostgreSQL als Datenbank +- Kommunikation zwischen Clients und Backend per WebSocket für Live-Daten +- zusätzliche REST-API für externe Zugriffe, Importe und Integrationen +- Webfrontend für Browser +- nativer Desktopclient für Linux, Windows und macOS +- gleiche fachliche Funktionen in Webclient und Desktopclient +- Live-Aktualisierung der UI, wenn Backend-Daten geändert werden +- verschlüsselte Kommunikation zwischen Clients und Backend +- Offline-Fähigkeit im Desktopclient +- automatische Updates für den Desktopclient + +## Begriffe + +Der Begriff `Kunde` wird nur für Kunden einer Firma verwendet, also z.B. für +Rechnungsempfänger. + +Für die Betreiber-/Mandantenebene wird stattdessen `Firma` verwendet. Eine Firma +ist die Organisation, die das System nutzt und ein eigenes PostgreSQL-Schema +bekommt. + +Technische Begriffe: + +- `Firma`: fachlicher Mandant, also Nutzerorganisation des Systems +- `organization`: technischer Name für eine Firma in Code, API und Datenbank +- `Firmenschema`: PostgreSQL-Schema einer Firma, z.B. `company_` +- `User`: globaler Benutzeraccount in `public` +- `Kunde`: Kunde einer Firma innerhalb eines Firmenschemas +- `Vorgang`: fachlicher Sammelbegriff für Aktivität, Aufgabe, Wiedervorlage, + Termin oder interne Bearbeitung mit Bezug zu Kunden, Lieferanten, Dokumenten + oder Belegen +- `activity`: technischer Name für einen Vorgang in Code, API und Datenbank + +Namensregel: + +- Tabellen, Spalten, API-Felder und Code-Bezeichner sind englisch. +- Die UI zeigt deutsche Begriffe und Labels. +- Deutsche UI-Texte, Fehlermeldungen und Dokumentation verwenden echte Umlaute + (`ä`, `ö`, `ü`, `Ä`, `Ö`, `Ü`) und `ß`. ASCII-Umschreibungen wie `ae`, + `oe`, `ue` oder `ss` werden nur in technischen Bezeichnern verwendet, wenn + das nötig ist. +- Beispiel: Datenbank/API `organization`, UI `Firma`. + +## Mandantenmodell + +Jede Firma bekommt ein eigenes PostgreSQL-Schema. Das trennt operative Daten im +SaaS-Betrieb klar voneinander und funktioniert gleichzeitig für lokale +Einzelfirmen-Installationen. + +Wichtige Entscheidung: + +- In `public` liegen nur globale Daten, die für Login und Firmenzuordnung + notwendig sind. +- Alle fachlichen Daten, Rollen, Rechte, Einstellungen, Nummernkreise und Logs + liegen im jeweiligen Firmenschema. +- Ein User kann mehreren Firmen zugeordnet sein. +- Eine Firma verwaltet nicht mehrere weitere Firmen/Mandanten in derselben + Installation. + +### `public`-Schema + +`public` bleibt bewusst klein. + +Geplante Tabellen: + +- `users` +- `organizations` +- `user_organizations` +- `organization_domains`, optional für SaaS-Subdomains +- `auth_identities`, für Passwortlogin, SSO und externe Identity Provider +- `sessions` oder `refresh_tokens` + +Nicht in `public`: + +- Rollen +- Rechte +- Audit-Log +- Fachmodule +- Nummernkreise +- API-Zugangsdaten +- Firmeneinstellungen + +Diese Daten gehören in das jeweilige Firmenschema. + +### Firmenschema + +Schema-Namensvorschlag: + +- `company_` + +`organization_id` ist eine technische, nicht sprechende Kennung. Schema-Namen +enthalten keine Firmennamen. + +Geplante Tabellen je Firmenschema: + +- `roles` +- `permissions` +- `role_permissions` +- `user_roles` +- `settings` +- `number_ranges` +- `audit_log` +- `customers` +- `suppliers` +- `contacts` +- `addresses` +- `items` +- `item_price_sources` +- `item_prices` +- `price_rules` +- `customer_price_terms` +- `supplier_price_terms` +- `cash_discount_terms` +- `stock_movements` +- `quotes` +- `quote_versions` +- `quote_items` +- `incoming_invoices` +- `incoming_invoice_items` +- `outgoing_invoices` +- `outgoing_invoice_items` +- `communications` +- `activities` +- `activity_links` +- `tasks` +- `document_templates` +- `documents` +- `document_versions` +- `imports` +- `import_mappings` +- `api_connectors` +- `webhooks` + +### Mandantenauflösung + +Vorgeschlagener Ablauf: + +1. User meldet sich global an. +2. Backend liest aus `public.user_organizations`, welchen Firmen der User zugeordnet ist. +3. Falls der User mehrere Firmen hat, wählt er eine aktive Firma aus. +4. Backend erstellt ein Token oder eine Session mit aktiver `organization_id`. +5. Jeder REST-Request und jede WebSocket-Verbindung wird gegen diese aktive Firma + geprüft. +6. Datenbankzugriffe setzen kontrolliert das passende Firmenschema. + +Für den öffentlichen Betrieb kann zusätzlich eine Subdomain genutzt werden: + +- `firma-a.example.com` +- `firma-b.example.com` + +Die Subdomain darf aber nur eine Vorauswahl sein. Die finale Berechtigung kommt +aus `public.user_organizations`. + +## Betriebsarten + +### Eigener Server bei einer Firma + +- eine Backend-Instanz +- eine PostgreSQL-Datenbank +- genau eine Firma +- optional mehrere User +- dieselbe Mandantenarchitektur wie im SaaS-Betrieb +- Installation per Docker Compose, Systemdienst oder Installationsprogramm +- keine öffentliche Selbstregistrierung +- Firmenschema wird während Installation oder beim ersten Start erzeugt +- erster Firmen-User wird während Installation oder Erstkonfiguration angelegt + +Die lokale Version soll bewusst nur eine Firma verwalten können. Dadurch bleibt +die lokale Installation einfacher, nutzt intern aber trotzdem dieselbe +Firmenschema-Architektur wie der öffentliche Server. + +### Öffentlicher SaaS-Server + +- Betrieb durch uns +- eine oder mehrere Backend-Instanzen +- zentrale PostgreSQL-Datenbank +- viele Firmen, jeweils eigenes Schema +- TLS zwingend +- Backup, Monitoring, Rate-Limits und Mandantentrennung zwingend +- Benutzer können mehreren Firmen zugeordnet sein +- öffentliche Registrierung möglich +- Freischaltung neuer Firmen erfolgt durch einen Administrator +- vor Freischaltung ist kein produktiver Zugriff auf das Firmenschema möglich + +## Registrierung und Freischaltung + +### Öffentliche Registrierung + +Für den öffentlichen Server gibt es eine Registrierungsmaske. + +Pflichtangaben: + +- Firmenname +- E-Mail-Adresse + +Ablauf: + +1. Interessent gibt Firmenname und E-Mail-Adresse ein. +2. Server legt einen globalen User in `public.users` an, falls die E-Mail noch + nicht existiert. +3. Server legt einen Firmeneintrag in `public.organizations` mit Status `pending_approval` + an. +4. Server legt die Zuordnung in `public.user_organizations` an. +5. Server erzeugt das Firmenschema entweder sofort im gesperrten Zustand oder + erst nach Freischaltung. Bevorzugt: Schema erst nach Admin-Freischaltung + erzeugen, damit abgelehnte Registrierungen keine operativen Strukturen + hinterlassen. +6. Ein Administrator prüft und schaltet die Firma frei. +7. Bei Freischaltung erzeugt das Backend das Firmenschema, Basistabellen, + Standardrollen, Standardrechte und Grundeinstellungen. +8. Server erzeugt ein zufälliges Initialpasswort. +9. Server sendet eine E-Mail an die registrierte Adresse. +10. User meldet sich mit E-Mail-Adresse und Initialpasswort an. +11. Beim ersten Login muss das Passwort sofort geändert werden. +12. Danach kann der User Firmendaten, Einstellungen, Nummernkreise, Vorlagen und + weitere Stammdaten festlegen. + +Die E-Mail-Adresse ist der Username. + +### Admin-Freischaltung + +Neue Firmenregistrierungen stehen in einer Admin-Oberfläche. + +Admin-Aktionen: + +- Registrierung ansehen +- Firma freischalten +- Registrierung ablehnen +- E-Mail erneut senden +- Firmenschema-Erstellung erneut versuchen, falls ein technischer Fehler auftrat + +Statusmodell für `public.organizations`: + +- `pending_approval` +- `approved` +- `active` +- `rejected` +- `suspended` + +Vorgeschlagene Bedeutung: + +- `pending_approval`: Registrierung eingegangen, aber noch nicht freigegeben +- `approved`: durch Admin freigegeben, technische Anlage läuft oder steht bevor +- `active`: Firma ist nutzbar +- `rejected`: Registrierung wurde abgelehnt +- `suspended`: Firma wurde nachträglich gesperrt + +### Initialpasswort und Passwortwechsel + +Initialpasswörter werden zufällig erzeugt und per E-Mail versendet. + +Sicherheitsregeln: + +- Passwort wird nie im Klartext gespeichert. +- Passwort-Hash liegt in `public.users.password_hash`. +- User bekommt Status `must_change_password`. +- Beim ersten Login sind nur Passwortänderung und Logout erlaubt. +- Initialpasswort sollte zeitlich ablaufen. +- Nach erfolgreicher Änderung wird `must_change_password` entfernt. + +Optional spätere Verbesserung: + +- Statt Klartextpasswort per E-Mail wird ein einmaliger Set-Password-Link + verschickt. Das ist sicherer, aber der aktuell geplante Ablauf ist: + zufälliges Passwort per E-Mail. + +### User zur Firma einladen + +Ein berechtigter Firmen-User kann weitere User einladen. + +Ablauf: + +1. Firmen-User gibt neue E-Mail-Adresse ein. +2. Backend prüft Recht, z.B. `users.invite`. +3. Falls die E-Mail in `public.users` noch nicht existiert, wird ein globaler + User angelegt. +4. Backend legt oder aktualisiert `public.user_organizations`. +5. Im Firmenschema werden die gewünschten Rollen in `user_roles` vergeben. +6. Backend erzeugt ein zufälliges Initialpasswort oder ein neues Initialpasswort, + falls der User noch kein aktives Passwort für diese Installation hat. +7. Backend setzt `must_change_password`. +8. Backend sendet eine Einladung per E-Mail. +9. Eingeladener User meldet sich mit E-Mail und Initialpasswort an. +10. Passwortänderung ist beim ersten Login zwingend. + +Wenn ein User bereits in einer anderen Firma aktiv ist, darf dessen bestehendes +Passwort nicht ungefragt überschrieben werden. In diesem Fall sollte die +Einladung als Firmenzuordnung erfolgen und der User sich mit seinem bestehenden +Login anmelden. Für diesen Fall kann die Einladung einen Hinweis statt eines +neuen Passworts senden. + +### Lokale Erstinstallation + +Für lokale Installationen wird ein eigener Erstkonfigurationsprozess geplant. + +Ablauf: + +1. Installation richtet Backend und PostgreSQL ein. +2. Beim ersten Start erkennt das Backend, dass keine Firma existiert. +3. Setup-Maske fragt Firmenname, Admin-E-Mail und Initialpasswort ab oder erzeugt + ein zufälliges Passwort. +4. Backend erstellt genau eine Firma und genau ein Firmenschema. +5. Admin-User wird als `owner` zugeordnet. +6. Danach wird der Setup-Modus dauerhaft deaktiviert. + +Eine zweite Firma darf in der lokalen Version nicht angelegt werden. + +## Sicherheit + +Mindestanforderungen: + +- TLS für öffentlichen Betrieb +- Zertifikatsverwaltung über Reverse Proxy, z.B. Caddy, Traefik oder Nginx +- zusätzliche sessionbasierte Nutzdatenverschlüsselung oberhalb von HTTPS/WSS +- keine fachlichen Nutzdaten unverschlüsselt über das Netzwerk +- keine fachlichen Nutzdaten unverschlüsselt in PostgreSQL +- Passwort-Hashing mit Argon2id oder vergleichbar +- Zwei-Faktor-Authentifizierung +- Single Sign-On +- rollenbasierte Rechte +- Rechte pro atomarer Aktion mit Lesen, Schreiben und Löschen +- firmenschema-bewusste Datenbankzugriffe +- Audit-Log je Firmenschema +- sichere Speicherung von API-Zugangsdaten +- Backups und Restore-Konzept je Firma +- Schutz gegen unautorisierte WebSocket-Abos +- Initialpasswörter laufen ab +- Passwortwechsel beim ersten Login erzwingen +- Admin-Freischaltung für SaaS-Registrierungen + +## Kommunikation und Verschlüsselung + +### Grundsatz + +Alle produktiven Verbindungen zwischen Client und Backend müssen verschlüsselt +sein. + +Protokolle: + +- REST über HTTPS +- WebSocket über WSS + +Unverschlüsselte Verbindungen sind nicht vorgesehen. Das gilt auch für lokale +Installationen. Für Entwicklung können gesonderte Debug-Schalter existieren, sie +dürfen aber nicht Teil des produktiven Betriebs sein. + +Zusätzlich zu HTTPS/WSS werden fachliche Nutzdaten auf Anwendungsebene +verschlüsselt. TLS schützt den Transportkanal, die Anwendungsschicht- +Verschlüsselung schützt die eigentlichen Payloads innerhalb der Session. + +Grundsatz: + +- keine fachlichen Nutzdaten werden unverschlüsselt übertragen +- keine fachlichen Nutzdaten werden unverschlüsselt in PostgreSQL gespeichert +- Metadaten werden nur dann unverschlüsselt gespeichert, wenn sie technisch für + Routing, Login, Rechteprüfung, Indizes oder Betrieb zwingend notwendig sind +- Klartext existiert nur kurzzeitig im Backend-Prozessspeicher während der + Verarbeitung + +### Sessionbasierte Nutzdatenverschlüsselung + +Für jede angemeldete Client-Session wird ein eigener Session-Schlüssel +ausgehandelt oder vom Backend sicher bereitgestellt. + +Ziel: + +- REST-Payloads zusätzlich zu HTTPS verschlüsseln +- WebSocket-Payloads zusätzlich zu WSS verschlüsseln +- Replay und Manipulation über AEAD-Verfahren verhindern +- Session-Schlüssel beim Logout, Passwortwechsel, User-Sperre oder Ablauf + ungültig machen + +Empfohlenes Verfahren: + +- asymmetrischer Schlüsselaustausch beim Login oder bei Firmenauswahl +- danach symmetrische Verschlüsselung pro Session +- AEAD-Verfahren, z.B. AES-256-GCM oder XChaCha20-Poly1305 +- jede Nachricht bekommt Nonce und Authentifizierungstag +- Nonces dürfen pro Session-Schlüssel nie wiederverwendet werden +- Protokollversion und Nachrichtentyp werden als Additional Authenticated Data + einbezogen + +Abstraktes Nachrichtenformat: + +```json +{ + "enc": "v1", + "kid": "session-key-id", + "nonce": "...", + "ciphertext": "...", + "tag": "..." +} +``` + +Für WebSocket bedeutet das: + +- `hello` und Schlüsselaushandlung laufen vor verschlüsselten Fachnachrichten +- danach sind Subscribe-, Command- und Event-Payloads verschlüsselt +- technische Hüllfelder dürfen sichtbar bleiben, soweit sie für Routing nötig + sind, z.B. `enc`, `kid`, `nonce` + +Für REST bedeutet das: + +- Authentifizierung und Sessionaufbau laufen über HTTPS +- fachliche Request- und Response-Bodies werden danach verschlüsselt +- Datei-Uploads werden ebenfalls clientseitig oder vor Speicherung verschlüsselt + +### Datenbankverschlüsselung + +PostgreSQL speichert keine fachlichen Nutzdaten im Klartext. + +Geplantes Modell: + +- feld- oder dokumentbasierte Verschlüsselung auf Anwendungsebene +- PostgreSQL sieht für fachliche Felder nur Ciphertext +- je Firma eigener Data Encryption Key +- je Datensatz oder je Feld eigene Nonce +- AEAD mit Authentifizierungstag +- Schlüsselmaterial liegt nicht im Firmenschema +- Master-Key kommt aus Server-Konfiguration, Secret Store oder später HSM/KMS + +Schlüsselhierarchie: + +1. Master Key, außerhalb der Datenbank +2. Firmenschlüssel pro Firma +3. optional Bereichs- oder Tabellenschlüssel +4. Datensatzverschlüsselung mit eigener Nonce + +Konsequenzen: + +- Volltextsuche auf verschlüsselten Feldern ist nicht direkt möglich +- Sortierung und Filterung auf verschlüsselten Klartextwerten ist eingeschränkt +- für benötigte Suche braucht es bewusst geplante Such-/Indexwerte +- solche Suchwerte dürfen keine sensiblen Klartexte enthalten +- Logs, Audit-Log und Change-Log dürfen keine entschlüsselten Nutzdaten enthalten + +Pragmatische Einordnung: + +- Passwörter werden nicht verschlüsselt, sondern gehasht. +- technische IDs, Statuswerte, Sequenzen und Foreign Keys können als notwendige + Metadaten unverschlüsselt bleiben, wenn sie für Betrieb, Routing, + Rechteprüfung und referenzielle Integrität nötig sind. +- besonders sensible Inhalte wie Adressen, Kommunikationsdaten, Rechnungsinhalte, + Dokumentmetadaten, API-Secrets und Preise werden verschlüsselt gespeichert. + +### Öffentlicher Server + +Der öffentliche Server läuft hinter einem Reverse Proxy. + +Empfohlene Aufgaben des Reverse Proxy: + +- TLS-Terminierung +- Zertifikatsverwaltung +- HTTP-zu-HTTPS-Redirect +- Weiterleitung von WebSocket-Upgrades +- Rate-Limits für Login und Registrierung +- Request-Größenlimits für Uploads +- Security Header + +Geeignete Optionen: + +- Caddy +- Traefik +- Nginx + +Empfehlung für den Start: + +- Caddy oder Traefik, weil automatische Zertifikatsverwaltung einfacher ist. + +Backend-intern: + +- Backend lauscht nur im internen Netz oder auf localhost. +- Extern erreichbar ist nur der Reverse Proxy. +- Der Reverse Proxy leitet an das Backend weiter. + +### Zertifikate + +Öffentlicher SaaS-Betrieb: + +- Zertifikate automatisch per ACME/Let's Encrypt +- automatische Erneuerung +- TLS mindestens Version 1.2, bevorzugt TLS 1.3 +- HSTS nach stabiler Domain- und Zertifikatskonfiguration aktivieren + +Lokaler Betrieb: + +- ebenfalls HTTPS/WSS +- möglich über selbstsigniertes Zertifikat oder lokale Zertifizierungsstelle +- Installationsprogramm kann ein lokales Zertifikat erzeugen +- Desktopclient muss lokale Zertifikate sauber vertrauen können + +Für lokale Installationen muss das Installationsprogramm die Zertifikatsfrage +lösen, z.B. durch lokale Zertifizierungsstelle, Zertifikatimport oder klaren +Administrationsdialog. + +### REST-Kommunikation + +REST wird für zustandsändernde Befehle, Dateiübertragungen und externe +Integrationen verwendet. + +Eigenschaften: + +- JSON als Standardformat +- HTTPS verpflichtend im SaaS-Betrieb +- Bearer Token im `Authorization`-Header +- API-Version im Pfad, z.B. `/api/v1/...` +- fachliche Request- und Response-Bodies werden nach Sessionaufbau zusätzlich + verschlüsselt +- idempotente Endpunkte dort, wo Wiederholungen realistisch sind +- Request-ID pro Anfrage für Logging und Fehlersuche + +Beispiel-Header: + +```text +Authorization: Bearer +X-Request-Id: +``` + +### WebSocket-Kommunikation + +WebSocket wird für Live-Updates, Subscriptions und schnelle UI-Aktualisierung +verwendet. + +Öffentlicher Betrieb: + +- ausschließlich `wss://` +- WebSocket-Verbindung erst nach Login und Firmenauswahl +- Authentifizierung über kurzlebigen Socket-Token oder Access Token +- aktive Firma ist im Token oder in der Socket-Startnachricht festgelegt +- jede Subscription wird gegen Rechte geprüft + +Empfohlener Verbindungsaufbau: + +1. Client meldet sich per REST an. +2. Client wählt aktive Firma. +3. Backend gibt Access Token und optional separaten kurzlebigen WebSocket-Token. +4. Client öffnet `wss://server/ws`. +5. Client sendet eine `hello`-Nachricht mit Token und gewünschter Protokollversion. +6. Backend prüft Token, Firma, Userstatus und Rechte. +7. Backend bestätigt mit `hello_ack`. +8. Client abonniert Topics. + +Beispielnachrichten: + +```json +{ + "type": "hello", + "protocol_version": 1, + "token": "" +} +``` + +```json +{ + "type": "subscribe", + "topic": "customers", + "since_sequence": 12345 +} +``` + +```json +{ + "type": "event", + "topic": "customers", + "sequence": 12346, + "operation": "updated", + "entity_id": "..." +} +``` + +### Token und Sessions + +Geplantes Modell: + +- Access Token kurzlebig +- Refresh Token länger gültig, aber serverseitig widerrufbar +- Refresh Token wird nur gehasht gespeichert +- WebSocket-Token optional separat und sehr kurzlebig +- Passwortwechsel, User-Deaktivierung oder Firmen-Sperre widerrufen Sessions + +Für den Webclient: + +- Access Token möglichst nur im Speicher halten +- Refresh Token bevorzugt als `HttpOnly`, `Secure`, `SameSite` Cookie +- bei reinem API-Betrieb alternativ sichere Token-Speicherung bewusst festlegen + +Für den Desktopclient: + +- Refresh Token im Betriebssystem-Keychain speichern +- Linux: Secret Service/KWallet, falls verfügbar +- Windows: Credential Manager oder DPAPI +- macOS: Keychain +- keine Tokens im Klartext in Konfigurationsdateien + +### Nachrichtenintegrität und Replay-Schutz + +TLS schützt Transport und Integrität auf Verbindungsebene. Zusätzlich sollen +kritische Operationen nachvollziehbar und wiederholungssicher sein. + +Maßnahmen: + +- Request-ID pro REST-Anfrage +- serverseitige Idempotency Keys für kritische POST-Aktionen, z.B. + Rechnungserstellung +- monoton steigende `sequence` im `change_log` +- WebSocket-Events enthalten `sequence` +- Clients können nach Reconnect ab einer bekannten `sequence` nachladen + +### E-Mail-Kommunikation + +System-E-Mails werden über eine Outbox versendet. + +Anwendungsfälle: + +- Initialpasswort nach Admin-Freischaltung +- Einladung neuer Firmen-User +- später Passwort-Reset +- später 2FA-/Sicherheitsbenachrichtigungen + +Sicherheitsanforderungen: + +- SMTP-Zugangsdaten nicht im Firmenschema im Klartext speichern +- zentrale SMTP-Konfiguration für SaaS-Betrieb +- lokale SMTP-Konfiguration für lokale Installation möglich +- Versandversuche und Fehler in `public.email_outbox` protokollieren + +### API-Zugangsdaten von Lieferanten + +Lieferanten-API-Zugangsdaten liegen im Firmenschema, müssen aber verschlüsselt +gespeichert werden. + +Geplantes Modell: + +- Verschlüsselung vor Speicherung in PostgreSQL +- Master-Key aus Server-Konfiguration oder Secret Store +- je Firma optional abgeleiteter Schlüssel +- Rotation des Master-Keys später einplanen +- Secrets nie im Audit-Log oder Change-Log im Klartext speichern + +### Webhooks + +Ausgehende Webhooks müssen signiert werden. + +Anforderungen: + +- HTTPS-Ziel-URLs bevorzugen +- HMAC-Signatur pro Webhook +- Timestamp im Signaturmaterial +- Retry mit Backoff +- Dead-Letter-Status nach wiederholtem Fehler +- kein Mitsenden sensibler Secrets + +### Lokales Netzwerk und Server-Erkennung + +Für den nativen Desktopclient müssen lokale Server auffindbar oder konfigurierbar +sein. + +Optionen: + +- manuelle Server-URL +- gespeicherte Serverprofile +- spätere automatische LAN-Erkennung + +Empfehlung für den Start: + +- manuelle Server-URL +- klare Anzeige, ob Verbindung verschlüsselt ist +- Warnung bei Zertifikats- oder Vertrauensproblemen + +### Fehlerfälle und Reconnect + +Clients müssen robuste Verbindungslogik haben. + +Vorgaben: + +- automatischer Reconnect mit Backoff +- Token-Refresh vor erneutem WebSocket-Aufbau +- nach Reconnect Sync über `since_sequence` +- sichtbarer Offline-/Verbindungsstatus in der UI +- keine stillschweigenden Datenverluste bei abgebrochenen Schreiboperationen + +## Rollen und Rechte + +Es gibt feste Standardrollen. Die Rechte selbst sind granular aufgebaut. + +Rechtemodell: + +- jedes fachliche Atom bekommt eigene Rechte +- mindestens `read`, `write`, `delete` +- Rechte werden im Firmenschema gespeichert +- feste Rollen bündeln Rechte +- die erste Person einer Firma ist der Besitzer (`owner`) und erhält beim + Anlegen der Firma alle initial vorgesehenen Rollen/Rechte +- nur der Besitzer darf Benutzer einladen und Rollen/Rechte für andere Benutzer + vergeben oder ändern +- alle weiteren Firmenbenutzer erhalten Rechte nicht automatisch, sondern erst + durch manuelle Freischaltung im Admin-Fenster `Benutzerrechte` +- jede größere Funktion bekommt eigene atomare Rechte, z.B. Kunden, + Lieferanten, Artikel, Angebote, Eingangsrechnungen, Ausgangsrechnungen, + Aktivitäten, Kommunikation, Preislistenimporte, Preis-API-Abgleiche, + Einstellungen, Benutzerverwaltung, Nummernkreise, Dokumente, Berichte und + Audit-Log + +Beispielrollen: + +- `owner` +- `admin` +- `sales` +- `accounting` +- `purchasing` +- `warehouse` +- `viewer` + +Beispielrechte: + +- `customers.read` +- `customers.write` +- `customers.delete` +- `activities.read` +- `activities.write` +- `activities.delete` +- `quotes.read` +- `quotes.write` +- `quotes.delete` +- `outgoing_invoices.read` +- `outgoing_invoices.write` +- `outgoing_invoices.delete` +- `items.read` +- `items.write` +- `items.delete` +- `price_rules.write` +- `imports.write` +- `settings.write` + +## Fachmodule + +### Dashboard + +Die Startmaske ist ein Dashboard mit Übersicht über laufende und offene Vorgänge. + +Inhalte: + +- offene Angebote +- fällige Aufgaben +- offene Eingangsrechnungen +- offene Ausgangsrechnungen +- aktuelle Importläufe +- Preisänderungen +- Warnungen aus Lagerbestand oder Preisberechnung +- offene/fällige Vorgänge +- Wiedervorlagen + +### Kunden + +Verwaltung von Firmen- und Privatkunden der jeweiligen Firma. + +Geplante Daten: + +- Kundennummer +- Name/Firma +- Kundentyp +- Rechnungsadresse +- Lieferadresse +- Ansprechpartner +- Kommunikationsdaten +- Zahlungsbedingungen +- Standardrabatt +- Skonto-Regel, falls abweichend von den Firmeneinstellungen +- USt-IdNr. +- Notizen +- Status + +### Lieferanten + +Verwaltung von Bezugsquellen und Einkaufskonditionen. + +Geplante Daten: + +- Lieferantennummer +- Name/Firma +- Adressen +- Ansprechpartner +- Zahlungsbedingungen +- Skonto-Regel, falls vom Lieferanten vorgegeben +- Artikelreferenzen +- Preislistenquellen +- API-Zugangsdaten + +### Artikel und Lager + +Zentrale Artikelverwaltung mit Preisberechnung und Lagerbestand. + +Geplante Daten: + +- Artikelnummer +- Bezeichnung +- Beschreibung +- Einheit +- Einkaufspreis +- Verkaufspreis +- Steuersatz +- Lieferant +- Herstellerartikelnummer +- EAN/GTIN +- Preisgültigkeit +- Lagerbestand +- Mindestbestand +- Lagerbewegungen + +Preisberechnung: + +- Einkaufspreis fest festlegen +- Einkaufspreis aus Quelle übernehmen +- Einkaufspreis mal Multiplikator +- Staffelpreise +- kundenspezifische Preise +- kundenbezogener Standardrabatt +- positionsbezogener Sonderpreis oder Sonderrabatt +- Skonto-Regeln für Zahlung innerhalb definierter Fristen +- Projektpreise +- Rundungsregeln +- Preisgültigkeit +- Preisänderungshistorie + +Preis- und Zahlungsbedingungen: + +- Kunden können einen festen Standardrabatt erhalten. +- Lieferanten können eigene Skonto- und Zahlungsbedingungen haben. +- Firmeneinstellungen definieren Standard-Skonto und Standard-Zahlungsziele. +- Kunden- oder Lieferanteneinstellungen überschreiben die Firmenstandards. +- Rechnungspositionen übernehmen zunächst den aktuellen Artikelpreis und die + anwendbaren Rabattregeln. +- Der Einzelpreis einer Rechnungsposition darf manuell überschrieben werden, + z.B. wegen Sonderabsprachen, Kulanz oder Spezialrabatt. +- Preisüberschreibungen müssen als solche gespeichert werden, inkl. optionalem + Grund und User. + +Geplante Tabellen: + +- `customer_price_terms`: kundenspezifische Rabatte und Preisbedingungen +- `supplier_price_terms`: lieferantenspezifische Einkaufs-/Zahlungsbedingungen +- `cash_discount_terms`: Skonto-Regeln mit Prozent, Frist und Gültigkeit + +`cash_discount_terms` ist bewusst eine eigene Tabelle, damit Firmeneinstellung, +Kunde, Lieferant und später ggf. einzelne Belege auf dieselben strukturierten +Skonto-Regeln verweisen können. + +### Angebote + +Angebote erstellen, versionieren, verwalten und in Rechnungen überführen. + +Geplante Funktionen: + +- frei konfigurierbarer Angebotsnummernkreis +- Kunde auswählen +- Positionen aus Artikeln oder Freitext +- Rabatte +- Steuern +- Zahlungs-/Lieferbedingungen +- Status: Entwurf, in Freigabe, freigegeben, gesendet, angenommen, abgelehnt, abgelaufen +- Angebotsversionen +- Angebotsvorlagen je Firma +- optionaler Freigabeprozess je nach Firmeneinstellung +- E-Mail-Versand aus dem System +- PDF-Erzeugung +- Umwandlung in Ausgangsrechnung + +### Ausgangsrechnungen + +Rechnungen an Kunden. + +Geplante Funktionen: + +- aus Angebot erzeugen +- direkt erstellen +- frei konfigurierbarer Rechnungsnummernkreis mit Pflicht-Platzhalter für Counter +- Positionen ausschließlich mit Artikelreferenz +- keine freien Text-/Freipositionen in Ausgangsrechnungen +- aktueller Artikelpreis wird beim Hinzufügen übernommen +- Einzelpreis und Rabatt pro Rechnungsposition dürfen überschrieben werden +- kundenbezogener Standardrabatt wird vorgeschlagen +- Skonto aus Firmeneinstellung oder Kundenregel wird vorgeschlagen +- Steuern passend für Deutschland +- Preisüberschreibungen mit Grund und User nachvollziehbar speichern +- Zahlungsstatus +- Mahnstatus +- PDF-Erzeugung +- revisionssichere Ablage nach Erstellung +- spätere Erweiterung für DATEV oder E-Rechnung möglich, aber nicht in der ersten Stufe + +### Eingangsrechnungen + +Rechnungen von Lieferanten. + +Geplante Funktionen: + +- Lieferant zuordnen +- Rechnungsdaten erfassen +- Positionen erfassen +- Dokument/PDF ablegen +- Zahlungsstatus +- Zahlungsziel und Skonto aus Lieferantenregel vorschlagen +- Kostenstellen oder Projekte, falls später benötigt +- Abgleich mit Artikeln und Lieferantenpreisen +- revisionssichere Ablage + +### Kommunikation, Vorgänge, Aufgaben und Wiedervorlagen + +Keine direkte E-Mail- oder Telefonie-Synchronisation in der ersten Stufe. + +Begriffliche Entscheidung: + +- UI-Hauptbegriff: `Vorgänge` +- technischer Name: `activities` +- `Aktivität` bleibt als Typ oder Kategorie möglich, ist aber als Hauptbegriff + zu unscharf + +Ein Vorgang beschreibt etwas, das fachlich passiert ist, gerade passiert oder +geplant ist. Dadurch lassen sich Kommunikationsnotizen, Aufgaben, Termine, +Wiedervorlagen und Bearbeitungsschritte in einer gemeinsamen Timeline anzeigen. + +Geplante Vorgangstypen: + +- E-Mail-Notiz +- Telefonnotiz +- interne Notiz +- Aufgabe +- Wiedervorlage +- Kalendertermin +- Systemereignis, z.B. Angebot angenommen oder Rechnung erstellt +- manueller Bearbeitungsschritt, z.B. Rückfrage geklärt + +Geplante Daten: + +- Titel +- Beschreibung/Notiz +- Vorgangstyp +- Status: offen, in Bearbeitung, erledigt, storniert +- Priorität: niedrig, normal, hoch, kritisch +- Fälligkeitsdatum +- Start-/Endzeit für Termine +- zuständiger User +- erstellt von +- erledigt von +- Tags/Kategorien +- Sichtbarkeit, z.B. intern oder für alle berechtigten User +- Bezug auf Kunde, Lieferant, Kontakt, Angebot, Rechnung, Artikel, Dokument oder Importlauf +- versionierte Anhänge +- technische Systemquelle, falls automatisch erzeugt + +Verknüpfungen: + +- Ein Vorgang kann mehrere Bezüge haben, z.B. Kunde + Angebot + Dokument. +- Bezüge werden nicht als viele optionale Spalten modelliert, sondern über eine + Link-Tabelle. +- Beispiel: `activity_links(activity_id, entity_type, entity_id)`. + +Live-Verhalten: + +- neue oder geänderte Vorgänge erzeugen WebSocket-Events +- Dashboard zeigt offene/fällige Vorgänge +- Detailmasken zeigen eine Timeline der zugehörigen Vorgänge +- erledigte Vorgänge bleiben nachvollziehbar erhalten + +Abgrenzung zu `communications`: + +- `communications` speichert strukturierte Kommunikation, sobald echte + E-Mail-/Telefonie-Integration oder Nachrichtenarchivierung implementiert wird. +- `activities` bildet die nutzbare Timeline und Aufgaben-/Wiedervorlagefunktion + schon in der ersten Stufe ab. +- In der ersten Stufe können E-Mail- und Telefonnotizen direkt als Vorgänge + geführt werden. + +### Dokumente und Vorlagen + +Dokumente werden auf dem Filesystem gespeichert. Metadaten und Versionen liegen in +PostgreSQL. + +Geplante Funktionen: + +- PDF-Ablage +- Uploads +- versionierte Anhänge +- editierbare Dokumentvorlagen +- Vorlagen je Firma +- Verknüpfung mit Kunden, Lieferanten, Angeboten und Rechnungen + +Keine Volltextsuche in Dokumenten in der ersten Stufe. + +## Artikelpreislisten und APIs + +### Dateiimport + +Unterstützte erste Formate: + +- CSV +- XML +- JSON + +XLSX bleibt eine mögliche spätere Erweiterung. + +Import-Pipeline: + +1. Datei hochladen +2. Format erkennen oder manuell auswählen +3. Spalten/Felder zu Zielfeldern mappen +4. Vorschau und Validierung +5. Import ausführen +6. Importbericht speichern +7. Einkaufspreise aktualisieren +8. Verkaufspreise neu berechnen +9. betroffene Clients per WebSocket informieren + +### Frei konfigurierbare Preislisten + +Da Lieferantenpreislisten frei konfigurierbar sein sollen, braucht das System +Mapping-Vorlagen: + +- Lieferant +- Format +- Feldzuordnung +- Identifikationsfeld, z.B. EAN, Artikelnummer oder Herstellerartikelnummer +- Währungsfeld +- Preisfeld +- Mengen-/Staffelfeld +- Gültigkeitsdatum +- Konfliktregel + +### Frei konfigurierbare API-Connectoren + +Geplante Connector-Konfiguration: + +- Lieferant +- Basis-URL +- Authentifizierung +- Header +- Query-Parameter +- Abrufintervall +- Request-Template +- Response-Mapping +- Fehlerprotokoll +- Testabruf + +Komplexere APIs werden wahrscheinlich dennoch einzelne Spezialadapter brauchen. +Die freie Konfiguration sollte daher einfache REST/JSON- und REST/XML-Fälle +abdecken, aber nicht als vollständiger Ersatz für jede Lieferanten-API geplant +werden. + +## Rechnungen, Steuern und Archivierung + +Zielmarkt ist Deutschland. + +Erste Stufe: + +- deutsche Steuersätze +- Steuerwerte so erfassen, dass eine spätere Übertragung oder Auswertung für + Elster möglich ist +- frei konfigurierbare Nummernkreise +- Nummernkreis muss mindestens einen Counter-Platzhalter enthalten +- Rechnungen nach Erstellung unveränderlich behandeln +- Korrekturen über Storno/Korrekturrechnung statt Bearbeitung finaler Rechnung + +Spätere Ausbaustufen: + +- DATEV-Export +- XRechnung +- ZUGFeRD +- direkte Buchhaltungsintegration + +## Backend-Architektur + +Vorgeschlagene Hauptbereiche: + +- Authentifizierung +- öffentliche Registrierung +- Admin-Freischaltung von Firmen +- SSO +- 2FA +- Firmenauswahl +- User-Einladungen +- E-Mail-Outbox +- Key-Management +- Session-Schlüsselverwaltung +- Payload-Verschlüsselung für REST und WebSocket +- Datenbankverschlüsselung auf Anwendungsebene +- Firmenschema-Auflösung +- rollenbasierte Autorisierung +- Migrationen für `public` +- Migrationen je Firmenschema +- REST-API +- WebSocket-Verbindungsverwaltung +- Event-Bus für Live-Updates +- Fachmodule +- Dateiimport +- API-Connectoren +- Dokumenterzeugung +- Dokumentablage im Filesystem +- Audit-Logging je Firmenschema +- Webhooks + +## Socket-Kommunikation + +Clients verbinden sich nach Login und Firmenauswahl mit dem Backend. + +Das Socket-Protokoll ist versioniert. Jede Verbindung beginnt mit einer +`hello`-Nachricht. Ohne gültige Authentifizierung und bestätigte Protokollversion +werden keine Subscriptions angenommen. + +Beispiele für Topics: + +- `dashboard` +- `customers` +- `suppliers` +- `items` +- `stock` +- `quotes` +- `incoming_invoices` +- `outgoing_invoices` +- `communications` +- `tasks` +- `imports` + +Beispiele für Server-Events: + +- `snapshot` +- `created` +- `updated` +- `deleted` +- `price_recalculated` +- `stock_changed` +- `import_finished` +- `invoice_created_from_quote` +- `task_due` + +Wichtig: + +- jede WebSocket-Verbindung muss authentifiziert sein +- jede Verbindung hat genau eine aktive Firma +- jede Verbindung hat eine bestätigte Protokollversion +- jede Verbindung hat nach dem Handshake einen Session-Schlüssel für + verschlüsselte Payloads +- jede Nachricht wird gegen Rechte im Firmenschema geprüft +- Events gehen nur an berechtigte User derselben Firma +- Clients müssen verlorene Verbindungen erkennen und neu synchronisieren können +- für Offline-Desktopclients braucht es einen Änderungslog oder Sync-Endpunkt +- Events enthalten eine `sequence` für Reconnect und Offline-Sync +- schreibende Aktionen laufen bevorzugt über REST, Events informieren danach alle + betroffenen Clients + +## REST-API und Webhooks + +Zusätzlich zum WebSocket-Protokoll wird eine REST-API geplant. + +Einsatz: + +- öffentliche Registrierung im SaaS-Betrieb +- Admin-Freischaltung neuer Firmen +- Login +- Passwortwechsel beim ersten Login +- Firmenauswahl +- User-Einladungen +- CRUD-Operationen +- Dateiimport +- Dokumentdownload +- externe Integrationen +- Export vorhandener Daten + +Webhooks: + +- je Firma konfigurierbar +- Signatur je Webhook +- Retry-Strategie +- Ereignisse wie Angebot angenommen, Rechnung erstellt, Import abgeschlossen + +Vorgeschlagene erste REST-Endpunkte: + +- `POST /api/registration/organization` +- `GET /api/admin/organization-registrations` +- `GET /api/admin/organization-registrations/{id}` +- `POST /api/admin/organization-registrations/{id}/approve` +- `POST /api/admin/organization-registrations/{id}/reject` +- `POST /api/admin/organization-registrations/{id}/resend-initial-email` +- `POST /api/admin/organization-registrations/{id}/retry-provisioning` +- `POST /api/auth/login` +- `POST /api/auth/change-initial-password` +- `GET /api/auth/organizations` +- `POST /api/auth/select-organization` +- `GET /api/organizations/current/setup` +- `PUT /api/organizations/current/setup` +- `POST /api/organizations/current/invitations` +- `POST /api/organizations/current/invitations/{id}/resend` +- `GET /api/organizations/current/users` +- `PATCH /api/organizations/current/users/{user_id}/roles` +- `POST /api/organizations/current/users/{user_id}/disable` + +Alle produktiven REST-Endpunkte werden versioniert, z.B. unter `/api/v1/...`. +Kritische POST-Endpunkte wie Rechnungserstellung oder Angebotsumwandlung sollen +Idempotency Keys unterstützen. + +## Desktop-Offline-Konzept + +Offline-Fähigkeit ist sinnvoll und wird eingeplant. + +Vorgeschlagener Ansatz: + +- lokaler Cache im Desktopclient +- lokale SQLite-Datenbank oder eingebetteter Speicher +- schreibende Offline-Aktionen zuerst nur für ausgewählte Bereiche erlauben +- Sync beim Wiederverbinden +- Konfliktstrategie pro Modul + +Empfohlene erste Offline-Stufe: + +- Lesen zuletzt synchronisierter Daten +- Vorgänge und Timelines anzeigen +- neue Notizen/Vorgangsentwürfe lokal speichern +- Aufgaben/Wiedervorlagen anzeigen +- Entwürfe lokal speichern +- keine finale Rechnungserstellung offline + +Finale Rechnungen sollten wegen Nummernkreis, Revisionssicherheit und +Mehrbenutzerbetrieb nur online erstellt werden. + +## Frontend-Planung + +Webclient und Desktopclient sollen denselben fachlichen Umfang bekommen. + +### Fensterbasiertes Arbeitsmodell + +Die Anwendung wird fensterbasiert geplant. User sollen mehrere Arbeitskontexte +parallel offen halten können, z.B. Rechnung, Artikelliste, Kundendetail und +Vorgangstimeline. + +Grundsätze: + +- Listen, Detailfenster, Suchdialoge und Auswahlfelder sind eigene Fenster oder + fensterähnliche Arbeitsbereiche. +- Mehrere Fenster können gleichzeitig geöffnet sein. +- Änderungen in einem Fenster müssen andere offene Fenster sofort erreichen. +- Auswahlfelder und Suchdialoge dürfen keine veralteten Daten anzeigen. +- Ein Fenster darf lokale Eingaben nicht verlieren, wenn im Hintergrund Daten + aktualisiert werden. +- Bei Konflikten muss der User nachvollziehbar entscheiden können, ob lokale + Änderungen beibehalten, neu geladen oder zusammengeführt werden. + +Beispiel: + +1. User erstellt eine Ausgangsrechnung. +2. Beim Hinzufügen einer Position fehlt ein Artikel oder der Preis ist falsch. +3. User öffnet parallel Artikelfenster oder Vorgangsfenster. +4. User legt den Artikel/Vorgang an oder korrigiert den Preis. +5. Das Rechnungsfenster erhält sofort ein Live-Event. +6. Artikel und Vorgang sind ohne Neuladen in der Rechnung auswählbar. + +Technische Anforderungen: + +- Jede Datenänderung erzeugt ein fachliches Live-Event mit Entity-Typ, ID, + Änderungsart und Versions-/Sequenznummer. +- Frontends führen zentrale Stores/Caches je Firma, z.B. für Artikel, Kunden, + Vorgänge und Preisregeln. +- Fenster abonnieren die Store-Änderungen statt eigene isolierte Listen zu + halten. +- Auswahlkomponenten aktualisieren ihre Ergebnislisten bei Store-Updates. +- Auswahlkomponenten für große Stammdatenmengen, z.B. Kunden, Lieferanten und + Artikel, zeigen nicht pauschal alle Datensätze an. Sie bieten eine Suche nach + Nummer und Name und begrenzen die sichtbaren Treffer. +- Bei großen Listen werden nur betroffene Datensätze aktualisiert, nicht die + komplette Maske neu gerendert. +- Detailfenster vergleichen `updated_at` oder eine Versionsnummer, bevor lokale + Änderungen gespeichert werden. +- WebSocket-Reconnect nutzt `since_sequence`, damit verpasste Änderungen + nachgeladen werden. + +Erste betroffene Bereiche: + +- Artikel- und Preisauswahl in Rechnungen und Angeboten +- Vorgänge/Timelines in Kunden-, Lieferanten-, Artikel- und Belegfenstern +- Kunden- und Lieferantenauswahl in Belegen +- Benutzer- und Rechteänderungen in offenen Einstellungsfenstern +- Importfortschritt und Preisneuberechnung + +Gemeinsame Kernmasken: + +- öffentliche Registrierungsmaske, nur SaaS +- Admin-Maske für Firmenfreischaltungen, nur Betreiber/Admin +- Login +- erzwungener Passwortwechsel +- Firmenauswahl +- Dashboard +- Kunden +- Lieferanten +- Artikel +- Lager +- Angebote +- Ausgangsrechnungen +- Eingangsrechnungen +- Kommunikation +- Vorgänge +- Aufgaben/Wiedervorlagen +- Dokumente +- Einstellungen +- Benutzerverwaltung und Einladungen +- Importassistent + +Benutzereinstellungen: + +- Benutzereinstellungen werden je Firma und je Benutzer gespeichert. +- Die Werte werden wie andere fachliche Einstellungen verschlüsselt abgelegt. +- Erste Einstellung: Navigationsdarstellung mit den Modi `scroll` und + `groups`. +- Standard ist `scroll`, damit alle Menüpunkte auch bei kleinen Fenstern + erreichbar bleiben. + +## Erste UI-Seiten: Organization und User + +Die ersten UI-Seiten bilden den Onboarding-Flow ab. Fachlich geht es um eine +Firma, technisch um `organization`. + +### Seite: Öffentliche Registrierung + +Ziel: + +- Eine neue Organization für den öffentlichen SaaS-Betrieb registrieren. +- Einen ersten User mit E-Mail-Adresse vormerken. +- Noch kein produktiver Zugriff vor Admin-Freischaltung. + +Route: + +- Web: `/register` +- Desktop: nicht erforderlich für öffentliche SaaS-Registrierung in der ersten + Stufe + +Felder: + +- `organization_name`, UI-Label `Firmenname` +- `email`, UI-Label `E-Mail-Adresse` +- `accept_terms`, UI-Label `Nutzungsbedingungen akzeptieren`, falls später nötig + +Validierung: + +- Firmenname ist Pflichtfeld +- Firmenname mindestens 2 Zeichen +- E-Mail ist Pflichtfeld +- E-Mail muss formal gültig sein +- Absenden mehrfach verhindern, solange Request läuft + +Primäre Aktion: + +- `Registrierung absenden` + +API: + +- `POST /api/v1/registration/organization` + +Request: + +```json +{ + "organization_name": "Muster GmbH", + "email": "admin@example.com" +} +``` + +Erfolgszustand: + +- UI zeigt, dass die Registrierung eingegangen ist. +- Hinweis: Freischaltung erfolgt durch Administrator. +- Kein Login-Link mit Zugriff suggerieren. + +Fehlerzustände: + +- E-Mail bereits registriert und bereits aktiver User +- Organization mit ähnlichem Namen existiert bereits +- Registrierung bereits offen +- Server nicht erreichbar +- Validierungsfehler + +Sicherheits-/Betriebshinweise: + +- Rate-Limit serverseitig +- keine Auskunft, ob eine E-Mail bereits bei einer anderen Firma aktiv ist +- Fehlermeldungen allgemein genug halten, um User Enumeration zu vermeiden + +### Seite: Admin-Liste Registrierungen + +Ziel: + +- Betreiber-Admin sieht neue Organization-Registrierungen. +- Admin kann prüfen, freischalten oder ablehnen. + +Route: + +- Web: `/admin/organization-registrations` +- Desktop: später optional, erste Stufe Web + +Tabelle: + +- Firmenname +- E-Mail-Adresse +- Status +- Eingegangen am +- Entschieden am +- Entschieden von + +Filter: + +- offen +- freigeschaltet +- abgelehnt +- gesperrt + +Aktionen: + +- Details öffnen +- Freischalten +- Ablehnen +- E-Mail erneut senden, falls bereits freigeschaltet +- technischen Anlageversuch wiederholen + +API: + +- `GET /api/v1/admin/organization-registrations` + +### Seite: Admin-Detail Registrierung + +Ziel: + +- Eine einzelne Registrierung prüfen und entscheiden. + +Route: + +- Web: `/admin/organization-registrations/:id` + +Angezeigte Daten: + +- Firmenname +- E-Mail-Adresse +- Status +- Zeitstempel +- technische Organization-ID, falls schon erzeugt +- Schema-Name, falls schon erzeugt +- Fehler der Schema-Erstellung, falls vorhanden + +Aktionen: + +- `Freischalten` +- `Ablehnen` +- `Zurück zur Liste` +- `Einladungs-E-Mail erneut senden` +- `Schema-Erstellung wiederholen` + +Freischalten: + +- erzeugt oder aktiviert `public.organizations` +- erzeugt `company_` +- erzeugt Basistabellen, Standardrollen und Standardrechte +- ordnet ersten User als `owner` zu +- erzeugt Initialpasswort +- setzt `must_change_password` +- legt E-Mail in `public.email_outbox` + +Ablehnen: + +- setzt Status `rejected` +- speichert optional internen Entscheidungsvermerk +- erzeugt kein Firmenschema + +API: + +- `GET /api/v1/admin/organization-registrations/{id}` +- `POST /api/v1/admin/organization-registrations/{id}/approve` +- `POST /api/v1/admin/organization-registrations/{id}/reject` +- `POST /api/v1/admin/organization-registrations/{id}/resend-initial-email` +- `POST /api/v1/admin/organization-registrations/{id}/retry-provisioning` + +### Seite: Erster Login mit Initialpasswort + +Ziel: + +- Der erste User meldet sich mit E-Mail und Initialpasswort an. +- Danach muss sofort das Passwort geändert werden. + +Route: + +- Web: `/login` +- Desktop: Login-Dialog im nativen Client + +Felder: + +- `email`, UI-Label `E-Mail-Adresse` +- `password`, UI-Label `Passwort` + +API: + +- `POST /api/v1/auth/login` + +Besondere Zustände: + +- `must_change_password = true`: UI leitet direkt zur Passwortänderung weiter +- Organization noch nicht aktiv: UI zeigt neutralen Hinweis +- Initialpasswort abgelaufen: UI zeigt Hinweis auf erneute Einladung oder Support + +### Seite: Passwort beim ersten Login ändern + +Ziel: + +- User ersetzt Initialpasswort durch eigenes Passwort. + +Route: + +- Web: `/change-initial-password` +- Desktop: eigener Dialog direkt nach Login + +Felder: + +- `current_password`, UI-Label `Aktuelles Passwort` +- `new_password`, UI-Label `Neues Passwort` +- `new_password_confirm`, UI-Label `Neues Passwort wiederholen` + +Validierung: + +- neues Passwort erfüllt Passwortrichtlinie +- Wiederholung stimmt überein +- neues Passwort darf nicht dem Initialpasswort entsprechen + +API: + +- `POST /api/v1/auth/change-initial-password` + +Erfolgszustand: + +- `must_change_password` wird entfernt +- Sessions mit altem Passwortstatus werden ungültig +- User wird zur Organization-Auswahl oder direkt ins Dashboard geleitet + +### Seite: Organization-Grunddaten nach erstem Login + +Ziel: + +- Der erste User ergänzt nach Freischaltung die Stammdaten der Firma. + +Route: + +- Web: `/setup/organization` +- Desktop: Setup-Dialog nach Login + +Felder erste Stufe: + +- Firmenname +- Rechtsform +- Straße und Hausnummer +- PLZ +- Ort +- Land +- USt-IdNr., optional +- E-Mail der Firma +- Telefonnummer, optional +- Standard-Steuersatz +- Standard-Zahlungsziel + +API: + +- `GET /api/v1/organizations/current/setup` +- `PUT /api/v1/organizations/current/setup` + +Erfolgszustand: + +- Organization-Setup wird als abgeschlossen markiert +- User gelangt zum Dashboard + +### Seite: User für Organization anlegen/einladen + +Ziel: + +- Ein berechtigter Firmen-User lädt weitere User ein. + +Route: + +- Web: `/settings/users` +- Desktop: `Benutzerrechte` + +Felder Einladung: + +- `email`, UI-Label `E-Mail-Adresse` +- `roles`, UI-Label `Rollen` + +Aktionen: + +- `Einladung senden` +- `Einladung erneut senden` +- `User deaktivieren` +- `Rollen ändern` + +API: + +- `GET /api/v1/organizations/current/users` +- `POST /api/v1/organizations/current/invitations` +- `PATCH /api/v1/organizations/current/users/{user_id}/roles` +- `POST /api/v1/organizations/current/invitations/{id}/resend` +- `PATCH /api/v1/organizations/current/users/{user_id}/roles` +- `POST /api/v1/organizations/current/users/{user_id}/disable` + +Besonderheiten: + +- Existierender globaler User bekommt keine Passwortüberschreibung. +- Neuer User bekommt Initialpasswort und `must_change_password`. +- Rollen werden im Firmenschema gespeichert. + +### UI-Zustände und Komponenten + +Gemeinsame Zustände: + +- `idle` +- `validating` +- `submitting` +- `success` +- `error` + +Wiederverwendbare Komponenten: + +- Formularfeld mit Validierungsfehler +- Passwortfeld +- Statushinweis +- Tabellenliste mit Filter +- Bestätigungsdialog +- Fehlerbanner + +Texte: + +- UI-Texte deutsch +- technische IDs nur in Admin-Detailseiten anzeigen +- keine internen Schema- oder Security-Details auf öffentlichen Seiten + +### Erste Implementierungsreihenfolge UI + +1. Öffentliche Registrierungsseite +2. Login-Seite +3. Passwortänderung beim ersten Login +4. Admin-Liste Registrierungen +5. Admin-Detail mit Freischalten/Ablehnen +6. Organization-Grunddaten-Setup +7. Benutzerverwaltung mit Einladungen + +## Erster Datenmodell-Entwurf + +Dieser Abschnitt ist noch kein finales SQL-Schema, aber die Grundlage für die +ersten Migrationen. + +### `public.users` + +Globale Benutzeraccounts. + +Felder: + +- `id` +- `email` +- `display_name` +- `password_hash`, nullable bei reinem SSO-Account +- `is_active` +- `must_change_password` +- `initial_password_expires_at` +- `created_at` +- `updated_at` +- `last_login_at` + +### `public.organizations` + +Globale Firmenliste zur Zuordnung von Usern zu Firmenschemas. + +Felder: + +- `id` +- `display_name` +- `schema_name` +- `status` +- `registration_email` +- `approved_by_user_id` +- `approved_at` +- `rejected_by_user_id` +- `rejected_at` +- `rejection_reason` +- `created_at` +- `updated_at` + +Der `schema_name` muss eindeutig sein und darf nur vom Backend erzeugt werden. +Vor der Admin-Freischaltung kann `schema_name` leer bleiben, wenn das +Firmenschema erst nach Freigabe erzeugt wird. + +### `public.user_organizations` + +Zuordnung von Usern zu Firmen. + +Felder: + +- `user_id` +- `organization_id` +- `status` +- `invited_by_user_id` +- `invited_at` +- `accepted_at` +- `created_at` +- `updated_at` + +Rollen werden hier bewusst nicht gespeichert. Die eigentlichen Rollen liegen im +Firmenschema. + +Mögliche Statuswerte: + +- `pending_invitation` +- `active` +- `disabled` + +### `public.auth_identities` + +Verknüpfung von Usern mit Login-Methoden. + +Felder: + +- `id` +- `user_id` +- `provider` +- `provider_subject` +- `email_at_provider` +- `created_at` +- `updated_at` + +Beispiele für `provider`: + +- `password` +- `oidc` +- `saml` +- `microsoft` +- `google` + +### `public.refresh_tokens` + +Session-/Token-Verwaltung. + +Felder: + +- `id` +- `user_id` +- `organization_id`, nullable solange keine Firma gewählt ist +- `token_hash` +- `expires_at` +- `revoked_at` +- `revoked_reason` +- `user_agent` +- `created_ip` +- `created_at` + +### `public.socket_tokens` + +Kurzlebige Tokens für WebSocket-Verbindungen. Diese Tabelle ist optional, wenn +Socket-Tokens als signierte, sehr kurzlebige Tokens ohne serverseitige Speicherung +umgesetzt werden. + +Felder: + +- `id` +- `user_id` +- `organization_id` +- `token_hash` +- `expires_at` +- `used_at` +- `revoked_at` +- `created_at` + +### `public.session_keys` + +Metadaten zu aktiven Session-Schlüsseln. Rohes Schlüsselmaterial darf nicht im +Klartext in PostgreSQL gespeichert werden. + +Felder: + +- `id` +- `user_id` +- `organization_id` +- `key_id` +- `wrapped_key`, falls serverseitige Wiederaufnahme nötig ist +- `algorithm` +- `created_at` +- `expires_at` +- `revoked_at` + +Wenn Session-Schlüssel nur im Arbeitsspeicher gehalten werden, kann diese Tabelle +entfallen oder nur Widerrufs-/Audit-Metadaten enthalten. + +### `public.idempotency_keys` + +Schutz gegen doppelte Ausführung kritischer REST-Aktionen. + +Felder: + +- `id` +- `user_id` +- `organization_id` +- `key` +- `request_hash` +- `response_status` +- `response_body_json` +- `expires_at` +- `created_at` + +### Verschlüsselte Fachfelder + +Für fachliche Tabellen wird ein einheitliches Muster verwendet. + +Mögliche Feldstruktur: + +- `*_ciphertext` +- `*_nonce` +- `*_tag` +- `*_key_id` + +Alternativ kann ein JSONB- oder BYTEA-Container genutzt werden: + +- `encrypted_payload` +- `encryption_meta` + +Entscheidungskriterium: + +- einzelne verschlüsselte Felder sind besser, wenn Teile eines Datensatzes + getrennt aktualisiert werden +- ein verschlüsselter Payload-Container ist einfacher, wenn Datensätze meistens + komplett gelesen und geschrieben werden + +### `public.organization_registration_requests` + +Optional separate Tabelle für SaaS-Registrierungen. Wenn die Registrierung direkt +in `public.organizations` abgebildet wird, ist diese Tabelle nicht zwingend nötig. + +Felder: + +- `id` +- `organization_name` +- `email` +- `status` +- `organization_id` +- `requested_at` +- `decided_by_user_id` +- `decided_at` +- `decision_note` + +Vorteil einer separaten Tabelle: + +- abgelehnte Registrierungen bleiben nachvollziehbar +- `public.organizations` enthält nur Firmen, die technisch angelegt werden sollen +- Admin-Freischaltung ist sauberer auditierbar + +### `public.user_invitations` + +Einladungen von Firmen-Usern an neue oder bestehende User. + +Felder: + +- `id` +- `organization_id` +- `email` +- `invited_by_user_id` +- `status` +- `expires_at` +- `accepted_at` +- `created_user_id` +- `created_at` + +Mögliche Statuswerte: + +- `pending` +- `accepted` +- `expired` +- `revoked` + +### `public.email_outbox` + +Queue für systemseitige E-Mails. + +Felder: + +- `id` +- `recipient_email` +- `template` +- `payload_json` +- `status` +- `attempt_count` +- `last_error` +- `send_after` +- `sent_at` +- `created_at` + +Verwendung: + +- Initialpasswort nach Admin-Freischaltung +- Einladung neuer Firmen-User +- erneuter Versand durch Administrator +- spätere Passwort-Reset-Mails + +### `company_*.settings` + +Firmeneinstellungen. + +Felder: + +- `key` +- `value_json` +- `updated_by_user_id` +- `updated_at` + +Beispiele: + +- Freigabeprozess für Angebote aktiv +- Standard-Steuersatz +- Standard-Zahlungsbedingungen +- Dokumentvorlage für Angebote +- Dokumentvorlage für Rechnungen + +### `company_*.roles` + +Feste Standardrollen, aber je Firma gespeichert, damit Rechte später erweitert +oder angepasst werden können. + +Felder: + +- `id` +- `code` +- `name` +- `description` +- `is_system_role` +- `created_at` +- `updated_at` + +### `company_*.permissions` + +Atomare Rechte. + +Felder: + +- `id` +- `code` +- `description` + +Beispiele: + +- `customers.read` +- `customers.write` +- `customers.delete` +- `quotes.approve` +- `invoices.finalize` +- `settings.write` + +### `company_*.role_permissions` + +Zuordnung von Rollen zu Rechten. + +Felder: + +- `role_id` +- `permission_id` + +### `company_*.user_roles` + +Zuordnung globaler User zu Rollen in dieser Firma. + +Felder: + +- `user_id` +- `role_id` +- `created_at` + +Der `user_id` verweist logisch auf `public.users.id`. Ein echter Foreign Key über +Schemas hinweg ist möglich, muss aber bewusst entschieden werden. + +### `company_*.number_ranges` + +Konfigurierbare Nummernkreise. + +Felder: + +- `id` +- `code` +- `pattern` +- `counter_value` +- `counter_padding` +- `reset_rule` +- `is_active` +- `updated_at` + +Regeln: + +- `pattern` muss einen Counter-Platzhalter enthalten. +- Counter-Erhöhung muss transaktional und konkurrenzsicher erfolgen. +- Finale Rechnungsnummern dürfen nach Vergabe nicht wiederverwendet werden. +- Standardformat ist ein Präfix plus neunstelliger, gruppierter Zähler: + `K000.000.001`, `L000.000.001`, `I000.000.001`, + `A000.000.001`, `R000.000.001`. +- Standard-Nummernkreise: + `customers = K{counter}`, `suppliers = L{counter}`, + `items = I{counter}`, `activities = A{counter}`, + `outgoing_invoices = R{counter}`. +- Ergänzende Nummernkreise nach gleichem Prinzip: + `incoming_invoices = ER{counter}`, `quotes = AN{counter}`. + +### `company_*.audit_log` + +Revisions- und Nachvollziehbarkeitslog. + +Felder: + +- `id` +- `actor_user_id` +- `action` +- `entity_type` +- `entity_id` +- `before_json` +- `after_json` +- `created_at` + +Für sehr große Installationen kann dieses Log später partitioniert werden. + +### `company_*.change_log` + +Grundlage für Live-Updates und Offline-Synchronisation. + +Felder: + +- `id` +- `sequence` +- `entity_type` +- `entity_id` +- `operation` +- `payload_json` +- `created_at` +- `created_by_user_id` + +Nutzung: + +- WebSocket-Events werden daraus erzeugt oder darauf gespiegelt. +- Desktopclients können seit ihrer letzten `sequence` Änderungen nachladen. +- Offline-Sync wird dadurch planbar. + +### `company_*.documents` und `company_*.document_versions` + +Metadaten für Dateien auf dem Filesystem. + +`documents`: + +- `id` +- `entity_type` +- `entity_id` +- `title` +- `current_version_id` +- `created_at` +- `created_by_user_id` + +`document_versions`: + +- `id` +- `document_id` +- `version_number` +- `storage_path` +- `mime_type` +- `sha256` +- `size_bytes` +- `created_at` +- `created_by_user_id` + +## Implementierungsreihenfolge + +Empfohlene Reihenfolge für die nächsten Entwicklungsschritte: + +1. `public`-Migrationen für User, Firmen und Zuordnungen +2. SaaS-Registrierung mit Status `pending_approval` +3. Admin-Freischaltung und Backend-Service zur Firmenschema-Erstellung +4. Mailversand für Initialpasswort und Einladungen +5. Firmenschema-Basismigrationen für Rollen, Rechte, Settings, Nummernkreise, + Audit-Log und Change-Log +6. Login ohne SSO, aber mit Architektur für spätere Provider +7. erzwungener Passwortwechsel beim ersten Login +8. Firmenauswahl nach Login +9. User-Einladung in bestehende Firma +10. mandantenbewusste REST- und WebSocket-Basisschicht +11. erstes Fachmodul `customers` +12. Live-Update für `customers` +13. Dashboard-Grundlage +14. Artikelmodul und CSV-Import + +SSO, 2FA, Desktop-Offline und automatische Updates sind wichtig, sollten aber nach +der mandantenfähigen Grundarchitektur kommen. Andernfalls müssten sie später +gegen eine noch instabile Daten- und Sessionstruktur gebaut werden. + +## TODOs + +### Erledigte Planungsentscheidungen + +- [x] SaaS-Betrieb mit vielen Firmen auf einer Instanz +- [x] öffentlicher Server wird selbst betrieben +- [x] jede Firma bekommt eigenes PostgreSQL-Schema +- [x] `public` enthält nur globale User- und Firmenzuordnung +- [x] Rollen, Rechte und Einstellungen liegen im Firmenschema +- [x] ein User kann mehreren Firmen angehören +- [x] Zielmarkt erste Stufe: Deutschland +- [x] REST-API zusätzlich zum WebSocket-Protokoll +- [x] Webhooks einplanen +- [x] Desktopclient mit Offline-Fähigkeit +- [x] automatische Desktopclient-Updates einplanen +- [x] Dokumente im Filesystem, Metadaten in PostgreSQL +- [x] öffentliche Registrierung für SaaS +- [x] Admin-Freischaltung neuer Firmen +- [x] Registrierung mit Firmenname und E-Mail-Adresse +- [x] E-Mail-Adresse ist Username +- [x] Initialpasswort per E-Mail +- [x] Passwortwechsel beim ersten Login verpflichtend +- [x] neue Firmen-User werden per Einladung hinzugefügt +- [x] lokale Version erlaubt genau eine Firma +- [x] SaaS-Kommunikation ausschließlich über HTTPS/WSS +- [x] lokale produktive Kommunikation ebenfalls ausschließlich über HTTPS/WSS +- [x] Reverse Proxy übernimmt TLS-Terminierung +- [x] WebSocket-Protokoll wird versioniert +- [x] WebSocket-Verbindungen werden per Token authentifiziert +- [x] Webhooks werden signiert +- [x] fachliche Payloads werden zusätzlich zu HTTPS/WSS sessionbasiert verschlüsselt +- [x] fachliche Daten werden in PostgreSQL nicht im Klartext gespeichert +- [x] pro Firma wird ein eigener Datenschlüssel geplant +- [x] technischer Mandantenname ist `organization` +- [x] Schema-Namenskonvention ist `company_` + +### Architektur + +- [x] Alle tabellen/namen sind englisch, werden aber in der UI deutsch angezeigt +- [x] endgültigen Namen für Mandantenobjekt festlegen: `Firma`, `Organisation` oder `Company`: organization +- [x] Schema-Namenskonvention final festlegen: company_ +- [ ] `public`-Tabellen detailliert entwerfen +- [ ] Firmenschema-Tabellen detailliert entwerfen +- [ ] Migrationsstrategie für neue und bestehende Firmenschemas festlegen: erst am schluss nötig +- [ ] sichere Kapselung für firmenschema-bewusste Datenbankzugriffe entwerfen +- [ ] Socket-Protokoll versionieren +- [ ] REST-API-Versionierung festlegen +- [ ] Event-Topics und Berechtigungsprüfung definieren +- [ ] Fehler- und Reconnect-Verhalten für Clients definieren +- [ ] fensterbasiertes Live-Refresh- und Store-Konzept final spezifizieren +- [ ] Offline-Sync-Strategie für Desktopclient spezifizieren +- [ ] SaaS-Onboarding-Flow final spezifizieren +- [ ] lokale Erstinstallation final spezifizieren +- [ ] E-Mail-Versandarchitektur festlegen +- [ ] Token-Modell final festlegen: JWT, opaque Tokens oder Hybrid +- [ ] WebSocket-Handshake final spezifizieren +- [ ] Idempotency-Key-Strategie für kritische REST-Aktionen festlegen +- [ ] Secret-Verschlüsselung für Lieferanten-API-Zugangsdaten spezifizieren +- [ ] Reconnect- und `since_sequence`-Sync formal spezifizieren +- [ ] lokale Zertifikatsstrategie final festlegen +- [ ] Debug-/Entwicklungsmodus klar vom produktiven Verschlüsselungsmodell trennen +- [ ] Session-Key-Aushandlung final spezifizieren +- [ ] AEAD-Algorithmus final auswählen +- [ ] Schlüsselhierarchie für Master Key, Firmenschlüssel und Session-Schlüssel spezifizieren +- [ ] Key-Rotation und Re-Encryption-Konzept planen +- [ ] Such-/Indexstrategie für verschlüsselte Daten planen +- [ ] festlegen, welche technischen Metadaten unverschlüsselt bleiben dürfen + +### Backend + +- [ ] öffentliche Registrierungs-API implementieren +- [ ] Admin-Freischaltungs-API implementieren +- [ ] Organization-Registration-Detail-API implementieren +- [ ] Initial-E-Mail erneut senden implementieren +- [ ] Provisioning-Retry implementieren +- [ ] Admin-Oberfläche für Registrierungsfreigaben anbinden +- [ ] Initialpasswort-Erzeugung implementieren +- [ ] E-Mail-Outbox implementieren +- [ ] Passwortwechsel beim ersten Login erzwingen +- [ ] User-Einladungen implementieren +- [ ] lokalen Setup-Modus für genau eine Firma implementieren +- [ ] Authentifizierung implementieren +- [ ] SSO-Provider-Konzept festlegen +- [ ] 2FA-Verfahren festlegen +- [ ] Firmenauswahl nach Login implementieren +- [ ] Rollen- und Rechtesystem im Firmenschema implementieren +- [ ] Firmenschema-Erstellung implementieren +- [ ] Public-Migrationen implementieren +- [ ] Firmenschema-Migrationen implementieren +- [ ] Live-Event-Bus je Firma einbauen +- [ ] Audit-Logging je Firmenschema ergänzen +- [ ] Datei-Upload und Dokumentablage implementieren +- [ ] PDF-Erzeugung evaluieren +- [ ] REST-API-Grundstruktur implementieren +- [ ] Webhook-Versand implementieren +- [ ] HTTPS/WSS-Betrieb hinter Reverse Proxy dokumentieren +- [ ] Access-/Refresh-Token-Handling implementieren +- [ ] WebSocket-Token oder WebSocket-Auth implementieren +- [ ] WebSocket-Handshake mit Protokollversion implementieren +- [ ] Live-Event-Typen je Entity definieren +- [ ] `since_sequence`-Nachlade-API für verpasste Live-Events implementieren +- [ ] Idempotency Keys für kritische POST-Endpunkte implementieren +- [ ] Verschlüsselung gespeicherter API-Secrets implementieren +- [ ] Session-Key-Verwaltung implementieren +- [ ] REST-Payload-Verschlüsselung implementieren +- [ ] WebSocket-Payload-Verschlüsselung implementieren +- [ ] Datenbank-Verschlüsselungsservice implementieren +- [ ] Firmenschlüssel-Erzeugung bei Firmenschema-Anlage implementieren +- [ ] Entschlüsselung in Logs, Audit-Log und Change-Log verhindern + +### Datenmodell + +- [ ] Firmenmodell in `public` detaillieren +- [ ] User- und Firmenzuordnung detaillieren +- [ ] Registrierungsanforderungen detaillieren +- [ ] User-Einladungen detaillieren +- [ ] E-Mail-Outbox detaillieren +- [ ] Passwortstatus und Initialpasswort-Ablauf detaillieren +- [ ] Refresh-Token-Modell detaillieren +- [ ] Socket-Token-Modell detaillieren +- [ ] Session-Key-Metadaten detaillieren +- [ ] Idempotency-Key-Modell detaillieren +- [ ] verschlüsselte Feldstruktur festlegen +- [ ] unverschlüsselte technische Metadaten je Tabelle festlegen +- [ ] Such-/Indexfelder für verschlüsselte Daten modellieren +- [ ] Rollenmodell detaillieren +- [ ] Rechtemodell detaillieren +- [ ] Kundenmodell detaillieren +- [ ] Lieferantenmodell detaillieren +- [ ] Artikelmodell detaillieren +- [ ] Kundenpreisbedingungsmodell (`customer_price_terms`) detaillieren +- [ ] Lieferantenpreisbedingungsmodell (`supplier_price_terms`) detaillieren +- [ ] Skonto-Regelmodell (`cash_discount_terms`) detaillieren +- [ ] Lagerbewegungsmodell detaillieren +- [ ] Angebotsmodell detaillieren +- [ ] Angebotsversionsmodell detaillieren +- [ ] Ausgangsrechnungsmodell detaillieren +- [ ] Rechnungspositionsmodell mit Artikelpflicht und Preisüberschreibung detaillieren +- [ ] Eingangsrechnungsmodell detaillieren +- [ ] Kommunikationsmodell detaillieren +- [ ] Vorgangsmodell (`activities`) detaillieren +- [ ] Vorgangsverknüpfungen (`activity_links`) detaillieren +- [ ] Aufgaben-/Wiedervorlagemodell detaillieren +- [ ] Dokument- und Dokumentversionsmodell detaillieren +- [ ] Preisquellen- und Preisregelmodell detaillieren +- [ ] Rabatt- und Skonto-Vererbungsregeln definieren +- [ ] Importmapping-Modell detaillieren + +### Artikelimporte und Preise + +- [ ] Beispielpreislisten sammeln +- [ ] CSV-Import als ersten Importweg implementieren +- [ ] XML-Import planen +- [ ] JSON-Import planen +- [ ] Spalten-/Feldmapping definieren +- [ ] Importvalidierung definieren +- [ ] Importhistorie definieren +- [ ] Preisneuberechnung nach Import spezifizieren +- [ ] Staffelpreise modellieren +- [ ] kundenspezifische Preise modellieren +- [ ] Projektpreise modellieren +- [ ] Lieferanten-API-Connector-Schnittstelle entwerfen +- [ ] Grenzen frei konfigurierbarer API-Connectoren dokumentieren + +### Frontends + +- [ ] gemeinsames UI-Konzept für Web und Desktop definieren +- [ ] Login-Flow für beide Clients planen +- [ ] Firmenauswahl planen +- [ ] öffentliche Registrierungsseite planen +- [ ] Admin-Liste für Organization-Registrierungen planen +- [ ] Admin-Detail für Organization-Freischaltung planen +- [ ] Passwortänderung beim ersten Login planen +- [ ] Organization-Grunddaten-Setup planen +- [ ] Benutzerverwaltung und Einladungen planen +- [ ] Dashboard entwerfen +- [ ] Kundenmaske entwerfen +- [ ] Lieferantenmaske entwerfen +- [ ] Artikelmaske entwerfen +- [ ] Lagermaske entwerfen +- [ ] Angebotsmaske mit Versionierung entwerfen +- [ ] Rechnungsmasken entwerfen +- [ ] Artikelpflicht und Preisüberschreibung in Rechnungsmasken entwerfen +- [ ] Kommunikationsverlauf entwerfen +- [ ] Vorgangstimeline entwerfen +- [ ] Vorgangsmaske mit Typen, Status, Priorität und Verknüpfungen entwerfen +- [ ] Aufgaben-/Wiedervorlagemaske entwerfen +- [ ] Dokumentverwaltung entwerfen +- [ ] Importassistent entwerfen +- [ ] Live-Refresh-Verhalten je Maske festlegen +- [ ] fensterübergreifende Aktualisierung von Listen, Suchdialogen und Auswahlfeldern planen +- [ ] Konfliktverhalten bei parallelen Fensteränderungen planen +- [ ] Offline-Verhalten im Desktopclient je Maske festlegen +- [ ] sichere Token-Speicherung im Desktopclient planen +- [ ] sichere Session-Key-Speicherung im Desktopclient planen +- [ ] Verschlüsselung des lokalen Offline-Caches planen +- [ ] Verbindungsstatus und TLS-Warnungen im Desktopclient planen +- [ ] Update-Mechanismus für Desktopclient auswählen + +### Betrieb + +- [ ] Docker-Setup für Backend und PostgreSQL erweitern +- [ ] TLS/Reverse-Proxy-Konzept für öffentlichen Betrieb definieren +- [ ] Zertifikatsstrategie festlegen +- [ ] Caddy, Traefik oder Nginx als Startoption auswählen +- [ ] lokale Zertifikatserzeugung für Installationsprogramm planen +- [ ] Security Header und HTTPS-Redirect konfigurieren +- [ ] Backup- und Restore-Konzept je Firma erstellen +- [ ] Logging und Monitoring planen +- [ ] Konfiguration für lokalen und öffentlichen Betrieb trennen +- [ ] Update-Strategie für Backend festlegen +- [ ] Update-Strategie für Desktopclient festlegen +- [ ] Dateisystem-Speicherlayout für Dokumente definieren +- [ ] Speicherquoten je Firma planen + +### Rechtliches und Buchhaltung + +- [ ] deutsche Steuerlogik detaillieren +- [ ] Elster-relevante Datenfelder identifizieren +- [ ] Revisionssichere Rechnungsablage konzipieren +- [ ] Storno-/Korrekturrechnungsprozess definieren +- [ ] Nummernkreisregeln und Platzhalter definieren +- [ ] spätere DATEV-Erweiterung vorbereiten +- [ ] spätere E-Rechnungs-Erweiterung vorbereiten + +## Nächster sinnvoller Schritt + +Als nächstes sollte das Datenmodell für `public` und das Firmenschema konkretisiert +werden. Besonders wichtig sind: + +1. `users`, `organizations`, `user_organizations` und Login/SSO/2FA +2. Rollen und atomare Rechte im Firmenschema +3. Nummernkreise und revisionssichere Rechnungen +4. Änderungslog für Live-Updates und spätere Offline-Synchronisation diff --git a/README.md b/README.md new file mode 100644 index 0000000..c4990a8 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# Company Tool + +Mehrprojekt-Struktur für eine firmeninterne Software mit Rust-Backend, PostgreSQL, +Webfrontend und klassischem Desktopclient. + +## Projekte + +- `backend/`: Rust-Backend mit PostgreSQL-Anbindung und WebSocket-Kommunikation +- `web-frontend/`: Browserbasiertes Frontend mit Vue 3, Vite und TypeScript +- `desktop-client/`: Nativer Client für Linux, Windows und macOS mit egui/eframe +- `shared-protocol/`: Gemeinsame Rust-Typen für Socket-Nachrichten + +## Kommunikation + +Das Backend stellt einen WebSocket unter `ws://localhost:8080/ws` bereit. Die +Verbindung beginnt mit einem `hello`-Handshake. Danach werden fachliche +Nachrichten als AES-256-GCM-verschlüsselte Envelopes übertragen. + +## PostgreSQL starten + +Ausführliche Installationsschritte stehen in [INSTALL.md](INSTALL.md). +Betriebsnotizen zu Schlüsseln, E-Mail, Backup und TLS stehen in +[BETRIEB.md](BETRIEB.md). + +```bash +cp .env.example .env +docker compose up -d postgres +``` + +## Backend starten + +```bash +cargo run -p companytool-backend +``` + +## Kommunikation testen + +In einem Terminal Backend starten, dann in einem zweiten Terminal: + +```bash +COMMUNICATION_TEST_MODE=1 cargo run -p companytool-backend +``` + +```bash +node scripts/communication-test.mjs +``` + +Optional gegen eine andere URL: + +```bash +node scripts/communication-test.mjs ws://localhost:8080/ws +``` + +Der Test öffnet zwei Clients, führt den verschlüsselten Handshake aus, +entschlüsselt Snapshots, sendet eine verschlüsselte Ping-Nachricht und prüft, ob +beide Clients ein verschlüsseltes Pong-Event erhalten. + +## Webfrontend starten + +```bash +cd web-frontend +npm install +npm run dev +``` + +## Desktopclient starten + +```bash +cargo run -p companytool-desktop-client +``` + +Mit explizitem Backend: + +```bash +cargo run -p companytool-desktop-client -- --api-url http://127.0.0.1:8080 --ws-url ws://127.0.0.1:8080/ws +``` diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..88cd023 --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "companytool-backend" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow = "1" +argon2 = "0.5" +axum = { version = "0.7", features = ["ws"] } +base64 = "0.22" +chrono = { version = "0.4", features = ["serde"] } +companytool-shared-protocol = { path = "../shared-protocol" } +dotenvy = "0.15" +futures-util = "0.3" +rand_core = { version = "0.6", features = ["getrandom"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync"] } +tower-http = { version = "0.6", features = ["cors", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1", features = ["v4"] } diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..abfc751 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,13 @@ +FROM rust:1-bookworm AS builder +WORKDIR /app +COPY . . +RUN cargo build --release -p companytool-backend + +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=builder /app/target/release/companytool-backend /usr/local/bin/companytool-backend +EXPOSE 8080 +CMD ["companytool-backend"] diff --git a/backend/company-migrations/0001_company_base.sql b/backend/company-migrations/0001_company_base.sql new file mode 100644 index 0000000..f23daf2 --- /dev/null +++ b/backend/company-migrations/0001_company_base.sql @@ -0,0 +1,80 @@ +-- Template migration for each organization schema. +-- Replace {schema} with the real schema name, e.g. company_. + +create table if not exists {schema}.settings ( + key text primary key, + value_ciphertext bytea not null, + value_nonce bytea not null, + value_key_id text not null, + updated_by_user_id uuid, + updated_at timestamptz not null default now() +); + +create table if not exists {schema}.roles ( + id uuid primary key, + code text not null unique, + name text not null, + description text, + is_system_role boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists {schema}.permissions ( + id uuid primary key, + code text not null unique, + description text +); + +create table if not exists {schema}.role_permissions ( + role_id uuid not null references {schema}.roles(id) on delete cascade, + permission_id uuid not null references {schema}.permissions(id) on delete cascade, + primary key (role_id, permission_id) +); + +create table if not exists {schema}.user_roles ( + user_id uuid not null, + role_id uuid not null references {schema}.roles(id) on delete cascade, + created_at timestamptz not null default now(), + primary key (user_id, role_id) +); + +create table if not exists {schema}.number_ranges ( + id uuid primary key, + code text not null unique, + pattern text not null, + counter_value bigint not null default 0, + counter_padding integer not null default 0, + reset_rule text, + is_active boolean not null default true, + updated_at timestamptz not null default now(), + constraint number_ranges_pattern_has_counter check (position('{counter}' in pattern) > 0) +); + +create table if not exists {schema}.audit_log ( + id uuid primary key, + actor_user_id uuid, + action text not null, + entity_type text not null, + entity_id uuid, + before_ciphertext bytea, + before_nonce bytea, + before_key_id text, + after_ciphertext bytea, + after_nonce bytea, + after_key_id text, + created_at timestamptz not null default now() +); + +create table if not exists {schema}.change_log ( + id uuid primary key, + sequence bigserial not null unique, + entity_type text not null, + entity_id uuid not null, + operation text not null, + payload_ciphertext bytea not null, + payload_nonce bytea not null, + payload_key_id text not null, + created_at timestamptz not null default now(), + created_by_user_id uuid +); diff --git a/backend/company-migrations/0002_activity_price_invoice_rules.sql b/backend/company-migrations/0002_activity_price_invoice_rules.sql new file mode 100644 index 0000000..7017954 --- /dev/null +++ b/backend/company-migrations/0002_activity_price_invoice_rules.sql @@ -0,0 +1,360 @@ +-- Template migration for each organization schema. +-- Replace {schema} with the real schema name, e.g. company_. + +create table if not exists {schema}.customers ( + id uuid primary key, + customer_number text unique, + name_ciphertext bytea not null, + name_nonce bytea not null, + name_key_id text not null, + status text not null default 'active', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint customers_status_valid check (status in ('active', 'inactive', 'blocked')) +); + +create table if not exists {schema}.suppliers ( + id uuid primary key, + supplier_number text unique, + name_ciphertext bytea not null, + name_nonce bytea not null, + name_key_id text not null, + status text not null default 'active', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint suppliers_status_valid check (status in ('active', 'inactive', 'blocked')) +); + +create table if not exists {schema}.items ( + id uuid primary key, + item_number text not null unique, + name_ciphertext bytea not null, + name_nonce bytea not null, + name_key_id text not null, + unit text not null default 'Stk', + tax_rate numeric(7, 4) not null default 19.0, + default_purchase_price numeric(14, 4), + default_sales_price numeric(14, 4), + status text not null default 'active', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint items_status_valid check (status in ('active', 'inactive', 'blocked')), + constraint items_tax_rate_non_negative check (tax_rate >= 0), + constraint items_default_purchase_price_non_negative check ( + default_purchase_price is null or default_purchase_price >= 0 + ), + constraint items_default_sales_price_non_negative check ( + default_sales_price is null or default_sales_price >= 0 + ) +); + +create table if not exists {schema}.cash_discount_terms ( + id uuid primary key, + code text not null unique, + name text not null, + discount_percent numeric(7, 4) not null, + discount_days integer not null, + net_days integer, + valid_from date, + valid_until date, + is_default_customer_term boolean not null default false, + is_default_supplier_term boolean not null default false, + is_active boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint cash_discount_terms_percent_valid check ( + discount_percent >= 0 and discount_percent <= 100 + ), + constraint cash_discount_terms_days_valid check ( + discount_days >= 0 and (net_days is null or net_days >= discount_days) + ), + constraint cash_discount_terms_valid_range check ( + valid_until is null or valid_from is null or valid_until >= valid_from + ) +); + +create unique index if not exists idx_cash_discount_terms_default_customer + on {schema}.cash_discount_terms (is_default_customer_term) + where is_default_customer_term; + +create unique index if not exists idx_cash_discount_terms_default_supplier + on {schema}.cash_discount_terms (is_default_supplier_term) + where is_default_supplier_term; + +create table if not exists {schema}.customer_price_terms ( + id uuid primary key, + customer_id uuid not null references {schema}.customers(id) on delete cascade, + standard_discount_percent numeric(7, 4) not null default 0, + cash_discount_term_id uuid references {schema}.cash_discount_terms(id), + valid_from date, + valid_until date, + is_active boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint customer_price_terms_discount_valid check ( + standard_discount_percent >= 0 and standard_discount_percent <= 100 + ), + constraint customer_price_terms_valid_range check ( + valid_until is null or valid_from is null or valid_until >= valid_from + ) +); + +create index if not exists idx_customer_price_terms_customer_active + on {schema}.customer_price_terms (customer_id, is_active, valid_from, valid_until); + +create table if not exists {schema}.supplier_price_terms ( + id uuid primary key, + supplier_id uuid not null references {schema}.suppliers(id) on delete cascade, + standard_discount_percent numeric(7, 4) not null default 0, + cash_discount_term_id uuid references {schema}.cash_discount_terms(id), + payment_days integer, + valid_from date, + valid_until date, + is_active boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint supplier_price_terms_discount_valid check ( + standard_discount_percent >= 0 and standard_discount_percent <= 100 + ), + constraint supplier_price_terms_payment_days_valid check ( + payment_days is null or payment_days >= 0 + ), + constraint supplier_price_terms_valid_range check ( + valid_until is null or valid_from is null or valid_until >= valid_from + ) +); + +create index if not exists idx_supplier_price_terms_supplier_active + on {schema}.supplier_price_terms (supplier_id, is_active, valid_from, valid_until); + +create table if not exists {schema}.activities ( + id uuid primary key, + activity_type text not null, + title_ciphertext bytea not null, + title_nonce bytea not null, + title_key_id text not null, + body_ciphertext bytea, + body_nonce bytea, + body_key_id text, + status text not null default 'open', + priority text not null default 'normal', + due_at timestamptz, + starts_at timestamptz, + ends_at timestamptz, + assigned_to_user_id uuid, + created_by_user_id uuid, + completed_by_user_id uuid, + completed_at timestamptz, + visibility text not null default 'internal', + system_source text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint activities_type_valid check ( + activity_type in ( + 'email_note', + 'phone_note', + 'internal_note', + 'task', + 'follow_up', + 'calendar_event', + 'system_event', + 'work_step' + ) + ), + constraint activities_status_valid check ( + status in ('open', 'in_progress', 'done', 'cancelled') + ), + constraint activities_priority_valid check ( + priority in ('low', 'normal', 'high', 'critical') + ), + constraint activities_visibility_valid check ( + visibility in ('internal', 'organization') + ), + constraint activities_body_encryption_complete check ( + ( + body_ciphertext is null + and body_nonce is null + and body_key_id is null + ) + or ( + body_ciphertext is not null + and body_nonce is not null + and body_key_id is not null + ) + ), + constraint activities_time_range_valid check ( + ends_at is null or starts_at is null or ends_at >= starts_at + ) +); + +create index if not exists idx_activities_status_due + on {schema}.activities (status, due_at); + +create index if not exists idx_activities_assigned_status + on {schema}.activities (assigned_to_user_id, status); + +create table if not exists {schema}.activity_links ( + activity_id uuid not null references {schema}.activities(id) on delete cascade, + entity_type text not null, + entity_id uuid not null, + created_at timestamptz not null default now(), + primary key (activity_id, entity_type, entity_id), + constraint activity_links_entity_type_valid check ( + entity_type in ( + 'customer', + 'supplier', + 'contact', + 'quote', + 'outgoing_invoice', + 'incoming_invoice', + 'item', + 'document', + 'import' + ) + ) +); + +create index if not exists idx_activity_links_entity + on {schema}.activity_links (entity_type, entity_id); + +create table if not exists {schema}.outgoing_invoices ( + id uuid primary key, + invoice_number text unique, + customer_id uuid not null references {schema}.customers(id), + status text not null default 'draft', + cash_discount_term_id uuid references {schema}.cash_discount_terms(id), + issued_at date, + due_at date, + created_by_user_id uuid, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + finalized_at timestamptz, + constraint outgoing_invoices_status_valid check ( + status in ('draft', 'finalized', 'sent', 'paid', 'cancelled', 'overdue') + ) +); + +create index if not exists idx_outgoing_invoices_customer_status + on {schema}.outgoing_invoices (customer_id, status); + +create table if not exists {schema}.outgoing_invoice_items ( + id uuid primary key, + invoice_id uuid not null references {schema}.outgoing_invoices(id) on delete cascade, + line_number integer not null, + item_id uuid not null references {schema}.items(id), + description_ciphertext bytea, + description_nonce bytea, + description_key_id text, + quantity numeric(14, 4) not null, + unit_price numeric(14, 4) not null, + original_unit_price numeric(14, 4), + discount_percent numeric(7, 4) not null default 0, + price_overridden boolean not null default false, + price_override_reason_ciphertext bytea, + price_override_reason_nonce bytea, + price_override_reason_key_id text, + price_overridden_by_user_id uuid, + price_overridden_at timestamptz, + tax_rate numeric(7, 4) not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (invoice_id, line_number), + constraint outgoing_invoice_items_quantity_positive check (quantity > 0), + constraint outgoing_invoice_items_unit_price_non_negative check (unit_price >= 0), + constraint outgoing_invoice_items_original_price_non_negative check ( + original_unit_price is null or original_unit_price >= 0 + ), + constraint outgoing_invoice_items_discount_valid check ( + discount_percent >= 0 and discount_percent <= 100 + ), + constraint outgoing_invoice_items_tax_rate_non_negative check (tax_rate >= 0), + constraint outgoing_invoice_items_description_encryption_complete check ( + ( + description_ciphertext is null + and description_nonce is null + and description_key_id is null + ) + or ( + description_ciphertext is not null + and description_nonce is not null + and description_key_id is not null + ) + ), + constraint outgoing_invoice_items_override_reason_encryption_complete check ( + ( + price_override_reason_ciphertext is null + and price_override_reason_nonce is null + and price_override_reason_key_id is null + ) + or ( + price_override_reason_ciphertext is not null + and price_override_reason_nonce is not null + and price_override_reason_key_id is not null + ) + ), + constraint outgoing_invoice_items_override_complete check ( + ( + price_overridden = false + and price_overridden_by_user_id is null + and price_overridden_at is null + ) + or ( + price_overridden = true + and price_overridden_by_user_id is not null + and price_overridden_at is not null + ) + ) +); + +create index if not exists idx_outgoing_invoice_items_item + on {schema}.outgoing_invoice_items (item_id); + +create table if not exists {schema}.incoming_invoices ( + id uuid primary key, + invoice_number text, + supplier_id uuid not null references {schema}.suppliers(id), + status text not null default 'draft', + cash_discount_term_id uuid references {schema}.cash_discount_terms(id), + invoice_date date, + due_at date, + created_by_user_id uuid, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint incoming_invoices_status_valid check ( + status in ('draft', 'received', 'approved', 'paid', 'cancelled', 'overdue') + ) +); + +create index if not exists idx_incoming_invoices_supplier_status + on {schema}.incoming_invoices (supplier_id, status); + +create table if not exists {schema}.incoming_invoice_items ( + id uuid primary key, + invoice_id uuid not null references {schema}.incoming_invoices(id) on delete cascade, + line_number integer not null, + item_id uuid references {schema}.items(id), + description_ciphertext bytea, + description_nonce bytea, + description_key_id text, + quantity numeric(14, 4) not null, + unit_price numeric(14, 4) not null, + tax_rate numeric(7, 4) not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (invoice_id, line_number), + constraint incoming_invoice_items_quantity_positive check (quantity > 0), + constraint incoming_invoice_items_unit_price_non_negative check (unit_price >= 0), + constraint incoming_invoice_items_tax_rate_non_negative check (tax_rate >= 0), + constraint incoming_invoice_items_description_encryption_complete check ( + ( + description_ciphertext is null + and description_nonce is null + and description_key_id is null + ) + or ( + description_ciphertext is not null + and description_nonce is not null + and description_key_id is not null + ) + ) +); diff --git a/backend/company-migrations/0003_customer_details.sql b/backend/company-migrations/0003_customer_details.sql new file mode 100644 index 0000000..c12213a --- /dev/null +++ b/backend/company-migrations/0003_customer_details.sql @@ -0,0 +1,45 @@ +-- Additional encrypted customer fields for organization schemas. + +alter table {schema}.customers + add column if not exists details_ciphertext bytea, + add column if not exists details_nonce bytea, + add column if not exists details_key_id text; + +alter table {schema}.customers + drop constraint if exists customers_details_encryption_complete; + +alter table {schema}.customers + add constraint customers_details_encryption_complete check ( + ( + details_ciphertext is null + and details_nonce is null + and details_key_id is null + ) + or ( + details_ciphertext is not null + and details_nonce is not null + and details_key_id is not null + ) + ); + +alter table {schema}.suppliers + add column if not exists details_ciphertext bytea, + add column if not exists details_nonce bytea, + add column if not exists details_key_id text; + +alter table {schema}.suppliers + drop constraint if exists suppliers_details_encryption_complete; + +alter table {schema}.suppliers + add constraint suppliers_details_encryption_complete check ( + ( + details_ciphertext is null + and details_nonce is null + and details_key_id is null + ) + or ( + details_ciphertext is not null + and details_nonce is not null + and details_key_id is not null + ) + ); diff --git a/backend/company-migrations/0004_item_price_history.sql b/backend/company-migrations/0004_item_price_history.sql new file mode 100644 index 0000000..9da9979 --- /dev/null +++ b/backend/company-migrations/0004_item_price_history.sql @@ -0,0 +1,22 @@ +-- Template migration for each organization schema. +-- Replace {schema} with the real schema name, e.g. company_. + +create table if not exists {schema}.item_price_history ( + id uuid primary key, + item_id uuid not null references {schema}.items(id) on delete cascade, + purchase_price numeric(14, 4), + sales_price numeric(14, 4), + source text not null default 'manual', + valid_from timestamptz not null default now(), + created_by_user_id uuid, + created_at timestamptz not null default now(), + constraint item_price_history_purchase_price_non_negative check ( + purchase_price is null or purchase_price >= 0 + ), + constraint item_price_history_sales_price_non_negative check ( + sales_price is null or sales_price >= 0 + ) +); + +create index if not exists idx_item_price_history_item_valid_from + on {schema}.item_price_history (item_id, valid_from desc); diff --git a/backend/company-migrations/0005_numbered_activities.sql b/backend/company-migrations/0005_numbered_activities.sql new file mode 100644 index 0000000..29ba3dc --- /dev/null +++ b/backend/company-migrations/0005_numbered_activities.sql @@ -0,0 +1,9 @@ +-- Template migration for each organization schema. +-- Replace {schema} with the real schema name, e.g. company_. + +alter table {schema}.activities + add column if not exists activity_number text; + +create unique index if not exists idx_activities_activity_number + on {schema}.activities (activity_number) + where activity_number is not null; diff --git a/backend/company-migrations/0006_update_number_range_prefixes.sql b/backend/company-migrations/0006_update_number_range_prefixes.sql new file mode 100644 index 0000000..b0612e7 --- /dev/null +++ b/backend/company-migrations/0006_update_number_range_prefixes.sql @@ -0,0 +1,22 @@ +-- Template migration for each organization schema. +-- Replace {schema} with the real schema name, e.g. company_. + +update {schema}.number_ranges +set pattern = 'KU{counter}', updated_at = now() +where code = 'customers'; + +update {schema}.number_ranges +set pattern = 'LI{counter}', updated_at = now() +where code = 'suppliers'; + +update {schema}.number_ranges +set pattern = 'AR{counter}', updated_at = now() +where code = 'items'; + +update {schema}.number_ranges +set pattern = 'AK{counter}', updated_at = now() +where code = 'activities'; + +update {schema}.number_ranges +set pattern = 'AR{counter}', updated_at = now() +where code = 'outgoing_invoices'; diff --git a/backend/company-migrations/0007_quotes.sql b/backend/company-migrations/0007_quotes.sql new file mode 100644 index 0000000..dff091e --- /dev/null +++ b/backend/company-migrations/0007_quotes.sql @@ -0,0 +1,82 @@ +-- Template migration for each organization schema. +-- Replace {schema} with the real schema name, e.g. company_. + +create table if not exists {schema}.quotes ( + id uuid primary key, + quote_number text not null unique, + customer_id uuid not null references {schema}.customers(id), + status text not null default 'draft', + valid_until date, + cash_discount_term_id uuid references {schema}.cash_discount_terms(id), + customer_discount_percent numeric(7, 4) not null default 0, + notes_ciphertext bytea, + notes_nonce bytea, + notes_key_id text, + created_by_user_id uuid, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint quotes_status_valid check ( + status in ('draft', 'sent', 'accepted', 'rejected', 'expired', 'cancelled') + ), + constraint quotes_customer_discount_valid check ( + customer_discount_percent >= 0 and customer_discount_percent <= 100 + ), + constraint quotes_notes_encryption_complete check ( + ( + notes_ciphertext is null + and notes_nonce is null + and notes_key_id is null + ) + or ( + notes_ciphertext is not null + and notes_nonce is not null + and notes_key_id is not null + ) + ) +); + +create index if not exists idx_quotes_customer_status + on {schema}.quotes (customer_id, status); + +create table if not exists {schema}.quote_items ( + id uuid primary key, + quote_id uuid not null references {schema}.quotes(id) on delete cascade, + line_number integer not null, + item_id uuid not null references {schema}.items(id), + description_ciphertext bytea, + description_nonce bytea, + description_key_id text, + quantity numeric(14, 4) not null, + unit_price numeric(14, 4) not null, + original_unit_price numeric(14, 4), + discount_percent numeric(7, 4) not null default 0, + price_overridden boolean not null default false, + tax_rate numeric(7, 4) not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (quote_id, line_number), + constraint quote_items_quantity_positive check (quantity > 0), + constraint quote_items_unit_price_non_negative check (unit_price >= 0), + constraint quote_items_original_price_non_negative check ( + original_unit_price is null or original_unit_price >= 0 + ), + constraint quote_items_discount_valid check ( + discount_percent >= 0 and discount_percent <= 100 + ), + constraint quote_items_tax_rate_non_negative check (tax_rate >= 0), + constraint quote_items_description_encryption_complete check ( + ( + description_ciphertext is null + and description_nonce is null + and description_key_id is null + ) + or ( + description_ciphertext is not null + and description_nonce is not null + and description_key_id is not null + ) + ) +); + +create index if not exists idx_quote_items_item + on {schema}.quote_items (item_id); diff --git a/backend/company-migrations/0008_invoice_links.sql b/backend/company-migrations/0008_invoice_links.sql new file mode 100644 index 0000000..5943808 --- /dev/null +++ b/backend/company-migrations/0008_invoice_links.sql @@ -0,0 +1,19 @@ +-- Template migration for each organization schema. +-- Replace {schema} with the real schema name, e.g. company_. + +alter table {schema}.outgoing_invoices + add column if not exists source_quote_id uuid references {schema}.quotes(id); + +alter table {schema}.outgoing_invoices + add column if not exists customer_discount_percent numeric(7, 4) not null default 0; + +alter table {schema}.outgoing_invoices + drop constraint if exists outgoing_invoices_customer_discount_valid; + +alter table {schema}.outgoing_invoices + add constraint outgoing_invoices_customer_discount_valid check ( + customer_discount_percent >= 0 and customer_discount_percent <= 100 + ); + +create index if not exists idx_outgoing_invoices_source_quote + on {schema}.outgoing_invoices (source_quote_id); diff --git a/backend/company-migrations/0009_price_imports.sql b/backend/company-migrations/0009_price_imports.sql new file mode 100644 index 0000000..ea7876a --- /dev/null +++ b/backend/company-migrations/0009_price_imports.sql @@ -0,0 +1,71 @@ +-- Template migration for each organization schema. +-- Replace {schema} with the real schema name, e.g. company_. + +create table if not exists {schema}.imports ( + id uuid primary key, + import_type text not null, + source_name text not null, + status text not null default 'previewed', + total_rows integer not null default 0, + applied_rows integer not null default 0, + error_rows integer not null default 0, + created_by_user_id uuid, + created_at timestamptz not null default now(), + finished_at timestamptz, + constraint imports_type_valid check (import_type in ('price_list', 'api_price_sync')), + constraint imports_status_valid check (status in ('previewed', 'applied', 'failed')) +); + +create table if not exists {schema}.import_mappings ( + id uuid primary key, + code text not null unique, + name text not null, + delimiter text not null default ';', + item_number_column text not null default 'item_number', + name_column text not null default 'name', + unit_column text not null default 'unit', + tax_rate_column text not null default 'tax_rate', + purchase_price_column text not null default 'purchase_price', + sales_price_column text not null default 'sales_price', + is_default boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index if not exists idx_import_mappings_default + on {schema}.import_mappings (is_default) + where is_default; + +create table if not exists {schema}.price_rules ( + id uuid primary key, + code text not null unique, + name text not null, + source_type text not null default 'import', + source_id uuid, + markup_percent numeric(7, 4) not null default 0, + rounding_mode text not null default 'none', + is_active boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint price_rules_source_type_valid check (source_type in ('import', 'api', 'supplier')), + constraint price_rules_markup_valid check (markup_percent >= -100 and markup_percent <= 1000), + constraint price_rules_rounding_mode_valid check (rounding_mode in ('none', 'cent', 'five_cent', 'ten_cent', 'whole')) +); + +create table if not exists {schema}.api_connectors ( + id uuid primary key, + code text not null unique, + name text not null, + connector_type text not null, + config_ciphertext bytea not null, + config_nonce bytea not null, + config_key_id text not null, + is_active boolean not null default true, + sync_interval_minutes integer, + last_sync_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint api_connectors_interval_valid check ( + sync_interval_minutes is null or sync_interval_minutes > 0 + ) +); diff --git a/backend/company-migrations/0010_communications_documents.sql b/backend/company-migrations/0010_communications_documents.sql new file mode 100644 index 0000000..d3b7dd5 --- /dev/null +++ b/backend/company-migrations/0010_communications_documents.sql @@ -0,0 +1,153 @@ +-- Template migration for each organization schema. +-- Replace {schema} with the real schema name, e.g. company_. + +create table if not exists {schema}.communications ( + id uuid primary key, + communication_type text not null, + direction text not null, + subject_ciphertext bytea not null, + subject_nonce bytea not null, + subject_key_id text not null, + body_ciphertext bytea, + body_nonce bytea, + body_key_id text, + status text not null default 'open', + occurred_at timestamptz, + created_by_user_id uuid, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint communications_type_valid check ( + communication_type in ('email', 'phone', 'letter', 'meeting', 'internal_note') + ), + constraint communications_direction_valid check ( + direction in ('inbound', 'outbound', 'internal') + ), + constraint communications_status_valid check ( + status in ('open', 'done', 'archived') + ), + constraint communications_body_encryption_complete check ( + ( + body_ciphertext is null + and body_nonce is null + and body_key_id is null + ) + or ( + body_ciphertext is not null + and body_nonce is not null + and body_key_id is not null + ) + ) +); + +create index if not exists idx_communications_type_status + on {schema}.communications (communication_type, status, occurred_at desc); + +create table if not exists {schema}.communication_links ( + communication_id uuid not null references {schema}.communications(id) on delete cascade, + entity_type text not null, + entity_id uuid not null, + created_at timestamptz not null default now(), + primary key (communication_id, entity_type, entity_id), + constraint communication_links_entity_type_valid check ( + entity_type in ( + 'customer', + 'supplier', + 'activity', + 'quote', + 'outgoing_invoice', + 'incoming_invoice', + 'item', + 'document' + ) + ) +); + +create index if not exists idx_communication_links_entity + on {schema}.communication_links (entity_type, entity_id); + +create table if not exists {schema}.documents ( + id uuid primary key, + title_ciphertext bytea not null, + title_nonce bytea not null, + title_key_id text not null, + description_ciphertext bytea, + description_nonce bytea, + description_key_id text, + status text not null default 'active', + created_by_user_id uuid, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint documents_status_valid check (status in ('active', 'archived', 'deleted')), + constraint documents_description_encryption_complete check ( + ( + description_ciphertext is null + and description_nonce is null + and description_key_id is null + ) + or ( + description_ciphertext is not null + and description_nonce is not null + and description_key_id is not null + ) + ) +); + +create table if not exists {schema}.document_versions ( + id uuid primary key, + document_id uuid not null references {schema}.documents(id) on delete cascade, + version_no integer not null, + file_name_ciphertext bytea not null, + file_name_nonce bytea not null, + file_name_key_id text not null, + content_type_ciphertext bytea not null, + content_type_nonce bytea not null, + content_type_key_id text not null, + file_size bigint not null, + storage_path text not null, + checksum_sha256 text not null, + uploaded_by_user_id uuid, + created_at timestamptz not null default now(), + unique (document_id, version_no), + constraint document_versions_file_size_valid check (file_size >= 0) +); + +create index if not exists idx_document_versions_document_version + on {schema}.document_versions (document_id, version_no desc); + +create table if not exists {schema}.document_links ( + document_id uuid not null references {schema}.documents(id) on delete cascade, + entity_type text not null, + entity_id uuid not null, + created_at timestamptz not null default now(), + primary key (document_id, entity_type, entity_id), + constraint document_links_entity_type_valid check ( + entity_type in ( + 'customer', + 'supplier', + 'activity', + 'communication', + 'quote', + 'outgoing_invoice', + 'incoming_invoice', + 'item' + ) + ) +); + +create index if not exists idx_document_links_entity + on {schema}.document_links (entity_type, entity_id); + +create table if not exists {schema}.document_audit_log ( + id uuid primary key, + document_id uuid not null references {schema}.documents(id) on delete cascade, + version_id uuid references {schema}.document_versions(id) on delete set null, + action text not null, + user_id uuid, + created_at timestamptz not null default now(), + constraint document_audit_log_action_valid check ( + action in ('upload', 'download', 'archive') + ) +); + +create index if not exists idx_document_audit_log_document + on {schema}.document_audit_log (document_id, created_at desc); diff --git a/backend/company-migrations/0011_user_settings.sql b/backend/company-migrations/0011_user_settings.sql new file mode 100644 index 0000000..15d5592 --- /dev/null +++ b/backend/company-migrations/0011_user_settings.sql @@ -0,0 +1,9 @@ +create table if not exists {schema}.user_settings ( + user_id uuid not null, + key text not null, + value_ciphertext bytea not null, + value_nonce bytea not null, + value_key_id text not null, + updated_at timestamptz not null default now(), + primary key (user_id, key) +); diff --git a/backend/company-migrations/0012_item_supplier_prices.sql b/backend/company-migrations/0012_item_supplier_prices.sql new file mode 100644 index 0000000..1f0167f --- /dev/null +++ b/backend/company-migrations/0012_item_supplier_prices.sql @@ -0,0 +1,28 @@ +alter table {schema}.items + add column if not exists manufacturer_code text; + +create table if not exists {schema}.item_supplier_prices ( + id uuid primary key, + item_id uuid not null references {schema}.items(id) on delete cascade, + supplier_id uuid not null references {schema}.suppliers(id) on delete cascade, + external_item_number text not null, + purchase_price numeric(14, 4) not null, + currency text not null default 'EUR', + is_preferred boolean not null default false, + valid_from date, + valid_until date, + source text not null default 'manual', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint item_supplier_prices_purchase_price_non_negative check (purchase_price >= 0), + constraint item_supplier_prices_currency_valid check (char_length(currency) = 3), + constraint item_supplier_prices_valid_range check ( + valid_until is null or valid_from is null or valid_until >= valid_from + ) +); + +create unique index if not exists idx_item_supplier_prices_supplier_external + on {schema}.item_supplier_prices (supplier_id, external_item_number); + +create index if not exists idx_item_supplier_prices_item + on {schema}.item_supplier_prices (item_id, purchase_price); diff --git a/backend/migrations/20260521170000_public_core.sql b/backend/migrations/20260521170000_public_core.sql new file mode 100644 index 0000000..b8137d5 --- /dev/null +++ b/backend/migrations/20260521170000_public_core.sql @@ -0,0 +1,94 @@ +create table if not exists users ( + id uuid primary key, + email text not null unique, + display_name_ciphertext bytea, + display_name_nonce bytea, + display_name_key_id text, + password_hash text, + is_active boolean not null default true, + must_change_password boolean not null default false, + initial_password_expires_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + last_login_at timestamptz, + constraint users_email_lowercase check (email = lower(email)), + constraint users_display_name_encryption_complete check ( + ( + display_name_ciphertext is null + and display_name_nonce is null + and display_name_key_id is null + ) + or ( + display_name_ciphertext is not null + and display_name_nonce is not null + and display_name_key_id is not null + ) + ) +); + +create table if not exists organizations ( + id uuid primary key, + display_name_ciphertext bytea, + display_name_nonce bytea, + display_name_key_id text, + schema_name text unique, + status text not null default 'pending_approval', + registration_email text not null, + setup_completed_at timestamptz, + approved_by_user_id uuid references users(id), + approved_at timestamptz, + rejected_by_user_id uuid references users(id), + rejected_at timestamptz, + rejection_reason text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint organizations_status_valid check ( + status in ('pending_approval', 'approved', 'active', 'rejected', 'suspended') + ), + constraint organizations_registration_email_lowercase check (registration_email = lower(registration_email)), + constraint organizations_schema_name_valid check ( + schema_name is null or schema_name ~ '^company_[a-z0-9_]+$' + ), + constraint organizations_display_name_encryption_complete check ( + ( + display_name_ciphertext is null + and display_name_nonce is null + and display_name_key_id is null + ) + or ( + display_name_ciphertext is not null + and display_name_nonce is not null + and display_name_key_id is not null + ) + ) +); + +create table if not exists user_organizations ( + user_id uuid not null references users(id) on delete cascade, + organization_id uuid not null references organizations(id) on delete cascade, + status text not null default 'pending_invitation', + invited_by_user_id uuid references users(id), + invited_at timestamptz, + accepted_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (user_id, organization_id), + constraint user_organizations_status_valid check ( + status in ('pending_invitation', 'active', 'disabled') + ) +); + +create table if not exists organization_domains ( + id uuid primary key, + organization_id uuid not null references organizations(id) on delete cascade, + domain text not null unique, + is_primary boolean not null default false, + created_at timestamptz not null default now(), + constraint organization_domains_domain_lowercase check (domain = lower(domain)) +); + +create index if not exists idx_user_organizations_organization_id + on user_organizations (organization_id); + +create index if not exists idx_organizations_status + on organizations (status); diff --git a/backend/migrations/20260521171000_public_auth_sessions.sql b/backend/migrations/20260521171000_public_auth_sessions.sql new file mode 100644 index 0000000..060e1cf --- /dev/null +++ b/backend/migrations/20260521171000_public_auth_sessions.sql @@ -0,0 +1,68 @@ +create table if not exists auth_identities ( + id uuid primary key, + user_id uuid not null references users(id) on delete cascade, + provider text not null, + provider_subject text not null, + email_at_provider text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (provider, provider_subject) +); + +create table if not exists refresh_tokens ( + id uuid primary key, + user_id uuid not null references users(id) on delete cascade, + organization_id uuid references organizations(id) on delete cascade, + token_hash text not null unique, + expires_at timestamptz not null, + revoked_at timestamptz, + revoked_reason text, + user_agent text, + created_ip text, + created_at timestamptz not null default now() +); + +create table if not exists socket_tokens ( + id uuid primary key, + user_id uuid not null references users(id) on delete cascade, + organization_id uuid not null references organizations(id) on delete cascade, + token_hash text not null unique, + expires_at timestamptz not null, + used_at timestamptz, + revoked_at timestamptz, + created_at timestamptz not null default now() +); + +create table if not exists session_keys ( + id uuid primary key, + user_id uuid not null references users(id) on delete cascade, + organization_id uuid not null references organizations(id) on delete cascade, + key_id text not null unique, + wrapped_key bytea, + algorithm text not null, + created_at timestamptz not null default now(), + expires_at timestamptz not null, + revoked_at timestamptz +); + +create table if not exists idempotency_keys ( + id uuid primary key, + user_id uuid not null references users(id) on delete cascade, + organization_id uuid references organizations(id) on delete cascade, + key text not null, + request_hash text not null, + response_status integer, + response_body_json jsonb, + expires_at timestamptz not null, + created_at timestamptz not null default now(), + unique (user_id, organization_id, key) +); + +create index if not exists idx_refresh_tokens_user_id + on refresh_tokens (user_id); + +create index if not exists idx_socket_tokens_user_organization + on socket_tokens (user_id, organization_id); + +create index if not exists idx_session_keys_user_organization + on session_keys (user_id, organization_id); diff --git a/backend/migrations/20260521172000_public_onboarding.sql b/backend/migrations/20260521172000_public_onboarding.sql new file mode 100644 index 0000000..58f13af --- /dev/null +++ b/backend/migrations/20260521172000_public_onboarding.sql @@ -0,0 +1,61 @@ +create table if not exists organization_registration_requests ( + id uuid primary key, + organization_name_ciphertext bytea not null, + organization_name_nonce bytea not null, + organization_name_key_id text not null, + email text not null, + status text not null default 'pending_approval', + organization_id uuid references organizations(id), + requested_at timestamptz not null default now(), + decided_by_user_id uuid references users(id), + decided_at timestamptz, + decision_note text, + constraint organization_registration_requests_email_lowercase check (email = lower(email)), + constraint organization_registration_requests_status_valid check ( + status in ('pending_approval', 'approved', 'active', 'rejected', 'suspended') + ) +); + +create table if not exists user_invitations ( + id uuid primary key, + organization_id uuid not null references organizations(id) on delete cascade, + email text not null, + invited_by_user_id uuid not null references users(id), + status text not null default 'pending', + expires_at timestamptz not null, + accepted_at timestamptz, + created_user_id uuid references users(id), + created_at timestamptz not null default now(), + constraint user_invitations_email_lowercase check (email = lower(email)), + constraint user_invitations_status_valid check ( + status in ('pending', 'accepted', 'expired', 'revoked') + ) +); + +create table if not exists email_outbox ( + id uuid primary key, + recipient_email text not null, + template text not null, + payload_ciphertext bytea not null, + payload_nonce bytea not null, + payload_key_id text not null, + status text not null default 'pending', + attempt_count integer not null default 0, + last_error text, + send_after timestamptz not null default now(), + sent_at timestamptz, + created_at timestamptz not null default now(), + constraint email_outbox_recipient_email_lowercase check (recipient_email = lower(recipient_email)), + constraint email_outbox_status_valid check ( + status in ('pending', 'sending', 'sent', 'failed') + ) +); + +create index if not exists idx_organization_registration_requests_status + on organization_registration_requests (status, requested_at); + +create index if not exists idx_user_invitations_organization_status + on user_invitations (organization_id, status); + +create index if not exists idx_email_outbox_status_send_after + on email_outbox (status, send_after); diff --git a/backend/migrations/20260521173000_communication_test_records.sql b/backend/migrations/20260521173000_communication_test_records.sql new file mode 100644 index 0000000..f5dab6c --- /dev/null +++ b/backend/migrations/20260521173000_communication_test_records.sql @@ -0,0 +1,9 @@ +create table if not exists records ( + id uuid primary key, + title text not null, + updated_at timestamptz not null +); + +insert into records (id, title, updated_at) +select '00000000-0000-0000-0000-000000000001'::uuid, 'Erster Datensatz', now() +where not exists (select 1 from records); diff --git a/backend/migrations/20260521174000_registration_terms.sql b/backend/migrations/20260521174000_registration_terms.sql new file mode 100644 index 0000000..694503b --- /dev/null +++ b/backend/migrations/20260521174000_registration_terms.sql @@ -0,0 +1,2 @@ +alter table organization_registration_requests + add column if not exists terms_accepted_at timestamptz; diff --git a/backend/migrations/20260601190000_security_operations.sql b/backend/migrations/20260601190000_security_operations.sql new file mode 100644 index 0000000..bb11fa0 --- /dev/null +++ b/backend/migrations/20260601190000_security_operations.sql @@ -0,0 +1,23 @@ +alter table user_invitations + add column if not exists token_hash text, + add column if not exists accepted_by_user_id uuid references users(id); + +create unique index if not exists idx_user_invitations_token_hash + on user_invitations (token_hash) + where token_hash is not null; + +create table if not exists password_reset_tokens ( + id uuid primary key, + user_id uuid not null references users(id) on delete cascade, + token_hash text not null unique, + expires_at timestamptz not null, + used_at timestamptz, + created_at timestamptz not null default now() +); + +create index if not exists idx_password_reset_tokens_user + on password_reset_tokens (user_id, expires_at); + +alter table email_outbox + add column if not exists subject text, + add column if not exists delivered_via text; diff --git a/backend/src/api.rs b/backend/src/api.rs new file mode 100644 index 0000000..91c93b5 --- /dev/null +++ b/backend/src/api.rs @@ -0,0 +1,6903 @@ +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine}; +use chrono::{DateTime, Utc}; +use companytool_shared_protocol::{RecordSummary, ServerMessage}; +use rand_core::RngCore; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use sqlx::{PgPool, Postgres, Row, Transaction}; +use std::{env, path::PathBuf}; +use uuid::Uuid; + +use crate::{crypto_at_rest::DataCrypto, AppState}; + +#[derive(Debug, Deserialize)] +pub struct RegisterOrganizationRequest { + pub organization_name: String, + pub email: String, + #[serde(default, alias = "terms_accepted")] + pub accept_terms: bool, +} + +#[derive(Debug, Deserialize)] +pub struct DevBootstrapLocalRequest { + pub organization_name: String, + pub email: String, +} + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} + +#[derive(Debug, Deserialize)] +pub struct SelectOrganizationRequest { + pub organization_id: Uuid, +} + +#[derive(Debug, Deserialize)] +pub struct ChangeInitialPasswordRequest { + pub email: String, + pub current_password: String, + pub new_password: String, + pub new_password_confirm: String, +} + +#[derive(Debug, Deserialize)] +pub struct RequestPasswordResetRequest { + pub email: String, +} + +#[derive(Debug, Deserialize)] +pub struct ResetPasswordRequest { + pub token: String, + pub new_password: String, + pub new_password_confirm: String, +} + +#[derive(Debug, Deserialize)] +pub struct AcceptInvitationRequest { + pub token: String, + pub new_password: String, + pub new_password_confirm: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct OrganizationSetupRequest { + pub display_name: String, + pub legal_form: Option, + pub street: String, + pub postal_code: String, + pub city: String, + pub country: String, + pub vat_id: Option, + pub email: String, + pub phone: Option, + pub default_tax_rate: String, + pub default_payment_days: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserNavigationSettings { + pub mode: String, +} + +#[derive(Debug, Deserialize)] +pub struct NumberRangeRequest { + pub pattern: String, + pub counter_value: i64, + pub counter_padding: i32, + pub reset_rule: Option, + pub is_active: bool, +} + +#[derive(Debug, Serialize)] +pub struct NumberRangeResponse { + pub id: Uuid, + pub code: String, + pub pattern: String, + pub counter_value: i64, + pub counter_padding: i32, + pub reset_rule: Option, + pub is_active: bool, +} + +#[derive(Debug, Serialize)] +pub struct NextNumberResponse { + pub code: String, + pub number: String, +} + +#[derive(Debug, Deserialize)] +pub struct InviteUserRequest { + pub email: String, + #[serde(default)] + pub roles: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateUserRolesRequest { + pub roles: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CustomerDetails { + pub street: String, + pub postal_code: String, + pub city: String, + pub country: String, + pub email: String, + pub phone: String, +} + +#[derive(Debug, Deserialize)] +pub struct CustomerRequest { + pub customer_number: String, + pub name: String, + pub status: String, + pub details: CustomerDetails, + pub standard_discount_percent: String, + pub cash_discount_term_id: Option, +} + +#[derive(Debug, Serialize)] +pub struct CustomerResponse { + pub id: Uuid, + pub customer_number: String, + pub name: String, + pub status: String, + pub details: CustomerDetails, + pub standard_discount_percent: String, + pub cash_discount_term_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SupplierRequest { + pub supplier_number: String, + pub name: String, + pub status: String, + pub details: CustomerDetails, + pub standard_discount_percent: String, + pub cash_discount_term_id: Option, + pub payment_days: Option, +} + +#[derive(Debug, Serialize)] +pub struct SupplierResponse { + pub id: Uuid, + pub supplier_number: String, + pub name: String, + pub status: String, + pub details: CustomerDetails, + pub standard_discount_percent: String, + pub cash_discount_term_id: Option, + pub payment_days: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ItemRequest { + pub item_number: String, + pub name: String, + pub unit: String, + pub tax_rate: String, + pub default_purchase_price: Option, + pub default_sales_price: Option, + pub status: String, +} + +#[derive(Debug, Serialize)] +pub struct ItemResponse { + pub id: Uuid, + pub item_number: String, + pub name: String, + pub unit: String, + pub tax_rate: String, + pub default_purchase_price: Option, + pub default_sales_price: Option, + pub status: String, +} + +#[derive(Debug, Serialize)] +pub struct ItemPriceHistoryResponse { + pub id: Uuid, + pub item_id: Uuid, + pub purchase_price: Option, + pub sales_price: Option, + pub source: String, + pub valid_from: DateTime, + pub created_by_user_id: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct CashDiscountTermRequest { + pub code: String, + pub name: String, + pub discount_percent: String, + pub discount_days: i32, + pub net_days: Option, + pub valid_from: Option, + pub valid_until: Option, + pub is_default_customer_term: bool, + pub is_default_supplier_term: bool, + pub is_active: bool, +} + +#[derive(Debug, Serialize)] +pub struct CashDiscountTermResponse { + pub id: Uuid, + pub code: String, + pub name: String, + pub discount_percent: String, + pub discount_days: i32, + pub net_days: Option, + pub valid_from: Option, + pub valid_until: Option, + pub is_default_customer_term: bool, + pub is_default_supplier_term: bool, + pub is_active: bool, +} + +#[derive(Debug, Deserialize)] +pub struct QuoteItemRequest { + pub item_id: Uuid, + pub description: String, + pub quantity: String, + pub unit_price: String, + pub original_unit_price: Option, + pub discount_percent: String, + pub tax_rate: String, +} + +#[derive(Debug, Deserialize)] +pub struct QuoteRequest { + pub quote_number: String, + pub customer_id: Uuid, + pub status: String, + pub valid_until: Option, + pub cash_discount_term_id: Option, + pub customer_discount_percent: String, + pub notes: String, + pub items: Vec, +} + +#[derive(Debug, Serialize)] +pub struct QuoteItemResponse { + pub id: Uuid, + pub line_number: i32, + pub item_id: Uuid, + pub description: String, + pub quantity: String, + pub unit_price: String, + pub original_unit_price: Option, + pub discount_percent: String, + pub tax_rate: String, + pub price_overridden: bool, +} + +#[derive(Debug, Serialize)] +pub struct QuoteResponse { + pub id: Uuid, + pub quote_number: String, + pub customer_id: Uuid, + pub status: String, + pub valid_until: Option, + pub cash_discount_term_id: Option, + pub customer_discount_percent: String, + pub notes: String, + pub items: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct OutgoingInvoiceItemRequest { + pub item_id: Uuid, + pub description: String, + pub quantity: String, + pub unit_price: String, + pub original_unit_price: Option, + pub discount_percent: String, + pub tax_rate: String, +} + +#[derive(Debug, Deserialize)] +pub struct OutgoingInvoiceRequest { + pub invoice_number: String, + pub customer_id: Uuid, + pub status: String, + pub cash_discount_term_id: Option, + pub customer_discount_percent: String, + pub issued_at: Option, + pub due_at: Option, + pub source_quote_id: Option, + pub items: Vec, +} + +#[derive(Debug, Serialize)] +pub struct OutgoingInvoiceItemResponse { + pub id: Uuid, + pub line_number: i32, + pub item_id: Uuid, + pub description: String, + pub quantity: String, + pub unit_price: String, + pub original_unit_price: Option, + pub discount_percent: String, + pub tax_rate: String, + pub price_overridden: bool, +} + +#[derive(Debug, Serialize)] +pub struct OutgoingInvoiceResponse { + pub id: Uuid, + pub invoice_number: String, + pub customer_id: Uuid, + pub status: String, + pub cash_discount_term_id: Option, + pub customer_discount_percent: String, + pub issued_at: Option, + pub due_at: Option, + pub source_quote_id: Option, + pub finalized_at: Option>, + pub items: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct IncomingInvoiceItemRequest { + pub item_id: Option, + pub description: String, + pub quantity: String, + pub unit_price: String, + pub tax_rate: String, +} + +#[derive(Debug, Deserialize)] +pub struct IncomingInvoiceRequest { + pub invoice_number: String, + pub supplier_id: Uuid, + pub status: String, + pub cash_discount_term_id: Option, + pub invoice_date: Option, + pub due_at: Option, + pub items: Vec, +} + +#[derive(Debug, Serialize)] +pub struct IncomingInvoiceItemResponse { + pub id: Uuid, + pub line_number: i32, + pub item_id: Option, + pub description: String, + pub quantity: String, + pub unit_price: String, + pub tax_rate: String, +} + +#[derive(Debug, Serialize)] +pub struct IncomingInvoiceResponse { + pub id: Uuid, + pub invoice_number: String, + pub supplier_id: Uuid, + pub status: String, + pub cash_discount_term_id: Option, + pub invoice_date: Option, + pub due_at: Option, + pub items: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct PriceListImportRequest { + pub source_name: String, + pub content: String, + pub delimiter: Option, +} + +#[derive(Debug, Serialize)] +pub struct PriceListImportPreview { + pub rows: Vec, + pub total_rows: usize, + pub valid_rows: usize, + pub error_rows: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PriceListImportRow { + pub row_number: usize, + pub item_number: String, + pub name: String, + pub unit: String, + pub tax_rate: String, + pub purchase_price: Option, + pub sales_price: Option, + pub action: String, + pub error: Option, +} + +#[derive(Debug, Serialize)] +pub struct PriceListImportApplyResponse { + pub import_id: Uuid, + pub applied_rows: usize, + pub error_rows: usize, +} + +#[derive(Debug, Deserialize)] +pub struct ApiConnectorRequest { + pub code: String, + pub name: String, + pub connector_type: String, + pub config: serde_json::Value, + pub is_active: bool, + pub sync_interval_minutes: Option, +} + +#[derive(Debug, Serialize)] +pub struct ApiConnectorResponse { + pub id: Uuid, + pub code: String, + pub name: String, + pub connector_type: String, + pub config: serde_json::Value, + pub is_active: bool, + pub sync_interval_minutes: Option, + pub last_sync_at: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct PriceRuleRequest { + pub code: String, + pub name: String, + pub source_type: String, + pub source_id: Option, + pub markup_percent: String, + pub rounding_mode: String, + pub is_active: bool, +} + +#[derive(Debug, Serialize)] +pub struct PriceRuleResponse { + pub id: Uuid, + pub code: String, + pub name: String, + pub source_type: String, + pub source_id: Option, + pub markup_percent: String, + pub rounding_mode: String, + pub is_active: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct EntityLinkRequest { + pub entity_type: String, + pub entity_id: Uuid, +} + +#[derive(Debug, Deserialize)] +pub struct CommunicationRequest { + pub communication_type: String, + pub direction: String, + pub subject: String, + pub body: String, + pub status: String, + pub occurred_at: Option, + #[serde(default)] + pub links: Vec, +} + +#[derive(Debug, Serialize)] +pub struct CommunicationResponse { + pub id: Uuid, + pub communication_type: String, + pub direction: String, + pub subject: String, + pub body: String, + pub status: String, + pub occurred_at: Option>, + pub links: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct DocumentUploadRequest { + pub title: String, + pub description: String, + pub file_name: String, + pub content_type: String, + pub content_base64: String, + #[serde(default)] + pub links: Vec, +} + +#[derive(Debug, Serialize)] +pub struct DocumentVersionResponse { + pub id: Uuid, + pub version_no: i32, + pub file_name: String, + pub content_type: String, + pub file_size: i64, + pub checksum_sha256: String, + pub created_at: DateTime, +} + +#[derive(Debug, Serialize)] +pub struct DocumentResponse { + pub id: Uuid, + pub title: String, + pub description: String, + pub status: String, + pub latest_version: Option, + pub links: Vec, +} + +#[derive(Debug, Serialize)] +pub struct DocumentDownloadResponse { + pub document_id: Uuid, + pub version_id: Uuid, + pub file_name: String, + pub content_type: String, + pub content_base64: String, +} + +#[derive(Debug, Serialize)] +pub struct DocumentAuditLogResponse { + pub id: Uuid, + pub document_id: Uuid, + pub version_id: Option, + pub action: String, + pub user_id: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct ActivityRequest { + #[serde(default)] + pub activity_number: Option, + pub activity_type: String, + pub title: String, + pub body: String, + pub status: String, + pub priority: String, + pub due_at: Option>, +} + +#[derive(Debug, Serialize)] +pub struct ActivityResponse { + pub id: Uuid, + pub activity_number: Option, + pub activity_type: String, + pub title: String, + pub body: String, + pub status: String, + pub priority: String, + pub due_at: Option>, +} + +#[derive(Debug, Serialize)] +pub struct OrganizationRegistrationListItem { + pub id: Uuid, + pub organization_name: String, + pub email: String, + pub status: String, + pub requested_at: DateTime, + pub decided_at: Option>, + pub decided_by_user_id: Option, +} + +#[derive(Debug, Serialize)] +pub struct OrganizationRegistrationDetail { + pub id: Uuid, + pub organization_name: String, + pub email: String, + pub status: String, + pub organization_id: Option, + pub schema_name: Option, + pub requested_at: DateTime, + pub decided_at: Option>, + pub decided_by_user_id: Option, + pub decision_note: Option, + pub provisioning_error: Option, +} + +#[derive(Debug, Serialize)] +pub struct OrganizationUserResponse { + pub user_id: Uuid, + pub email: String, + pub status: String, + pub roles: Vec, +} + +pub async fn dev_bootstrap_local( + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + if !dev_mode_enabled() { + return Err(ApiError::forbidden("Dev-Bootstrap ist nicht aktiv")); + } + + let db = state.db()?; + let organization_name = payload.organization_name.trim(); + let email = normalize_email(&payload.email)?; + + if organization_name.len() < 2 { + return Err(ApiError::bad_request("Firmenname ist zu kurz")); + } + + let user_id = ensure_user(db, &email).await?; + let organization_id = Uuid::new_v4(); + let schema_name = company_schema_name(organization_id); + let encrypted_name = state.crypto.encrypt(&organization_name.to_string())?; + let initial_password = generate_initial_password(); + let password_hash = hash_password(&initial_password)?; + + let mut tx = db.begin().await?; + sqlx::query( + r#" + insert into organizations ( + id, display_name_ciphertext, display_name_nonce, display_name_key_id, + schema_name, status, registration_email, approved_at, setup_completed_at + ) values ($1, $2, $3, $4, $5, 'active', $6, now(), now()) + "#, + ) + .bind(organization_id) + .bind(encrypted_name.ciphertext) + .bind(encrypted_name.nonce) + .bind(encrypted_name.key_id) + .bind(&schema_name) + .bind(&email) + .execute(&mut *tx) + .await?; + + sqlx::query( + r#" + insert into user_organizations (user_id, organization_id, status, accepted_at) + values ($1, $2, 'active', now()) + on conflict (user_id, organization_id) do update set + status = 'active', + accepted_at = coalesce(user_organizations.accepted_at, now()), + updated_at = now() + "#, + ) + .bind(user_id) + .bind(organization_id) + .execute(&mut *tx) + .await?; + + sqlx::query( + r#" + update users + set password_hash = $2, + must_change_password = false, + initial_password_expires_at = null, + updated_at = now() + where id = $1 + "#, + ) + .bind(user_id) + .bind(password_hash) + .execute(&mut *tx) + .await?; + + provision_company_schema_tx(&mut tx, &schema_name).await?; + assign_all_roles_tx(&mut tx, &schema_name, user_id).await?; + tx.commit().await?; + + Ok(Json(json!({ + "organization_id": organization_id, + "schema_name": schema_name, + "user_id": user_id, + "email": email, + "password": initial_password, + "dev_mode": true + }))) +} + +pub async fn register_organization( + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let db = state.db()?; + let organization_name = payload.organization_name.trim(); + let email = normalize_email(&payload.email)?; + + if organization_name.len() < 2 { + return Err(ApiError::bad_request("Firmenname ist zu kurz")); + } + + if !payload.accept_terms { + return Err(ApiError::bad_request( + "Nutzungsbedingungen müssen akzeptiert werden", + )); + } + + let existing_open: Option = sqlx::query_scalar( + "select id from organization_registration_requests where email = $1 and status = 'pending_approval' limit 1", + ) + .bind(&email) + .fetch_optional(db) + .await?; + + if existing_open.is_some() { + return Err(ApiError::conflict( + "Es gibt bereits eine offene Registrierung", + )); + } + + let user_id = ensure_user(db, &email).await?; + let encrypted_name = state.crypto.encrypt(&organization_name.to_string())?; + let registration_id = Uuid::new_v4(); + + sqlx::query( + r#" + insert into organization_registration_requests ( + id, + organization_name_ciphertext, + organization_name_nonce, + organization_name_key_id, + email, + terms_accepted_at + ) values ($1, $2, $3, $4, $5, now()) + "#, + ) + .bind(registration_id) + .bind(encrypted_name.ciphertext) + .bind(encrypted_name.nonce) + .bind(encrypted_name.key_id) + .bind(&email) + .execute(db) + .await?; + + Ok(Json(json!({ + "id": registration_id, + "user_id": user_id, + "status": "pending_approval" + }))) +} + +pub async fn list_organization_registrations( + State(state): State, +) -> Result>, ApiError> { + let db = state.db()?; + let rows = sqlx::query( + r#" + select id, organization_name_ciphertext, organization_name_nonce, organization_name_key_id, + email, status, requested_at, decided_at, decided_by_user_id + from organization_registration_requests + order by requested_at desc + "#, + ) + .fetch_all(db) + .await?; + + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + items.push(OrganizationRegistrationListItem { + id: row.get("id"), + organization_name: decrypt_string( + &state.crypto, + row.get("organization_name_ciphertext"), + row.get("organization_name_nonce"), + row.get("organization_name_key_id"), + )?, + email: row.get("email"), + status: row.get("status"), + requested_at: row.get("requested_at"), + decided_at: row.get("decided_at"), + decided_by_user_id: row.get("decided_by_user_id"), + }); + } + + Ok(Json(items)) +} + +pub async fn get_organization_registration( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + Ok(Json(load_registration_detail(&state, id).await?)) +} + +pub async fn approve_organization_registration( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let row = load_registration_row(db, id).await?; + let email: String = row.get("email"); + let organization_name = decrypt_string( + &state.crypto, + row.get("organization_name_ciphertext"), + row.get("organization_name_nonce"), + row.get("organization_name_key_id"), + )?; + + let user_id = ensure_user(db, &email).await?; + let organization_id = row + .try_get::, _>("organization_id") + .ok() + .flatten() + .unwrap_or_else(Uuid::new_v4); + let schema_name = company_schema_name(organization_id); + let encrypted_name = state.crypto.encrypt(&organization_name)?; + + let mut tx = db.begin().await?; + sqlx::query( + r#" + insert into organizations ( + id, display_name_ciphertext, display_name_nonce, display_name_key_id, + schema_name, status, registration_email, approved_at + ) values ($1, $2, $3, $4, $5, 'active', $6, now()) + on conflict (id) do update set + status = 'active', + schema_name = excluded.schema_name, + approved_at = coalesce(organizations.approved_at, now()), + updated_at = now() + "#, + ) + .bind(organization_id) + .bind(encrypted_name.ciphertext) + .bind(encrypted_name.nonce) + .bind(encrypted_name.key_id) + .bind(&schema_name) + .bind(&email) + .execute(&mut *tx) + .await?; + + sqlx::query( + r#" + insert into user_organizations (user_id, organization_id, status, accepted_at) + values ($1, $2, 'active', now()) + on conflict (user_id, organization_id) do update set + status = 'active', + accepted_at = coalesce(user_organizations.accepted_at, now()), + updated_at = now() + "#, + ) + .bind(user_id) + .bind(organization_id) + .execute(&mut *tx) + .await?; + + sqlx::query( + r#" + update organization_registration_requests + set status = 'active', organization_id = $2, decided_at = now() + where id = $1 + "#, + ) + .bind(id) + .bind(organization_id) + .execute(&mut *tx) + .await?; + + let initial_password = generate_initial_password(); + let password_hash = hash_password(&initial_password)?; + sqlx::query( + r#" + update users + set password_hash = $2, + must_change_password = true, + initial_password_expires_at = now() + interval '7 days', + updated_at = now() + where id = $1 + "#, + ) + .bind(user_id) + .bind(password_hash) + .execute(&mut *tx) + .await?; + + provision_company_schema_tx(&mut tx, &schema_name).await?; + assign_all_roles_tx(&mut tx, &schema_name, user_id).await?; + tx.commit().await?; + enqueue_initial_password_email(&state.crypto, db, &email, &initial_password).await?; + emit_change(&state, "Benutzer eingeladen"); + + let mut response = json!({ + "id": id, + "organization_id": organization_id, + "schema_name": schema_name, + "status": "active" + }); + if dev_mode_enabled() { + response["dev_initial_password"] = json!(initial_password); + } + Ok(Json(response)) +} + +pub async fn reject_organization_registration( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let db = state.db()?; + sqlx::query( + "update organization_registration_requests set status = 'rejected', decided_at = now() where id = $1", + ) + .bind(id) + .execute(db) + .await?; + + Ok(Json(json!({ "id": id, "status": "rejected" }))) +} + +pub async fn resend_initial_email( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let detail = load_registration_detail(&state, id).await?; + let password = generate_initial_password(); + let user_id = ensure_user(db, &detail.email).await?; + let password_hash = hash_password(&password)?; + + sqlx::query( + r#" + update users + set password_hash = $2, + must_change_password = true, + initial_password_expires_at = now() + interval '7 days', + updated_at = now() + where id = $1 + "#, + ) + .bind(user_id) + .bind(password_hash) + .execute(db) + .await?; + enqueue_initial_password_email(&state.crypto, db, &detail.email, &password).await?; + + let mut response = json!({ "queued": true }); + if dev_mode_enabled() { + response["dev_initial_password"] = json!(password); + } + Ok(Json(response)) +} + +pub async fn retry_provisioning( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let detail = load_registration_detail(&state, id).await?; + let organization_id = detail + .organization_id + .ok_or_else(|| ApiError::bad_request("Registrierung ist noch nicht freigeschaltet"))?; + let schema_name = detail + .schema_name + .unwrap_or_else(|| company_schema_name(organization_id)); + provision_company_schema(db, &schema_name).await?; + + Ok(Json( + json!({ "schema_name": schema_name, "provisioned": true }), + )) +} + +pub async fn login( + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let db = state.db()?; + let email = normalize_email(&payload.email)?; + let row = sqlx::query( + "select id, password_hash, must_change_password from users where email = $1 and is_active = true", + ) + .bind(&email) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::unauthorized("Login fehlgeschlagen"))?; + + let password_hash: Option = row.get("password_hash"); + let Some(password_hash) = password_hash else { + return Err(ApiError::unauthorized("Login fehlgeschlagen")); + }; + verify_password(&payload.password, &password_hash)?; + + let user_id: Uuid = row.get("id"); + sqlx::query("update users set last_login_at = now(), updated_at = now() where id = $1") + .bind(user_id) + .execute(db) + .await?; + + let orgs = sqlx::query( + r#" + select o.id, o.schema_name, o.status + from organizations o + join user_organizations uo on uo.organization_id = o.id + where uo.user_id = $1 and uo.status = 'active' + order by o.created_at + "#, + ) + .bind(user_id) + .fetch_all(db) + .await?; + + let organizations = orgs + .into_iter() + .map(|row| { + json!({ + "id": row.get::("id"), + "schema_name": row.get::, _>("schema_name"), + "status": row.get::("status") + }) + }) + .collect::>(); + let selected_organization_id = organizations + .first() + .and_then(|organization| organization.get("id")) + .and_then(|value| serde_json::from_value::(value.clone()).ok()); + let access_token = create_session_token(db, user_id, selected_organization_id).await?; + + Ok(Json(json!({ + "user_id": user_id, + "access_token": access_token, + "organization_id": selected_organization_id, + "must_change_password": row.get::("must_change_password"), + "organizations": organizations + }))) +} + +pub async fn change_initial_password( + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + validate_new_password(&payload.new_password, &payload.new_password_confirm)?; + + let db = state.db()?; + let email = normalize_email(&payload.email)?; + let row = + sqlx::query("select id, password_hash from users where email = $1 and is_active = true") + .bind(&email) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::unauthorized("Login fehlgeschlagen"))?; + let password_hash: Option = row.get("password_hash"); + verify_password( + &payload.current_password, + password_hash.as_deref().unwrap_or_default(), + )?; + let new_hash = hash_password(&payload.new_password)?; + + sqlx::query( + r#" + update users + set password_hash = $2, + must_change_password = false, + initial_password_expires_at = null, + updated_at = now() + where id = $1 + "#, + ) + .bind(row.get::("id")) + .bind(new_hash) + .execute(db) + .await?; + + Ok(Json(json!({ "changed": true }))) +} + +pub async fn request_password_reset( + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let db = state.db()?; + let email = normalize_email(&payload.email)?; + let user_id = + sqlx::query_scalar::<_, Uuid>("select id from users where email=$1 and is_active=true") + .bind(&email) + .fetch_optional(db) + .await?; + + let mut dev_reset_token = None; + if let Some(user_id) = user_id { + let token = generate_token(); + let token_hash = hash_token(&token); + sqlx::query( + r#" + insert into password_reset_tokens (id, user_id, token_hash, expires_at) + values ($1,$2,$3,now() + interval '30 minutes') + "#, + ) + .bind(Uuid::new_v4()) + .bind(user_id) + .bind(&token_hash) + .execute(db) + .await?; + enqueue_password_reset_email(&state.crypto, db, &email, &token).await?; + if dev_mode_enabled() { + dev_reset_token = Some(token); + } + } + + let mut response = json!({ "queued": true }); + if let Some(token) = dev_reset_token { + response["dev_reset_token"] = json!(token); + } + Ok(Json(response)) +} + +pub async fn reset_password( + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + validate_new_password(&payload.new_password, &payload.new_password_confirm)?; + let db = state.db()?; + let token_hash = hash_token(&payload.token); + let row = sqlx::query( + r#" + select id, user_id + from password_reset_tokens + where token_hash=$1 and used_at is null and expires_at > now() + "#, + ) + .bind(&token_hash) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::bad_request("Reset-Token ist ungültig oder abgelaufen"))?; + let reset_id: Uuid = row.get("id"); + let user_id: Uuid = row.get("user_id"); + let new_hash = hash_password(&payload.new_password)?; + let mut tx = db.begin().await?; + sqlx::query( + r#" + update users + set password_hash=$2, must_change_password=false, + initial_password_expires_at=null, updated_at=now() + where id=$1 + "#, + ) + .bind(user_id) + .bind(new_hash) + .execute(&mut *tx) + .await?; + sqlx::query("update password_reset_tokens set used_at=now() where id=$1") + .bind(reset_id) + .execute(&mut *tx) + .await?; + sqlx::query( + "update refresh_tokens set revoked_at=now(), revoked_reason='password_reset' where user_id=$1 and revoked_at is null", + ) + .bind(user_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(json!({ "changed": true }))) +} + +pub async fn accept_invitation( + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + validate_new_password(&payload.new_password, &payload.new_password_confirm)?; + let db = state.db()?; + let token_hash = hash_token(&payload.token); + let row = sqlx::query( + r#" + select id, organization_id, email, created_user_id + from user_invitations + where token_hash=$1 and status='pending' and expires_at > now() + "#, + ) + .bind(&token_hash) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::bad_request("Einladung ist ungültig oder abgelaufen"))?; + let invitation_id: Uuid = row.get("id"); + let organization_id: Uuid = row.get("organization_id"); + let email: String = row.get("email"); + let user_id: Uuid = row + .get::, _>("created_user_id") + .unwrap_or_else(Uuid::new_v4); + let password_hash = hash_password(&payload.new_password)?; + + let mut tx = db.begin().await?; + sqlx::query( + r#" + update users + set password_hash=$2, must_change_password=false, + initial_password_expires_at=null, updated_at=now() + where id=$1 + "#, + ) + .bind(user_id) + .bind(password_hash) + .execute(&mut *tx) + .await?; + sqlx::query( + r#" + update user_organizations + set status='active', accepted_at=now(), updated_at=now() + where user_id=$1 and organization_id=$2 + "#, + ) + .bind(user_id) + .bind(organization_id) + .execute(&mut *tx) + .await?; + sqlx::query( + r#" + update user_invitations + set status='accepted', accepted_at=now(), accepted_by_user_id=$2 + where id=$1 + "#, + ) + .bind(invitation_id) + .bind(user_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(json!({ + "accepted": true, + "email": email, + "organization_id": organization_id + }))) +} + +pub async fn auth_organizations( + headers: HeaderMap, + State(state): State, +) -> Result>, ApiError> { + let db = state.db()?; + let auth = require_auth(db, &headers).await?; + let rows = sqlx::query( + r#" + select o.id, o.schema_name, o.status + from organizations o + join user_organizations uo on uo.organization_id = o.id + where uo.user_id = $1 and uo.status = 'active' + order by o.created_at + "#, + ) + .bind(auth.user_id) + .fetch_all(db) + .await?; + Ok(Json( + rows.into_iter() + .map(|row| { + json!({ + "id": row.get::("id"), + "schema_name": row.get::, _>("schema_name"), + "status": row.get::("status") + }) + }) + .collect(), + )) +} + +pub async fn select_organization( + headers: HeaderMap, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let db = state.db()?; + let auth = require_auth(db, &headers).await?; + let context = + load_context_for_user_and_organization(db, auth.user_id, payload.organization_id).await?; + update_session_organization(db, &headers, context.organization_id).await?; + Ok(Json(json!({ + "selected": true, + "organization_id": context.organization_id, + "schema_name": context.schema_name + }))) +} + +pub async fn get_organization_setup( + headers: HeaderMap, + State(state): State, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "settings.read").await?; + let sql = format!( + r#" + select value_ciphertext, value_nonce, value_key_id + from {schema}.settings + where key = 'organization_setup' + "#, + schema = context.schema_name + ); + let setup = match sqlx::query(&sql).fetch_optional(db).await? { + Some(row) => Some(state.crypto.decrypt::( + row.get("value_ciphertext"), + row.get("value_nonce"), + row.get("value_key_id"), + )?), + None => None, + }; + + Ok(Json(json!({ + "organization_id": context.organization_id, + "schema_name": context.schema_name, + "setup": setup + }))) +} + +pub async fn put_organization_setup( + headers: HeaderMap, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "settings.write").await?; + let organization_id = context.organization_id; + let encrypted_name = state.crypto.encrypt(&payload.display_name)?; + let encrypted_settings = state.crypto.encrypt(&payload)?; + + sqlx::query( + r#" + update organizations + set display_name_ciphertext = $2, + display_name_nonce = $3, + display_name_key_id = $4, + setup_completed_at = coalesce(setup_completed_at, now()), + updated_at = now() + where id = $1 + "#, + ) + .bind(organization_id) + .bind(encrypted_name.ciphertext) + .bind(encrypted_name.nonce) + .bind(encrypted_name.key_id) + .execute(db) + .await?; + + let schema_name = context.schema_name; + let sql = format!( + r#" + insert into {schema}.settings (key, value_ciphertext, value_nonce, value_key_id) + values ('organization_setup', $1, $2, $3) + on conflict (key) do update set + value_ciphertext = excluded.value_ciphertext, + value_nonce = excluded.value_nonce, + value_key_id = excluded.value_key_id, + updated_at = now() + "#, + schema = schema_name + ); + sqlx::query(&sql) + .bind(encrypted_settings.ciphertext) + .bind(encrypted_settings.nonce) + .bind(encrypted_settings.key_id) + .execute(db) + .await?; + + emit_change(&state, "Firmendaten geändert"); + Ok(Json(json!({ "saved": true }))) +} + +pub async fn get_user_navigation_settings( + headers: HeaderMap, + State(state): State, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_auth(db, &headers).await?; + ensure_safe_schema_name(&context.schema_name)?; + let sql = format!( + r#" + select value_ciphertext, value_nonce, value_key_id + from {schema}.user_settings + where user_id = $1 and key = 'navigation' + "#, + schema = context.schema_name + ); + let settings = match sqlx::query(&sql) + .bind(context.user_id) + .fetch_optional(db) + .await? + { + Some(row) => state.crypto.decrypt::( + row.get("value_ciphertext"), + row.get("value_nonce"), + row.get("value_key_id"), + )?, + None => default_user_navigation_settings(), + }; + Ok(Json(normalize_user_navigation_settings(settings)?)) +} + +pub async fn put_user_navigation_settings( + headers: HeaderMap, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_auth(db, &headers).await?; + ensure_safe_schema_name(&context.schema_name)?; + let settings = normalize_user_navigation_settings(payload)?; + let encrypted = state.crypto.encrypt(&settings)?; + let sql = format!( + r#" + insert into {schema}.user_settings (user_id, key, value_ciphertext, value_nonce, value_key_id) + values ($1, 'navigation', $2, $3, $4) + on conflict (user_id, key) do update set + value_ciphertext = excluded.value_ciphertext, + value_nonce = excluded.value_nonce, + value_key_id = excluded.value_key_id, + updated_at = now() + "#, + schema = context.schema_name + ); + sqlx::query(&sql) + .bind(context.user_id) + .bind(encrypted.ciphertext) + .bind(encrypted.nonce) + .bind(encrypted.key_id) + .execute(db) + .await?; + Ok(Json(settings)) +} + +pub async fn list_number_ranges( + headers: HeaderMap, + State(state): State, +) -> Result>, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "number_ranges.read").await?; + let sql = format!( + r#" + select id, code, pattern, counter_value, counter_padding, reset_rule, is_active + from {schema}.number_ranges + order by code + "#, + schema = context.schema_name + ); + let rows = sqlx::query(&sql).fetch_all(db).await?; + Ok(Json(rows.into_iter().map(number_range_from_row).collect())) +} + +pub async fn update_number_range( + headers: HeaderMap, + State(state): State, + Path(code): Path, + Json(payload): Json, +) -> Result, ApiError> { + validate_number_range(&payload)?; + let db = state.db()?; + let context = require_permission(db, &headers, "number_ranges.write").await?; + let sql = format!( + r#" + update {schema}.number_ranges + set pattern=$2, counter_value=$3, counter_padding=$4, + reset_rule=$5, is_active=$6, updated_at=now() + where code=$1 + returning id, code, pattern, counter_value, counter_padding, reset_rule, is_active + "#, + schema = context.schema_name + ); + let row = sqlx::query(&sql) + .bind(code) + .bind(payload.pattern.trim()) + .bind(payload.counter_value) + .bind(payload.counter_padding) + .bind( + payload + .reset_rule + .as_deref() + .filter(|value| !value.trim().is_empty()), + ) + .bind(payload.is_active) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::not_found("Nummernkreis nicht gefunden"))?; + emit_change(&state, "Nummernkreis geändert"); + Ok(Json(number_range_from_row(row))) +} + +pub async fn generate_next_number( + headers: HeaderMap, + State(state): State, + Path(code): Path, +) -> Result, ApiError> { + ensure_known_number_range_code(&code)?; + let db = state.db()?; + let context = require_permission(db, &headers, number_range_write_permission(&code)).await?; + let number = next_number(db, &context.schema_name, &code).await?; + Ok(Json(NextNumberResponse { code, number })) +} + +pub async fn list_customers( + headers: HeaderMap, + State(state): State, +) -> Result>, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "customers.read").await?; + let sql = format!( + r#" + select c.id, c.customer_number, c.name_ciphertext, c.name_nonce, c.name_key_id, + c.status, c.details_ciphertext, c.details_nonce, c.details_key_id, + coalesce(terms.standard_discount_percent, 0)::text standard_discount_percent, + terms.cash_discount_term_id + from {schema}.customers c + left join lateral ( + select standard_discount_percent, cash_discount_term_id + from {schema}.customer_price_terms + where customer_id = c.id and is_active = true + order by updated_at desc + limit 1 + ) terms on true + order by c.updated_at desc, c.customer_number + "#, + schema = context.schema_name + ); + let rows = sqlx::query(&sql).fetch_all(db).await?; + let mut customers = Vec::with_capacity(rows.len()); + for row in rows { + customers.push(customer_from_row(&state.crypto, row)?); + } + Ok(Json(customers)) +} + +pub async fn create_customer( + headers: HeaderMap, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let mut payload = payload; + validate_customer_request(&payload)?; + let db = state.db()?; + let context = require_permission(db, &headers, "customers.write").await?; + payload.customer_number = next_number_if_blank( + db, + &context.schema_name, + "customers", + &payload.customer_number, + ) + .await?; + let customer_id = Uuid::new_v4(); + let encrypted_name = state.crypto.encrypt(&payload.name.trim().to_string())?; + let encrypted_details = state.crypto.encrypt(&payload.details)?; + let sql = format!( + r#" + insert into {schema}.customers ( + id, customer_number, name_ciphertext, name_nonce, name_key_id, status, + details_ciphertext, details_nonce, details_key_id + ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9) + "#, + schema = context.schema_name + ); + sqlx::query(&sql) + .bind(customer_id) + .bind(payload.customer_number.trim()) + .bind(encrypted_name.ciphertext) + .bind(encrypted_name.nonce) + .bind(encrypted_name.key_id) + .bind(&payload.status) + .bind(encrypted_details.ciphertext) + .bind(encrypted_details.nonce) + .bind(encrypted_details.key_id) + .execute(db) + .await?; + save_customer_terms(db, &context.schema_name, customer_id, &payload).await?; + emit_change(&state, "Kunde angelegt"); + Ok(Json(customer_response(customer_id, payload))) +} + +pub async fn update_customer( + headers: HeaderMap, + State(state): State, + Path(customer_id): Path, + Json(payload): Json, +) -> Result, ApiError> { + validate_customer_request(&payload)?; + let db = state.db()?; + let context = require_permission(db, &headers, "customers.write").await?; + let encrypted_name = state.crypto.encrypt(&payload.name.trim().to_string())?; + let encrypted_details = state.crypto.encrypt(&payload.details)?; + let sql = format!( + r#" + update {schema}.customers + set customer_number = customer_number, name_ciphertext = $3, name_nonce = $4, + name_key_id = $5, status = $6, details_ciphertext = $7, + details_nonce = $8, details_key_id = $9, updated_at = now() + where id = $1 + "#, + schema = context.schema_name + ); + let result = sqlx::query(&sql) + .bind(customer_id) + .bind(payload.customer_number.trim()) + .bind(encrypted_name.ciphertext) + .bind(encrypted_name.nonce) + .bind(encrypted_name.key_id) + .bind(&payload.status) + .bind(encrypted_details.ciphertext) + .bind(encrypted_details.nonce) + .bind(encrypted_details.key_id) + .execute(db) + .await?; + if result.rows_affected() == 0 { + return Err(ApiError::not_found("Kunde nicht gefunden")); + } + save_customer_terms(db, &context.schema_name, customer_id, &payload).await?; + emit_change(&state, "Kunde geändert"); + Ok(Json(customer_response(customer_id, payload))) +} + +pub async fn delete_customer( + headers: HeaderMap, + State(state): State, + Path(customer_id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "customers.delete").await?; + let sql = format!( + "update {schema}.customers set status = 'inactive', updated_at = now() where id = $1", + schema = context.schema_name + ); + let result = sqlx::query(&sql).bind(customer_id).execute(db).await?; + if result.rows_affected() == 0 { + return Err(ApiError::not_found("Kunde nicht gefunden")); + } + emit_change(&state, "Kunde deaktiviert"); + Ok(Json(json!({ "deleted": true, "id": customer_id }))) +} + +pub async fn list_suppliers( + headers: HeaderMap, + State(state): State, +) -> Result>, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "suppliers.read").await?; + let sql = format!( + r#" + select s.id, s.supplier_number, s.name_ciphertext, s.name_nonce, s.name_key_id, + s.status, s.details_ciphertext, s.details_nonce, s.details_key_id, + coalesce(terms.standard_discount_percent, 0)::text standard_discount_percent, + terms.cash_discount_term_id, terms.payment_days + from {schema}.suppliers s + left join lateral ( + select standard_discount_percent, cash_discount_term_id, payment_days + from {schema}.supplier_price_terms + where supplier_id = s.id and is_active = true + order by updated_at desc limit 1 + ) terms on true + order by s.updated_at desc, s.supplier_number + "#, + schema = context.schema_name + ); + let rows = sqlx::query(&sql).fetch_all(db).await?; + let mut suppliers = Vec::with_capacity(rows.len()); + for row in rows { + suppliers.push(supplier_from_row(&state.crypto, row)?); + } + Ok(Json(suppliers)) +} + +pub async fn create_supplier( + headers: HeaderMap, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + validate_supplier_request(&payload)?; + let db = state.db()?; + let context = require_permission(db, &headers, "suppliers.write").await?; + let supplier_id = Uuid::new_v4(); + let mut payload = payload; + payload.supplier_number = next_number_if_blank( + db, + &context.schema_name, + "suppliers", + &payload.supplier_number, + ) + .await?; + write_supplier( + db, + &state.crypto, + &context.schema_name, + supplier_id, + &payload, + true, + ) + .await?; + save_supplier_terms(db, &context.schema_name, supplier_id, &payload).await?; + emit_change(&state, "Lieferant angelegt"); + Ok(Json(supplier_response(supplier_id, payload))) +} + +pub async fn update_supplier( + headers: HeaderMap, + State(state): State, + Path(supplier_id): Path, + Json(payload): Json, +) -> Result, ApiError> { + validate_supplier_request(&payload)?; + let db = state.db()?; + let context = require_permission(db, &headers, "suppliers.write").await?; + write_supplier( + db, + &state.crypto, + &context.schema_name, + supplier_id, + &payload, + false, + ) + .await?; + save_supplier_terms(db, &context.schema_name, supplier_id, &payload).await?; + emit_change(&state, "Lieferant geändert"); + Ok(Json(supplier_response(supplier_id, payload))) +} + +pub async fn delete_supplier( + headers: HeaderMap, + State(state): State, + Path(supplier_id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "suppliers.delete").await?; + set_record_inactive(db, &context.schema_name, "suppliers", supplier_id).await?; + emit_change(&state, "Lieferant deaktiviert"); + Ok(Json(json!({ "deleted": true, "id": supplier_id }))) +} + +pub async fn list_cash_discount_terms( + headers: HeaderMap, + State(state): State, +) -> Result>, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "cash_discount_terms.read").await?; + let sql = format!( + r#" + select id, code, name, discount_percent::text discount_percent, + discount_days, net_days, valid_from::text valid_from, + valid_until::text valid_until, is_default_customer_term, + is_default_supplier_term, is_active + from {schema}.cash_discount_terms + order by is_active desc, code + "#, + schema = context.schema_name + ); + let rows = sqlx::query(&sql).fetch_all(db).await?; + Ok(Json( + rows.into_iter().map(cash_discount_term_from_row).collect(), + )) +} + +pub async fn create_cash_discount_term( + headers: HeaderMap, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + validate_cash_discount_term(&payload)?; + let db = state.db()?; + let context = require_permission(db, &headers, "cash_discount_terms.write").await?; + let term_id = Uuid::new_v4(); + write_cash_discount_term(db, &context.schema_name, term_id, &payload, true).await?; + emit_change(&state, "Skonto-Regel angelegt"); + Ok(Json(cash_discount_term_response(term_id, payload))) +} + +pub async fn update_cash_discount_term( + headers: HeaderMap, + State(state): State, + Path(term_id): Path, + Json(payload): Json, +) -> Result, ApiError> { + validate_cash_discount_term(&payload)?; + let db = state.db()?; + let context = require_permission(db, &headers, "cash_discount_terms.write").await?; + write_cash_discount_term(db, &context.schema_name, term_id, &payload, false).await?; + emit_change(&state, "Skonto-Regel geändert"); + Ok(Json(cash_discount_term_response(term_id, payload))) +} + +pub async fn delete_cash_discount_term( + headers: HeaderMap, + State(state): State, + Path(term_id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "cash_discount_terms.delete").await?; + let sql = format!( + r#" + update {schema}.cash_discount_terms + set is_active = false, + is_default_customer_term = false, + is_default_supplier_term = false, + updated_at = now() + where id = $1 + "#, + schema = context.schema_name + ); + let result = sqlx::query(&sql).bind(term_id).execute(db).await?; + ensure_changed(result.rows_affected(), "Skonto-Regel nicht gefunden")?; + emit_change(&state, "Skonto-Regel deaktiviert"); + Ok(Json(json!({ "deleted": true, "id": term_id }))) +} + +pub async fn list_quotes( + headers: HeaderMap, + State(state): State, +) -> Result>, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "quotes.read").await?; + let sql = format!( + r#" + select id, quote_number, customer_id, status, valid_until::text valid_until, + cash_discount_term_id, customer_discount_percent::text customer_discount_percent, + notes_ciphertext, notes_nonce, notes_key_id + from {schema}.quotes + order by updated_at desc, quote_number + "#, + schema = context.schema_name + ); + let rows = sqlx::query(&sql).fetch_all(db).await?; + let mut quotes = Vec::with_capacity(rows.len()); + for row in rows { + let quote_id = row.get("id"); + quotes.push(quote_from_row(db, &state.crypto, &context.schema_name, row, quote_id).await?); + } + Ok(Json(quotes)) +} + +pub async fn create_quote( + headers: HeaderMap, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "quotes.write").await?; + let quote_id = Uuid::new_v4(); + let mut payload = payload; + apply_customer_terms_to_quote(db, &context.schema_name, &mut payload).await?; + validate_quote_request(&payload)?; + payload.quote_number = + next_number_if_blank(db, &context.schema_name, "quotes", &payload.quote_number).await?; + write_quote(db, &state.crypto, &context, quote_id, &payload, true).await?; + emit_change(&state, "Angebot angelegt"); + Ok(Json(quote_response(quote_id, payload))) +} + +pub async fn update_quote( + headers: HeaderMap, + State(state): State, + Path(quote_id): Path, + Json(payload): Json, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "quotes.write").await?; + let mut payload = payload; + apply_customer_terms_to_quote(db, &context.schema_name, &mut payload).await?; + validate_quote_request(&payload)?; + write_quote(db, &state.crypto, &context, quote_id, &payload, false).await?; + emit_change(&state, "Angebot geändert"); + Ok(Json(quote_response(quote_id, payload))) +} + +pub async fn delete_quote( + headers: HeaderMap, + State(state): State, + Path(quote_id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "quotes.delete").await?; + let sql = format!( + "update {schema}.quotes set status='cancelled', updated_at=now() where id=$1", + schema = context.schema_name + ); + let result = sqlx::query(&sql).bind(quote_id).execute(db).await?; + ensure_changed(result.rows_affected(), "Angebot nicht gefunden")?; + emit_change(&state, "Angebot storniert"); + Ok(Json(json!({ "deleted": true, "id": quote_id }))) +} + +pub async fn convert_quote_to_outgoing_invoice( + headers: HeaderMap, + State(state): State, + Path(quote_id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "quotes.convert_to_invoice").await?; + let quote = load_quote_by_id(db, &state.crypto, &context.schema_name, quote_id).await?; + let invoice_id = Uuid::new_v4(); + let invoice = OutgoingInvoiceRequest { + invoice_number: next_number(db, &context.schema_name, "outgoing_invoices").await?, + customer_id: quote.customer_id, + status: "draft".to_string(), + cash_discount_term_id: quote.cash_discount_term_id, + customer_discount_percent: quote.customer_discount_percent.clone(), + issued_at: None, + due_at: None, + source_quote_id: Some(quote.id), + items: quote + .items + .iter() + .map(|item| OutgoingInvoiceItemRequest { + item_id: item.item_id, + description: item.description.clone(), + quantity: item.quantity.clone(), + unit_price: item.unit_price.clone(), + original_unit_price: item.original_unit_price.clone(), + discount_percent: item.discount_percent.clone(), + tax_rate: item.tax_rate.clone(), + }) + .collect(), + }; + validate_outgoing_invoice_request(&invoice)?; + write_outgoing_invoice(db, &state.crypto, &context, invoice_id, &invoice, true).await?; + emit_change(&state, "Angebot in Rechnung umgewandelt"); + Ok(Json(outgoing_invoice_response(invoice_id, invoice, None))) +} + +pub async fn list_outgoing_invoices( + headers: HeaderMap, + State(state): State, +) -> Result>, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "outgoing_invoices.read").await?; + let sql = format!( + r#" + select id, invoice_number, customer_id, status, cash_discount_term_id, + customer_discount_percent::text customer_discount_percent, + issued_at::text issued_at, due_at::text due_at, source_quote_id, finalized_at + from {schema}.outgoing_invoices + order by updated_at desc, invoice_number + "#, + schema = context.schema_name + ); + let rows = sqlx::query(&sql).fetch_all(db).await?; + let mut invoices = Vec::with_capacity(rows.len()); + for row in rows { + let invoice_id = row.get("id"); + invoices.push( + outgoing_invoice_from_row(db, &state.crypto, &context.schema_name, row, invoice_id) + .await?, + ); + } + Ok(Json(invoices)) +} + +pub async fn create_outgoing_invoice( + headers: HeaderMap, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "outgoing_invoices.write").await?; + let invoice_id = Uuid::new_v4(); + let mut payload = payload; + apply_customer_terms_to_outgoing_invoice(db, &context.schema_name, &mut payload).await?; + validate_outgoing_invoice_request(&payload)?; + payload.invoice_number = next_number_if_blank( + db, + &context.schema_name, + "outgoing_invoices", + &payload.invoice_number, + ) + .await?; + write_outgoing_invoice(db, &state.crypto, &context, invoice_id, &payload, true).await?; + emit_change(&state, "Ausgangsrechnung angelegt"); + Ok(Json(outgoing_invoice_response(invoice_id, payload, None))) +} + +pub async fn update_outgoing_invoice( + headers: HeaderMap, + State(state): State, + Path(invoice_id): Path, + Json(payload): Json, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "outgoing_invoices.write").await?; + ensure_outgoing_invoice_editable(db, &context.schema_name, invoice_id).await?; + let mut payload = payload; + apply_customer_terms_to_outgoing_invoice(db, &context.schema_name, &mut payload).await?; + validate_outgoing_invoice_request(&payload)?; + write_outgoing_invoice(db, &state.crypto, &context, invoice_id, &payload, false).await?; + emit_change(&state, "Ausgangsrechnung geändert"); + Ok(Json(outgoing_invoice_response(invoice_id, payload, None))) +} + +pub async fn delete_outgoing_invoice( + headers: HeaderMap, + State(state): State, + Path(invoice_id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "outgoing_invoices.delete").await?; + ensure_outgoing_invoice_editable(db, &context.schema_name, invoice_id).await?; + let sql = format!( + "update {schema}.outgoing_invoices set status='cancelled', updated_at=now() where id=$1", + schema = context.schema_name + ); + let result = sqlx::query(&sql).bind(invoice_id).execute(db).await?; + ensure_changed(result.rows_affected(), "Ausgangsrechnung nicht gefunden")?; + emit_change(&state, "Ausgangsrechnung storniert"); + Ok(Json(json!({ "deleted": true, "id": invoice_id }))) +} + +pub async fn finalize_outgoing_invoice( + headers: HeaderMap, + State(state): State, + Path(invoice_id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "invoices.finalize").await?; + let sql = format!( + r#" + update {schema}.outgoing_invoices + set status='finalized', finalized_at=coalesce(finalized_at, now()), updated_at=now() + where id=$1 and finalized_at is null + "#, + schema = context.schema_name + ); + let result = sqlx::query(&sql).bind(invoice_id).execute(db).await?; + ensure_changed( + result.rows_affected(), + "Rechnung nicht gefunden oder bereits abgeschlossen", + )?; + emit_change(&state, "Ausgangsrechnung abgeschlossen"); + Ok(Json(json!({ "finalized": true, "id": invoice_id }))) +} + +pub async fn list_incoming_invoices( + headers: HeaderMap, + State(state): State, +) -> Result>, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "incoming_invoices.read").await?; + let sql = format!( + r#" + select id, invoice_number, supplier_id, status, cash_discount_term_id, + invoice_date::text invoice_date, due_at::text due_at + from {schema}.incoming_invoices + order by updated_at desc, invoice_number + "#, + schema = context.schema_name + ); + let rows = sqlx::query(&sql).fetch_all(db).await?; + let mut invoices = Vec::with_capacity(rows.len()); + for row in rows { + let invoice_id = row.get("id"); + invoices.push( + incoming_invoice_from_row(db, &state.crypto, &context.schema_name, row, invoice_id) + .await?, + ); + } + Ok(Json(invoices)) +} + +pub async fn create_incoming_invoice( + headers: HeaderMap, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "incoming_invoices.write").await?; + let invoice_id = Uuid::new_v4(); + let mut payload = payload; + apply_supplier_terms_to_incoming_invoice(db, &context.schema_name, &mut payload).await?; + payload.invoice_number = next_number_if_blank( + db, + &context.schema_name, + "incoming_invoices", + &payload.invoice_number, + ) + .await?; + validate_incoming_invoice_request(&payload)?; + write_incoming_invoice(db, &state.crypto, &context, invoice_id, &payload, true).await?; + emit_change(&state, "Eingangsrechnung angelegt"); + Ok(Json(incoming_invoice_response(invoice_id, payload))) +} + +pub async fn update_incoming_invoice( + headers: HeaderMap, + State(state): State, + Path(invoice_id): Path, + Json(payload): Json, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "incoming_invoices.write").await?; + let mut payload = payload; + apply_supplier_terms_to_incoming_invoice(db, &context.schema_name, &mut payload).await?; + validate_incoming_invoice_request(&payload)?; + write_incoming_invoice(db, &state.crypto, &context, invoice_id, &payload, false).await?; + emit_change(&state, "Eingangsrechnung geändert"); + Ok(Json(incoming_invoice_response(invoice_id, payload))) +} + +pub async fn delete_incoming_invoice( + headers: HeaderMap, + State(state): State, + Path(invoice_id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "incoming_invoices.delete").await?; + let sql = format!( + "update {schema}.incoming_invoices set status='cancelled', updated_at=now() where id=$1", + schema = context.schema_name + ); + let result = sqlx::query(&sql).bind(invoice_id).execute(db).await?; + ensure_changed(result.rows_affected(), "Eingangsrechnung nicht gefunden")?; + emit_change(&state, "Eingangsrechnung storniert"); + Ok(Json(json!({ "deleted": true, "id": invoice_id }))) +} + +pub async fn preview_price_list_import( + headers: HeaderMap, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "price_lists.import").await?; + let rows = parse_price_list_rows(db, &context.schema_name, &payload).await?; + let valid_rows = rows.iter().filter(|row| row.error.is_none()).count(); + let error_rows = rows.len().saturating_sub(valid_rows); + Ok(Json(PriceListImportPreview { + total_rows: rows.len(), + valid_rows, + error_rows, + rows, + })) +} + +pub async fn apply_price_list_import( + headers: HeaderMap, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "price_lists.import").await?; + let rows = parse_price_list_rows(db, &context.schema_name, &payload).await?; + let valid_rows = rows + .iter() + .filter(|row| row.error.is_none()) + .cloned() + .collect::>(); + let import_id = Uuid::new_v4(); + let insert_import = format!( + r#" + insert into {schema}.imports ( + id, import_type, source_name, status, total_rows, applied_rows, + error_rows, created_by_user_id, finished_at + ) values ($1, 'price_list', $2, 'applied', $3, $4, $5, $6, now()) + "#, + schema = context.schema_name + ); + sqlx::query(&insert_import) + .bind(import_id) + .bind(payload.source_name.trim()) + .bind(rows.len() as i32) + .bind(valid_rows.len() as i32) + .bind((rows.len() - valid_rows.len()) as i32) + .bind(context.user_id) + .execute(db) + .await?; + + for row in &valid_rows { + upsert_imported_item(db, &state.crypto, &context, import_id, row).await?; + } + + emit_change(&state, "Preislistenimport abgeschlossen"); + Ok(Json(PriceListImportApplyResponse { + import_id, + applied_rows: valid_rows.len(), + error_rows: rows.len() - valid_rows.len(), + })) +} + +pub async fn list_api_connectors( + headers: HeaderMap, + State(state): State, +) -> Result>, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "price_apis.sync").await?; + let sql = format!( + r#" + select id, code, name, connector_type, config_ciphertext, config_nonce, + config_key_id, is_active, sync_interval_minutes, last_sync_at + from {schema}.api_connectors + order by name + "#, + schema = context.schema_name + ); + let rows = sqlx::query(&sql).fetch_all(db).await?; + let mut connectors = Vec::with_capacity(rows.len()); + for row in rows { + connectors.push(api_connector_from_row(&state.crypto, row)?); + } + Ok(Json(connectors)) +} + +pub async fn create_api_connector( + headers: HeaderMap, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + validate_api_connector_request(&payload)?; + let db = state.db()?; + let context = require_permission(db, &headers, "price_apis.sync").await?; + let connector_id = Uuid::new_v4(); + write_api_connector( + db, + &state.crypto, + &context.schema_name, + connector_id, + &payload, + true, + ) + .await?; + emit_change(&state, "Preis-API-Connector angelegt"); + Ok(Json(api_connector_response(connector_id, payload, None))) +} + +pub async fn update_api_connector( + headers: HeaderMap, + State(state): State, + Path(connector_id): Path, + Json(payload): Json, +) -> Result, ApiError> { + validate_api_connector_request(&payload)?; + let db = state.db()?; + let context = require_permission(db, &headers, "price_apis.sync").await?; + write_api_connector( + db, + &state.crypto, + &context.schema_name, + connector_id, + &payload, + false, + ) + .await?; + emit_change(&state, "Preis-API-Connector geändert"); + Ok(Json(api_connector_response(connector_id, payload, None))) +} + +pub async fn delete_api_connector( + headers: HeaderMap, + State(state): State, + Path(connector_id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "price_apis.sync").await?; + let sql = format!( + "update {schema}.api_connectors set is_active=false, updated_at=now() where id=$1", + schema = context.schema_name + ); + let result = sqlx::query(&sql).bind(connector_id).execute(db).await?; + ensure_changed(result.rows_affected(), "API-Connector nicht gefunden")?; + emit_change(&state, "Preis-API-Connector deaktiviert"); + Ok(Json(json!({ "deleted": true, "id": connector_id }))) +} + +pub async fn sync_api_connector( + headers: HeaderMap, + State(state): State, + Path(connector_id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "price_apis.sync").await?; + let select_sql = format!( + r#" + select id, code, name, connector_type, config_ciphertext, config_nonce, + config_key_id, is_active, sync_interval_minutes, last_sync_at + from {schema}.api_connectors + where id=$1 and is_active=true + "#, + schema = context.schema_name + ); + let row = sqlx::query(&select_sql) + .bind(connector_id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::not_found("API-Connector nicht gefunden oder inaktiv"))?; + let connector = api_connector_from_row(&state.crypto, row)?; + let mut applied_rows = 0usize; + let mut error_rows = 0usize; + + if let Some(content) = connector + .config + .get("price_list_csv") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + { + let payload = PriceListImportRequest { + source_name: format!("api:{}", connector.code), + content: content.to_string(), + delimiter: connector + .config + .get("delimiter") + .and_then(|value| value.as_str()) + .map(ToString::to_string), + }; + let rows = parse_price_list_rows(db, &context.schema_name, &payload).await?; + let valid_rows = rows + .iter() + .filter(|row| row.error.is_none()) + .cloned() + .collect::>(); + let import_id = Uuid::new_v4(); + let insert_import = format!( + r#" + insert into {schema}.imports ( + id, import_type, source_name, status, total_rows, applied_rows, + error_rows, created_by_user_id, finished_at + ) values ($1, 'api_price_sync', $2, 'applied', $3, $4, $5, $6, now()) + "#, + schema = context.schema_name + ); + sqlx::query(&insert_import) + .bind(import_id) + .bind(&payload.source_name) + .bind(rows.len() as i32) + .bind(valid_rows.len() as i32) + .bind((rows.len() - valid_rows.len()) as i32) + .bind(context.user_id) + .execute(db) + .await?; + + for row in &valid_rows { + upsert_imported_item(db, &state.crypto, &context, import_id, row).await?; + } + applied_rows = valid_rows.len(); + error_rows = rows.len() - valid_rows.len(); + } + + let update_sql = format!( + "update {schema}.api_connectors set last_sync_at=now(), updated_at=now() where id=$1 and is_active=true", + schema = context.schema_name + ); + let result = sqlx::query(&update_sql) + .bind(connector_id) + .execute(db) + .await?; + ensure_changed( + result.rows_affected(), + "API-Connector nicht gefunden oder inaktiv", + )?; + emit_change(&state, "Preis-API-Abgleich ausgeführt"); + Ok(Json(json!({ + "synced": true, + "id": connector_id, + "applied_rows": applied_rows, + "error_rows": error_rows + }))) +} + +pub async fn list_price_rules( + headers: HeaderMap, + State(state): State, +) -> Result>, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "price_rules.write").await?; + let sql = format!( + r#" + select id, code, name, source_type, source_id, markup_percent::text markup_percent, + rounding_mode, is_active + from {schema}.price_rules + order by name + "#, + schema = context.schema_name + ); + let rows = sqlx::query(&sql).fetch_all(db).await?; + Ok(Json(rows.into_iter().map(price_rule_from_row).collect())) +} + +pub async fn create_price_rule( + headers: HeaderMap, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + validate_price_rule_request(&payload)?; + let db = state.db()?; + let context = require_permission(db, &headers, "price_rules.write").await?; + let rule_id = Uuid::new_v4(); + write_price_rule(db, &context.schema_name, rule_id, &payload, true).await?; + emit_change(&state, "Preisregel angelegt"); + Ok(Json(price_rule_response(rule_id, payload))) +} + +pub async fn update_price_rule( + headers: HeaderMap, + State(state): State, + Path(rule_id): Path, + Json(payload): Json, +) -> Result, ApiError> { + validate_price_rule_request(&payload)?; + let db = state.db()?; + let context = require_permission(db, &headers, "price_rules.write").await?; + write_price_rule(db, &context.schema_name, rule_id, &payload, false).await?; + emit_change(&state, "Preisregel geändert"); + Ok(Json(price_rule_response(rule_id, payload))) +} + +pub async fn delete_price_rule( + headers: HeaderMap, + State(state): State, + Path(rule_id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "price_rules.write").await?; + let sql = format!( + "update {schema}.price_rules set is_active=false, updated_at=now() where id=$1", + schema = context.schema_name + ); + let result = sqlx::query(&sql).bind(rule_id).execute(db).await?; + ensure_changed(result.rows_affected(), "Preisregel nicht gefunden")?; + emit_change(&state, "Preisregel deaktiviert"); + Ok(Json(json!({ "deleted": true, "id": rule_id }))) +} + +pub async fn list_communications( + headers: HeaderMap, + State(state): State, +) -> Result>, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "communication.read").await?; + let sql = format!( + r#" + select id, communication_type, direction, subject_ciphertext, subject_nonce, + subject_key_id, body_ciphertext, body_nonce, body_key_id, status, occurred_at + from {schema}.communications + where status <> 'archived' + order by coalesce(occurred_at, updated_at) desc + "#, + schema = context.schema_name + ); + let rows = sqlx::query(&sql).fetch_all(db).await?; + let mut records = Vec::with_capacity(rows.len()); + for row in rows { + records.push(communication_from_row(db, &state.crypto, &context.schema_name, row).await?); + } + Ok(Json(records)) +} + +pub async fn create_communication( + headers: HeaderMap, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + validate_communication_request(&payload)?; + let db = state.db()?; + let context = require_permission(db, &headers, "communication.write").await?; + let communication_id = Uuid::new_v4(); + write_communication( + db, + &state.crypto, + &context, + communication_id, + &payload, + true, + ) + .await?; + emit_change(&state, "Kommunikation gespeichert"); + get_communication_by_id(db, &state.crypto, &context.schema_name, communication_id).await +} + +pub async fn update_communication( + headers: HeaderMap, + State(state): State, + Path(communication_id): Path, + Json(payload): Json, +) -> Result, ApiError> { + validate_communication_request(&payload)?; + let db = state.db()?; + let context = require_permission(db, &headers, "communication.write").await?; + write_communication( + db, + &state.crypto, + &context, + communication_id, + &payload, + false, + ) + .await?; + emit_change(&state, "Kommunikation geändert"); + get_communication_by_id(db, &state.crypto, &context.schema_name, communication_id).await +} + +pub async fn delete_communication( + headers: HeaderMap, + State(state): State, + Path(communication_id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "communication.write").await?; + let sql = format!( + "update {schema}.communications set status='archived', updated_at=now() where id=$1", + schema = context.schema_name + ); + let result = sqlx::query(&sql).bind(communication_id).execute(db).await?; + ensure_changed(result.rows_affected(), "Kommunikation nicht gefunden")?; + emit_change(&state, "Kommunikation archiviert"); + Ok(Json(json!({ "deleted": true, "id": communication_id }))) +} + +pub async fn list_documents( + headers: HeaderMap, + State(state): State, +) -> Result>, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "documents.read").await?; + let sql = format!( + r#" + select id, title_ciphertext, title_nonce, title_key_id, + description_ciphertext, description_nonce, description_key_id, status + from {schema}.documents + where status <> 'deleted' + order by updated_at desc + "#, + schema = context.schema_name + ); + let rows = sqlx::query(&sql).fetch_all(db).await?; + let mut documents = Vec::with_capacity(rows.len()); + for row in rows { + documents.push(document_from_row(db, &state.crypto, &context.schema_name, row).await?); + } + Ok(Json(documents)) +} + +pub async fn upload_document( + headers: HeaderMap, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + validate_document_upload_request(&payload)?; + let db = state.db()?; + let context = require_permission(db, &headers, "documents.write").await?; + let content = BASE64_STANDARD + .decode(payload.content_base64.trim()) + .map_err(|_| ApiError::bad_request("Dateiinhalt ist kein gültiges Base64"))?; + if content.len() > 20 * 1024 * 1024 { + return Err(ApiError::bad_request("Datei ist größer als 20 MB")); + } + let document_id = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let encrypted_content = state.crypto.encrypt(&content)?; + let checksum = Sha256::digest(&content) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let storage_path = document_storage_path(&context.schema_name, document_id, version_id)?; + if let Some(parent) = storage_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write( + &storage_path, + serde_json::to_vec(&json!({ + "ciphertext": BASE64_STANDARD.encode(encrypted_content.ciphertext), + "nonce": BASE64_STANDARD.encode(encrypted_content.nonce), + "key_id": encrypted_content.key_id + }))?, + ) + .await?; + + let title = state.crypto.encrypt(&payload.title.trim().to_string())?; + let description = encrypted_optional_string(&state.crypto, &payload.description)?; + let file_name = state + .crypto + .encrypt(&payload.file_name.trim().to_string())?; + let content_type = state + .crypto + .encrypt(&payload.content_type.trim().to_string())?; + let insert_document = format!( + r#" + insert into {schema}.documents ( + id, title_ciphertext, title_nonce, title_key_id, + description_ciphertext, description_nonce, description_key_id, created_by_user_id + ) values ($1,$2,$3,$4,$5,$6,$7,$8) + "#, + schema = context.schema_name + ); + sqlx::query(&insert_document) + .bind(document_id) + .bind(title.ciphertext) + .bind(title.nonce) + .bind(title.key_id) + .bind(description.as_ref().map(|value| value.ciphertext.clone())) + .bind(description.as_ref().map(|value| value.nonce.clone())) + .bind(description.as_ref().map(|value| value.key_id.clone())) + .bind(context.user_id) + .execute(db) + .await?; + + let insert_version = format!( + r#" + insert into {schema}.document_versions ( + id, document_id, version_no, file_name_ciphertext, file_name_nonce, + file_name_key_id, content_type_ciphertext, content_type_nonce, + content_type_key_id, file_size, storage_path, checksum_sha256, uploaded_by_user_id + ) values ($1,$2,1,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + "#, + schema = context.schema_name + ); + sqlx::query(&insert_version) + .bind(version_id) + .bind(document_id) + .bind(file_name.ciphertext) + .bind(file_name.nonce) + .bind(file_name.key_id) + .bind(content_type.ciphertext) + .bind(content_type.nonce) + .bind(content_type.key_id) + .bind(content.len() as i64) + .bind(storage_path.to_string_lossy().to_string()) + .bind(checksum) + .bind(context.user_id) + .execute(db) + .await?; + write_document_links(db, &context.schema_name, document_id, &payload.links).await?; + insert_document_audit( + db, + &context.schema_name, + document_id, + Some(version_id), + "upload", + context.user_id, + ) + .await?; + emit_change(&state, "Dokument hochgeladen"); + get_document_by_id(db, &state.crypto, &context.schema_name, document_id).await +} + +pub async fn download_document( + headers: HeaderMap, + State(state): State, + Path(document_id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "documents.read").await?; + let sql = format!( + r#" + select id, document_id, version_no, file_name_ciphertext, file_name_nonce, + file_name_key_id, content_type_ciphertext, content_type_nonce, + content_type_key_id, file_size, storage_path, checksum_sha256, created_at + from {schema}.document_versions + where document_id=$1 + order by version_no desc + limit 1 + "#, + schema = context.schema_name + ); + let row = sqlx::query(&sql) + .bind(document_id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::not_found("Dokument nicht gefunden"))?; + let version_id: Uuid = row.get("id"); + let file_name = state.crypto.decrypt( + &row.get::, _>("file_name_ciphertext"), + &row.get::, _>("file_name_nonce"), + &row.get::("file_name_key_id"), + )?; + let content_type = state.crypto.decrypt( + &row.get::, _>("content_type_ciphertext"), + &row.get::, _>("content_type_nonce"), + &row.get::("content_type_key_id"), + )?; + let storage_path = row.get::("storage_path"); + let envelope = tokio::fs::read(&storage_path).await?; + let envelope = serde_json::from_slice::(&envelope)?; + let ciphertext = envelope + .get("ciphertext") + .and_then(|value| value.as_str()) + .ok_or_else(|| ApiError::internal("Dokumentenspeicher ist beschädigt".to_string())) + .and_then(|value| { + BASE64_STANDARD + .decode(value) + .map_err(|_| ApiError::internal("Dokumentenspeicher ist beschädigt".to_string())) + })?; + let nonce = envelope + .get("nonce") + .and_then(|value| value.as_str()) + .ok_or_else(|| ApiError::internal("Dokumentenspeicher ist beschädigt".to_string())) + .and_then(|value| { + BASE64_STANDARD + .decode(value) + .map_err(|_| ApiError::internal("Dokumentenspeicher ist beschädigt".to_string())) + })?; + let key_id = envelope + .get("key_id") + .and_then(|value| value.as_str()) + .ok_or_else(|| ApiError::internal("Dokumentenspeicher ist beschädigt".to_string()))?; + let content: Vec = state.crypto.decrypt(&ciphertext, &nonce, key_id)?; + insert_document_audit( + db, + &context.schema_name, + document_id, + Some(version_id), + "download", + context.user_id, + ) + .await?; + Ok(Json(DocumentDownloadResponse { + document_id, + version_id, + file_name, + content_type, + content_base64: BASE64_STANDARD.encode(content), + })) +} + +pub async fn delete_document( + headers: HeaderMap, + State(state): State, + Path(document_id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "documents.write").await?; + let sql = format!( + "update {schema}.documents set status='deleted', updated_at=now() where id=$1", + schema = context.schema_name + ); + let result = sqlx::query(&sql).bind(document_id).execute(db).await?; + ensure_changed(result.rows_affected(), "Dokument nicht gefunden")?; + insert_document_audit( + db, + &context.schema_name, + document_id, + None, + "archive", + context.user_id, + ) + .await?; + emit_change(&state, "Dokument archiviert"); + Ok(Json(json!({ "deleted": true, "id": document_id }))) +} + +pub async fn list_document_audit_log( + headers: HeaderMap, + State(state): State, + Path(document_id): Path, +) -> Result>, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "documents.read").await?; + let sql = format!( + r#" + select id, document_id, version_id, action, user_id, created_at + from {schema}.document_audit_log + where document_id=$1 + order by created_at desc + "#, + schema = context.schema_name + ); + let rows = sqlx::query(&sql).bind(document_id).fetch_all(db).await?; + Ok(Json( + rows.into_iter() + .map(|row| DocumentAuditLogResponse { + id: row.get("id"), + document_id: row.get("document_id"), + version_id: row.get("version_id"), + action: row.get("action"), + user_id: row.get("user_id"), + created_at: row.get("created_at"), + }) + .collect(), + )) +} + +pub async fn list_items( + headers: HeaderMap, + State(state): State, +) -> Result>, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "items.read").await?; + let sql = format!( + r#" + select id, item_number, name_ciphertext, name_nonce, name_key_id, unit, + tax_rate::text tax_rate, default_purchase_price::text default_purchase_price, + default_sales_price::text default_sales_price, status + from {schema}.items order by updated_at desc, item_number + "#, + schema = context.schema_name + ); + let rows = sqlx::query(&sql).fetch_all(db).await?; + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + items.push(item_from_row(&state.crypto, row)?); + } + Ok(Json(items)) +} + +pub async fn create_item( + headers: HeaderMap, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + validate_item_request(&payload)?; + let db = state.db()?; + let context = require_permission(db, &headers, "items.write").await?; + let item_id = Uuid::new_v4(); + let mut payload = payload; + payload.item_number = + next_number_if_blank(db, &context.schema_name, "items", &payload.item_number).await?; + write_item( + db, + &state.crypto, + &context.schema_name, + item_id, + &payload, + true, + ) + .await?; + record_item_price_history(db, &context.schema_name, item_id, &payload, context.user_id).await?; + emit_change(&state, "Artikel angelegt"); + Ok(Json(item_response(item_id, payload))) +} + +pub async fn update_item( + headers: HeaderMap, + State(state): State, + Path(item_id): Path, + Json(payload): Json, +) -> Result, ApiError> { + validate_item_request(&payload)?; + let db = state.db()?; + let context = require_permission(db, &headers, "items.write").await?; + write_item( + db, + &state.crypto, + &context.schema_name, + item_id, + &payload, + false, + ) + .await?; + record_item_price_history(db, &context.schema_name, item_id, &payload, context.user_id).await?; + emit_change(&state, "Artikel geändert"); + Ok(Json(item_response(item_id, payload))) +} + +pub async fn list_item_price_history( + headers: HeaderMap, + State(state): State, + Path(item_id): Path, +) -> Result>, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "items.read").await?; + let sql = format!( + r#" + select id, item_id, purchase_price::text purchase_price, + sales_price::text sales_price, source, valid_from, + created_by_user_id, created_at + from {schema}.item_price_history + where item_id = $1 + order by valid_from desc, created_at desc + "#, + schema = context.schema_name + ); + let rows = sqlx::query(&sql).bind(item_id).fetch_all(db).await?; + Ok(Json( + rows.into_iter().map(item_price_history_from_row).collect(), + )) +} + +pub async fn delete_item( + headers: HeaderMap, + State(state): State, + Path(item_id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "items.delete").await?; + set_record_inactive(db, &context.schema_name, "items", item_id).await?; + emit_change(&state, "Artikel deaktiviert"); + Ok(Json(json!({ "deleted": true, "id": item_id }))) +} + +pub async fn list_activities( + headers: HeaderMap, + State(state): State, +) -> Result>, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "activities.read").await?; + let sql = format!( + r#" + select id, activity_number, activity_type, title_ciphertext, title_nonce, title_key_id, + body_ciphertext, body_nonce, body_key_id, status, priority, due_at + from {schema}.activities order by updated_at desc, due_at + "#, + schema = context.schema_name + ); + let rows = sqlx::query(&sql).fetch_all(db).await?; + let mut activities = Vec::with_capacity(rows.len()); + for row in rows { + activities.push(activity_from_row(&state.crypto, row)?); + } + Ok(Json(activities)) +} + +pub async fn create_activity( + headers: HeaderMap, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + validate_activity_request(&payload)?; + let db = state.db()?; + let context = require_permission(db, &headers, "activities.write").await?; + let activity_id = Uuid::new_v4(); + let mut payload = payload; + if payload + .activity_number + .as_deref() + .unwrap_or("") + .trim() + .is_empty() + { + payload.activity_number = Some(next_number(db, &context.schema_name, "activities").await?); + } + write_activity(db, &state.crypto, &context, activity_id, &payload, true).await?; + emit_change(&state, "Aktivität angelegt"); + Ok(Json(activity_response(activity_id, payload))) +} + +pub async fn update_activity( + headers: HeaderMap, + State(state): State, + Path(activity_id): Path, + Json(payload): Json, +) -> Result, ApiError> { + validate_activity_request(&payload)?; + let db = state.db()?; + let context = require_permission(db, &headers, "activities.write").await?; + write_activity(db, &state.crypto, &context, activity_id, &payload, false).await?; + emit_change(&state, "Aktivität geändert"); + Ok(Json(activity_response(activity_id, payload))) +} + +pub async fn delete_activity( + headers: HeaderMap, + State(state): State, + Path(activity_id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "activities.delete").await?; + let sql = format!( + "update {schema}.activities set status = 'cancelled', updated_at = now() where id = $1", + schema = context.schema_name + ); + let result = sqlx::query(&sql).bind(activity_id).execute(db).await?; + ensure_changed(result.rows_affected(), "Aktivität nicht gefunden")?; + emit_change(&state, "Aktivität storniert"); + Ok(Json(json!({ "deleted": true, "id": activity_id }))) +} + +pub async fn list_users( + headers: HeaderMap, + State(state): State, +) -> Result>, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "users.read").await?; + let organization_id = context.organization_id; + let schema_name = context.schema_name; + let rows = sqlx::query( + r#" + select u.id, u.email, uo.status + from users u + join user_organizations uo on uo.user_id = u.id + where uo.organization_id = $1 + order by u.email + "#, + ) + .bind(organization_id) + .fetch_all(db) + .await?; + + let mut response = Vec::with_capacity(rows.len()); + for row in rows { + let user_id: Uuid = row.get("id"); + let roles_sql = format!( + "select r.code from {schema}.roles r join {schema}.user_roles ur on ur.role_id = r.id where ur.user_id = $1 order by r.code", + schema = schema_name + ); + let roles = sqlx::query_scalar::<_, String>(&roles_sql) + .bind(user_id) + .fetch_all(db) + .await?; + + response.push(OrganizationUserResponse { + user_id, + email: row.get("email"), + status: row.get("status"), + roles, + }); + } + + Ok(Json(response)) +} + +pub async fn invite_user( + headers: HeaderMap, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "users.invite").await?; + require_owner(db, &context.schema_name, context.user_id).await?; + let organization_id = context.organization_id; + let actor_user_id = context.user_id; + let schema_name = context.schema_name; + let email = normalize_email(&payload.email)?; + let user_id = ensure_user(db, &email).await?; + let invitation_token = generate_token(); + let invitation_token_hash = hash_token(&invitation_token); + + sqlx::query( + r#" + update users + set must_change_password = true, + initial_password_expires_at = now() + interval '7 days', + updated_at = now() + where id = $1 and password_hash is null + "#, + ) + .bind(user_id) + .execute(db) + .await?; + + sqlx::query( + r#" + insert into user_organizations (user_id, organization_id, status, invited_by_user_id, invited_at) + values ($1, $2, 'pending_invitation', $3, now()) + on conflict (user_id, organization_id) do update set + invited_by_user_id = excluded.invited_by_user_id, + invited_at = now(), + updated_at = now() + "#, + ) + .bind(user_id) + .bind(organization_id) + .bind(actor_user_id) + .execute(db) + .await?; + + let invitation_id = Uuid::new_v4(); + sqlx::query( + r#" + insert into user_invitations ( + id, organization_id, email, invited_by_user_id, expires_at, created_user_id, token_hash + ) values ($1, $2, $3, $4, now() + interval '7 days', $5, $6) + "#, + ) + .bind(invitation_id) + .bind(organization_id) + .bind(&email) + .bind(actor_user_id) + .bind(user_id) + .bind(invitation_token_hash) + .execute(db) + .await?; + + let roles = if payload.roles.is_empty() { + vec!["viewer".to_string()] + } else { + payload.roles + }; + for role in roles { + assign_role(db, &schema_name, user_id, &role).await?; + } + enqueue_invitation_email( + &state.crypto, + db, + &email, + &invitation_token, + organization_id, + ) + .await?; + + let mut response = json!({ + "id": invitation_id, + "user_id": user_id + }); + if dev_mode_enabled() { + response["dev_invitation_token"] = json!(invitation_token); + } + Ok(Json(response)) +} + +pub async fn update_user_roles( + headers: HeaderMap, + State(state): State, + Path(user_id): Path, + Json(payload): Json, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "users.roles.write").await?; + require_owner(db, &context.schema_name, context.user_id).await?; + let organization_id = context.organization_id; + let actor_user_id = context.user_id; + let schema_name = context.schema_name; + + if payload.roles.is_empty() { + return Err(ApiError::bad_request( + "Mindestens eine Rolle ist erforderlich", + )); + } + + let member_exists: Option = sqlx::query_scalar( + "select user_id from user_organizations where organization_id = $1 and user_id = $2 limit 1", + ) + .bind(organization_id) + .bind(user_id) + .fetch_optional(db) + .await?; + + if member_exists.is_none() { + return Err(ApiError::not_found("Benutzer ist nicht Teil dieser Firma")); + } + + if user_id == actor_user_id && !payload.roles.iter().any(|role| role == "owner") { + return Err(ApiError::bad_request( + "Der Besitzer kann sich die Besitzerrolle nicht selbst entziehen", + )); + } + + replace_user_roles(db, &schema_name, user_id, &payload.roles).await?; + emit_change(&state, "Benutzerrechte geändert"); + + Ok(Json(json!({ + "saved": true, + "user_id": user_id, + "roles": payload.roles + }))) +} + +pub async fn disable_user( + headers: HeaderMap, + State(state): State, + Path(user_id): Path, +) -> Result, ApiError> { + let db = state.db()?; + let context = require_permission(db, &headers, "users.disable").await?; + require_owner(db, &context.schema_name, context.user_id).await?; + + if user_id == context.user_id { + return Err(ApiError::bad_request( + "Der Besitzer kann sich nicht selbst deaktivieren", + )); + } + + let result = sqlx::query( + r#" + update user_organizations + set status = 'disabled', updated_at = now() + where organization_id = $1 and user_id = $2 + "#, + ) + .bind(context.organization_id) + .bind(user_id) + .execute(db) + .await?; + + if result.rows_affected() == 0 { + return Err(ApiError::not_found("Benutzer ist nicht Teil dieser Firma")); + } + + emit_change(&state, "Benutzer deaktiviert"); + Ok(Json(json!({ "disabled": true, "user_id": user_id }))) +} + +fn validate_customer_request(payload: &CustomerRequest) -> Result<(), ApiError> { + if payload.name.trim().len() < 2 { + return Err(ApiError::bad_request("Kundenname ist zu kurz")); + } + if !["active", "inactive", "blocked"].contains(&payload.status.as_str()) { + return Err(ApiError::bad_request("Ungültiger Kundenstatus")); + } + let discount = payload + .standard_discount_percent + .parse::() + .map_err(|_| ApiError::bad_request("Ungültiger Kundenrabatt"))?; + if !(0.0..=100.0).contains(&discount) { + return Err(ApiError::bad_request( + "Kundenrabatt muss zwischen 0 und 100 liegen", + )); + } + Ok(()) +} + +fn customer_response(customer_id: Uuid, payload: CustomerRequest) -> CustomerResponse { + CustomerResponse { + id: customer_id, + customer_number: payload.customer_number.trim().to_string(), + name: payload.name.trim().to_string(), + status: payload.status, + details: payload.details, + standard_discount_percent: payload.standard_discount_percent, + cash_discount_term_id: payload.cash_discount_term_id, + } +} + +fn customer_from_row( + crypto: &DataCrypto, + row: sqlx::postgres::PgRow, +) -> Result { + let details = match row.get::>, _>("details_ciphertext") { + Some(ciphertext) => crypto.decrypt::( + &ciphertext, + &row.get::, _>("details_nonce"), + &row.get::("details_key_id"), + )?, + None => CustomerDetails::default(), + }; + Ok(CustomerResponse { + id: row.get("id"), + customer_number: row + .get::, _>("customer_number") + .unwrap_or_default(), + name: crypto.decrypt( + &row.get::, _>("name_ciphertext"), + &row.get::, _>("name_nonce"), + &row.get::("name_key_id"), + )?, + status: row.get("status"), + details, + standard_discount_percent: row.get("standard_discount_percent"), + cash_discount_term_id: row.get("cash_discount_term_id"), + }) +} + +async fn save_customer_terms( + db: &PgPool, + schema_name: &str, + customer_id: Uuid, + payload: &CustomerRequest, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let deactivate_sql = format!( + "update {schema}.customer_price_terms set is_active = false, updated_at = now() where customer_id = $1 and is_active = true", + schema = schema_name + ); + sqlx::query(&deactivate_sql) + .bind(customer_id) + .execute(db) + .await?; + let insert_sql = format!( + r#" + insert into {schema}.customer_price_terms ( + id, customer_id, standard_discount_percent, cash_discount_term_id + ) values ($1, $2, $3::numeric, $4) + "#, + schema = schema_name + ); + sqlx::query(&insert_sql) + .bind(Uuid::new_v4()) + .bind(customer_id) + .bind(payload.standard_discount_percent.trim()) + .bind(payload.cash_discount_term_id) + .execute(db) + .await?; + Ok(()) +} + +fn validate_supplier_request(payload: &SupplierRequest) -> Result<(), ApiError> { + if payload.name.trim().len() < 2 { + return Err(ApiError::bad_request("Lieferantenname ist erforderlich")); + } + validate_status(&payload.status)?; + validate_percent(&payload.standard_discount_percent, "Lieferantenrabatt")?; + if payload.payment_days.is_some_and(|days| days < 0) { + return Err(ApiError::bad_request( + "Zahlungsziel darf nicht negativ sein", + )); + } + Ok(()) +} + +async fn write_supplier( + db: &PgPool, + crypto: &DataCrypto, + schema_name: &str, + supplier_id: Uuid, + payload: &SupplierRequest, + insert: bool, +) -> Result<(), ApiError> { + let name = crypto.encrypt(&payload.name.trim().to_string())?; + let details = crypto.encrypt(&payload.details)?; + let sql = if insert { + format!( + r#"insert into {schema}.suppliers ( + id, supplier_number, name_ciphertext, name_nonce, name_key_id, status, + details_ciphertext, details_nonce, details_key_id + ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9)"#, + schema = schema_name + ) + } else { + format!( + r#"update {schema}.suppliers set supplier_number=supplier_number, name_ciphertext=$3, + name_nonce=$4, name_key_id=$5, status=$6, details_ciphertext=$7, + details_nonce=$8, details_key_id=$9, updated_at=now() where id=$1"#, + schema = schema_name + ) + }; + let result = sqlx::query(&sql) + .bind(supplier_id) + .bind(payload.supplier_number.trim()) + .bind(name.ciphertext) + .bind(name.nonce) + .bind(name.key_id) + .bind(&payload.status) + .bind(details.ciphertext) + .bind(details.nonce) + .bind(details.key_id) + .execute(db) + .await?; + if !insert { + ensure_changed(result.rows_affected(), "Lieferant nicht gefunden")?; + } + Ok(()) +} + +async fn save_supplier_terms( + db: &PgPool, + schema_name: &str, + supplier_id: Uuid, + payload: &SupplierRequest, +) -> Result<(), ApiError> { + let deactivate = format!( + "update {schema}.supplier_price_terms set is_active=false, updated_at=now() where supplier_id=$1 and is_active=true", + schema = schema_name + ); + sqlx::query(&deactivate) + .bind(supplier_id) + .execute(db) + .await?; + let insert = format!( + r#"insert into {schema}.supplier_price_terms ( + id, supplier_id, standard_discount_percent, cash_discount_term_id, payment_days + ) values ($1,$2,$3::numeric,$4,$5)"#, + schema = schema_name + ); + sqlx::query(&insert) + .bind(Uuid::new_v4()) + .bind(supplier_id) + .bind(payload.standard_discount_percent.trim()) + .bind(payload.cash_discount_term_id) + .bind(payload.payment_days) + .execute(db) + .await?; + Ok(()) +} + +async fn apply_customer_terms_to_quote( + db: &PgPool, + schema_name: &str, + payload: &mut QuoteRequest, +) -> Result<(), ApiError> { + if let Some((discount, cash_discount_term_id)) = + load_customer_terms(db, schema_name, payload.customer_id).await? + { + if payload.customer_discount_percent.trim().is_empty() + || payload.customer_discount_percent.trim() == "0" + || payload.customer_discount_percent.trim() == "0.00" + { + payload.customer_discount_percent = discount; + } + if payload.cash_discount_term_id.is_none() { + payload.cash_discount_term_id = cash_discount_term_id; + } + } + Ok(()) +} + +async fn apply_customer_terms_to_outgoing_invoice( + db: &PgPool, + schema_name: &str, + payload: &mut OutgoingInvoiceRequest, +) -> Result<(), ApiError> { + if let Some((discount, cash_discount_term_id)) = + load_customer_terms(db, schema_name, payload.customer_id).await? + { + if payload.customer_discount_percent.trim().is_empty() + || payload.customer_discount_percent.trim() == "0" + || payload.customer_discount_percent.trim() == "0.00" + { + payload.customer_discount_percent = discount; + } + if payload.cash_discount_term_id.is_none() { + payload.cash_discount_term_id = cash_discount_term_id; + } + } + Ok(()) +} + +async fn apply_supplier_terms_to_incoming_invoice( + db: &PgPool, + schema_name: &str, + payload: &mut IncomingInvoiceRequest, +) -> Result<(), ApiError> { + if payload.cash_discount_term_id.is_none() { + payload.cash_discount_term_id = + load_supplier_cash_discount_term(db, schema_name, payload.supplier_id).await?; + } + Ok(()) +} + +async fn load_customer_terms( + db: &PgPool, + schema_name: &str, + customer_id: Uuid, +) -> Result)>, ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + select standard_discount_percent::text standard_discount_percent, + cash_discount_term_id + from {schema}.customer_price_terms + where customer_id=$1 and is_active=true + order by updated_at desc + limit 1 + "#, + schema = schema_name + ); + Ok(sqlx::query(&sql) + .bind(customer_id) + .fetch_optional(db) + .await? + .map(|row| { + ( + row.get("standard_discount_percent"), + row.get("cash_discount_term_id"), + ) + })) +} + +async fn load_supplier_cash_discount_term( + db: &PgPool, + schema_name: &str, + supplier_id: Uuid, +) -> Result, ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + select cash_discount_term_id + from {schema}.supplier_price_terms + where supplier_id=$1 and is_active=true + order by updated_at desc + limit 1 + "#, + schema = schema_name + ); + Ok(sqlx::query_scalar::<_, Option>(&sql) + .bind(supplier_id) + .fetch_optional(db) + .await? + .flatten()) +} + +fn supplier_from_row( + crypto: &DataCrypto, + row: sqlx::postgres::PgRow, +) -> Result { + let details = match row.get::>, _>("details_ciphertext") { + Some(ciphertext) => crypto.decrypt::( + &ciphertext, + &row.get::, _>("details_nonce"), + &row.get::("details_key_id"), + )?, + None => CustomerDetails::default(), + }; + Ok(SupplierResponse { + id: row.get("id"), + supplier_number: row + .get::, _>("supplier_number") + .unwrap_or_default(), + name: crypto.decrypt( + &row.get::, _>("name_ciphertext"), + &row.get::, _>("name_nonce"), + &row.get::("name_key_id"), + )?, + status: row.get("status"), + details, + standard_discount_percent: row.get("standard_discount_percent"), + cash_discount_term_id: row.get("cash_discount_term_id"), + payment_days: row.get("payment_days"), + }) +} + +fn supplier_response(id: Uuid, payload: SupplierRequest) -> SupplierResponse { + SupplierResponse { + id, + supplier_number: payload.supplier_number.trim().to_string(), + name: payload.name.trim().to_string(), + status: payload.status, + details: payload.details, + standard_discount_percent: payload.standard_discount_percent, + cash_discount_term_id: payload.cash_discount_term_id, + payment_days: payload.payment_days, + } +} + +fn validate_cash_discount_term(payload: &CashDiscountTermRequest) -> Result<(), ApiError> { + if payload.code.trim().is_empty() || payload.name.trim().len() < 2 { + return Err(ApiError::bad_request( + "Code und Name der Skonto-Regel sind erforderlich", + )); + } + validate_percent(&payload.discount_percent, "Skonto")?; + if payload.discount_days < 0 { + return Err(ApiError::bad_request( + "Skontotage dürfen nicht negativ sein", + )); + } + if let Some(net_days) = payload.net_days { + if net_days < payload.discount_days { + return Err(ApiError::bad_request( + "Netto-Zahlungsziel darf nicht kleiner als die Skontofrist sein", + )); + } + } + Ok(()) +} + +async fn write_cash_discount_term( + db: &PgPool, + schema_name: &str, + term_id: Uuid, + payload: &CashDiscountTermRequest, + insert: bool, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + if payload.is_default_customer_term { + let sql = format!( + "update {schema}.cash_discount_terms set is_default_customer_term=false, updated_at=now() where id <> $1", + schema = schema_name + ); + sqlx::query(&sql).bind(term_id).execute(db).await?; + } + if payload.is_default_supplier_term { + let sql = format!( + "update {schema}.cash_discount_terms set is_default_supplier_term=false, updated_at=now() where id <> $1", + schema = schema_name + ); + sqlx::query(&sql).bind(term_id).execute(db).await?; + } + + let sql = if insert { + format!( + r#" + insert into {schema}.cash_discount_terms ( + id, code, name, discount_percent, discount_days, net_days, + valid_from, valid_until, is_default_customer_term, + is_default_supplier_term, is_active + ) values ($1,$2,$3,$4::numeric,$5,$6,$7::date,$8::date,$9,$10,$11) + "#, + schema = schema_name + ) + } else { + format!( + r#" + update {schema}.cash_discount_terms + set code=$2, name=$3, discount_percent=$4::numeric, + discount_days=$5, net_days=$6, valid_from=$7::date, + valid_until=$8::date, is_default_customer_term=$9, + is_default_supplier_term=$10, is_active=$11, updated_at=now() + where id=$1 + "#, + schema = schema_name + ) + }; + let result = sqlx::query(&sql) + .bind(term_id) + .bind(payload.code.trim()) + .bind(payload.name.trim()) + .bind(payload.discount_percent.trim()) + .bind(payload.discount_days) + .bind(payload.net_days) + .bind( + payload + .valid_from + .as_deref() + .filter(|value| !value.is_empty()), + ) + .bind( + payload + .valid_until + .as_deref() + .filter(|value| !value.is_empty()), + ) + .bind(payload.is_default_customer_term) + .bind(payload.is_default_supplier_term) + .bind(payload.is_active) + .execute(db) + .await?; + if !insert { + ensure_changed(result.rows_affected(), "Skonto-Regel nicht gefunden")?; + } + Ok(()) +} + +fn cash_discount_term_response( + id: Uuid, + payload: CashDiscountTermRequest, +) -> CashDiscountTermResponse { + CashDiscountTermResponse { + id, + code: payload.code.trim().to_string(), + name: payload.name.trim().to_string(), + discount_percent: payload.discount_percent.trim().to_string(), + discount_days: payload.discount_days, + net_days: payload.net_days, + valid_from: payload.valid_from, + valid_until: payload.valid_until, + is_default_customer_term: payload.is_default_customer_term, + is_default_supplier_term: payload.is_default_supplier_term, + is_active: payload.is_active, + } +} + +fn cash_discount_term_from_row(row: sqlx::postgres::PgRow) -> CashDiscountTermResponse { + CashDiscountTermResponse { + id: row.get("id"), + code: row.get("code"), + name: row.get("name"), + discount_percent: row.get("discount_percent"), + discount_days: row.get("discount_days"), + net_days: row.get("net_days"), + valid_from: row.get("valid_from"), + valid_until: row.get("valid_until"), + is_default_customer_term: row.get("is_default_customer_term"), + is_default_supplier_term: row.get("is_default_supplier_term"), + is_active: row.get("is_active"), + } +} + +fn validate_quote_request(payload: &QuoteRequest) -> Result<(), ApiError> { + validate_status_choice( + &payload.status, + &[ + "draft", + "sent", + "accepted", + "rejected", + "expired", + "cancelled", + ], + "Ungültiger Angebotsstatus", + )?; + validate_percent(&payload.customer_discount_percent, "Kundenrabatt")?; + if payload.items.is_empty() { + return Err(ApiError::bad_request( + "Ein Angebot braucht mindestens eine Position", + )); + } + for item in &payload.items { + validate_number(&item.quantity, "Menge")?; + if item.quantity.parse::().unwrap_or(0.0) <= 0.0 { + return Err(ApiError::bad_request("Menge muss größer als 0 sein")); + } + validate_number(&item.unit_price, "Positionspreis")?; + validate_number(&item.tax_rate, "Steuersatz")?; + validate_percent(&item.discount_percent, "Positionsrabatt")?; + if let Some(original_price) = &item.original_unit_price { + validate_number(original_price, "Originalpreis")?; + } + } + Ok(()) +} + +async fn write_quote( + db: &PgPool, + crypto: &DataCrypto, + context: &AuthContext, + quote_id: Uuid, + payload: &QuoteRequest, + insert: bool, +) -> Result<(), ApiError> { + ensure_safe_schema_name(&context.schema_name)?; + ensure_customer_exists(db, &context.schema_name, payload.customer_id).await?; + for item in &payload.items { + ensure_item_exists(db, &context.schema_name, item.item_id).await?; + } + let notes = if payload.notes.trim().is_empty() { + None + } else { + Some(crypto.encrypt(&payload.notes)?) + }; + let sql = if insert { + format!( + r#" + insert into {schema}.quotes ( + id, quote_number, customer_id, status, valid_until, + cash_discount_term_id, customer_discount_percent, + notes_ciphertext, notes_nonce, notes_key_id, created_by_user_id + ) values ($1,$2,$3,$4,$5::date,$6,$7::numeric,$8,$9,$10,$11) + "#, + schema = context.schema_name + ) + } else { + format!( + r#" + update {schema}.quotes + set quote_number=quote_number, customer_id=$3, status=$4, valid_until=$5::date, + cash_discount_term_id=$6, customer_discount_percent=$7::numeric, + notes_ciphertext=$8, notes_nonce=$9, notes_key_id=$10, + updated_at=now() + where id=$1 + "#, + schema = context.schema_name + ) + }; + let result = sqlx::query(&sql) + .bind(quote_id) + .bind(payload.quote_number.trim()) + .bind(payload.customer_id) + .bind(&payload.status) + .bind( + payload + .valid_until + .as_deref() + .filter(|value| !value.is_empty()), + ) + .bind(payload.cash_discount_term_id) + .bind(payload.customer_discount_percent.trim()) + .bind(notes.as_ref().map(|field| field.ciphertext.clone())) + .bind(notes.as_ref().map(|field| field.nonce.clone())) + .bind(notes.as_ref().map(|field| field.key_id.clone())) + .bind(context.user_id) + .execute(db) + .await?; + if !insert { + ensure_changed(result.rows_affected(), "Angebot nicht gefunden")?; + let delete_sql = format!( + "delete from {schema}.quote_items where quote_id=$1", + schema = context.schema_name + ); + sqlx::query(&delete_sql).bind(quote_id).execute(db).await?; + } + write_quote_items(db, crypto, &context.schema_name, quote_id, &payload.items).await +} + +async fn write_quote_items( + db: &PgPool, + crypto: &DataCrypto, + schema_name: &str, + quote_id: Uuid, + items: &[QuoteItemRequest], +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + insert into {schema}.quote_items ( + id, quote_id, line_number, item_id, description_ciphertext, + description_nonce, description_key_id, quantity, unit_price, + original_unit_price, discount_percent, price_overridden, tax_rate + ) values ($1,$2,$3,$4,$5,$6,$7,$8::numeric,$9::numeric,$10::numeric,$11::numeric,$12,$13::numeric) + "#, + schema = schema_name + ); + for (index, item) in items.iter().enumerate() { + let description = if item.description.trim().is_empty() { + None + } else { + Some(crypto.encrypt(&item.description)?) + }; + let price_overridden = item + .original_unit_price + .as_ref() + .is_some_and(|original| original.trim() != item.unit_price.trim()); + sqlx::query(&sql) + .bind(Uuid::new_v4()) + .bind(quote_id) + .bind((index + 1) as i32) + .bind(item.item_id) + .bind(description.as_ref().map(|field| field.ciphertext.clone())) + .bind(description.as_ref().map(|field| field.nonce.clone())) + .bind(description.as_ref().map(|field| field.key_id.clone())) + .bind(item.quantity.trim()) + .bind(item.unit_price.trim()) + .bind(item.original_unit_price.as_deref()) + .bind(item.discount_percent.trim()) + .bind(price_overridden) + .bind(item.tax_rate.trim()) + .execute(db) + .await?; + } + Ok(()) +} + +async fn ensure_customer_exists( + db: &PgPool, + schema_name: &str, + customer_id: Uuid, +) -> Result<(), ApiError> { + let sql = format!( + "select exists(select 1 from {schema}.customers where id=$1 and status='active')", + schema = schema_name + ); + if sqlx::query_scalar::<_, bool>(&sql) + .bind(customer_id) + .fetch_one(db) + .await? + { + Ok(()) + } else { + Err(ApiError::bad_request( + "Kunde ist nicht vorhanden oder inaktiv", + )) + } +} + +async fn ensure_item_exists(db: &PgPool, schema_name: &str, item_id: Uuid) -> Result<(), ApiError> { + let sql = format!( + "select exists(select 1 from {schema}.items where id=$1 and status='active')", + schema = schema_name + ); + if sqlx::query_scalar::<_, bool>(&sql) + .bind(item_id) + .fetch_one(db) + .await? + { + Ok(()) + } else { + Err(ApiError::bad_request( + "Artikel ist nicht vorhanden oder inaktiv", + )) + } +} + +fn quote_response(id: Uuid, payload: QuoteRequest) -> QuoteResponse { + QuoteResponse { + id, + quote_number: payload.quote_number.trim().to_string(), + customer_id: payload.customer_id, + status: payload.status, + valid_until: payload.valid_until, + cash_discount_term_id: payload.cash_discount_term_id, + customer_discount_percent: payload.customer_discount_percent, + notes: payload.notes, + items: payload + .items + .into_iter() + .enumerate() + .map(|(index, item)| { + let price_overridden = item + .original_unit_price + .as_ref() + .is_some_and(|original| original.trim() != item.unit_price.trim()); + QuoteItemResponse { + id: Uuid::new_v4(), + line_number: (index + 1) as i32, + item_id: item.item_id, + description: item.description, + quantity: item.quantity, + unit_price: item.unit_price, + original_unit_price: item.original_unit_price, + discount_percent: item.discount_percent, + tax_rate: item.tax_rate, + price_overridden, + } + }) + .collect(), + } +} + +async fn quote_from_row( + db: &PgPool, + crypto: &DataCrypto, + schema_name: &str, + row: sqlx::postgres::PgRow, + quote_id: Uuid, +) -> Result { + let notes = match row.get::>, _>("notes_ciphertext") { + Some(ciphertext) => crypto.decrypt::( + &ciphertext, + &row.get::, _>("notes_nonce"), + &row.get::("notes_key_id"), + )?, + None => String::new(), + }; + Ok(QuoteResponse { + id: quote_id, + quote_number: row.get("quote_number"), + customer_id: row.get("customer_id"), + status: row.get("status"), + valid_until: row.get("valid_until"), + cash_discount_term_id: row.get("cash_discount_term_id"), + customer_discount_percent: row.get("customer_discount_percent"), + notes, + items: load_quote_items(db, crypto, schema_name, quote_id).await?, + }) +} + +async fn load_quote_items( + db: &PgPool, + crypto: &DataCrypto, + schema_name: &str, + quote_id: Uuid, +) -> Result, ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + select id, line_number, item_id, description_ciphertext, description_nonce, + description_key_id, quantity::text quantity, unit_price::text unit_price, + original_unit_price::text original_unit_price, + discount_percent::text discount_percent, tax_rate::text tax_rate, + price_overridden + from {schema}.quote_items + where quote_id=$1 + order by line_number + "#, + schema = schema_name + ); + let rows = sqlx::query(&sql).bind(quote_id).fetch_all(db).await?; + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + let description = match row.get::>, _>("description_ciphertext") { + Some(ciphertext) => crypto.decrypt::( + &ciphertext, + &row.get::, _>("description_nonce"), + &row.get::("description_key_id"), + )?, + None => String::new(), + }; + items.push(QuoteItemResponse { + id: row.get("id"), + line_number: row.get("line_number"), + item_id: row.get("item_id"), + description, + quantity: row.get("quantity"), + unit_price: row.get("unit_price"), + original_unit_price: row.get("original_unit_price"), + discount_percent: row.get("discount_percent"), + tax_rate: row.get("tax_rate"), + price_overridden: row.get("price_overridden"), + }); + } + Ok(items) +} + +async fn load_quote_by_id( + db: &PgPool, + crypto: &DataCrypto, + schema_name: &str, + quote_id: Uuid, +) -> Result { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + select id, quote_number, customer_id, status, valid_until::text valid_until, + cash_discount_term_id, customer_discount_percent::text customer_discount_percent, + notes_ciphertext, notes_nonce, notes_key_id + from {schema}.quotes + where id=$1 + "#, + schema = schema_name + ); + let row = sqlx::query(&sql) + .bind(quote_id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::not_found("Angebot nicht gefunden"))?; + quote_from_row(db, crypto, schema_name, row, quote_id).await +} + +fn validate_outgoing_invoice_request(payload: &OutgoingInvoiceRequest) -> Result<(), ApiError> { + validate_status_choice( + &payload.status, + &["draft", "finalized", "sent", "paid", "cancelled", "overdue"], + "Ungültiger Rechnungsstatus", + )?; + validate_percent(&payload.customer_discount_percent, "Kundenrabatt")?; + if payload.items.is_empty() { + return Err(ApiError::bad_request( + "Eine Rechnung braucht mindestens eine Position", + )); + } + for item in &payload.items { + validate_number(&item.quantity, "Menge")?; + if item.quantity.parse::().unwrap_or(0.0) <= 0.0 { + return Err(ApiError::bad_request("Menge muss größer als 0 sein")); + } + validate_number(&item.unit_price, "Positionspreis")?; + validate_number(&item.tax_rate, "Steuersatz")?; + validate_percent(&item.discount_percent, "Positionsrabatt")?; + if let Some(original_price) = &item.original_unit_price { + validate_number(original_price, "Originalpreis")?; + } + } + Ok(()) +} + +async fn write_outgoing_invoice( + db: &PgPool, + crypto: &DataCrypto, + context: &AuthContext, + invoice_id: Uuid, + payload: &OutgoingInvoiceRequest, + insert: bool, +) -> Result<(), ApiError> { + ensure_safe_schema_name(&context.schema_name)?; + ensure_customer_exists(db, &context.schema_name, payload.customer_id).await?; + for item in &payload.items { + ensure_item_exists(db, &context.schema_name, item.item_id).await?; + } + let sql = if insert { + format!( + r#" + insert into {schema}.outgoing_invoices ( + id, invoice_number, customer_id, status, cash_discount_term_id, + customer_discount_percent, issued_at, due_at, source_quote_id, created_by_user_id + ) values ($1,$2,$3,$4,$5,$6::numeric,$7::date,$8::date,$9,$10) + "#, + schema = context.schema_name + ) + } else { + format!( + r#" + update {schema}.outgoing_invoices + set invoice_number=invoice_number, customer_id=$3, status=$4, cash_discount_term_id=$5, + customer_discount_percent=$6::numeric, issued_at=$7::date, + due_at=$8::date, source_quote_id=$9, updated_at=now() + where id=$1 + "#, + schema = context.schema_name + ) + }; + let result = sqlx::query(&sql) + .bind(invoice_id) + .bind(payload.invoice_number.trim()) + .bind(payload.customer_id) + .bind(&payload.status) + .bind(payload.cash_discount_term_id) + .bind(payload.customer_discount_percent.trim()) + .bind( + payload + .issued_at + .as_deref() + .filter(|value| !value.is_empty()), + ) + .bind(payload.due_at.as_deref().filter(|value| !value.is_empty())) + .bind(payload.source_quote_id) + .bind(context.user_id) + .execute(db) + .await?; + if !insert { + ensure_changed(result.rows_affected(), "Ausgangsrechnung nicht gefunden")?; + let delete_sql = format!( + "delete from {schema}.outgoing_invoice_items where invoice_id=$1", + schema = context.schema_name + ); + sqlx::query(&delete_sql) + .bind(invoice_id) + .execute(db) + .await?; + } + write_outgoing_invoice_items(db, crypto, context, invoice_id, &payload.items).await +} + +async fn write_outgoing_invoice_items( + db: &PgPool, + crypto: &DataCrypto, + context: &AuthContext, + invoice_id: Uuid, + items: &[OutgoingInvoiceItemRequest], +) -> Result<(), ApiError> { + let sql = format!( + r#" + insert into {schema}.outgoing_invoice_items ( + id, invoice_id, line_number, item_id, description_ciphertext, + description_nonce, description_key_id, quantity, unit_price, + original_unit_price, discount_percent, price_overridden, + price_overridden_by_user_id, price_overridden_at, tax_rate + ) values ($1,$2,$3,$4,$5,$6,$7,$8::numeric,$9::numeric,$10::numeric,$11::numeric,$12,$13,$14,$15::numeric) + "#, + schema = context.schema_name + ); + for (index, item) in items.iter().enumerate() { + let description = if item.description.trim().is_empty() { + None + } else { + Some(crypto.encrypt(&item.description)?) + }; + let price_overridden = item + .original_unit_price + .as_ref() + .is_some_and(|original| original.trim() != item.unit_price.trim()); + sqlx::query(&sql) + .bind(Uuid::new_v4()) + .bind(invoice_id) + .bind((index + 1) as i32) + .bind(item.item_id) + .bind(description.as_ref().map(|field| field.ciphertext.clone())) + .bind(description.as_ref().map(|field| field.nonce.clone())) + .bind(description.as_ref().map(|field| field.key_id.clone())) + .bind(item.quantity.trim()) + .bind(item.unit_price.trim()) + .bind(item.original_unit_price.as_deref()) + .bind(item.discount_percent.trim()) + .bind(price_overridden) + .bind(if price_overridden { + Some(context.user_id) + } else { + None + }) + .bind(if price_overridden { + Some(Utc::now()) + } else { + None + }) + .bind(item.tax_rate.trim()) + .execute(db) + .await?; + } + Ok(()) +} + +fn outgoing_invoice_response( + id: Uuid, + payload: OutgoingInvoiceRequest, + finalized_at: Option>, +) -> OutgoingInvoiceResponse { + OutgoingInvoiceResponse { + id, + invoice_number: payload.invoice_number.trim().to_string(), + customer_id: payload.customer_id, + status: payload.status, + cash_discount_term_id: payload.cash_discount_term_id, + customer_discount_percent: payload.customer_discount_percent, + issued_at: payload.issued_at, + due_at: payload.due_at, + source_quote_id: payload.source_quote_id, + finalized_at, + items: payload + .items + .into_iter() + .enumerate() + .map(|(index, item)| { + let price_overridden = item + .original_unit_price + .as_ref() + .is_some_and(|original| original.trim() != item.unit_price.trim()); + OutgoingInvoiceItemResponse { + id: Uuid::new_v4(), + line_number: (index + 1) as i32, + item_id: item.item_id, + description: item.description, + quantity: item.quantity, + unit_price: item.unit_price, + original_unit_price: item.original_unit_price, + discount_percent: item.discount_percent, + tax_rate: item.tax_rate, + price_overridden, + } + }) + .collect(), + } +} + +async fn outgoing_invoice_from_row( + db: &PgPool, + crypto: &DataCrypto, + schema_name: &str, + row: sqlx::postgres::PgRow, + invoice_id: Uuid, +) -> Result { + Ok(OutgoingInvoiceResponse { + id: invoice_id, + invoice_number: row + .get::, _>("invoice_number") + .unwrap_or_default(), + customer_id: row.get("customer_id"), + status: row.get("status"), + cash_discount_term_id: row.get("cash_discount_term_id"), + customer_discount_percent: row.get("customer_discount_percent"), + issued_at: row.get("issued_at"), + due_at: row.get("due_at"), + source_quote_id: row.get("source_quote_id"), + finalized_at: row.get("finalized_at"), + items: load_outgoing_invoice_items(db, crypto, schema_name, invoice_id).await?, + }) +} + +async fn load_outgoing_invoice_items( + db: &PgPool, + crypto: &DataCrypto, + schema_name: &str, + invoice_id: Uuid, +) -> Result, ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + select id, line_number, item_id, description_ciphertext, description_nonce, + description_key_id, quantity::text quantity, unit_price::text unit_price, + original_unit_price::text original_unit_price, + discount_percent::text discount_percent, tax_rate::text tax_rate, + price_overridden + from {schema}.outgoing_invoice_items + where invoice_id=$1 + order by line_number + "#, + schema = schema_name + ); + let rows = sqlx::query(&sql).bind(invoice_id).fetch_all(db).await?; + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + let description = match row.get::>, _>("description_ciphertext") { + Some(ciphertext) => crypto.decrypt::( + &ciphertext, + &row.get::, _>("description_nonce"), + &row.get::("description_key_id"), + )?, + None => String::new(), + }; + items.push(OutgoingInvoiceItemResponse { + id: row.get("id"), + line_number: row.get("line_number"), + item_id: row.get("item_id"), + description, + quantity: row.get("quantity"), + unit_price: row.get("unit_price"), + original_unit_price: row.get("original_unit_price"), + discount_percent: row.get("discount_percent"), + tax_rate: row.get("tax_rate"), + price_overridden: row.get("price_overridden"), + }); + } + Ok(items) +} + +async fn ensure_outgoing_invoice_editable( + db: &PgPool, + schema_name: &str, + invoice_id: Uuid, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + "select finalized_at from {schema}.outgoing_invoices where id=$1", + schema = schema_name + ); + let finalized_at = sqlx::query_scalar::<_, Option>>(&sql) + .bind(invoice_id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::not_found("Ausgangsrechnung nicht gefunden"))?; + if finalized_at.is_some() { + Err(ApiError::bad_request( + "Abgeschlossene Rechnungen dürfen nicht geändert werden", + )) + } else { + Ok(()) + } +} + +fn validate_incoming_invoice_request(payload: &IncomingInvoiceRequest) -> Result<(), ApiError> { + validate_status_choice( + &payload.status, + &[ + "draft", + "received", + "approved", + "paid", + "cancelled", + "overdue", + ], + "Ungültiger Eingangsrechnungsstatus", + )?; + if payload.invoice_number.trim().is_empty() { + return Err(ApiError::bad_request( + "Rechnungsnummer des Lieferanten fehlt", + )); + } + if payload.items.is_empty() { + return Err(ApiError::bad_request( + "Eine Eingangsrechnung braucht mindestens eine Position", + )); + } + for item in &payload.items { + validate_number(&item.quantity, "Menge")?; + if item.quantity.parse::().unwrap_or(0.0) <= 0.0 { + return Err(ApiError::bad_request("Menge muss größer als 0 sein")); + } + validate_number(&item.unit_price, "Positionspreis")?; + validate_number(&item.tax_rate, "Steuersatz")?; + } + Ok(()) +} + +async fn write_incoming_invoice( + db: &PgPool, + crypto: &DataCrypto, + context: &AuthContext, + invoice_id: Uuid, + payload: &IncomingInvoiceRequest, + insert: bool, +) -> Result<(), ApiError> { + ensure_supplier_exists(db, &context.schema_name, payload.supplier_id).await?; + for item in &payload.items { + if let Some(item_id) = item.item_id { + ensure_item_exists(db, &context.schema_name, item_id).await?; + } + } + let sql = if insert { + format!( + r#" + insert into {schema}.incoming_invoices ( + id, invoice_number, supplier_id, status, cash_discount_term_id, + invoice_date, due_at, created_by_user_id + ) values ($1,$2,$3,$4,$5,$6::date,$7::date,$8) + "#, + schema = context.schema_name + ) + } else { + format!( + r#" + update {schema}.incoming_invoices + set invoice_number=invoice_number, supplier_id=$3, status=$4, + cash_discount_term_id=$5, invoice_date=$6::date, + due_at=$7::date, updated_at=now() + where id=$1 + "#, + schema = context.schema_name + ) + }; + let result = sqlx::query(&sql) + .bind(invoice_id) + .bind(payload.invoice_number.trim()) + .bind(payload.supplier_id) + .bind(&payload.status) + .bind(payload.cash_discount_term_id) + .bind( + payload + .invoice_date + .as_deref() + .filter(|value| !value.is_empty()), + ) + .bind(payload.due_at.as_deref().filter(|value| !value.is_empty())) + .bind(context.user_id) + .execute(db) + .await?; + if !insert { + ensure_changed(result.rows_affected(), "Eingangsrechnung nicht gefunden")?; + let delete_sql = format!( + "delete from {schema}.incoming_invoice_items where invoice_id=$1", + schema = context.schema_name + ); + sqlx::query(&delete_sql) + .bind(invoice_id) + .execute(db) + .await?; + } + write_incoming_invoice_items(db, crypto, &context.schema_name, invoice_id, &payload.items).await +} + +async fn write_incoming_invoice_items( + db: &PgPool, + crypto: &DataCrypto, + schema_name: &str, + invoice_id: Uuid, + items: &[IncomingInvoiceItemRequest], +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + insert into {schema}.incoming_invoice_items ( + id, invoice_id, line_number, item_id, description_ciphertext, + description_nonce, description_key_id, quantity, unit_price, tax_rate + ) values ($1,$2,$3,$4,$5,$6,$7,$8::numeric,$9::numeric,$10::numeric) + "#, + schema = schema_name + ); + for (index, item) in items.iter().enumerate() { + let description = if item.description.trim().is_empty() { + None + } else { + Some(crypto.encrypt(&item.description)?) + }; + sqlx::query(&sql) + .bind(Uuid::new_v4()) + .bind(invoice_id) + .bind((index + 1) as i32) + .bind(item.item_id) + .bind(description.as_ref().map(|field| field.ciphertext.clone())) + .bind(description.as_ref().map(|field| field.nonce.clone())) + .bind(description.as_ref().map(|field| field.key_id.clone())) + .bind(item.quantity.trim()) + .bind(item.unit_price.trim()) + .bind(item.tax_rate.trim()) + .execute(db) + .await?; + } + Ok(()) +} + +fn incoming_invoice_response(id: Uuid, payload: IncomingInvoiceRequest) -> IncomingInvoiceResponse { + IncomingInvoiceResponse { + id, + invoice_number: payload.invoice_number.trim().to_string(), + supplier_id: payload.supplier_id, + status: payload.status, + cash_discount_term_id: payload.cash_discount_term_id, + invoice_date: payload.invoice_date, + due_at: payload.due_at, + items: payload + .items + .into_iter() + .enumerate() + .map(|(index, item)| IncomingInvoiceItemResponse { + id: Uuid::new_v4(), + line_number: (index + 1) as i32, + item_id: item.item_id, + description: item.description, + quantity: item.quantity, + unit_price: item.unit_price, + tax_rate: item.tax_rate, + }) + .collect(), + } +} + +async fn incoming_invoice_from_row( + db: &PgPool, + crypto: &DataCrypto, + schema_name: &str, + row: sqlx::postgres::PgRow, + invoice_id: Uuid, +) -> Result { + Ok(IncomingInvoiceResponse { + id: invoice_id, + invoice_number: row + .get::, _>("invoice_number") + .unwrap_or_default(), + supplier_id: row.get("supplier_id"), + status: row.get("status"), + cash_discount_term_id: row.get("cash_discount_term_id"), + invoice_date: row.get("invoice_date"), + due_at: row.get("due_at"), + items: load_incoming_invoice_items(db, crypto, schema_name, invoice_id).await?, + }) +} + +async fn load_incoming_invoice_items( + db: &PgPool, + crypto: &DataCrypto, + schema_name: &str, + invoice_id: Uuid, +) -> Result, ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + select id, line_number, item_id, description_ciphertext, description_nonce, + description_key_id, quantity::text quantity, unit_price::text unit_price, + tax_rate::text tax_rate + from {schema}.incoming_invoice_items + where invoice_id=$1 + order by line_number + "#, + schema = schema_name + ); + let rows = sqlx::query(&sql).bind(invoice_id).fetch_all(db).await?; + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + let description = match row.get::>, _>("description_ciphertext") { + Some(ciphertext) => crypto.decrypt::( + &ciphertext, + &row.get::, _>("description_nonce"), + &row.get::("description_key_id"), + )?, + None => String::new(), + }; + items.push(IncomingInvoiceItemResponse { + id: row.get("id"), + line_number: row.get("line_number"), + item_id: row.get("item_id"), + description, + quantity: row.get("quantity"), + unit_price: row.get("unit_price"), + tax_rate: row.get("tax_rate"), + }); + } + Ok(items) +} + +async fn ensure_supplier_exists( + db: &PgPool, + schema_name: &str, + supplier_id: Uuid, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + "select exists(select 1 from {schema}.suppliers where id=$1 and status='active')", + schema = schema_name + ); + if sqlx::query_scalar::<_, bool>(&sql) + .bind(supplier_id) + .fetch_one(db) + .await? + { + Ok(()) + } else { + Err(ApiError::bad_request( + "Lieferant ist nicht vorhanden oder inaktiv", + )) + } +} + +async fn parse_price_list_rows( + db: &PgPool, + schema_name: &str, + payload: &PriceListImportRequest, +) -> Result, ApiError> { + ensure_safe_schema_name(schema_name)?; + let delimiter = payload + .delimiter + .as_deref() + .filter(|value| !value.is_empty()) + .unwrap_or(";"); + let mut lines = payload + .content + .lines() + .filter(|line| !line.trim().is_empty()); + let header = lines + .next() + .ok_or_else(|| ApiError::bad_request("CSV-Datei enthält keine Kopfzeile"))?; + let headers = split_csv_line(header, delimiter); + let required = [ + "item_number", + "name", + "unit", + "tax_rate", + "purchase_price", + "sales_price", + ]; + for column in required { + if !headers.iter().any(|header| header == column) { + return Err(ApiError::bad_request(&format!( + "CSV-Spalte fehlt: {column}" + ))); + } + } + let mut rows = Vec::new(); + for (index, line) in lines.enumerate() { + let values = split_csv_line(line, delimiter); + let value = |name: &str| -> String { + headers + .iter() + .position(|header| header == name) + .and_then(|position| values.get(position)) + .cloned() + .unwrap_or_default() + }; + let row_number = index + 2; + let item_number = value("item_number"); + let name = value("name"); + let unit = value("unit"); + let tax_rate = value("tax_rate"); + let purchase_price = empty_string_to_none(value("purchase_price")); + let sales_price = empty_string_to_none(value("sales_price")); + let mut error = None; + if item_number.trim().is_empty() { + error = Some("Artikelnummer fehlt".to_string()); + } else if name.trim().is_empty() { + error = Some("Bezeichnung fehlt".to_string()); + } else if tax_rate.parse::().is_err() { + error = Some("Steuersatz ist ungültig".to_string()); + } else if purchase_price + .as_ref() + .is_some_and(|price| price.parse::().is_err()) + { + error = Some("Einkaufspreis ist ungültig".to_string()); + } else if sales_price + .as_ref() + .is_some_and(|price| price.parse::().is_err()) + { + error = Some("Verkaufspreis ist ungültig".to_string()); + } + let action = if item_number_exists(db, schema_name, &item_number).await? { + "update" + } else { + "create" + }; + rows.push(PriceListImportRow { + row_number, + item_number, + name, + unit: if unit.trim().is_empty() { + "Stk".to_string() + } else { + unit + }, + tax_rate, + purchase_price, + sales_price, + action: action.to_string(), + error, + }); + } + Ok(rows) +} + +fn split_csv_line(line: &str, delimiter: &str) -> Vec { + line.split(delimiter) + .map(|value| value.trim().trim_matches('"').to_string()) + .collect() +} + +fn empty_string_to_none(value: String) -> Option { + if value.trim().is_empty() { + None + } else { + Some(value) + } +} + +async fn item_number_exists( + db: &PgPool, + schema_name: &str, + item_number: &str, +) -> Result { + let sql = format!( + "select exists(select 1 from {schema}.items where item_number=$1)", + schema = schema_name + ); + Ok(sqlx::query_scalar::<_, bool>(&sql) + .bind(item_number.trim()) + .fetch_one(db) + .await?) +} + +async fn upsert_imported_item( + db: &PgPool, + crypto: &DataCrypto, + context: &AuthContext, + import_id: Uuid, + row: &PriceListImportRow, +) -> Result<(), ApiError> { + let item_id = match find_item_id_by_number(db, &context.schema_name, &row.item_number).await? { + Some(item_id) => { + let name = crypto.encrypt(&row.name)?; + let sql = format!( + r#" + update {schema}.items + set name_ciphertext=$2, name_nonce=$3, name_key_id=$4, + unit=$5, tax_rate=$6::numeric, + default_purchase_price=$7::numeric, + default_sales_price=$8::numeric, + status='active', updated_at=now() + where id=$1 + "#, + schema = context.schema_name + ); + sqlx::query(&sql) + .bind(item_id) + .bind(name.ciphertext) + .bind(name.nonce) + .bind(name.key_id) + .bind(row.unit.trim()) + .bind(row.tax_rate.trim()) + .bind(row.purchase_price.as_deref()) + .bind(row.sales_price.as_deref()) + .execute(db) + .await?; + item_id + } + None => { + let item_id = Uuid::new_v4(); + let name = crypto.encrypt(&row.name)?; + let sql = format!( + r#" + insert into {schema}.items ( + id, item_number, name_ciphertext, name_nonce, name_key_id, + unit, tax_rate, default_purchase_price, default_sales_price, status + ) values ($1,$2,$3,$4,$5,$6,$7::numeric,$8::numeric,$9::numeric,'active') + "#, + schema = context.schema_name + ); + sqlx::query(&sql) + .bind(item_id) + .bind(row.item_number.trim()) + .bind(name.ciphertext) + .bind(name.nonce) + .bind(name.key_id) + .bind(row.unit.trim()) + .bind(row.tax_rate.trim()) + .bind(row.purchase_price.as_deref()) + .bind(row.sales_price.as_deref()) + .execute(db) + .await?; + item_id + } + }; + record_item_price_history_with_source( + db, + &context.schema_name, + item_id, + row.purchase_price.as_deref(), + row.sales_price.as_deref(), + &format!("import:{import_id}"), + context.user_id, + ) + .await +} + +async fn find_item_id_by_number( + db: &PgPool, + schema_name: &str, + item_number: &str, +) -> Result, ApiError> { + let sql = format!( + "select id from {schema}.items where item_number=$1", + schema = schema_name + ); + Ok(sqlx::query_scalar::<_, Uuid>(&sql) + .bind(item_number.trim()) + .fetch_optional(db) + .await?) +} + +fn validate_api_connector_request(payload: &ApiConnectorRequest) -> Result<(), ApiError> { + if payload.code.trim().is_empty() || payload.name.trim().len() < 2 { + return Err(ApiError::bad_request("Code und Name sind erforderlich")); + } + if payload.connector_type.trim().is_empty() { + return Err(ApiError::bad_request("Connector-Typ fehlt")); + } + if payload + .sync_interval_minutes + .is_some_and(|minutes| minutes <= 0) + { + return Err(ApiError::bad_request( + "Synchronisationsintervall ist ungültig", + )); + } + Ok(()) +} + +async fn write_api_connector( + db: &PgPool, + crypto: &DataCrypto, + schema_name: &str, + connector_id: Uuid, + payload: &ApiConnectorRequest, + insert: bool, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let config = crypto.encrypt(&payload.config)?; + let sql = if insert { + format!( + r#" + insert into {schema}.api_connectors ( + id, code, name, connector_type, config_ciphertext, config_nonce, + config_key_id, is_active, sync_interval_minutes + ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9) + "#, + schema = schema_name + ) + } else { + format!( + r#" + update {schema}.api_connectors + set code=$2, name=$3, connector_type=$4, config_ciphertext=$5, + config_nonce=$6, config_key_id=$7, is_active=$8, + sync_interval_minutes=$9, updated_at=now() + where id=$1 + "#, + schema = schema_name + ) + }; + let result = sqlx::query(&sql) + .bind(connector_id) + .bind(payload.code.trim()) + .bind(payload.name.trim()) + .bind(payload.connector_type.trim()) + .bind(config.ciphertext) + .bind(config.nonce) + .bind(config.key_id) + .bind(payload.is_active) + .bind(payload.sync_interval_minutes) + .execute(db) + .await?; + if !insert { + ensure_changed(result.rows_affected(), "API-Connector nicht gefunden")?; + } + Ok(()) +} + +fn api_connector_response( + id: Uuid, + payload: ApiConnectorRequest, + last_sync_at: Option>, +) -> ApiConnectorResponse { + ApiConnectorResponse { + id, + code: payload.code, + name: payload.name, + connector_type: payload.connector_type, + config: payload.config, + is_active: payload.is_active, + sync_interval_minutes: payload.sync_interval_minutes, + last_sync_at, + } +} + +fn api_connector_from_row( + crypto: &DataCrypto, + row: sqlx::postgres::PgRow, +) -> Result { + Ok(ApiConnectorResponse { + id: row.get("id"), + code: row.get("code"), + name: row.get("name"), + connector_type: row.get("connector_type"), + config: crypto.decrypt( + &row.get::, _>("config_ciphertext"), + &row.get::, _>("config_nonce"), + &row.get::("config_key_id"), + )?, + is_active: row.get("is_active"), + sync_interval_minutes: row.get("sync_interval_minutes"), + last_sync_at: row.get("last_sync_at"), + }) +} + +fn validate_price_rule_request(payload: &PriceRuleRequest) -> Result<(), ApiError> { + if payload.code.trim().is_empty() || payload.name.trim().len() < 2 { + return Err(ApiError::bad_request("Code und Name sind erforderlich")); + } + if !matches!(payload.source_type.trim(), "import" | "api" | "supplier") { + return Err(ApiError::bad_request("Quellentyp ist ungültig")); + } + if !matches!( + payload.rounding_mode.trim(), + "none" | "cent" | "five_cent" | "ten_cent" | "whole" + ) { + return Err(ApiError::bad_request("Rundungsmodus ist ungültig")); + } + validate_number(&payload.markup_percent, "Aufschlag")?; + let markup = payload + .markup_percent + .trim() + .parse::() + .map_err(|_| ApiError::bad_request("Aufschlag ist ungültig"))?; + if !(-100.0..=1000.0).contains(&markup) { + return Err(ApiError::bad_request( + "Aufschlag muss zwischen -100 und 1000 Prozent liegen", + )); + } + Ok(()) +} + +async fn write_price_rule( + db: &PgPool, + schema_name: &str, + rule_id: Uuid, + payload: &PriceRuleRequest, + insert: bool, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = if insert { + format!( + r#" + insert into {schema}.price_rules ( + id, code, name, source_type, source_id, markup_percent, rounding_mode, is_active + ) values ($1,$2,$3,$4,$5,$6::numeric,$7,$8) + "#, + schema = schema_name + ) + } else { + format!( + r#" + update {schema}.price_rules + set code=$2, name=$3, source_type=$4, source_id=$5, + markup_percent=$6::numeric, rounding_mode=$7, is_active=$8, + updated_at=now() + where id=$1 + "#, + schema = schema_name + ) + }; + let result = sqlx::query(&sql) + .bind(rule_id) + .bind(payload.code.trim()) + .bind(payload.name.trim()) + .bind(payload.source_type.trim()) + .bind(payload.source_id) + .bind(payload.markup_percent.trim()) + .bind(payload.rounding_mode.trim()) + .bind(payload.is_active) + .execute(db) + .await?; + if !insert { + ensure_changed(result.rows_affected(), "Preisregel nicht gefunden")?; + } + Ok(()) +} + +fn price_rule_response(id: Uuid, payload: PriceRuleRequest) -> PriceRuleResponse { + PriceRuleResponse { + id, + code: payload.code, + name: payload.name, + source_type: payload.source_type, + source_id: payload.source_id, + markup_percent: payload.markup_percent, + rounding_mode: payload.rounding_mode, + is_active: payload.is_active, + } +} + +fn price_rule_from_row(row: sqlx::postgres::PgRow) -> PriceRuleResponse { + PriceRuleResponse { + id: row.get("id"), + code: row.get("code"), + name: row.get("name"), + source_type: row.get("source_type"), + source_id: row.get("source_id"), + markup_percent: row.get("markup_percent"), + rounding_mode: row.get("rounding_mode"), + is_active: row.get("is_active"), + } +} + +fn validate_communication_request(payload: &CommunicationRequest) -> Result<(), ApiError> { + validate_status_choice( + &payload.communication_type, + &["email", "phone", "letter", "meeting", "internal_note"], + "Kommunikationstyp ist ungültig", + )?; + validate_status_choice( + &payload.direction, + &["inbound", "outbound", "internal"], + "Richtung ist ungültig", + )?; + validate_status_choice( + &payload.status, + &["open", "done", "archived"], + "Kommunikationsstatus ist ungültig", + )?; + if payload.subject.trim().len() < 2 { + return Err(ApiError::bad_request("Betreff ist erforderlich")); + } + validate_entity_links(&payload.links, &communication_link_entity_types()) +} + +fn validate_document_upload_request(payload: &DocumentUploadRequest) -> Result<(), ApiError> { + if payload.title.trim().len() < 2 { + return Err(ApiError::bad_request("Dokumenttitel ist erforderlich")); + } + if payload.file_name.trim().len() < 2 { + return Err(ApiError::bad_request("Dateiname ist erforderlich")); + } + if payload.content_type.trim().is_empty() { + return Err(ApiError::bad_request("Content-Type ist erforderlich")); + } + validate_entity_links(&payload.links, &document_link_entity_types()) +} + +fn communication_link_entity_types() -> [&'static str; 8] { + [ + "customer", + "supplier", + "activity", + "quote", + "outgoing_invoice", + "incoming_invoice", + "item", + "document", + ] +} + +fn document_link_entity_types() -> [&'static str; 8] { + [ + "customer", + "supplier", + "activity", + "communication", + "quote", + "outgoing_invoice", + "incoming_invoice", + "item", + ] +} + +fn validate_entity_links(links: &[EntityLinkRequest], allowed: &[&str]) -> Result<(), ApiError> { + for link in links { + if !allowed.contains(&link.entity_type.as_str()) { + return Err(ApiError::bad_request("Bezugstyp ist ungültig")); + } + } + Ok(()) +} + +fn encrypted_optional_string( + crypto: &DataCrypto, + value: &str, +) -> Result, ApiError> { + if value.trim().is_empty() { + Ok(None) + } else { + Ok(Some(crypto.encrypt(&value.trim().to_string())?)) + } +} + +async fn write_communication( + db: &PgPool, + crypto: &DataCrypto, + context: &AuthContext, + communication_id: Uuid, + payload: &CommunicationRequest, + insert: bool, +) -> Result<(), ApiError> { + ensure_safe_schema_name(&context.schema_name)?; + let subject = crypto.encrypt(&payload.subject.trim().to_string())?; + let body = encrypted_optional_string(crypto, &payload.body)?; + let sql = if insert { + format!( + r#" + insert into {schema}.communications ( + id, communication_type, direction, subject_ciphertext, subject_nonce, + subject_key_id, body_ciphertext, body_nonce, body_key_id, status, + occurred_at, created_by_user_id + ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::timestamptz,$12) + "#, + schema = context.schema_name + ) + } else { + format!( + r#" + update {schema}.communications + set communication_type=$2, direction=$3, subject_ciphertext=$4, + subject_nonce=$5, subject_key_id=$6, body_ciphertext=$7, + body_nonce=$8, body_key_id=$9, status=$10, + occurred_at=$11::timestamptz, updated_at=now() + where id=$1 + "#, + schema = context.schema_name + ) + }; + let result = sqlx::query(&sql) + .bind(communication_id) + .bind(payload.communication_type.trim()) + .bind(payload.direction.trim()) + .bind(subject.ciphertext) + .bind(subject.nonce) + .bind(subject.key_id) + .bind(body.as_ref().map(|value| value.ciphertext.clone())) + .bind(body.as_ref().map(|value| value.nonce.clone())) + .bind(body.as_ref().map(|value| value.key_id.clone())) + .bind(payload.status.trim()) + .bind(payload.occurred_at.as_deref()) + .bind(context.user_id) + .execute(db) + .await?; + if !insert { + ensure_changed(result.rows_affected(), "Kommunikation nicht gefunden")?; + } + write_communication_links(db, &context.schema_name, communication_id, &payload.links).await +} + +async fn write_communication_links( + db: &PgPool, + schema_name: &str, + communication_id: Uuid, + links: &[EntityLinkRequest], +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let delete_sql = format!( + "delete from {schema}.communication_links where communication_id=$1", + schema = schema_name + ); + sqlx::query(&delete_sql) + .bind(communication_id) + .execute(db) + .await?; + let insert_sql = format!( + "insert into {schema}.communication_links (communication_id, entity_type, entity_id) values ($1,$2,$3) on conflict do nothing", + schema = schema_name + ); + for link in links { + sqlx::query(&insert_sql) + .bind(communication_id) + .bind(link.entity_type.trim()) + .bind(link.entity_id) + .execute(db) + .await?; + } + Ok(()) +} + +async fn write_document_links( + db: &PgPool, + schema_name: &str, + document_id: Uuid, + links: &[EntityLinkRequest], +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let delete_sql = format!( + "delete from {schema}.document_links where document_id=$1", + schema = schema_name + ); + sqlx::query(&delete_sql) + .bind(document_id) + .execute(db) + .await?; + let insert_sql = format!( + "insert into {schema}.document_links (document_id, entity_type, entity_id) values ($1,$2,$3) on conflict do nothing", + schema = schema_name + ); + for link in links { + sqlx::query(&insert_sql) + .bind(document_id) + .bind(link.entity_type.trim()) + .bind(link.entity_id) + .execute(db) + .await?; + } + Ok(()) +} + +async fn get_communication_by_id( + db: &PgPool, + crypto: &DataCrypto, + schema_name: &str, + communication_id: Uuid, +) -> Result, ApiError> { + let sql = format!( + r#" + select id, communication_type, direction, subject_ciphertext, subject_nonce, + subject_key_id, body_ciphertext, body_nonce, body_key_id, status, occurred_at + from {schema}.communications + where id=$1 + "#, + schema = schema_name + ); + let row = sqlx::query(&sql) + .bind(communication_id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::not_found("Kommunikation nicht gefunden"))?; + Ok(Json( + communication_from_row(db, crypto, schema_name, row).await?, + )) +} + +async fn communication_from_row( + db: &PgPool, + crypto: &DataCrypto, + schema_name: &str, + row: sqlx::postgres::PgRow, +) -> Result { + let id: Uuid = row.get("id"); + Ok(CommunicationResponse { + id, + communication_type: row.get("communication_type"), + direction: row.get("direction"), + subject: crypto.decrypt( + &row.get::, _>("subject_ciphertext"), + &row.get::, _>("subject_nonce"), + &row.get::("subject_key_id"), + )?, + body: decrypt_optional_string(crypto, &row, "body")?.unwrap_or_default(), + status: row.get("status"), + occurred_at: row.get("occurred_at"), + links: load_links( + db, + schema_name, + "communication_links", + "communication_id", + id, + ) + .await?, + }) +} + +fn decrypt_optional_string( + crypto: &DataCrypto, + row: &sqlx::postgres::PgRow, + prefix: &str, +) -> Result, ApiError> { + let ciphertext_column = format!("{prefix}_ciphertext"); + let nonce_column = format!("{prefix}_nonce"); + let key_column = format!("{prefix}_key_id"); + let ciphertext = row.get::>, _>(ciphertext_column.as_str()); + let nonce = row.get::>, _>(nonce_column.as_str()); + let key_id = row.get::, _>(key_column.as_str()); + match (ciphertext, nonce, key_id) { + (Some(ciphertext), Some(nonce), Some(key_id)) => { + Ok(Some(crypto.decrypt(&ciphertext, &nonce, &key_id)?)) + } + _ => Ok(None), + } +} + +async fn load_links( + db: &PgPool, + schema_name: &str, + table: &str, + id_column: &str, + id: Uuid, +) -> Result, ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + "select entity_type, entity_id from {schema}.{table} where {id_column}=$1 order by entity_type", + schema = schema_name + ); + let rows = sqlx::query(&sql).bind(id).fetch_all(db).await?; + Ok(rows + .into_iter() + .map(|row| EntityLinkRequest { + entity_type: row.get("entity_type"), + entity_id: row.get("entity_id"), + }) + .collect()) +} + +async fn get_document_by_id( + db: &PgPool, + crypto: &DataCrypto, + schema_name: &str, + document_id: Uuid, +) -> Result, ApiError> { + let sql = format!( + r#" + select id, title_ciphertext, title_nonce, title_key_id, + description_ciphertext, description_nonce, description_key_id, status + from {schema}.documents + where id=$1 + "#, + schema = schema_name + ); + let row = sqlx::query(&sql) + .bind(document_id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::not_found("Dokument nicht gefunden"))?; + Ok(Json(document_from_row(db, crypto, schema_name, row).await?)) +} + +async fn document_from_row( + db: &PgPool, + crypto: &DataCrypto, + schema_name: &str, + row: sqlx::postgres::PgRow, +) -> Result { + let id: Uuid = row.get("id"); + Ok(DocumentResponse { + id, + title: crypto.decrypt( + &row.get::, _>("title_ciphertext"), + &row.get::, _>("title_nonce"), + &row.get::("title_key_id"), + )?, + description: decrypt_optional_string(crypto, &row, "description")?.unwrap_or_default(), + status: row.get("status"), + latest_version: latest_document_version(db, crypto, schema_name, id).await?, + links: load_links(db, schema_name, "document_links", "document_id", id).await?, + }) +} + +async fn latest_document_version( + db: &PgPool, + crypto: &DataCrypto, + schema_name: &str, + document_id: Uuid, +) -> Result, ApiError> { + let sql = format!( + r#" + select id, version_no, file_name_ciphertext, file_name_nonce, file_name_key_id, + content_type_ciphertext, content_type_nonce, content_type_key_id, + file_size, checksum_sha256, created_at + from {schema}.document_versions + where document_id=$1 + order by version_no desc + limit 1 + "#, + schema = schema_name + ); + let Some(row) = sqlx::query(&sql) + .bind(document_id) + .fetch_optional(db) + .await? + else { + return Ok(None); + }; + Ok(Some(DocumentVersionResponse { + id: row.get("id"), + version_no: row.get("version_no"), + file_name: crypto.decrypt( + &row.get::, _>("file_name_ciphertext"), + &row.get::, _>("file_name_nonce"), + &row.get::("file_name_key_id"), + )?, + content_type: crypto.decrypt( + &row.get::, _>("content_type_ciphertext"), + &row.get::, _>("content_type_nonce"), + &row.get::("content_type_key_id"), + )?, + file_size: row.get("file_size"), + checksum_sha256: row.get("checksum_sha256"), + created_at: row.get("created_at"), + })) +} + +fn document_storage_path( + schema_name: &str, + document_id: Uuid, + version_id: Uuid, +) -> Result { + ensure_safe_schema_name(schema_name)?; + let base = env::var("COMPANYTOOL_DOCUMENT_STORAGE_DIR") + .unwrap_or_else(|_| "storage/documents".to_string()); + Ok(PathBuf::from(base) + .join(schema_name) + .join(document_id.to_string()) + .join(format!("{version_id}.json"))) +} + +async fn insert_document_audit( + db: &PgPool, + schema_name: &str, + document_id: Uuid, + version_id: Option, + action: &str, + user_id: Uuid, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + "insert into {schema}.document_audit_log (id, document_id, version_id, action, user_id) values ($1,$2,$3,$4,$5)", + schema = schema_name + ); + sqlx::query(&sql) + .bind(Uuid::new_v4()) + .bind(document_id) + .bind(version_id) + .bind(action) + .bind(user_id) + .execute(db) + .await?; + Ok(()) +} + +fn validate_item_request(payload: &ItemRequest) -> Result<(), ApiError> { + if payload.name.trim().len() < 2 { + return Err(ApiError::bad_request("Artikelname ist erforderlich")); + } + validate_status(&payload.status)?; + validate_number(&payload.tax_rate, "Steuersatz")?; + if let Some(price) = &payload.default_purchase_price { + validate_number(price, "Einkaufspreis")?; + } + if let Some(price) = &payload.default_sales_price { + validate_number(price, "Verkaufspreis")?; + } + Ok(()) +} + +async fn write_item( + db: &PgPool, + crypto: &DataCrypto, + schema_name: &str, + item_id: Uuid, + payload: &ItemRequest, + insert: bool, +) -> Result<(), ApiError> { + let name = crypto.encrypt(&payload.name.trim().to_string())?; + let sql = if insert { + format!( + r#"insert into {schema}.items (id,item_number,name_ciphertext,name_nonce,name_key_id,unit,tax_rate,default_purchase_price,default_sales_price,status) + values ($1,$2,$3,$4,$5,$6,$7::numeric,$8::numeric,$9::numeric,$10)"#, + schema = schema_name + ) + } else { + format!( + r#"update {schema}.items set item_number=item_number,name_ciphertext=$3,name_nonce=$4,name_key_id=$5,unit=$6,tax_rate=$7::numeric, + default_purchase_price=$8::numeric,default_sales_price=$9::numeric,status=$10,updated_at=now() where id=$1"#, + schema = schema_name + ) + }; + let result = sqlx::query(&sql) + .bind(item_id) + .bind(payload.item_number.trim()) + .bind(name.ciphertext) + .bind(name.nonce) + .bind(name.key_id) + .bind(payload.unit.trim()) + .bind(payload.tax_rate.trim()) + .bind(payload.default_purchase_price.as_deref()) + .bind(payload.default_sales_price.as_deref()) + .bind(&payload.status) + .execute(db) + .await?; + if !insert { + ensure_changed(result.rows_affected(), "Artikel nicht gefunden")?; + } + Ok(()) +} + +fn item_response(id: Uuid, payload: ItemRequest) -> ItemResponse { + ItemResponse { + id, + item_number: payload.item_number, + name: payload.name, + unit: payload.unit, + tax_rate: payload.tax_rate, + default_purchase_price: payload.default_purchase_price, + default_sales_price: payload.default_sales_price, + status: payload.status, + } +} + +fn item_from_row( + crypto: &DataCrypto, + row: sqlx::postgres::PgRow, +) -> Result { + Ok(ItemResponse { + id: row.get("id"), + item_number: row.get("item_number"), + name: crypto.decrypt( + &row.get::, _>("name_ciphertext"), + &row.get::, _>("name_nonce"), + &row.get::("name_key_id"), + )?, + unit: row.get("unit"), + tax_rate: row.get("tax_rate"), + default_purchase_price: row.get("default_purchase_price"), + default_sales_price: row.get("default_sales_price"), + status: row.get("status"), + }) +} + +async fn record_item_price_history( + db: &PgPool, + schema_name: &str, + item_id: Uuid, + payload: &ItemRequest, + user_id: Uuid, +) -> Result<(), ApiError> { + record_item_price_history_with_source( + db, + schema_name, + item_id, + payload.default_purchase_price.as_deref(), + payload.default_sales_price.as_deref(), + "manual", + user_id, + ) + .await +} + +async fn record_item_price_history_with_source( + db: &PgPool, + schema_name: &str, + item_id: Uuid, + purchase_price: Option<&str>, + sales_price: Option<&str>, + source: &str, + user_id: Uuid, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + insert into {schema}.item_price_history ( + id, item_id, purchase_price, sales_price, source, created_by_user_id + ) values ($1, $2, $3::numeric, $4::numeric, $5, $6) + "#, + schema = schema_name + ); + sqlx::query(&sql) + .bind(Uuid::new_v4()) + .bind(item_id) + .bind(purchase_price) + .bind(sales_price) + .bind(source) + .bind(user_id) + .execute(db) + .await?; + Ok(()) +} + +fn item_price_history_from_row(row: sqlx::postgres::PgRow) -> ItemPriceHistoryResponse { + ItemPriceHistoryResponse { + id: row.get("id"), + item_id: row.get("item_id"), + purchase_price: row.get("purchase_price"), + sales_price: row.get("sales_price"), + source: row.get("source"), + valid_from: row.get("valid_from"), + created_by_user_id: row.get("created_by_user_id"), + created_at: row.get("created_at"), + } +} + +fn validate_activity_request(payload: &ActivityRequest) -> Result<(), ApiError> { + if payload.title.trim().len() < 2 { + return Err(ApiError::bad_request("Titel der Aktivität ist zu kurz")); + } + if ![ + "email_note", + "phone_note", + "internal_note", + "task", + "follow_up", + "calendar_event", + "system_event", + "work_step", + ] + .contains(&payload.activity_type.as_str()) + { + return Err(ApiError::bad_request("Ungültiger Aktivitätstyp")); + } + if !["open", "in_progress", "done", "cancelled"].contains(&payload.status.as_str()) { + return Err(ApiError::bad_request("Ungültiger Aktivitätsstatus")); + } + if !["low", "normal", "high", "critical"].contains(&payload.priority.as_str()) { + return Err(ApiError::bad_request("Ungültige Priorität")); + } + Ok(()) +} + +async fn write_activity( + db: &PgPool, + crypto: &DataCrypto, + context: &AuthContext, + activity_id: Uuid, + payload: &ActivityRequest, + insert: bool, +) -> Result<(), ApiError> { + let title = crypto.encrypt(&payload.title.trim().to_string())?; + let body = if payload.body.trim().is_empty() { + None + } else { + Some(crypto.encrypt(&payload.body)?) + }; + let sql = if insert { + format!( + r#"insert into {schema}.activities (id,activity_number,activity_type,title_ciphertext,title_nonce,title_key_id,body_ciphertext,body_nonce,body_key_id,status,priority,due_at,created_by_user_id) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)"#, + schema = context.schema_name + ) + } else { + format!( + r#"update {schema}.activities set activity_number=activity_number,activity_type=$3,title_ciphertext=$4,title_nonce=$5,title_key_id=$6,body_ciphertext=$7,body_nonce=$8,body_key_id=$9,status=$10,priority=$11,due_at=$12,created_by_user_id=coalesce(created_by_user_id,$13),updated_at=now() where id=$1"#, + schema = context.schema_name + ) + }; + let result = sqlx::query(&sql) + .bind(activity_id) + .bind( + payload + .activity_number + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()), + ) + .bind(&payload.activity_type) + .bind(title.ciphertext) + .bind(title.nonce) + .bind(title.key_id) + .bind(body.as_ref().map(|field| field.ciphertext.clone())) + .bind(body.as_ref().map(|field| field.nonce.clone())) + .bind(body.as_ref().map(|field| field.key_id.clone())) + .bind(&payload.status) + .bind(&payload.priority) + .bind(payload.due_at) + .bind(context.user_id) + .execute(db) + .await?; + if !insert { + ensure_changed(result.rows_affected(), "Aktivität nicht gefunden")?; + } + Ok(()) +} + +fn activity_response(id: Uuid, payload: ActivityRequest) -> ActivityResponse { + ActivityResponse { + id, + activity_number: payload.activity_number, + activity_type: payload.activity_type, + title: payload.title, + body: payload.body, + status: payload.status, + priority: payload.priority, + due_at: payload.due_at, + } +} + +fn activity_from_row( + crypto: &DataCrypto, + row: sqlx::postgres::PgRow, +) -> Result { + let body = match row.get::>, _>("body_ciphertext") { + Some(ciphertext) => crypto.decrypt::( + &ciphertext, + &row.get::, _>("body_nonce"), + &row.get::("body_key_id"), + )?, + None => String::new(), + }; + Ok(ActivityResponse { + id: row.get("id"), + activity_number: row.get("activity_number"), + activity_type: row.get("activity_type"), + title: crypto.decrypt( + &row.get::, _>("title_ciphertext"), + &row.get::, _>("title_nonce"), + &row.get::("title_key_id"), + )?, + body, + status: row.get("status"), + priority: row.get("priority"), + due_at: row.get("due_at"), + }) +} + +async fn set_record_inactive( + db: &PgPool, + schema_name: &str, + table: &str, + id: Uuid, +) -> Result<(), ApiError> { + let sql = + format!("update {schema_name}.{table} set status='inactive', updated_at=now() where id=$1"); + let result = sqlx::query(&sql).bind(id).execute(db).await?; + ensure_changed(result.rows_affected(), "Datensatz nicht gefunden") +} + +fn ensure_changed(rows_affected: u64, message: &str) -> Result<(), ApiError> { + if rows_affected == 0 { + Err(ApiError::not_found(message)) + } else { + Ok(()) + } +} + +fn validate_status(status: &str) -> Result<(), ApiError> { + if ["active", "inactive", "blocked"].contains(&status) { + Ok(()) + } else { + Err(ApiError::bad_request("Ungültiger Status")) + } +} + +fn validate_status_choice(value: &str, allowed: &[&str], message: &str) -> Result<(), ApiError> { + if allowed.contains(&value) { + Ok(()) + } else { + Err(ApiError::bad_request(message)) + } +} + +fn validate_number(value: &str, field: &str) -> Result<(), ApiError> { + let parsed = value + .parse::() + .map_err(|_| ApiError::bad_request(&format!("Ungültiger Wert: {field}")))?; + if parsed < 0.0 { + Err(ApiError::bad_request(&format!( + "{field} darf nicht negativ sein" + ))) + } else { + Ok(()) + } +} + +fn validate_percent(value: &str, field: &str) -> Result<(), ApiError> { + let parsed = value + .parse::() + .map_err(|_| ApiError::bad_request(&format!("Ungültiger Wert: {field}")))?; + if (0.0..=100.0).contains(&parsed) { + Ok(()) + } else { + Err(ApiError::bad_request(&format!( + "{field} muss zwischen 0 und 100 liegen" + ))) + } +} + +fn validate_number_range(payload: &NumberRangeRequest) -> Result<(), ApiError> { + if !payload.pattern.contains("{counter}") { + return Err(ApiError::bad_request( + "Muster muss den Platzhalter {counter} enthalten", + )); + } + if payload.counter_value < 0 { + return Err(ApiError::bad_request("Zähler darf nicht negativ sein")); + } + if !(1..=18).contains(&payload.counter_padding) { + return Err(ApiError::bad_request( + "Zählerlänge muss zwischen 1 und 18 liegen", + )); + } + Ok(()) +} + +fn number_range_from_row(row: sqlx::postgres::PgRow) -> NumberRangeResponse { + NumberRangeResponse { + id: row.get("id"), + code: row.get("code"), + pattern: row.get("pattern"), + counter_value: row.get("counter_value"), + counter_padding: row.get("counter_padding"), + reset_rule: row.get("reset_rule"), + is_active: row.get("is_active"), + } +} + +async fn next_number_if_blank( + db: &PgPool, + schema_name: &str, + code: &str, + current_value: &str, +) -> Result { + if current_value.trim().is_empty() { + next_number(db, schema_name, code).await + } else { + Ok(current_value.trim().to_string()) + } +} + +fn ensure_known_number_range_code(code: &str) -> Result<(), ApiError> { + if [ + "customers", + "suppliers", + "items", + "activities", + "quotes", + "outgoing_invoices", + "incoming_invoices", + "documents", + "price_lists", + "communications", + ] + .contains(&code) + { + Ok(()) + } else { + Err(ApiError::bad_request("Unbekannter Nummernkreis")) + } +} + +fn number_range_write_permission(code: &str) -> &'static str { + match code { + "customers" => "customers.write", + "suppliers" => "suppliers.write", + "items" => "items.write", + "activities" => "activities.write", + "quotes" => "quotes.write", + "outgoing_invoices" => "outgoing_invoices.write", + "incoming_invoices" => "incoming_invoices.write", + "documents" => "documents.write", + "price_lists" => "price_lists.write", + "communications" => "communications.write", + _ => "number_ranges.write", + } +} + +async fn next_number(db: &PgPool, schema_name: &str, code: &str) -> Result { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + update {schema}.number_ranges + set counter_value = counter_value + 1, + updated_at = now() + where code = $1 and is_active = true + returning pattern, counter_value, counter_padding + "#, + schema = schema_name + ); + let row = sqlx::query(&sql) + .bind(code) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::bad_request("Nummernkreis fehlt oder ist inaktiv"))?; + Ok(format_number( + row.get("pattern"), + row.get("counter_value"), + row.get("counter_padding"), + )) +} + +fn format_number(pattern: String, counter_value: i64, counter_padding: i32) -> String { + let padded = format!( + "{:0width$}", + counter_value, + width = counter_padding.max(1) as usize + ); + let grouped = padded + .as_bytes() + .rchunks(3) + .rev() + .map(|chunk| std::str::from_utf8(chunk).unwrap_or_default()) + .collect::>() + .join("."); + pattern.replace("{counter}", &grouped) +} + +async fn load_registration_detail( + state: &AppState, + id: Uuid, +) -> Result { + let db = state.db()?; + let row = sqlx::query( + r#" + select r.id, r.organization_name_ciphertext, r.organization_name_nonce, + r.organization_name_key_id, r.email, r.status, r.organization_id, + r.requested_at, r.decided_by_user_id, r.decided_at, r.decision_note, + o.schema_name + from organization_registration_requests r + left join organizations o on o.id = r.organization_id + where r.id = $1 + "#, + ) + .bind(id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::not_found("Registrierung nicht gefunden"))?; + + Ok(OrganizationRegistrationDetail { + id: row.get("id"), + organization_name: decrypt_string( + &state.crypto, + row.get("organization_name_ciphertext"), + row.get("organization_name_nonce"), + row.get("organization_name_key_id"), + )?, + email: row.get("email"), + status: row.get("status"), + organization_id: row.get("organization_id"), + schema_name: row.get("schema_name"), + requested_at: row.get("requested_at"), + decided_at: row.get("decided_at"), + decided_by_user_id: row.get("decided_by_user_id"), + decision_note: row.get("decision_note"), + provisioning_error: None, + }) +} + +async fn load_registration_row(db: &PgPool, id: Uuid) -> Result { + sqlx::query("select * from organization_registration_requests where id = $1") + .bind(id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::not_found("Registrierung nicht gefunden")) +} + +async fn ensure_user(db: &PgPool, email: &str) -> Result { + if let Some(id) = sqlx::query_scalar::<_, Uuid>("select id from users where email = $1") + .bind(email) + .fetch_optional(db) + .await? + { + return Ok(id); + } + + let id = Uuid::new_v4(); + sqlx::query("insert into users (id, email) values ($1, $2)") + .bind(id) + .bind(email) + .execute(db) + .await?; + Ok(id) +} + +#[derive(Debug, Clone)] +struct AuthContext { + user_id: Uuid, + organization_id: Uuid, + schema_name: String, +} + +async fn create_session_token( + db: &PgPool, + user_id: Uuid, + organization_id: Option, +) -> Result { + let token = generate_token(); + let token_hash = hash_token(&token); + sqlx::query( + r#" + insert into refresh_tokens (id, user_id, organization_id, token_hash, expires_at) + values ($1, $2, $3, $4, now() + interval '30 days') + "#, + ) + .bind(Uuid::new_v4()) + .bind(user_id) + .bind(organization_id) + .bind(token_hash) + .execute(db) + .await?; + Ok(token) +} + +async fn require_auth(db: &PgPool, headers: &HeaderMap) -> Result { + let token_hash = bearer_token(headers) + .map(hash_token) + .ok_or_else(|| ApiError::unauthorized("Anmeldung erforderlich"))?; + let row = sqlx::query( + r#" + select rt.user_id, rt.organization_id, o.schema_name + from refresh_tokens rt + join organizations o on o.id = rt.organization_id + join user_organizations uo on uo.organization_id = o.id and uo.user_id = rt.user_id + where rt.token_hash = $1 + and rt.revoked_at is null + and rt.expires_at > now() + and o.status = 'active' + and uo.status = 'active' + limit 1 + "#, + ) + .bind(token_hash) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::unauthorized("Session ist ungültig oder abgelaufen"))?; + + let user_id: Uuid = row.get("user_id"); + let organization_id = row + .get::, _>("organization_id") + .ok_or_else(|| ApiError::bad_request("Keine Firma ausgewählt"))?; + let schema_name = row + .get::, _>("schema_name") + .unwrap_or_else(|| company_schema_name(organization_id)); + + Ok(AuthContext { + user_id, + organization_id, + schema_name, + }) +} + +async fn require_permission( + db: &PgPool, + headers: &HeaderMap, + permission_code: &str, +) -> Result { + let context = require_auth(db, headers).await?; + if user_has_permission(db, &context.schema_name, context.user_id, permission_code).await? { + Ok(context) + } else { + Err(ApiError::forbidden("Berechtigung fehlt")) + } +} + +async fn load_context_for_user_and_organization( + db: &PgPool, + user_id: Uuid, + organization_id: Uuid, +) -> Result { + let row = sqlx::query( + r#" + select o.id organization_id, o.schema_name + from organizations o + join user_organizations uo on uo.organization_id = o.id + where o.status = 'active' + and uo.status = 'active' + and uo.user_id = $1 + and o.id = $2 + limit 1 + "#, + ) + .bind(user_id) + .bind(organization_id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::forbidden("Kein Zugriff auf diese Firma"))?; + + Ok(AuthContext { + user_id, + organization_id: row.get("organization_id"), + schema_name: row + .get::, _>("schema_name") + .unwrap_or_else(|| company_schema_name(organization_id)), + }) +} + +async fn update_session_organization( + db: &PgPool, + headers: &HeaderMap, + organization_id: Uuid, +) -> Result<(), ApiError> { + let token_hash = bearer_token(headers) + .map(hash_token) + .ok_or_else(|| ApiError::unauthorized("Anmeldung erforderlich"))?; + sqlx::query("update refresh_tokens set organization_id = $2 where token_hash = $1") + .bind(token_hash) + .bind(organization_id) + .execute(db) + .await?; + Ok(()) +} + +fn bearer_token(headers: &HeaderMap) -> Option<&str> { + headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .filter(|value| !value.trim().is_empty()) +} + +fn emit_change(state: &AppState, title: &str) { + let _ = state.events.send(ServerMessage::RecordChanged { + record: RecordSummary { + id: Uuid::new_v4(), + title: title.to_string(), + updated_at: Utc::now(), + }, + }); +} + +async fn enqueue_initial_password_email( + crypto: &DataCrypto, + db: &PgPool, + email: &str, + password: &str, +) -> Result<(), ApiError> { + enqueue_email( + crypto, + db, + email, + "initial_password", + "Company Tool Initialpasswort", + json!({ + "email": email, + "initial_password": password + }), + ) + .await +} + +async fn enqueue_password_reset_email( + crypto: &DataCrypto, + db: &PgPool, + email: &str, + token: &str, +) -> Result<(), ApiError> { + enqueue_email( + crypto, + db, + email, + "password_reset", + "Company Tool Passwort zurücksetzen", + json!({ + "email": email, + "reset_token": token + }), + ) + .await +} + +async fn enqueue_invitation_email( + crypto: &DataCrypto, + db: &PgPool, + email: &str, + token: &str, + organization_id: Uuid, +) -> Result<(), ApiError> { + enqueue_email( + crypto, + db, + email, + "invitation", + "Company Tool Einladung", + json!({ + "email": email, + "invitation_token": token, + "organization_id": organization_id + }), + ) + .await +} + +async fn enqueue_email( + crypto: &DataCrypto, + db: &PgPool, + email: &str, + template: &str, + subject: &str, + payload_value: serde_json::Value, +) -> Result<(), ApiError> { + let payload = crypto.encrypt(&payload_value)?; + let email_id = Uuid::new_v4(); + sqlx::query( + r#" + insert into email_outbox ( + id, recipient_email, template, subject, payload_ciphertext, payload_nonce, payload_key_id + ) values ($1, $2, $3, $4, $5, $6, $7) + "#, + ) + .bind(email_id) + .bind(email) + .bind(template) + .bind(subject) + .bind(&payload.ciphertext) + .bind(&payload.nonce) + .bind(&payload.key_id) + .execute(db) + .await?; + deliver_email_if_configured(db, email_id, email, template, subject, &payload_value).await?; + Ok(()) +} + +async fn deliver_email_if_configured( + db: &PgPool, + email_id: Uuid, + email: &str, + template: &str, + subject: &str, + payload: &serde_json::Value, +) -> Result<(), ApiError> { + let transport = env::var("COMPANYTOOL_EMAIL_TRANSPORT").unwrap_or_else(|_| { + if dev_mode_enabled() { + "dev_outbox".to_string() + } else { + "outbox".to_string() + } + }); + match transport.as_str() { + "file" | "dev_file" => { + let dir = env::var("COMPANYTOOL_EMAIL_FILE_DIR") + .unwrap_or_else(|_| "storage/email-outbox".to_string()); + tokio::fs::create_dir_all(&dir).await?; + let path = PathBuf::from(dir).join(format!("{email_id}.json")); + tokio::fs::write( + path, + serde_json::to_vec_pretty(&json!({ + "id": email_id, + "recipient_email": email, + "template": template, + "subject": subject, + "payload": payload, + "created_at": Utc::now() + }))?, + ) + .await?; + sqlx::query( + "update email_outbox set status='sent', delivered_via=$2, sent_at=now() where id=$1", + ) + .bind(email_id) + .bind(transport) + .execute(db) + .await?; + } + "outbox" | "dev_outbox" => {} + other => { + sqlx::query( + "update email_outbox set status='failed', last_error=$2, attempt_count=attempt_count+1 where id=$1", + ) + .bind(email_id) + .bind(format!("Unbekannter E-Mail-Transport: {other}")) + .execute(db) + .await?; + } + } + Ok(()) +} + +async fn provision_company_schema(db: &PgPool, schema_name: &str) -> Result<(), ApiError> { + let mut tx = db.begin().await?; + provision_company_schema_tx(&mut tx, schema_name).await?; + tx.commit().await?; + Ok(()) +} + +async fn provision_company_schema_tx( + tx: &mut Transaction<'_, Postgres>, + schema_name: &str, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + sqlx::query(&format!("create schema if not exists {schema_name}")) + .execute(&mut **tx) + .await?; + + for template in [ + include_str!("../company-migrations/0001_company_base.sql"), + include_str!("../company-migrations/0002_activity_price_invoice_rules.sql"), + include_str!("../company-migrations/0003_customer_details.sql"), + include_str!("../company-migrations/0004_item_price_history.sql"), + include_str!("../company-migrations/0005_numbered_activities.sql"), + include_str!("../company-migrations/0006_update_number_range_prefixes.sql"), + include_str!("../company-migrations/0007_quotes.sql"), + include_str!("../company-migrations/0008_invoice_links.sql"), + include_str!("../company-migrations/0009_price_imports.sql"), + include_str!("../company-migrations/0010_communications_documents.sql"), + include_str!("../company-migrations/0011_user_settings.sql"), + include_str!("../company-migrations/0012_item_supplier_prices.sql"), + ] { + let sql = template + .lines() + .filter(|line| !line.trim_start().starts_with("--")) + .collect::>() + .join("\n") + .replace("{schema}", schema_name); + for statement in split_sql_statements(&sql) { + let statement = statement.trim(); + if !statement.is_empty() && !statement.starts_with("--") { + sqlx::query(statement).execute(&mut **tx).await?; + } + } + } + + seed_roles_and_permissions_tx(tx, schema_name).await?; + seed_number_ranges_tx(tx, schema_name).await?; + seed_import_mappings_tx(tx, schema_name).await?; + Ok(()) +} + +fn default_user_navigation_settings() -> UserNavigationSettings { + UserNavigationSettings { + mode: "scroll".to_string(), + } +} + +fn normalize_user_navigation_settings( + settings: UserNavigationSettings, +) -> Result { + let mode = match settings.mode.trim().to_lowercase().as_str() { + "scroll" | "compact" => "scroll".to_string(), + "groups" => "groups".to_string(), + _ => { + return Err(ApiError::bad_request("Ungültige Navigationseinstellung")); + } + }; + Ok(UserNavigationSettings { mode }) +} + +fn split_sql_statements(sql: &str) -> Vec { + let mut statements = Vec::new(); + let mut current = String::new(); + let mut in_single_quote = false; + let mut chars = sql.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\'' { + current.push(ch); + if in_single_quote && chars.peek() == Some(&'\'') { + current.push(chars.next().unwrap_or('\'')); + } else { + in_single_quote = !in_single_quote; + } + } else if ch == ';' && !in_single_quote { + statements.push(current.trim().to_string()); + current.clear(); + } else { + current.push(ch); + } + } + + if !current.trim().is_empty() { + statements.push(current.trim().to_string()); + } + + statements +} + +pub async fn sync_all_company_schemas(db: &PgPool) -> anyhow::Result<()> { + let rows = sqlx::query("select id, schema_name from organizations where status = 'active'") + .fetch_all(db) + .await?; + + for row in rows { + let organization_id: Uuid = row.get("id"); + let schema_name = row + .get::, _>("schema_name") + .unwrap_or_else(|| company_schema_name(organization_id)); + provision_company_schema(db, &schema_name) + .await + .map_err(|error| anyhow::anyhow!(error.message))?; + } + + Ok(()) +} + +async fn seed_roles_and_permissions_tx( + tx: &mut Transaction<'_, Postgres>, + schema_name: &str, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + for (code, name) in ROLE_SEEDS { + let sql = format!( + "insert into {schema}.roles (id, code, name, is_system_role) values ($1, $2, $3, true) on conflict (code) do nothing", + schema = schema_name + ); + sqlx::query(&sql) + .bind(Uuid::new_v4()) + .bind(code) + .bind(name) + .execute(&mut **tx) + .await?; + } + + for code in PERMISSION_SEEDS { + let sql = format!( + "insert into {schema}.permissions (id, code) values ($1, $2) on conflict (code) do nothing", + schema = schema_name + ); + sqlx::query(&sql) + .bind(Uuid::new_v4()) + .bind(code) + .execute(&mut **tx) + .await?; + } + + assign_role_permissions_tx(tx, schema_name).await?; + Ok(()) +} + +async fn seed_number_ranges_tx( + tx: &mut Transaction<'_, Postgres>, + schema_name: &str, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + for (code, pattern) in NUMBER_RANGE_SEEDS { + let sql = format!( + r#" + insert into {schema}.number_ranges ( + id, code, pattern, counter_value, counter_padding, reset_rule, is_active + ) values ($1, $2, $3, 0, 9, null, true) + on conflict (code) do nothing + "#, + schema = schema_name + ); + sqlx::query(&sql) + .bind(Uuid::new_v4()) + .bind(code) + .bind(pattern) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + +async fn seed_import_mappings_tx( + tx: &mut Transaction<'_, Postgres>, + schema_name: &str, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + insert into {schema}.import_mappings ( + id, code, name, delimiter, item_number_column, name_column, unit_column, + tax_rate_column, purchase_price_column, sales_price_column, is_default + ) values ( + $1, 'default_price_list_csv', 'Standard-Preislisten-CSV', ';', + 'item_number', 'name', 'unit', 'tax_rate', 'purchase_price', 'sales_price', true + ) + on conflict (code) do nothing + "#, + schema = schema_name + ); + sqlx::query(&sql) + .bind(Uuid::new_v4()) + .execute(&mut **tx) + .await?; + Ok(()) +} + +const NUMBER_RANGE_SEEDS: &[(&str, &str)] = &[ + ("items", "AR{counter}"), + ("activities", "AK{counter}"), + ("outgoing_invoices", "AR{counter}"), + ("incoming_invoices", "ER{counter}"), + ("customers", "KU{counter}"), + ("suppliers", "LI{counter}"), + ("quotes", "AN{counter}"), +]; + +const ROLE_SEEDS: &[(&str, &str)] = &[ + ("owner", "Besitzer"), + ("admin", "Admin"), + ("sales", "Vertrieb"), + ("accounting", "Buchhaltung"), + ("viewer", "Lesen"), +]; + +const PERMISSION_SEEDS: &[&str] = &[ + "users.read", + "users.invite", + "users.roles.write", + "users.disable", + "settings.read", + "settings.write", + "number_ranges.read", + "number_ranges.write", + "customers.read", + "customers.write", + "customers.delete", + "customer_discounts.write", + "suppliers.read", + "suppliers.write", + "suppliers.delete", + "supplier_cash_discounts.write", + "cash_discount_terms.read", + "cash_discount_terms.write", + "cash_discount_terms.delete", + "items.read", + "items.write", + "items.delete", + "item_prices.write", + "price_lists.import", + "price_apis.sync", + "price_rules.write", + "quotes.read", + "quotes.write", + "quotes.delete", + "quotes.convert_to_invoice", + "outgoing_invoices.read", + "outgoing_invoices.write", + "outgoing_invoices.delete", + "invoices.finalize", + "incoming_invoices.read", + "incoming_invoices.write", + "incoming_invoices.delete", + "activities.read", + "activities.write", + "activities.delete", + "communication.read", + "communication.write", + "documents.read", + "documents.write", + "reports.read", + "audit_log.read", +]; + +const ADMIN_PERMISSIONS: &[&str] = &[ + "users.read", + "users.invite", + "settings.read", + "settings.write", + "number_ranges.read", + "number_ranges.write", + "customers.read", + "customers.write", + "customers.delete", + "customer_discounts.write", + "suppliers.read", + "suppliers.write", + "suppliers.delete", + "supplier_cash_discounts.write", + "cash_discount_terms.read", + "cash_discount_terms.write", + "cash_discount_terms.delete", + "items.read", + "items.write", + "items.delete", + "item_prices.write", + "price_lists.import", + "price_apis.sync", + "price_rules.write", + "quotes.read", + "quotes.write", + "quotes.delete", + "quotes.convert_to_invoice", + "outgoing_invoices.read", + "outgoing_invoices.write", + "outgoing_invoices.delete", + "invoices.finalize", + "incoming_invoices.read", + "incoming_invoices.write", + "incoming_invoices.delete", + "activities.read", + "activities.write", + "activities.delete", + "communication.read", + "communication.write", + "documents.read", + "documents.write", + "reports.read", + "audit_log.read", +]; + +const SALES_PERMISSIONS: &[&str] = &[ + "customers.read", + "customers.write", + "customer_discounts.write", + "cash_discount_terms.read", + "items.read", + "item_prices.write", + "quotes.read", + "quotes.write", + "quotes.convert_to_invoice", + "outgoing_invoices.read", + "outgoing_invoices.write", + "invoices.finalize", + "activities.read", + "activities.write", + "communication.read", + "communication.write", + "documents.read", + "documents.write", +]; + +const ACCOUNTING_PERMISSIONS: &[&str] = &[ + "customers.read", + "suppliers.read", + "items.read", + "quotes.read", + "outgoing_invoices.read", + "outgoing_invoices.write", + "invoices.finalize", + "incoming_invoices.read", + "incoming_invoices.write", + "customer_discounts.write", + "supplier_cash_discounts.write", + "cash_discount_terms.read", + "activities.read", + "activities.write", + "communication.read", + "documents.read", + "documents.write", + "reports.read", +]; + +const VIEWER_PERMISSIONS: &[&str] = &[ + "settings.read", + "customers.read", + "suppliers.read", + "cash_discount_terms.read", + "items.read", + "quotes.read", + "outgoing_invoices.read", + "incoming_invoices.read", + "activities.read", + "communication.read", + "documents.read", + "reports.read", +]; + +async fn assign_role_permissions_tx( + tx: &mut Transaction<'_, Postgres>, + schema_name: &str, +) -> Result<(), ApiError> { + for (role, permissions) in [ + ("owner", PERMISSION_SEEDS), + ("admin", ADMIN_PERMISSIONS), + ("sales", SALES_PERMISSIONS), + ("accounting", ACCOUNTING_PERMISSIONS), + ("viewer", VIEWER_PERMISSIONS), + ] { + for permission in permissions { + let sql = format!( + r#" + insert into {schema}.role_permissions (role_id, permission_id) + select r.id, p.id + from {schema}.roles r + cross join {schema}.permissions p + where r.code = $1 and p.code = $2 + on conflict (role_id, permission_id) do nothing + "#, + schema = schema_name + ); + sqlx::query(&sql) + .bind(role) + .bind(permission) + .execute(&mut **tx) + .await?; + } + } + Ok(()) +} + +async fn assign_role( + db: &PgPool, + schema_name: &str, + user_id: Uuid, + role_code: &str, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + insert into {schema}.user_roles (user_id, role_id) + select $1, id from {schema}.roles where code = $2 + on conflict (user_id, role_id) do nothing + "#, + schema = schema_name + ); + sqlx::query(&sql) + .bind(user_id) + .bind(role_code) + .execute(db) + .await?; + Ok(()) +} + +async fn assign_role_tx( + tx: &mut Transaction<'_, Postgres>, + schema_name: &str, + user_id: Uuid, + role_code: &str, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + insert into {schema}.user_roles (user_id, role_id) + select $1, id from {schema}.roles where code = $2 + on conflict (user_id, role_id) do nothing + "#, + schema = schema_name + ); + sqlx::query(&sql) + .bind(user_id) + .bind(role_code) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn assign_all_roles_tx( + tx: &mut Transaction<'_, Postgres>, + schema_name: &str, + user_id: Uuid, +) -> Result<(), ApiError> { + for role in ["owner", "admin", "sales", "accounting", "viewer"] { + assign_role_tx(tx, schema_name, user_id, role).await?; + } + Ok(()) +} + +async fn replace_user_roles( + db: &PgPool, + schema_name: &str, + user_id: Uuid, + roles: &[String], +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + for role in roles { + ensure_role_exists(db, schema_name, role).await?; + } + + let delete_sql = format!( + "delete from {schema}.user_roles where user_id = $1", + schema = schema_name + ); + sqlx::query(&delete_sql).bind(user_id).execute(db).await?; + + for role in roles { + assign_role(db, schema_name, user_id, role).await?; + } + Ok(()) +} + +async fn ensure_role_exists( + db: &PgPool, + schema_name: &str, + role_code: &str, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + "select id from {schema}.roles where code = $1 limit 1", + schema = schema_name + ); + let exists: Option = sqlx::query_scalar(&sql) + .bind(role_code) + .fetch_optional(db) + .await?; + + if exists.is_some() { + Ok(()) + } else { + Err(ApiError::bad_request(&format!( + "Unbekannte Rolle: {role_code}" + ))) + } +} + +async fn require_owner(db: &PgPool, schema_name: &str, user_id: Uuid) -> Result<(), ApiError> { + if user_has_role(db, schema_name, user_id, "owner").await? { + Ok(()) + } else { + Err(ApiError::forbidden( + "Nur der Besitzer darf Benutzerrechte verwalten", + )) + } +} + +async fn user_has_role( + db: &PgPool, + schema_name: &str, + user_id: Uuid, + role_code: &str, +) -> Result { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + select exists( + select 1 + from {schema}.roles r + join {schema}.user_roles ur on ur.role_id = r.id + where ur.user_id = $1 and r.code = $2 + ) + "#, + schema = schema_name + ); + let has_role = sqlx::query_scalar::<_, bool>(&sql) + .bind(user_id) + .bind(role_code) + .fetch_one(db) + .await?; + Ok(has_role) +} + +async fn user_has_permission( + db: &PgPool, + schema_name: &str, + user_id: Uuid, + permission_code: &str, +) -> Result { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + select exists( + select 1 + from {schema}.user_roles ur + join {schema}.role_permissions rp on rp.role_id = ur.role_id + join {schema}.permissions p on p.id = rp.permission_id + where ur.user_id = $1 and p.code = $2 + ) + "#, + schema = schema_name + ); + let has_permission = sqlx::query_scalar::<_, bool>(&sql) + .bind(user_id) + .bind(permission_code) + .fetch_one(db) + .await?; + Ok(has_permission) +} + +fn company_schema_name(organization_id: Uuid) -> String { + format!("company_{}", organization_id.simple()) +} + +fn ensure_safe_schema_name(schema_name: &str) -> Result<(), ApiError> { + if schema_name + .chars() + .all(|char| char.is_ascii_lowercase() || char.is_ascii_digit() || char == '_') + && schema_name.starts_with("company_") + { + Ok(()) + } else { + Err(ApiError::bad_request("Ungültiger Schema-Name")) + } +} + +fn decrypt_string( + crypto: &DataCrypto, + ciphertext: Vec, + nonce: Vec, + key_id: String, +) -> Result { + Ok(crypto.decrypt(&ciphertext, &nonce, &key_id)?) +} + +fn normalize_email(email: &str) -> Result { + let email = email.trim().to_lowercase(); + if email.contains('@') && email.len() >= 5 { + Ok(email) + } else { + Err(ApiError::bad_request("Ungültige E-Mail-Adresse")) + } +} + +fn validate_new_password(password: &str, password_confirm: &str) -> Result<(), ApiError> { + if password != password_confirm { + return Err(ApiError::bad_request( + "Neue Passwörter stimmen nicht überein", + )); + } + if password.len() < 12 { + return Err(ApiError::bad_request( + "Neues Passwort muss mindestens 12 Zeichen lang sein", + )); + } + if !password.chars().any(|ch| ch.is_ascii_lowercase()) + || !password.chars().any(|ch| ch.is_ascii_uppercase()) + || !password.chars().any(|ch| ch.is_ascii_digit()) + { + return Err(ApiError::bad_request( + "Neues Passwort muss Kleinbuchstaben, Großbuchstaben und Zahlen enthalten", + )); + } + Ok(()) +} + +fn dev_mode_enabled() -> bool { + cfg!(debug_assertions) || env::var("COMPANYTOOL_DEV_MODE").as_deref() == Ok("1") +} + +fn generate_initial_password() -> String { + let mut bytes = [0_u8; 18]; + rand_core::OsRng.fill_bytes(&mut bytes); + base64_url(&bytes) +} + +fn generate_token() -> String { + let mut bytes = [0_u8; 32]; + rand_core::OsRng.fill_bytes(&mut bytes); + base64_url(&bytes) +} + +fn hash_token(token: &str) -> String { + let digest = Sha256::digest(token.as_bytes()); + base64_url(&digest) +} + +fn base64_url(bytes: &[u8]) -> String { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + URL_SAFE_NO_PAD.encode(bytes) +} + +fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Ok(Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|error| ApiError::internal(error.to_string()))? + .to_string()) +} + +fn verify_password(password: &str, hash: &str) -> Result<(), ApiError> { + let parsed_hash = + PasswordHash::new(hash).map_err(|_| ApiError::unauthorized("Login fehlgeschlagen"))?; + Argon2::default() + .verify_password(password.as_bytes(), &parsed_hash) + .map_err(|_| ApiError::unauthorized("Login fehlgeschlagen")) +} + +pub struct ApiError { + status: StatusCode, + message: String, +} + +impl ApiError { + pub(crate) fn bad_request(message: &str) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: message.to_string(), + } + } + + fn unauthorized(message: &str) -> Self { + Self { + status: StatusCode::UNAUTHORIZED, + message: message.to_string(), + } + } + + fn forbidden(message: &str) -> Self { + Self { + status: StatusCode::FORBIDDEN, + message: message.to_string(), + } + } + + fn not_found(message: &str) -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: message.to_string(), + } + } + + fn conflict(message: &str) -> Self { + Self { + status: StatusCode::CONFLICT, + message: message.to_string(), + } + } + + fn internal(message: String) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + message, + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.status, Json(json!({ "message": self.message }))).into_response() + } +} + +impl From for ApiError { + fn from(error: sqlx::Error) -> Self { + Self::internal(error.to_string()) + } +} + +impl From for ApiError { + fn from(error: anyhow::Error) -> Self { + Self::internal(error.to_string()) + } +} + +impl From for ApiError { + fn from(error: std::io::Error) -> Self { + Self::internal(error.to_string()) + } +} + +impl From for ApiError { + fn from(error: serde_json::Error) -> Self { + Self::internal(error.to_string()) + } +} diff --git a/backend/src/crypto_at_rest.rs b/backend/src/crypto_at_rest.rs new file mode 100644 index 0000000..72564dc --- /dev/null +++ b/backend/src/crypto_at_rest.rs @@ -0,0 +1,54 @@ +use base64::{engine::general_purpose::STANDARD, Engine}; +use companytool_shared_protocol::crypto::SessionKey; +use serde::{de::DeserializeOwned, Serialize}; +use tracing::warn; + +#[derive(Clone)] +pub struct DataCrypto { + key: SessionKey, +} + +pub struct EncryptedField { + pub ciphertext: Vec, + pub nonce: Vec, + pub key_id: String, +} + +impl DataCrypto { + pub fn from_env() -> Self { + let key_id = std::env::var("COMPANYTOOL_DATA_KEY_ID") + .unwrap_or_else(|_| "dev-data-key-v1".to_string()); + let key_base64 = std::env::var("COMPANYTOOL_DATA_KEY_BASE64").unwrap_or_else(|_| { + warn!("COMPANYTOOL_DATA_KEY_BASE64 fehlt; verwende nur für Entwicklung geeigneten festen Schlüssel"); + STANDARD.encode([7_u8; 32]) + }); + let key = SessionKey::from_base64(key_id, &key_base64).expect("valid data encryption key"); + + Self { key } + } + + pub fn encrypt(&self, value: &T) -> anyhow::Result { + let envelope = self.key.encrypt(value)?; + Ok(EncryptedField { + ciphertext: STANDARD.decode(envelope.ciphertext)?, + nonce: STANDARD.decode(envelope.nonce)?, + key_id: envelope.key_id, + }) + } + + pub fn decrypt( + &self, + ciphertext: &[u8], + nonce: &[u8], + key_id: &str, + ) -> anyhow::Result { + let envelope = companytool_shared_protocol::EncryptedEnvelope { + enc: "aes-256-gcm-v1".to_string(), + key_id: key_id.to_string(), + nonce: STANDARD.encode(nonce), + ciphertext: STANDARD.encode(ciphertext), + }; + + Ok(self.key.decrypt(&envelope)?) + } +} diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..9485d82 --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,472 @@ +mod api; +mod crypto_at_rest; +mod models; + +use std::{env, net::SocketAddr}; + +use anyhow::Context; +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + State, + }, + response::IntoResponse, + routing::{get, patch, post}, + Json, Router, +}; +use chrono::Utc; +use companytool_shared_protocol::{ + crypto::SessionKey, ClientMessage, HelloAckMessage, ProtocolErrorMessage, RecordSummary, + ServerMessage, WireMessage, PROTOCOL_VERSION, +}; +use crypto_at_rest::DataCrypto; +use futures_util::{SinkExt, StreamExt}; +use sqlx::{PgPool, Row}; +use tokio::sync::broadcast; +use tower_http::{cors::CorsLayer, trace::TraceLayer}; +use tracing::{info, warn}; +use uuid::Uuid; + +#[derive(Clone)] +struct AppState { + db: Option, + crypto: DataCrypto, + events: broadcast::Sender, +} + +impl AppState { + fn db(&self) -> Result<&PgPool, api::ApiError> { + self.db + .as_ref() + .ok_or_else(|| api::ApiError::bad_request("Datenbank ist im Testmodus nicht aktiv")) + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenvy::dotenv().ok(); + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let communication_test_mode = env::var("COMMUNICATION_TEST_MODE").as_deref() == Ok("1"); + let bind: SocketAddr = env::var("BACKEND_BIND") + .unwrap_or_else(|_| "127.0.0.1:8080".to_string()) + .parse() + .context("BACKEND_BIND ist keine gültige Adresse")?; + + let db = if communication_test_mode { + warn!("COMMUNICATION_TEST_MODE aktiv: backend startet ohne datenbank"); + None + } else { + let database_url = + env::var("DATABASE_URL").context("DATABASE_URL fehlt, siehe .env.example")?; + let db = PgPool::connect(&database_url).await?; + migrate(&db).await?; + Some(db) + }; + + let (events, _) = broadcast::channel(256); + let state = AppState { + db, + crypto: DataCrypto::from_env(), + events, + }; + let app = Router::new() + .route("/health", get(health)) + .route("/ws", get(ws_handler)) + .route( + "/api/v1/dev/bootstrap-local", + post(api::dev_bootstrap_local), + ) + .route( + "/api/v1/registration/organization", + post(api::register_organization), + ) + .route( + "/api/v1/admin/organization-registrations", + get(api::list_organization_registrations), + ) + .route( + "/api/v1/admin/organization-registrations/:id", + get(api::get_organization_registration), + ) + .route( + "/api/v1/admin/organization-registrations/:id/approve", + post(api::approve_organization_registration), + ) + .route( + "/api/v1/admin/organization-registrations/:id/reject", + post(api::reject_organization_registration), + ) + .route( + "/api/v1/admin/organization-registrations/:id/resend-initial-email", + post(api::resend_initial_email), + ) + .route( + "/api/v1/admin/organization-registrations/:id/retry-provisioning", + post(api::retry_provisioning), + ) + .route("/api/v1/auth/login", post(api::login)) + .route( + "/api/v1/auth/change-initial-password", + post(api::change_initial_password), + ) + .route( + "/api/v1/auth/request-password-reset", + post(api::request_password_reset), + ) + .route("/api/v1/auth/reset-password", post(api::reset_password)) + .route( + "/api/v1/auth/accept-invitation", + post(api::accept_invitation), + ) + .route("/api/v1/auth/organizations", get(api::auth_organizations)) + .route( + "/api/v1/auth/select-organization", + post(api::select_organization), + ) + .route( + "/api/v1/organizations/current/setup", + get(api::get_organization_setup).put(api::put_organization_setup), + ) + .route( + "/api/v1/users/me/settings/navigation", + get(api::get_user_navigation_settings).put(api::put_user_navigation_settings), + ) + .route("/api/v1/number-ranges", get(api::list_number_ranges)) + .route( + "/api/v1/number-ranges/:code/next", + axum::routing::post(api::generate_next_number), + ) + .route( + "/api/v1/number-ranges/:code", + axum::routing::put(api::update_number_range), + ) + .route( + "/api/v1/customers", + get(api::list_customers).post(api::create_customer), + ) + .route( + "/api/v1/customers/:customer_id", + axum::routing::put(api::update_customer).delete(api::delete_customer), + ) + .route( + "/api/v1/suppliers", + get(api::list_suppliers).post(api::create_supplier), + ) + .route( + "/api/v1/suppliers/:supplier_id", + axum::routing::put(api::update_supplier).delete(api::delete_supplier), + ) + .route("/api/v1/items", get(api::list_items).post(api::create_item)) + .route( + "/api/v1/items/:item_id/prices", + get(api::list_item_price_history), + ) + .route( + "/api/v1/items/:item_id", + axum::routing::put(api::update_item).delete(api::delete_item), + ) + .route( + "/api/v1/cash-discount-terms", + get(api::list_cash_discount_terms).post(api::create_cash_discount_term), + ) + .route( + "/api/v1/cash-discount-terms/:term_id", + axum::routing::put(api::update_cash_discount_term) + .delete(api::delete_cash_discount_term), + ) + .route( + "/api/v1/quotes", + get(api::list_quotes).post(api::create_quote), + ) + .route( + "/api/v1/quotes/:quote_id", + axum::routing::put(api::update_quote).delete(api::delete_quote), + ) + .route( + "/api/v1/quotes/:quote_id/convert-to-invoice", + post(api::convert_quote_to_outgoing_invoice), + ) + .route( + "/api/v1/outgoing-invoices", + get(api::list_outgoing_invoices).post(api::create_outgoing_invoice), + ) + .route( + "/api/v1/outgoing-invoices/:invoice_id", + axum::routing::put(api::update_outgoing_invoice).delete(api::delete_outgoing_invoice), + ) + .route( + "/api/v1/outgoing-invoices/:invoice_id/finalize", + post(api::finalize_outgoing_invoice), + ) + .route( + "/api/v1/incoming-invoices", + get(api::list_incoming_invoices).post(api::create_incoming_invoice), + ) + .route( + "/api/v1/incoming-invoices/:invoice_id", + axum::routing::put(api::update_incoming_invoice).delete(api::delete_incoming_invoice), + ) + .route( + "/api/v1/imports/price-list/preview", + post(api::preview_price_list_import), + ) + .route( + "/api/v1/imports/price-list/apply", + post(api::apply_price_list_import), + ) + .route( + "/api/v1/api-connectors", + get(api::list_api_connectors).post(api::create_api_connector), + ) + .route( + "/api/v1/api-connectors/:connector_id", + axum::routing::put(api::update_api_connector).delete(api::delete_api_connector), + ) + .route( + "/api/v1/api-connectors/:connector_id/sync", + post(api::sync_api_connector), + ) + .route( + "/api/v1/price-rules", + get(api::list_price_rules).post(api::create_price_rule), + ) + .route( + "/api/v1/price-rules/:rule_id", + axum::routing::put(api::update_price_rule).delete(api::delete_price_rule), + ) + .route( + "/api/v1/activities", + get(api::list_activities).post(api::create_activity), + ) + .route( + "/api/v1/activities/:activity_id", + axum::routing::put(api::update_activity).delete(api::delete_activity), + ) + .route( + "/api/v1/communications", + get(api::list_communications).post(api::create_communication), + ) + .route( + "/api/v1/communications/:communication_id", + axum::routing::put(api::update_communication).delete(api::delete_communication), + ) + .route( + "/api/v1/documents", + get(api::list_documents).post(api::upload_document), + ) + .route( + "/api/v1/documents/:document_id/download", + get(api::download_document), + ) + .route( + "/api/v1/documents/:document_id/audit-log", + get(api::list_document_audit_log), + ) + .route( + "/api/v1/documents/:document_id", + axum::routing::delete(api::delete_document), + ) + .route( + "/api/v1/organizations/current/invitations", + post(api::invite_user), + ) + .route("/api/v1/organizations/current/users", get(api::list_users)) + .route( + "/api/v1/organizations/current/users/:user_id/roles", + patch(api::update_user_roles), + ) + .route( + "/api/v1/organizations/current/users/:user_id/disable", + post(api::disable_user), + ) + .with_state(state) + .layer(CorsLayer::permissive()) + .layer(TraceLayer::new_for_http()); + + let listener = tokio::net::TcpListener::bind(bind).await?; + info!("backend lauscht auf http://{bind}"); + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await?; + + Ok(()) +} + +async fn health() -> Json { + Json(serde_json::json!({ "status": "ok" })) +} + +async fn ws_handler(ws: WebSocketUpgrade, State(state): State) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_socket(socket, state)) +} + +async fn handle_socket(socket: WebSocket, state: AppState) { + let (mut sender, mut receiver) = socket.split(); + let mut events = state.events.subscribe(); + let mut session_key: Option = None; + + loop { + tokio::select! { + Some(Ok(message)) = receiver.next() => { + if let Message::Text(text) = message { + if let Some(new_session_key) = handle_client_wire_message(&text, &state, &mut sender, session_key.as_ref()).await { + if let Ok(snapshot) = load_snapshot(state.db.as_ref()).await { + if send_encrypted_server_message( + &mut sender, + &new_session_key, + ServerMessage::Snapshot { records: snapshot }, + ).await.is_err() { + break; + } + } + session_key = Some(new_session_key); + } + } + } + Ok(event) = events.recv() => { + if let Some(active_session_key) = session_key.as_ref() { + if let Err(error) = send_encrypted_server_message(&mut sender, active_session_key, event).await { + warn!(%error, "server message konnte nicht gesendet werden"); + break; + } + } + } + else => break, + } + } +} + +async fn handle_client_wire_message( + text: &str, + state: &AppState, + sender: &mut futures_util::stream::SplitSink, + session_key: Option<&SessionKey>, +) -> Option { + match serde_json::from_str::(text) { + Ok(WireMessage::Hello(hello)) => { + if hello.protocol_version != PROTOCOL_VERSION { + let _ = send_wire_error(sender, "nicht unterstützte Protokollversion").await; + return None; + } + + match SessionKey::from_base64(hello.key_id.clone(), &hello.session_key) { + Ok(new_session_key) => { + let ack = WireMessage::HelloAck(HelloAckMessage { + protocol_version: PROTOCOL_VERSION, + key_id: hello.key_id, + }); + if send_wire_message(sender, ack).await.is_err() { + return None; + } + Some(new_session_key) + } + Err(error) => { + let _ = + send_wire_error(sender, &format!("ungültiger Session-Key: {error}")).await; + None + } + } + } + Ok(WireMessage::Encrypted(envelope)) => { + let Some(active_session_key) = session_key else { + let _ = send_wire_error(sender, "verschlüsselte Nachricht vor hello").await; + return None; + }; + + match active_session_key.decrypt::(&envelope) { + Ok(message) => handle_client_message(message, state).await, + Err(error) => { + let _ = send_wire_error( + sender, + &format!("Nachricht konnte nicht entschlüsselt werden: {error}"), + ) + .await; + } + } + None + } + Ok(WireMessage::HelloAck(_) | WireMessage::Error(_)) => None, + Err(error) => { + let _ = send_wire_error(sender, &format!("ungültige Wire-Nachricht: {error}")).await; + None + } + } +} + +async fn handle_client_message(message: ClientMessage, state: &AppState) { + match message { + ClientMessage::Ping => { + let _ = state.events.send(ServerMessage::Pong); + } + ClientMessage::Subscribe { topic } => { + info!(%topic, "client hat topic abonniert"); + } + } +} + +async fn send_encrypted_server_message( + sender: &mut futures_util::stream::SplitSink, + session_key: &SessionKey, + message: ServerMessage, +) -> anyhow::Result<()> { + let envelope = session_key.encrypt(&message)?; + send_wire_message(sender, WireMessage::Encrypted(envelope)).await +} + +async fn send_wire_error( + sender: &mut futures_util::stream::SplitSink, + message: &str, +) -> anyhow::Result<()> { + send_wire_message( + sender, + WireMessage::Error(ProtocolErrorMessage { + message: message.to_string(), + }), + ) + .await +} + +async fn send_wire_message( + sender: &mut futures_util::stream::SplitSink, + message: WireMessage, +) -> anyhow::Result<()> { + let text = serde_json::to_string(&message)?; + sender.send(Message::Text(text)).await?; + Ok(()) +} + +async fn migrate(db: &PgPool) -> anyhow::Result<()> { + sqlx::migrate!("./migrations").run(db).await?; + api::sync_all_company_schemas(db).await?; + Ok(()) +} + +async fn load_snapshot(db: Option<&PgPool>) -> anyhow::Result> { + let Some(db) = db else { + return Ok(vec![RecordSummary { + id: Uuid::nil(), + title: "Kommunikationstest-Datensatz".to_string(), + updated_at: Utc::now(), + }]); + }; + + let rows = sqlx::query("select id, title, updated_at from records order by updated_at desc") + .fetch_all(db) + .await?; + + Ok(rows + .into_iter() + .map(|row| RecordSummary { + id: row.get("id"), + title: row.get("title"), + updated_at: row.get("updated_at"), + }) + .collect()) +} + +async fn shutdown_signal() { + let _ = tokio::signal::ctrl_c().await; +} diff --git a/backend/src/models.rs b/backend/src/models.rs new file mode 100644 index 0000000..ead542b --- /dev/null +++ b/backend/src/models.rs @@ -0,0 +1,210 @@ +#![allow(dead_code)] + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "text", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum OrganizationStatus { + PendingApproval, + Approved, + Active, + Rejected, + Suspended, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "text", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum UserOrganizationStatus { + PendingInvitation, + Active, + Disabled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "text", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum RegistrationStatus { + PendingApproval, + Approved, + Active, + Rejected, + Suspended, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "text", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum InvitationStatus { + Pending, + Accepted, + Expired, + Revoked, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "text", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum EmailOutboxStatus { + Pending, + Sending, + Sent, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct User { + pub id: Uuid, + pub email: String, + pub display_name_ciphertext: Option>, + pub display_name_nonce: Option>, + pub display_name_key_id: Option, + pub password_hash: Option, + pub is_active: bool, + pub must_change_password: bool, + pub initial_password_expires_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub last_login_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Organization { + pub id: Uuid, + pub display_name_ciphertext: Option>, + pub display_name_nonce: Option>, + pub display_name_key_id: Option, + pub schema_name: Option, + pub status: OrganizationStatus, + pub registration_email: String, + pub setup_completed_at: Option>, + pub approved_by_user_id: Option, + pub approved_at: Option>, + pub rejected_by_user_id: Option, + pub rejected_at: Option>, + pub rejection_reason: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserOrganization { + pub user_id: Uuid, + pub organization_id: Uuid, + pub status: UserOrganizationStatus, + pub invited_by_user_id: Option, + pub invited_at: Option>, + pub accepted_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct AuthIdentity { + pub id: Uuid, + pub user_id: Uuid, + pub provider: String, + pub provider_subject: String, + pub email_at_provider: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct RefreshToken { + pub id: Uuid, + pub user_id: Uuid, + pub organization_id: Option, + pub token_hash: String, + pub expires_at: DateTime, + pub revoked_at: Option>, + pub revoked_reason: Option, + pub user_agent: Option, + pub created_ip: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct SocketToken { + pub id: Uuid, + pub user_id: Uuid, + pub organization_id: Uuid, + pub token_hash: String, + pub expires_at: DateTime, + pub used_at: Option>, + pub revoked_at: Option>, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct SessionKeyRecord { + pub id: Uuid, + pub user_id: Uuid, + pub organization_id: Uuid, + pub key_id: String, + pub wrapped_key: Option>, + pub algorithm: String, + pub created_at: DateTime, + pub expires_at: DateTime, + pub revoked_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct IdempotencyKey { + pub id: Uuid, + pub user_id: Uuid, + pub organization_id: Option, + pub key: String, + pub request_hash: String, + pub response_status: Option, + pub response_body_json: Option, + pub expires_at: DateTime, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct OrganizationRegistrationRequest { + pub id: Uuid, + pub organization_name_ciphertext: Vec, + pub organization_name_nonce: Vec, + pub organization_name_key_id: String, + pub email: String, + pub status: RegistrationStatus, + pub organization_id: Option, + pub requested_at: DateTime, + pub decided_by_user_id: Option, + pub decided_at: Option>, + pub decision_note: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserInvitation { + pub id: Uuid, + pub organization_id: Uuid, + pub email: String, + pub invited_by_user_id: Uuid, + pub status: InvitationStatus, + pub expires_at: DateTime, + pub accepted_at: Option>, + pub created_user_id: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct EmailOutboxItem { + pub id: Uuid, + pub recipient_email: String, + pub template: String, + pub payload_ciphertext: Vec, + pub payload_nonce: Vec, + pub payload_key_id: String, + pub status: EmailOutboxStatus, + pub attempt_count: i32, + pub last_error: Option, + pub send_after: DateTime, + pub sent_at: Option>, + pub created_at: DateTime, +} diff --git a/deploy/nginx-companytool.conf b/deploy/nginx-companytool.conf new file mode 100644 index 0000000..e411674 --- /dev/null +++ b/deploy/nginx-companytool.conf @@ -0,0 +1,32 @@ +server { + listen 443 ssl http2; + server_name companytool.example.com; + + ssl_certificate /etc/letsencrypt/live/companytool.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/companytool.example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } + + location /ws { + proxy_pass http://127.0.0.1:8080/ws; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } +} + +server { + listen 80; + server_name companytool.example.com; + return 301 https://$host$request_uri; +} diff --git a/desktop-client/Cargo.toml b/desktop-client/Cargo.toml new file mode 100644 index 0000000..201992d --- /dev/null +++ b/desktop-client/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "companytool-desktop-client" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow = "1" +companytool-shared-protocol = { path = "../shared-protocol" } +eframe = "0.29" +egui = "0.29" +futures-util = "0.3" +image = { version = "0.25", default-features = false, features = ["png"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "sync"] } +tokio-tungstenite = "0.24" +toml = "0.8" +url = "2" diff --git a/desktop-client/companytool-client.toml b/desktop-client/companytool-client.toml new file mode 100644 index 0000000..aca7c6b --- /dev/null +++ b/desktop-client/companytool-client.toml @@ -0,0 +1,3 @@ +[server] +api_base_url = "http://localhost:8080" +ws_url = "ws://localhost:8080/ws" diff --git a/desktop-client/src/main.rs b/desktop-client/src/main.rs new file mode 100644 index 0000000..2dd48e0 --- /dev/null +++ b/desktop-client/src/main.rs @@ -0,0 +1,6792 @@ +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::mpsc::{self, Receiver}, + time::Duration, +}; + +use companytool_shared_protocol::{ + crypto::SessionKey, ClientMessage, HelloMessage, RecordSummary, ServerMessage, WireMessage, + PROTOCOL_VERSION, +}; +use eframe::egui; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; + +const DEFAULT_CONFIG_PATH: &str = "desktop-client/companytool-client.toml"; +const DEFAULT_API_BASE_URL: &str = "http://localhost:8080"; +const DEFAULT_WS_URL: &str = "ws://localhost:8080/ws"; +const LOGO_BYTES: &[u8] = include_bytes!("../../images/icons/companytool-logo.png"); + +fn main() -> eframe::Result<()> { + let args = std::env::args().collect::>(); + let config = + ClientConfig::load(resolve_config_path(&args).as_deref()).unwrap_or_else(|error| { + eprintln!("client config konnte nicht geladen werden: {error}"); + eprintln!("verwende standardkonfiguration"); + ClientConfig::default() + }); + let api_base_url = resolve_api_base_url(&args, &config); + let ws_url = resolve_ws_url(&args, &config); + + if args.iter().any(|arg| arg == "--communication-test") { + match run_headless_communication_test(&ws_url) { + Ok(()) => { + println!("native client communication test ok"); + return Ok(()); + } + Err(error) => { + eprintln!("native client communication test failed: {error}"); + std::process::exit(1); + } + } + } + + if args.iter().any(|arg| arg == "--registration-test") { + match run_headless_registration_test(&args, &api_base_url) { + Ok(()) => { + println!("native client registration test ok"); + return Ok(()); + } + Err(error) => { + eprintln!("native client registration test failed: {error}"); + std::process::exit(1); + } + } + } + + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([1280.0, 720.0]) + .with_min_inner_size([1100.0, 650.0]) + .with_icon(eframe::icon_data::from_png_bytes(LOGO_BYTES).unwrap_or_default()), + ..Default::default() + }; + eframe::run_native( + "Company Tool", + options, + Box::new(|cc| { + Ok(Box::new(CompanyToolApp::new( + &api_base_url, + &ws_url, + &cc.egui_ctx, + ))) + }), + ) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ClientConfig { + server: ServerConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ServerConfig { + #[serde(default = "default_api_base_url")] + api_base_url: String, + #[serde(default = "default_ws_url")] + ws_url: String, +} + +impl Default for ClientConfig { + fn default() -> Self { + Self { + server: ServerConfig { + api_base_url: DEFAULT_API_BASE_URL.to_string(), + ws_url: DEFAULT_WS_URL.to_string(), + }, + } + } +} + +fn default_api_base_url() -> String { + DEFAULT_API_BASE_URL.to_string() +} + +fn default_ws_url() -> String { + DEFAULT_WS_URL.to_string() +} + +impl ClientConfig { + fn load(path: Option<&Path>) -> anyhow::Result { + let Some(path) = path else { + return Ok(Self::default()); + }; + + if !path.exists() { + return Ok(Self::default()); + } + + let content = std::fs::read_to_string(path)?; + let config = toml::from_str(&content)?; + Ok(config) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_config_uses_defaults() { + let config = ClientConfig::load(Some(Path::new("/tmp/companytool-missing-config.toml"))) + .expect("missing config should use defaults"); + + assert_eq!(config.server.api_base_url, DEFAULT_API_BASE_URL); + assert_eq!(config.server.ws_url, DEFAULT_WS_URL); + } + + #[test] + fn config_file_overrides_server_urls() { + let path = std::env::temp_dir().join(format!( + "companytool-client-test-{}.toml", + std::process::id() + )); + std::fs::write( + &path, + r#" +[server] +api_base_url = "http://127.0.0.1:18087" +ws_url = "ws://127.0.0.1:18087/ws" +"#, + ) + .expect("write test config"); + + let config = ClientConfig::load(Some(&path)).expect("config should load"); + let _ = std::fs::remove_file(&path); + + assert_eq!(config.server.api_base_url, "http://127.0.0.1:18087"); + assert_eq!(config.server.ws_url, "ws://127.0.0.1:18087/ws"); + } + + #[test] + fn command_line_urls_override_config() { + let config = ClientConfig::default(); + let args = vec![ + "companytool-desktop-client".to_string(), + "--api-url".to_string(), + "http://example.test:8081".to_string(), + "--ws-url".to_string(), + "ws://example.test:8081/ws".to_string(), + ]; + + assert_eq!( + resolve_api_base_url(&args, &config), + "http://example.test:8081" + ); + assert_eq!(resolve_ws_url(&args, &config), "ws://example.test:8081/ws"); + } +} + +fn resolve_config_path(args: &[String]) -> Option { + if let Some(path) = args + .iter() + .position(|arg| arg == "--config") + .and_then(|index| args.get(index + 1)) + { + return Some(PathBuf::from(path)); + } + + if let Ok(path) = std::env::var("COMPANYTOOL_CLIENT_CONFIG") { + return Some(PathBuf::from(path)); + } + + Some(PathBuf::from(DEFAULT_CONFIG_PATH)) +} + +fn resolve_ws_url(args: &[String], config: &ClientConfig) -> String { + args.iter() + .position(|arg| arg == "--ws-url") + .and_then(|index| args.get(index + 1)) + .cloned() + .or_else(|| std::env::var("COMPANYTOOL_WS_URL").ok()) + .unwrap_or_else(|| config.server.ws_url.clone()) +} + +fn resolve_api_base_url(args: &[String], config: &ClientConfig) -> String { + args.iter() + .position(|arg| arg == "--api-url") + .and_then(|index| args.get(index + 1)) + .cloned() + .or_else(|| std::env::var("COMPANYTOOL_API_BASE_URL").ok()) + .unwrap_or_else(|| config.server.api_base_url.clone()) +} + +fn arg_value(args: &[String], name: &str) -> Option { + args.iter() + .position(|arg| arg == name) + .and_then(|index| args.get(index + 1)) + .cloned() +} + +fn run_headless_registration_test(args: &[String], api_base_url: &str) -> anyhow::Result<()> { + let organization_name = arg_value(args, "--organization-name") + .unwrap_or_else(|| "Native Client Test GmbH".to_string()); + let email = arg_value(args, "--email").unwrap_or_else(|| { + format!( + "native-client-test-{}@example.test", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() + ) + }); + + let runtime = tokio::runtime::Runtime::new()?; + let response = runtime + .block_on(register_organization( + api_base_url, + organization_name, + email.clone(), + true, + )) + .map_err(anyhow::Error::msg)?; + + anyhow::ensure!( + !response.id.trim().is_empty(), + "registrierung lieferte keine id" + ); + println!( + "registration id={} status={} email={}", + response.id, response.status, email + ); + Ok(()) +} + +fn run_headless_communication_test(ws_url: &str) -> anyhow::Result<()> { + let receiver = spawn_socket_client(ws_url); + let mut saw_snapshot = false; + let mut saw_pong = false; + let deadline = std::time::Instant::now() + Duration::from_secs(5); + + while std::time::Instant::now() < deadline { + match receiver.recv_timeout(Duration::from_millis(250)) { + Ok(ServerMessage::Snapshot { records }) => { + anyhow::ensure!(!records.is_empty(), "snapshot hatte keine datensaetze"); + saw_snapshot = true; + } + Ok(ServerMessage::Pong) => { + saw_pong = true; + } + Ok(ServerMessage::Error { message }) => { + anyhow::bail!(message); + } + Ok(ServerMessage::RecordChanged { .. }) => {} + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(error) => anyhow::bail!(error), + } + + if saw_snapshot && saw_pong { + return Ok(()); + } + } + + anyhow::bail!("timeout: snapshot={saw_snapshot}, pong={saw_pong}") +} + +fn spawn_socket_client(ws_url: &str) -> Receiver { + let (sender, receiver) = mpsc::channel(); + let url = ws_url.to_string(); + + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async move { + match tokio_tungstenite::connect_async(&url).await { + Ok((stream, _)) => { + let (mut write, mut read) = stream.split(); + let session_key = SessionKey::generate(); + let hello = WireMessage::Hello(HelloMessage { + protocol_version: PROTOCOL_VERSION, + key_id: session_key.key_id().to_string(), + session_key: session_key.to_base64(), + }); + + if let Ok(text) = serde_json::to_string(&hello) { + let _ = write + .send(tokio_tungstenite::tungstenite::Message::Text(text)) + .await; + } + + while let Some(Ok(message)) = read.next().await { + if let Ok(text) = message.to_text() { + match serde_json::from_str::(text) { + Ok(WireMessage::HelloAck(_)) => { + send_encrypted_client_message( + &mut write, + &session_key, + ClientMessage::Subscribe { + topic: "records".to_string(), + }, + ) + .await; + send_encrypted_client_message( + &mut write, + &session_key, + ClientMessage::Ping, + ) + .await; + } + Ok(WireMessage::Encrypted(envelope)) => { + if let Ok(server_message) = + session_key.decrypt::(&envelope) + { + let _ = sender.send(server_message); + } + } + Ok(WireMessage::Error(error)) => { + let _ = sender.send(ServerMessage::Error { + message: error.message, + }); + } + Ok(WireMessage::Hello(_)) | Err(_) => {} + } + } + } + } + Err(error) => { + let _ = sender.send(ServerMessage::Error { + message: format!("Verbindung fehlgeschlagen: {error}"), + }); + } + } + }); + }); + + receiver +} + +async fn send_encrypted_client_message( + write: &mut futures_util::stream::SplitSink< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + tokio_tungstenite::tungstenite::Message, + >, + session_key: &SessionKey, + message: ClientMessage, +) { + if let Ok(envelope) = session_key.encrypt(&message) { + if let Ok(text) = serde_json::to_string(&WireMessage::Encrypted(envelope)) { + let _ = write + .send(tokio_tungstenite::tungstenite::Message::Text(text)) + .await; + } + } +} + +fn apply_app_style(ctx: &egui::Context) { + let mut style = (*ctx.style()).clone(); + style.visuals = egui::Visuals::light(); + style.visuals.window_fill = background_color(); + style.visuals.panel_fill = background_color(); + style.visuals.extreme_bg_color = egui::Color32::from_rgb(255, 255, 255); + style.visuals.window_stroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(198, 216, 218)); + style.visuals.widgets.noninteractive.bg_fill = egui::Color32::from_rgb(238, 248, 246); + style.visuals.widgets.noninteractive.fg_stroke = + egui::Stroke::new(1.0, egui::Color32::from_rgb(23, 32, 38)); + style.visuals.override_text_color = None; + style.visuals.widgets.inactive.rounding = 6.0.into(); + style.visuals.widgets.inactive.bg_fill = accent_soft_color(); + style.visuals.widgets.inactive.fg_stroke = + egui::Stroke::new(1.0, egui::Color32::from_rgb(23, 32, 38)); + style.visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, accent_soft_color()); + style.visuals.widgets.hovered.rounding = 6.0.into(); + style.visuals.widgets.hovered.bg_fill = egui::Color32::from_rgb(199, 235, 230); + style.visuals.widgets.hovered.fg_stroke = + egui::Stroke::new(1.0, egui::Color32::from_rgb(23, 32, 38)); + style.visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, accent_color()); + style.visuals.widgets.active.rounding = 6.0.into(); + style.visuals.widgets.active.bg_fill = accent_color(); + style.visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE); + style.visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, accent_color()); + ctx.set_style(style); +} + +fn background_color() -> egui::Color32 { + egui::Color32::from_rgb(246, 250, 249) +} + +fn surface_color() -> egui::Color32 { + egui::Color32::from_rgb(255, 255, 255) +} + +fn accent_color() -> egui::Color32 { + egui::Color32::from_rgb(17, 138, 127) +} + +fn accent_dark_color() -> egui::Color32 { + egui::Color32::from_rgb(16, 84, 92) +} + +fn accent_soft_color() -> egui::Color32 { + egui::Color32::from_rgb(222, 244, 240) +} + +fn muted_text_color() -> egui::Color32 { + egui::Color32::from_rgb(90, 106, 114) +} + +fn load_logo_texture(ctx: &egui::Context) -> Option { + let image = image::load_from_memory(LOGO_BYTES).ok()?.to_rgba8(); + let size = [image.width() as usize, image.height() as usize]; + let pixels = image.into_raw(); + Some(ctx.load_texture( + "companytool-logo", + egui::ColorImage::from_rgba_unmultiplied(size, &pixels), + egui::TextureOptions::LINEAR, + )) +} + +fn brand(ui: &mut egui::Ui, logo: Option<&egui::TextureHandle>) { + ui.horizontal(|ui| { + if let Some(logo) = logo { + ui.add(egui::Image::new((logo.id(), egui::vec2(34.0, 34.0))).rounding(6.0)); + } else { + let (rect, _) = ui.allocate_exact_size(egui::vec2(34.0, 34.0), egui::Sense::hover()); + ui.painter().rect_filled(rect, 6.0, accent_color()); + } + ui.vertical(|ui| { + ui.label(egui::RichText::new("Company Tool").strong()); + ui.label( + egui::RichText::new("Organisationen") + .color(muted_text_color()) + .size(13.0), + ); + }); + }); +} + +fn nav_group_label(ui: &mut egui::Ui, label: &str) { + ui.add_space(12.0); + ui.label( + egui::RichText::new(label) + .color(muted_text_color()) + .size(11.0) + .strong(), + ); +} + +fn page_header(ui: &mut egui::Ui, title: &str, description: &str) { + ui.heading(egui::RichText::new(title).size(28.0).strong()); + ui.add_space(4.0); + ui.label(egui::RichText::new(description).color(muted_text_color())); + ui.add_space(18.0); +} + +fn panel(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui)) { + egui::Frame::none() + .fill(surface_color()) + .stroke(egui::Stroke::new( + 1.0, + egui::Color32::from_rgb(217, 225, 228), + )) + .rounding(8.0) + .inner_margin(egui::Margin::same(18.0)) + .show(ui, |ui| { + add_contents(ui); + }); + ui.add_space(18.0); +} + +fn window_minimize_control(ui: &mut egui::Ui) -> bool { + let mut clicked = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add_sized([34.0, 26.0], egui::Button::new("_")).clicked() { + clicked = true; + } + }); + clicked +} + +fn sidebar_action_button(label: &str) -> egui::Button<'static> { + egui::Button::new( + egui::RichText::new(label.to_string()) + .color(egui::Color32::WHITE) + .strong(), + ) + .fill(accent_dark_color()) + .stroke(egui::Stroke::NONE) + .rounding(6.0) +} + +fn primary_action_button(label: &str) -> egui::Button<'static> { + egui::Button::new( + egui::RichText::new(label.to_string()) + .color(egui::Color32::WHITE) + .strong(), + ) + .fill(accent_color()) + .stroke(egui::Stroke::NONE) + .rounding(6.0) +} + +fn form_row(ui: &mut egui::Ui, label: &str, add_field: impl FnOnce(&mut egui::Ui)) { + ui.vertical(|ui| { + ui.label( + egui::RichText::new(label) + .color(egui::Color32::from_rgb(67, 82, 88)) + .strong() + .size(14.0), + ); + ui.add_space(3.0); + ui.scope(|ui| { + ui.set_width(ui.available_width().min(680.0)); + add_field(ui); + }); + }); + ui.add_space(10.0); +} + +macro_rules! master_detail { + ($ui:expr, $left:block, $right:block $(,)?) => {{ + let _ = $ui; + $left + $right + }}; +} + +fn cash_discount_combo( + ui: &mut egui::Ui, + id: &str, + selected_id: &mut Option, + terms: &[CashDiscountTerm], +) { + let selected_text = selected_id + .as_ref() + .and_then(|id| terms.iter().find(|term| &term.id == id)) + .map(|term| format!("{} - {}", term.code, term.name)) + .unwrap_or_else(|| "Keine".to_string()); + egui::ComboBox::from_id_salt(id) + .selected_text(selected_text) + .show_ui(ui, |ui| { + ui.selectable_value(selected_id, None, "Keine"); + for term in terms.iter().filter(|term| term.is_active) { + ui.selectable_value( + selected_id, + Some(term.id.clone()), + format!("{} - {}", term.code, term.name), + ); + } + }); +} + +fn matches_number_or_name(number: &str, name: &str, query: &str) -> bool { + let query = query.trim().to_lowercase(); + query.is_empty() + || number.to_lowercase().contains(&query) + || name.to_lowercase().contains(&query) +} + +fn number_or_pending(number: &str) -> &str { + if number.trim().is_empty() { + "wird automatisch vergeben" + } else { + number + } +} + +fn customer_combo( + ui: &mut egui::Ui, + selected_id: &mut String, + customers: &[Customer], + search: &mut String, +) { + let selected_text = customers + .iter() + .find(|customer| customer.id == *selected_id) + .map(|customer| format!("{} - {}", customer.customer_number, customer.name)) + .unwrap_or_else(|| "Bitte wählen".to_string()); + egui::ComboBox::from_id_salt("quote_customer") + .selected_text(selected_text) + .show_ui(ui, |ui| { + ui.text_edit_singleline(search); + ui.separator(); + ui.selectable_value(selected_id, String::new(), "Bitte wählen"); + for customer in customers + .iter() + .filter(|customer| customer.status == "active") + .filter(|customer| { + matches_number_or_name(&customer.customer_number, &customer.name, search) + }) + .take(20) + { + ui.selectable_value( + selected_id, + customer.id.clone(), + format!("{} - {}", customer.customer_number, customer.name), + ); + } + }); +} + +fn item_combo(ui: &mut egui::Ui, selected_id: &mut String, items: &[Item], search: &mut String) { + let selected_text = items + .iter() + .find(|item| item.id == *selected_id) + .map(|item| format!("{} - {}", item.item_number, item.name)) + .unwrap_or_else(|| "Bitte wählen".to_string()); + egui::ComboBox::from_id_salt("quote_item") + .selected_text(selected_text) + .show_ui(ui, |ui| { + ui.text_edit_singleline(search); + ui.separator(); + ui.selectable_value(selected_id, String::new(), "Bitte wählen"); + for item in items + .iter() + .filter(|item| item.status == "active") + .filter(|item| matches_number_or_name(&item.item_number, &item.name, search)) + .take(20) + { + ui.selectable_value( + selected_id, + item.id.clone(), + format!("{} - {}", item.item_number, item.name), + ); + } + }); +} + +fn supplier_combo( + ui: &mut egui::Ui, + selected_id: &mut String, + suppliers: &[Supplier], + search: &mut String, +) { + let selected_text = suppliers + .iter() + .find(|supplier| supplier.id == *selected_id) + .map(|supplier| format!("{} - {}", supplier.supplier_number, supplier.name)) + .unwrap_or_else(|| "Bitte wählen".to_string()); + egui::ComboBox::from_id_salt("invoice_supplier") + .selected_text(selected_text) + .show_ui(ui, |ui| { + ui.text_edit_singleline(search); + ui.separator(); + ui.selectable_value(selected_id, String::new(), "Bitte wählen"); + for supplier in suppliers + .iter() + .filter(|supplier| supplier.status == "active") + .filter(|supplier| { + matches_number_or_name(&supplier.supplier_number, &supplier.name, search) + }) + .take(20) + { + ui.selectable_value( + selected_id, + supplier.id.clone(), + format!("{} - {}", supplier.supplier_number, supplier.name), + ); + } + }); +} + +fn apply_quote_item_defaults(line: &mut QuoteItemForm, items: &[Item]) { + if let Some(item) = items.iter().find(|item| item.id == line.item_id) { + line.description = item.name.clone(); + line.unit_price = item + .default_sales_price + .clone() + .unwrap_or_else(|| "0".to_string()); + line.original_unit_price = item.default_sales_price.clone(); + line.tax_rate = item.tax_rate.clone(); + } +} + +fn invoice_items_editor( + ui: &mut egui::Ui, + lines: &mut Vec, + items: &[Item], + item_search: &mut String, +) { + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.heading("Positionen"); + if ui.button("Position hinzufügen").clicked() { + lines.push(OutgoingInvoiceItemForm::default()); + } + }); + let mut remove_index = None; + let can_remove = lines.len() > 1; + for (index, line) in lines.iter_mut().enumerate() { + ui.separator(); + ui.label(format!("Position {}", index + 1)); + form_row(ui, "Artikel", |ui| { + item_combo(ui, &mut line.item_id, items, item_search) + }); + if ui.button("Artikelwerte übernehmen").clicked() { + if let Some(item) = items.iter().find(|item| item.id == line.item_id) { + line.description = item.name.clone(); + line.unit_price = item + .default_sales_price + .clone() + .unwrap_or_else(|| "0".to_string()); + line.original_unit_price = item.default_sales_price.clone(); + line.tax_rate = item.tax_rate.clone(); + } + } + form_row(ui, "Beschreibung", |ui| { + ui.text_edit_singleline(&mut line.description); + }); + egui::Grid::new(format!("outgoing_invoice_item_fields_{index}")) + .num_columns(2) + .spacing([14.0, 8.0]) + .show(ui, |ui| { + ui.label("Menge"); + ui.text_edit_singleline(&mut line.quantity); + ui.end_row(); + ui.label("Preis"); + ui.text_edit_singleline(&mut line.unit_price); + ui.end_row(); + ui.label("Rabatt %"); + ui.text_edit_singleline(&mut line.discount_percent); + ui.end_row(); + ui.label("Steuer %"); + ui.text_edit_singleline(&mut line.tax_rate); + ui.end_row(); + }); + if can_remove && ui.button("Entfernen").clicked() { + remove_index = Some(index); + } + } + if let Some(index) = remove_index { + lines.remove(index); + } +} + +fn incoming_invoice_items_editor( + ui: &mut egui::Ui, + lines: &mut Vec, + items: &[Item], + item_search: &mut String, +) { + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.heading("Positionen"); + if ui.button("Position hinzufügen").clicked() { + lines.push(IncomingInvoiceItemForm::default()); + } + }); + let mut remove_index = None; + let can_remove = lines.len() > 1; + for (index, line) in lines.iter_mut().enumerate() { + ui.separator(); + ui.label(format!("Position {}", index + 1)); + let mut selected = line.item_id.clone().unwrap_or_default(); + form_row(ui, "Artikel", |ui| { + item_combo(ui, &mut selected, items, item_search) + }); + line.item_id = if selected.is_empty() { + None + } else { + Some(selected) + }; + form_row(ui, "Beschreibung", |ui| { + ui.text_edit_singleline(&mut line.description); + }); + egui::Grid::new(format!("incoming_invoice_item_fields_{index}")) + .num_columns(2) + .spacing([14.0, 8.0]) + .show(ui, |ui| { + ui.label("Menge"); + ui.text_edit_singleline(&mut line.quantity); + ui.end_row(); + ui.label("Preis"); + ui.text_edit_singleline(&mut line.unit_price); + ui.end_row(); + ui.label("Steuer %"); + ui.text_edit_singleline(&mut line.tax_rate); + ui.end_row(); + }); + if can_remove && ui.button("Entfernen").clicked() { + remove_index = Some(index); + } + } + if let Some(index) = remove_index { + lines.remove(index); + } +} + +fn number_range_label(code: &str) -> &'static str { + match code { + "items" => "Artikel", + "activities" => "Aktivitäten", + "outgoing_invoices" => "Rechnungen", + "incoming_invoices" => "Eingangsrechnungen", + "customers" => "Kunden", + "suppliers" => "Lieferanten", + "quotes" => "Angebote", + _ => "Nummernkreis", + } +} + +fn role_checkboxes(ui: &mut egui::Ui, roles: &mut Vec) { + ui.vertical(|ui| { + for (code, label) in available_roles() { + let mut enabled = roles.iter().any(|role| role == code); + if ui.checkbox(&mut enabled, label).changed() { + if enabled { + roles.push(code.to_string()); + } else { + roles.retain(|role| role != code); + } + } + } + }); +} + +fn available_roles() -> [(&'static str, &'static str); 5] { + [ + ("owner", "Besitzer"), + ("admin", "Admin"), + ("sales", "Vertrieb"), + ("accounting", "Buchhaltung"), + ("viewer", "Lesen"), + ] +} + +fn normalized_roles(roles: &[String]) -> Vec { + let mut normalized = roles + .iter() + .filter(|role| available_roles().iter().any(|(code, _)| code == role)) + .cloned() + .collect::>(); + normalized.sort(); + normalized.dedup(); + if normalized.is_empty() { + normalized.push("viewer".to_string()); + } + normalized +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum NavigationMode { + Scroll, + Groups, +} + +impl Default for NavigationMode { + fn default() -> Self { + Self::Scroll + } +} + +impl NavigationMode { + fn label(self) -> &'static str { + match self { + Self::Scroll => "Scrollbar", + Self::Groups => "Gruppen einklappen", + } + } + + fn button_height(self) -> f32 { + 36.0 + } +} + +struct CompanyToolApp { + api_base_url: String, + ws_url: String, + receiver: Receiver, + logo_texture: Option, + login_sender: mpsc::Sender, + login_receiver: Receiver, + registration_sender: mpsc::Sender, + registration_receiver: Receiver, + dev_bootstrap_sender: mpsc::Sender, + dev_bootstrap_receiver: Receiver, + user_admin_sender: mpsc::Sender, + user_admin_receiver: Receiver, + admin_sender: mpsc::Sender, + admin_receiver: Receiver, + records: Vec, + status: String, + live_revision: u64, + live_update_status: String, + navigation_mode: NavigationMode, + navigation_settings_status: String, + minimized_windows: HashSet<&'static str>, + view: AppView, + authenticated_email: Option, + access_token: Option, + selected_organization_id: Option, + login_email: String, + login_password: String, + login_status: String, + login_pending: bool, + dev_organization_name: String, + dev_email: String, + dev_status: String, + dev_pending: bool, + dev_credentials: Option, + organization_name: String, + registration_email: String, + accept_terms: bool, + registration_status: String, + registration_pending: bool, + users: Vec, + users_status: String, + users_pending: bool, + saving_user_id: Option, + invite_email: String, + invite_roles: Vec, + users_window_open: bool, + organization_setup: OrganizationSetupForm, + organization_setup_status: String, + organization_setup_pending: bool, + organization_setup_window_open: bool, + number_ranges: Vec, + number_range_form: NumberRangeForm, + selected_number_range_code: Option, + number_ranges_status: String, + number_ranges_window_open: bool, + registrations: Vec, + registrations_status: String, + registrations_pending: bool, + admin_registrations_window_open: bool, + customers: Vec, + customer_form: CustomerForm, + selected_customer_id: Option, + customers_status: String, + customers_pending: bool, + customers_window_open: bool, + suppliers: Vec, + supplier_form: SupplierForm, + selected_supplier_id: Option, + suppliers_status: String, + suppliers_window_open: bool, + cash_discount_terms: Vec, + cash_discount_term_form: CashDiscountTermForm, + selected_cash_discount_term_id: Option, + cash_discount_terms_status: String, + cash_discount_terms_window_open: bool, + items: Vec, + item_form: ItemForm, + selected_item_id: Option, + item_price_history: Vec, + items_status: String, + items_window_open: bool, + quotes: Vec, + quote_form: QuoteForm, + selected_quote_id: Option, + quotes_status: String, + quotes_window_open: bool, + outgoing_invoices: Vec, + outgoing_invoice_form: OutgoingInvoiceForm, + selected_outgoing_invoice_id: Option, + outgoing_invoices_status: String, + outgoing_invoices_window_open: bool, + incoming_invoices: Vec, + incoming_invoice_form: IncomingInvoiceForm, + selected_incoming_invoice_id: Option, + incoming_invoices_status: String, + incoming_invoices_window_open: bool, + activities: Vec, + activity_form: ActivityForm, + selected_activity_id: Option, + activities_status: String, + activities_window_open: bool, + price_import_form: PriceListImportForm, + price_import_preview: Option, + price_import_status: String, + price_import_window_open: bool, + api_connectors: Vec, + api_connector_form: ApiConnectorForm, + selected_api_connector_id: Option, + api_connectors_status: String, + api_connectors_window_open: bool, + price_rules: Vec, + price_rule_form: PriceRuleForm, + selected_price_rule_id: Option, + price_rules_status: String, + price_rules_window_open: bool, + customer_lookup_search: String, + supplier_lookup_search: String, + item_lookup_search: String, + customer_list_search: String, + supplier_list_search: String, + item_list_search: String, + activity_list_search: String, + quote_list_search: String, + outgoing_invoice_list_search: String, + incoming_invoice_list_search: String, +} + +impl CompanyToolApp { + fn new(api_base_url: &str, ws_url: &str, ctx: &egui::Context) -> Self { + let (login_sender, login_receiver) = mpsc::channel(); + let (registration_sender, registration_receiver) = mpsc::channel(); + let (dev_bootstrap_sender, dev_bootstrap_receiver) = mpsc::channel(); + let (user_admin_sender, user_admin_receiver) = mpsc::channel(); + let (admin_sender, admin_receiver) = mpsc::channel(); + + Self { + api_base_url: api_base_url.to_string(), + ws_url: ws_url.to_string(), + receiver: spawn_socket_client(ws_url), + logo_texture: load_logo_texture(ctx), + login_sender, + login_receiver, + registration_sender, + registration_receiver, + dev_bootstrap_sender, + dev_bootstrap_receiver, + user_admin_sender, + user_admin_receiver, + admin_sender, + admin_receiver, + records: Vec::new(), + status: "Verbinde...".to_string(), + live_revision: 0, + live_update_status: "Keine Live-Änderung empfangen.".to_string(), + navigation_mode: NavigationMode::Scroll, + navigation_settings_status: String::new(), + minimized_windows: HashSet::new(), + view: AppView::Login, + authenticated_email: None, + access_token: None, + selected_organization_id: None, + login_email: String::new(), + login_password: String::new(), + login_status: String::new(), + login_pending: false, + dev_organization_name: "Lokale Testfirma".to_string(), + dev_email: "admin@example.test".to_string(), + dev_status: String::new(), + dev_pending: false, + dev_credentials: None, + organization_name: String::new(), + registration_email: String::new(), + accept_terms: false, + registration_status: String::new(), + registration_pending: false, + users: Vec::new(), + users_status: "Noch keine Benutzer geladen.".to_string(), + users_pending: false, + saving_user_id: None, + invite_email: String::new(), + invite_roles: vec!["viewer".to_string()], + users_window_open: false, + organization_setup: OrganizationSetupForm::default(), + organization_setup_status: String::new(), + organization_setup_pending: false, + organization_setup_window_open: false, + number_ranges: Vec::new(), + number_range_form: NumberRangeForm::default(), + selected_number_range_code: None, + number_ranges_status: "Noch keine Nummernkreise geladen.".to_string(), + number_ranges_window_open: false, + registrations: Vec::new(), + registrations_status: "Noch keine Registrierungen geladen.".to_string(), + registrations_pending: false, + admin_registrations_window_open: false, + customers: Vec::new(), + customer_form: CustomerForm::default(), + selected_customer_id: None, + customers_status: "Noch keine Kunden geladen.".to_string(), + customers_pending: false, + customers_window_open: false, + suppliers: Vec::new(), + supplier_form: SupplierForm::default(), + selected_supplier_id: None, + suppliers_status: "Noch keine Lieferanten geladen.".to_string(), + suppliers_window_open: false, + cash_discount_terms: Vec::new(), + cash_discount_term_form: CashDiscountTermForm::default(), + selected_cash_discount_term_id: None, + cash_discount_terms_status: "Noch keine Skonto-Regeln geladen.".to_string(), + cash_discount_terms_window_open: false, + items: Vec::new(), + item_form: ItemForm::default(), + selected_item_id: None, + item_price_history: Vec::new(), + items_status: "Noch keine Artikel geladen.".to_string(), + items_window_open: false, + quotes: Vec::new(), + quote_form: QuoteForm::default(), + selected_quote_id: None, + quotes_status: "Noch keine Angebote geladen.".to_string(), + quotes_window_open: false, + outgoing_invoices: Vec::new(), + outgoing_invoice_form: OutgoingInvoiceForm::default(), + selected_outgoing_invoice_id: None, + outgoing_invoices_status: "Noch keine Ausgangsrechnungen geladen.".to_string(), + outgoing_invoices_window_open: false, + incoming_invoices: Vec::new(), + incoming_invoice_form: IncomingInvoiceForm::default(), + selected_incoming_invoice_id: None, + incoming_invoices_status: "Noch keine Eingangsrechnungen geladen.".to_string(), + incoming_invoices_window_open: false, + activities: Vec::new(), + activity_form: ActivityForm::default(), + selected_activity_id: None, + activities_status: "Noch keine Aktivitäten geladen.".to_string(), + activities_window_open: false, + price_import_form: PriceListImportForm::default(), + price_import_preview: None, + price_import_status: "Noch keine Preisliste geprüft.".to_string(), + price_import_window_open: false, + api_connectors: Vec::new(), + api_connector_form: ApiConnectorForm::default(), + selected_api_connector_id: None, + api_connectors_status: "Noch keine Preis-APIs geladen.".to_string(), + api_connectors_window_open: false, + price_rules: Vec::new(), + price_rule_form: PriceRuleForm::default(), + selected_price_rule_id: None, + price_rules_status: "Noch keine Preisregeln geladen.".to_string(), + price_rules_window_open: false, + customer_lookup_search: String::new(), + supplier_lookup_search: String::new(), + item_lookup_search: String::new(), + customer_list_search: String::new(), + supplier_list_search: String::new(), + item_list_search: String::new(), + activity_list_search: String::new(), + quote_list_search: String::new(), + outgoing_invoice_list_search: String::new(), + incoming_invoice_list_search: String::new(), + } + } + + fn drain_messages(&mut self) { + while let Ok(message) = self.receiver.try_recv() { + match message { + ServerMessage::Snapshot { records } => { + self.records = records; + self.status = "Verbunden".to_string(); + } + ServerMessage::RecordChanged { record } => { + self.live_revision += 1; + self.live_update_status = + format!("Live-Änderung {}: {}", self.live_revision, record.title); + if let Some(existing) = + self.records.iter_mut().find(|item| item.id == record.id) + { + *existing = record; + } else { + self.records.insert(0, record); + } + self.status = "Aktualisiert".to_string(); + if self.customers_window_open { + self.load_customers(); + } + if self.suppliers_window_open { + self.load_suppliers(); + } + if self.cash_discount_terms_window_open + || self.customers_window_open + || self.suppliers_window_open + { + self.load_cash_discount_terms(); + } + if self.items_window_open { + self.load_items(); + if let Some(item_id) = self.selected_item_id.clone() { + self.load_item_price_history(item_id); + } + } + if self.quotes_window_open { + self.load_quotes(); + self.load_customers(); + self.load_items(); + } + if self.outgoing_invoices_window_open { + self.load_outgoing_invoices(); + self.load_customers(); + self.load_items(); + } + if self.incoming_invoices_window_open { + self.load_incoming_invoices(); + self.load_suppliers(); + self.load_items(); + } + if self.activities_window_open { + self.load_activities(); + } + if self.api_connectors_window_open { + self.load_api_connectors(); + } + if self.price_rules_window_open { + self.load_price_rules(); + } + if self.price_import_window_open && self.price_import_preview.is_some() { + self.preview_price_import(); + } + } + ServerMessage::Pong => { + self.status = "Backend erreichbar".to_string(); + } + ServerMessage::Error { message } => { + self.status = message; + } + } + } + + while let Ok(result) = self.login_receiver.try_recv() { + self.login_pending = false; + match result { + Ok(response) => { + self.authenticated_email = Some(self.login_email.trim().to_string()); + self.access_token = Some(response.access_token.clone()); + self.selected_organization_id = + response.organization_id.clone().or_else(|| { + response + .organizations + .first() + .map(|organization| organization.id.clone()) + }); + self.login_password.clear(); + self.load_user_navigation_settings(); + let organization_text = match response.organizations.first() { + Some(organization) => format!( + "{} Organization(en), aktiv: {} ({})", + response.organizations.len(), + organization + .schema_name + .as_deref() + .unwrap_or(&organization.id), + organization.status + ), + None => "keine aktive Organization".to_string(), + }; + self.login_status = if response.must_change_password { + format!( + "Login erfolgreich für {}. Passwortänderung ist erforderlich. {}.", + response.user_id, organization_text + ) + } else { + format!( + "Login erfolgreich für {}. {}.", + response.user_id, organization_text + ) + }; + self.view = AppView::Status; + } + Err(message) => { + self.login_status = message; + } + } + } + + while let Ok(result) = self.registration_receiver.try_recv() { + self.registration_pending = false; + self.registration_status = match result { + Ok(response) => format!( + "Registrierung eingegangen. Status: {}, ID: {}", + response.status, response.id + ), + Err(message) => message, + }; + } + + while let Ok(result) = self.dev_bootstrap_receiver.try_recv() { + self.dev_pending = false; + match result { + Ok(response) => { + self.login_email = response.email.clone(); + self.login_password = response.password.clone(); + self.dev_status = "Testfirma und User wurden angelegt.".to_string(); + self.dev_credentials = Some(response); + } + Err(message) => { + self.dev_status = message; + self.dev_credentials = None; + } + } + } + + while let Ok(event) = self.user_admin_receiver.try_recv() { + match event { + UserAdminEvent::UsersLoaded(result) => { + self.users_pending = false; + match result { + Ok(users) => { + self.users = users; + self.users_status = if self.users.is_empty() { + "Keine Benutzer vorhanden.".to_string() + } else { + "Benutzer geladen.".to_string() + }; + } + Err(message) => { + self.users.clear(); + self.users_status = message; + } + } + } + UserAdminEvent::InvitationSent(result) => { + self.users_pending = false; + match result { + Ok(message) => { + self.users_status = message; + self.invite_email.clear(); + self.load_users(); + } + Err(message) => { + self.users_status = message; + } + } + } + UserAdminEvent::RolesSaved { user_id, result } => { + self.saving_user_id = None; + match result { + Ok(()) => { + self.users_status = "Benutzerrechte gespeichert.".to_string(); + self.load_users(); + } + Err(message) => { + self.users_status = format!("{user_id}: {message}"); + } + } + } + } + } + + while let Ok(event) = self.admin_receiver.try_recv() { + match event { + AdminEvent::NavigationSettingsLoaded(result) => match result { + Ok(settings) => { + self.navigation_mode = settings.mode; + self.navigation_settings_status = String::new(); + } + Err(message) => { + self.navigation_settings_status = message; + } + }, + AdminEvent::NavigationSettingsSaved(result) => match result { + Ok(settings) => { + self.navigation_mode = settings.mode; + self.navigation_settings_status = "Menü gespeichert.".to_string(); + } + Err(message) => { + self.navigation_settings_status = message; + } + }, + AdminEvent::OrganizationSetupSaved(result) => { + self.organization_setup_pending = false; + self.organization_setup_status = match result { + Ok(()) => "Firmendaten gespeichert.".to_string(), + Err(message) => message, + }; + } + AdminEvent::OrganizationSetupLoaded(result) => { + self.organization_setup_pending = false; + match result { + Ok(Some(setup)) => { + self.organization_setup = setup; + self.organization_setup_status = "Firmendaten geladen.".to_string(); + } + Ok(None) => { + self.organization_setup_status = + "Noch keine Firmendaten gespeichert.".to_string(); + } + Err(message) => { + self.organization_setup_status = message; + } + } + } + AdminEvent::RegistrationsLoaded(result) => { + self.registrations_pending = false; + match result { + Ok(registrations) => { + self.registrations = registrations; + self.registrations_status = if self.registrations.is_empty() { + "Keine Registrierungen vorhanden.".to_string() + } else { + "Registrierungen geladen.".to_string() + }; + } + Err(message) => { + self.registrations.clear(); + self.registrations_status = message; + } + } + } + AdminEvent::RegistrationActionDone(result) => { + self.registrations_pending = false; + self.registrations_status = match result { + Ok(message) => message, + Err(message) => message, + }; + self.load_registrations(); + } + AdminEvent::NumberRangesLoaded(result) => match result { + Ok(records) => { + self.number_ranges = records; + self.number_ranges_status = "Nummernkreise geladen.".to_string(); + } + Err(message) => self.number_ranges_status = message, + }, + AdminEvent::NumberRangeSaved(result) => match result { + Ok(record) => { + self.selected_number_range_code = Some(record.code.clone()); + self.number_range_form = NumberRangeForm::from(&record); + self.number_ranges_status = "Nummernkreis gespeichert.".to_string(); + self.load_number_ranges(); + } + Err(message) => self.number_ranges_status = message, + }, + AdminEvent::NextNumberReserved { code, result } => match result { + Ok(number) => self.apply_reserved_number(&code, number), + Err(message) => self.set_number_status(&code, message), + }, + AdminEvent::CustomersLoaded(result) => { + self.customers_pending = false; + match result { + Ok(customers) => { + self.customers = customers; + self.customers_status = "Kunden geladen.".to_string(); + } + Err(message) => { + self.customers_status = message; + } + } + } + AdminEvent::CustomerSaved(result) => { + self.customers_pending = false; + match result { + Ok(customer) => { + self.selected_customer_id = Some(customer.id.clone()); + self.customer_form = CustomerForm::from(&customer); + self.customers_status = "Kunde gespeichert.".to_string(); + self.load_customers(); + } + Err(message) => { + self.customers_status = message; + } + } + } + AdminEvent::CustomerDeleted(result) => { + self.customers_pending = false; + self.customers_status = match result { + Ok(()) => "Kunde deaktiviert.".to_string(), + Err(message) => message, + }; + self.load_customers(); + } + AdminEvent::SuppliersLoaded(result) => match result { + Ok(records) => { + self.suppliers = records; + self.suppliers_status = "Lieferanten geladen.".to_string(); + } + Err(message) => self.suppliers_status = message, + }, + AdminEvent::SupplierSaved(result) => match result { + Ok(record) => { + self.selected_supplier_id = Some(record.id.clone()); + self.supplier_form = SupplierForm::from(&record); + self.suppliers_status = "Lieferant gespeichert.".to_string(); + self.load_suppliers(); + } + Err(message) => self.suppliers_status = message, + }, + AdminEvent::SupplierDeleted(result) => { + self.suppliers_status = result + .map(|_| "Lieferant deaktiviert.".to_string()) + .unwrap_or_else(|message| message); + self.load_suppliers(); + } + AdminEvent::CashDiscountTermsLoaded(result) => match result { + Ok(records) => { + self.cash_discount_terms = records; + self.cash_discount_terms_status = "Skonto-Regeln geladen.".to_string(); + } + Err(message) => self.cash_discount_terms_status = message, + }, + AdminEvent::CashDiscountTermSaved(result) => match result { + Ok(record) => { + self.selected_cash_discount_term_id = Some(record.id.clone()); + self.cash_discount_term_form = CashDiscountTermForm::from(&record); + self.cash_discount_terms_status = "Skonto-Regel gespeichert.".to_string(); + self.load_cash_discount_terms(); + } + Err(message) => self.cash_discount_terms_status = message, + }, + AdminEvent::CashDiscountTermDeleted(result) => { + self.cash_discount_terms_status = result + .map(|_| "Skonto-Regel deaktiviert.".to_string()) + .unwrap_or_else(|message| message); + self.load_cash_discount_terms(); + } + AdminEvent::ItemsLoaded(result) => match result { + Ok(records) => { + self.items = records; + self.items_status = "Artikel geladen.".to_string(); + } + Err(message) => self.items_status = message, + }, + AdminEvent::ItemSaved(result) => match result { + Ok(record) => { + self.selected_item_id = Some(record.id.clone()); + self.item_form = ItemForm::from(&record); + self.items_status = "Artikel gespeichert.".to_string(); + self.load_items(); + self.load_item_price_history(record.id); + } + Err(message) => self.items_status = message, + }, + AdminEvent::ItemPriceHistoryLoaded(result) => match result { + Ok(records) => self.item_price_history = records, + Err(message) => self.items_status = message, + }, + AdminEvent::ItemDeleted(result) => { + self.items_status = result + .map(|_| "Artikel deaktiviert.".to_string()) + .unwrap_or_else(|message| message); + self.load_items(); + } + AdminEvent::QuotesLoaded(result) => match result { + Ok(records) => { + self.quotes = records; + self.quotes_status = "Angebote geladen.".to_string(); + } + Err(message) => self.quotes_status = message, + }, + AdminEvent::QuoteSaved(result) => match result { + Ok(record) => { + self.selected_quote_id = Some(record.id.clone()); + self.quote_form = QuoteForm::from(&record); + self.quotes_status = "Angebot gespeichert.".to_string(); + self.load_quotes(); + } + Err(message) => self.quotes_status = message, + }, + AdminEvent::QuoteDeleted(result) => { + self.quotes_status = result + .map(|_| "Angebot storniert.".to_string()) + .unwrap_or_else(|message| message); + self.load_quotes(); + } + AdminEvent::OutgoingInvoicesLoaded(result) => match result { + Ok(records) => { + self.outgoing_invoices = records; + self.outgoing_invoices_status = "Ausgangsrechnungen geladen.".to_string(); + } + Err(message) => self.outgoing_invoices_status = message, + }, + AdminEvent::OutgoingInvoiceSaved(result) => match result { + Ok(record) => { + self.selected_outgoing_invoice_id = Some(record.id.clone()); + self.outgoing_invoice_form = OutgoingInvoiceForm::from(&record); + self.outgoing_invoices_status = "Ausgangsrechnung gespeichert.".to_string(); + self.load_outgoing_invoices(); + } + Err(message) => self.outgoing_invoices_status = message, + }, + AdminEvent::OutgoingInvoiceDeleted(result) => { + self.outgoing_invoices_status = result + .map(|_| "Ausgangsrechnung storniert.".to_string()) + .unwrap_or_else(|message| message); + self.load_outgoing_invoices(); + } + AdminEvent::OutgoingInvoiceFinalized(result) => { + self.outgoing_invoices_status = result + .map(|_| "Ausgangsrechnung abgeschlossen.".to_string()) + .unwrap_or_else(|message| message); + self.load_outgoing_invoices(); + } + AdminEvent::IncomingInvoicesLoaded(result) => match result { + Ok(records) => { + self.incoming_invoices = records; + self.incoming_invoices_status = "Eingangsrechnungen geladen.".to_string(); + } + Err(message) => self.incoming_invoices_status = message, + }, + AdminEvent::IncomingInvoiceSaved(result) => match result { + Ok(record) => { + self.selected_incoming_invoice_id = Some(record.id.clone()); + self.incoming_invoice_form = IncomingInvoiceForm::from(&record); + self.incoming_invoices_status = "Eingangsrechnung gespeichert.".to_string(); + self.load_incoming_invoices(); + } + Err(message) => self.incoming_invoices_status = message, + }, + AdminEvent::IncomingInvoiceDeleted(result) => { + self.incoming_invoices_status = result + .map(|_| "Eingangsrechnung storniert.".to_string()) + .unwrap_or_else(|message| message); + self.load_incoming_invoices(); + } + AdminEvent::ActivitiesLoaded(result) => match result { + Ok(records) => { + self.activities = records; + self.activities_status = "Aktivitäten geladen.".to_string(); + } + Err(message) => self.activities_status = message, + }, + AdminEvent::ActivitySaved(result) => match result { + Ok(record) => { + self.selected_activity_id = Some(record.id.clone()); + self.activity_form = ActivityForm::from(&record); + self.activities_status = "Aktivität gespeichert.".to_string(); + self.load_activities(); + } + Err(message) => self.activities_status = message, + }, + AdminEvent::ActivityDeleted(result) => { + self.activities_status = result + .map(|_| "Aktivität storniert.".to_string()) + .unwrap_or_else(|message| message); + self.load_activities(); + } + AdminEvent::PriceImportPreviewed(result) => match result { + Ok(preview) => { + self.price_import_status = format!( + "Vorschau: {} gültig, {} Fehler.", + preview.valid_rows, preview.error_rows + ); + self.price_import_preview = Some(preview); + } + Err(message) => self.price_import_status = message, + }, + AdminEvent::PriceImportApplied(result) => match result { + Ok(response) => { + self.price_import_status = format!( + "Import {}: {} Zeilen übernommen, {} Fehler.", + response.import_id, response.applied_rows, response.error_rows + ); + self.load_items(); + self.preview_price_import(); + } + Err(message) => self.price_import_status = message, + }, + AdminEvent::ApiConnectorsLoaded(result) => match result { + Ok(records) => { + self.api_connectors = records; + self.api_connectors_status = "Preis-APIs geladen.".to_string(); + } + Err(message) => self.api_connectors_status = message, + }, + AdminEvent::ApiConnectorSaved(result) => match result { + Ok(record) => { + self.selected_api_connector_id = Some(record.id.clone()); + self.api_connector_form = ApiConnectorForm::from(&record); + self.api_connectors_status = "Preis-API gespeichert.".to_string(); + self.load_api_connectors(); + } + Err(message) => self.api_connectors_status = message, + }, + AdminEvent::ApiConnectorDeleted(result) => { + self.api_connectors_status = result + .map(|_| "Preis-API deaktiviert.".to_string()) + .unwrap_or_else(|message| message); + self.load_api_connectors(); + } + AdminEvent::ApiConnectorSynced(result) => match result { + Ok(response) => { + self.api_connectors_status = format!( + "Abgleich {} ausgeführt: {} Zeilen übernommen, {} Fehler.", + &response.id[..response.id.len().min(8)], + response.applied_rows, + response.error_rows + ); + if !response.synced { + self.api_connectors_status = + "Abgleich wurde nicht bestätigt.".to_string(); + } + self.load_api_connectors(); + self.load_items(); + } + Err(message) => self.api_connectors_status = message, + }, + AdminEvent::PriceRulesLoaded(result) => match result { + Ok(records) => { + self.price_rules = records; + self.price_rules_status = "Preisregeln geladen.".to_string(); + } + Err(message) => self.price_rules_status = message, + }, + AdminEvent::PriceRuleSaved(result) => match result { + Ok(record) => { + self.selected_price_rule_id = Some(record.id.clone()); + self.price_rule_form = PriceRuleForm::from(&record); + self.price_rules_status = "Preisregel gespeichert.".to_string(); + self.load_price_rules(); + } + Err(message) => self.price_rules_status = message, + }, + AdminEvent::PriceRuleDeleted(result) => { + self.price_rules_status = result + .map(|_| "Preisregel deaktiviert.".to_string()) + .unwrap_or_else(|message| message); + self.load_price_rules(); + } + } + } + } + + fn submit_login(&mut self) { + let email = self.login_email.trim().to_string(); + let password = self.login_password.clone(); + + if !email.contains('@') { + self.login_status = "E-Mail-Adresse ist ungültig.".to_string(); + return; + } + + if password.is_empty() { + self.login_status = "Passwort fehlt.".to_string(); + return; + } + + self.login_pending = true; + self.login_status = "Melde an...".to_string(); + + let api_base_url = self.api_base_url.clone(); + let sender = self.login_sender.clone(); + + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async move { + let result = login_user(&api_base_url, email, password).await; + let _ = sender.send(result); + }); + }); + } + + fn logout(&mut self) { + self.authenticated_email = None; + self.access_token = None; + self.selected_organization_id = None; + self.navigation_mode = NavigationMode::Scroll; + self.navigation_settings_status.clear(); + self.login_password.clear(); + self.login_status = "Abgemeldet.".to_string(); + self.view = AppView::Login; + } + + fn load_user_navigation_settings(&mut self) { + let Some(token) = self.access_token.clone() else { + return; + }; + let base = self.api_base_url.clone(); + let sender = self.admin_sender.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async move { + let result = get_user_navigation_settings(&base, &token).await; + let _ = sender.send(AdminEvent::NavigationSettingsLoaded(result)); + }); + }); + } + + fn save_navigation_mode(&mut self, mode: NavigationMode) { + let Some(token) = self.access_token.clone() else { + return; + }; + self.navigation_mode = mode; + self.navigation_settings_status = "Speichere Menü...".to_string(); + let base = self.api_base_url.clone(); + let sender = self.admin_sender.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async move { + let result = + put_user_navigation_settings(&base, &token, UserNavigationSettings { mode }) + .await; + let _ = sender.send(AdminEvent::NavigationSettingsSaved(result)); + }); + }); + } + + fn submit_dev_bootstrap(&mut self) { + let organization_name = self.dev_organization_name.trim().to_string(); + let email = self.dev_email.trim().to_string(); + + if organization_name.len() < 2 { + self.dev_status = "Firmenname ist zu kurz.".to_string(); + return; + } + if !email.contains('@') { + self.dev_status = "E-Mail-Adresse ist ungültig.".to_string(); + return; + } + + self.dev_pending = true; + self.dev_status = "Lege lokale Testfirma an...".to_string(); + self.dev_credentials = None; + + let api_base_url = self.api_base_url.clone(); + let sender = self.dev_bootstrap_sender.clone(); + + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async move { + let result = dev_bootstrap_local(&api_base_url, organization_name, email).await; + let _ = sender.send(result); + }); + }); + } + + fn submit_registration(&mut self) { + let organization_name = self.organization_name.trim().to_string(); + let email = self.registration_email.trim().to_string(); + + if organization_name.len() < 2 { + self.registration_status = "Firmenname ist zu kurz.".to_string(); + return; + } + + if !email.contains('@') { + self.registration_status = "E-Mail-Adresse ist ungültig.".to_string(); + return; + } + + if !self.accept_terms { + self.registration_status = "Nutzungsbedingungen müssen akzeptiert werden.".to_string(); + return; + } + + self.registration_pending = true; + self.registration_status = "Sende Registrierung...".to_string(); + + let api_base_url = self.api_base_url.clone(); + let sender = self.registration_sender.clone(); + let accept_terms = self.accept_terms; + + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async move { + let result = + register_organization(&api_base_url, organization_name, email, accept_terms) + .await; + let _ = sender.send(result); + }); + }); + } + + fn load_users(&mut self) { + self.users_pending = true; + self.users_status = "Lade Benutzer...".to_string(); + let Some(access_token) = self.access_token.clone() else { + self.users_status = "Anmeldung erforderlich.".to_string(); + return; + }; + let api_base_url = self.api_base_url.clone(); + let sender = self.user_admin_sender.clone(); + + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async move { + let result = list_users(&api_base_url, &access_token).await; + let _ = sender.send(UserAdminEvent::UsersLoaded(result)); + }); + }); + } + + fn invite_user(&mut self) { + let email = self.invite_email.trim().to_string(); + if !email.contains('@') { + self.users_status = "E-Mail-Adresse ist ungültig.".to_string(); + return; + } + + self.users_pending = true; + self.users_status = "Sende Einladung...".to_string(); + let Some(access_token) = self.access_token.clone() else { + self.users_status = "Anmeldung erforderlich.".to_string(); + return; + }; + let api_base_url = self.api_base_url.clone(); + let roles = normalized_roles(&self.invite_roles); + let sender = self.user_admin_sender.clone(); + + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async move { + let result = invite_organization_user(&api_base_url, &access_token, email, roles) + .await + .map(|_| "Einladung angelegt.".to_string()); + let _ = sender.send(UserAdminEvent::InvitationSent(result)); + }); + }); + } + + fn save_user_roles(&mut self, user_id: String, roles: Vec) { + self.saving_user_id = Some(user_id.clone()); + self.users_status = "Speichere Benutzerrechte...".to_string(); + let Some(access_token) = self.access_token.clone() else { + self.users_status = "Anmeldung erforderlich.".to_string(); + return; + }; + let api_base_url = self.api_base_url.clone(); + let sender = self.user_admin_sender.clone(); + + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async move { + let result = update_user_roles( + &api_base_url, + &access_token, + &user_id, + normalized_roles(&roles), + ) + .await; + let _ = sender.send(UserAdminEvent::RolesSaved { user_id, result }); + }); + }); + } + + fn save_organization_setup(&mut self) { + self.organization_setup_pending = true; + self.organization_setup_status = "Speichere Firmendaten...".to_string(); + let Some(access_token) = self.access_token.clone() else { + self.organization_setup_pending = false; + self.organization_setup_status = "Anmeldung erforderlich.".to_string(); + return; + }; + let api_base_url = self.api_base_url.clone(); + let payload = self.organization_setup.clone(); + let sender = self.admin_sender.clone(); + + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async move { + let result = save_organization_setup(&api_base_url, &access_token, payload).await; + let _ = sender.send(AdminEvent::OrganizationSetupSaved(result)); + }); + }); + } + + fn load_organization_setup(&mut self) { + self.organization_setup_pending = true; + self.organization_setup_status = "Lade Firmendaten...".to_string(); + let Some(access_token) = self.access_token.clone() else { + self.organization_setup_pending = false; + self.organization_setup_status = "Anmeldung erforderlich.".to_string(); + return; + }; + let api_base_url = self.api_base_url.clone(); + let sender = self.admin_sender.clone(); + + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async move { + let result = load_organization_setup(&api_base_url, &access_token).await; + let _ = sender.send(AdminEvent::OrganizationSetupLoaded(result)); + }); + }); + } + + fn load_registrations(&mut self) { + self.registrations_pending = true; + self.registrations_status = "Lade Registrierungen...".to_string(); + let api_base_url = self.api_base_url.clone(); + let sender = self.admin_sender.clone(); + + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async move { + let result = list_registrations(&api_base_url).await; + let _ = sender.send(AdminEvent::RegistrationsLoaded(result)); + }); + }); + } + + fn approve_registration(&mut self, registration_id: String) { + self.registrations_pending = true; + self.registrations_status = "Schalte Registrierung frei...".to_string(); + let api_base_url = self.api_base_url.clone(); + let sender = self.admin_sender.clone(); + + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async move { + let result = approve_registration(&api_base_url, ®istration_id) + .await + .map(|response| { + format!( + "Freigeschaltet. Initialpasswort: {}", + response.dev_initial_password + ) + }); + let _ = sender.send(AdminEvent::RegistrationActionDone(result)); + }); + }); + } + + fn load_customers(&mut self) { + self.customers_pending = true; + let Some(access_token) = self.access_token.clone() else { + self.customers_status = "Anmeldung erforderlich.".to_string(); + self.customers_pending = false; + return; + }; + let api_base_url = self.api_base_url.clone(); + let sender = self.admin_sender.clone(); + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async move { + let result = list_customers(&api_base_url, &access_token).await; + let _ = sender.send(AdminEvent::CustomersLoaded(result)); + }); + }); + } + + fn new_customer(&mut self) { + self.selected_customer_id = None; + self.customer_form = CustomerForm::default(); + self.customers_status = "Kundennummer wird reserviert.".to_string(); + self.reserve_next_number("customers"); + } + + fn save_customer(&mut self) { + self.customers_pending = true; + let Some(access_token) = self.access_token.clone() else { + self.customers_status = "Anmeldung erforderlich.".to_string(); + self.customers_pending = false; + return; + }; + let api_base_url = self.api_base_url.clone(); + let customer_id = self.selected_customer_id.clone(); + let form = self.customer_form.clone(); + let sender = self.admin_sender.clone(); + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async move { + let result = save_customer(&api_base_url, &access_token, customer_id, form).await; + let _ = sender.send(AdminEvent::CustomerSaved(result)); + }); + }); + } + + fn deactivate_customer(&mut self) { + let Some(customer_id) = self.selected_customer_id.clone() else { + return; + }; + let Some(access_token) = self.access_token.clone() else { + self.customers_status = "Anmeldung erforderlich.".to_string(); + return; + }; + self.customers_pending = true; + let api_base_url = self.api_base_url.clone(); + let sender = self.admin_sender.clone(); + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async move { + let result = delete_customer(&api_base_url, &access_token, &customer_id).await; + let _ = sender.send(AdminEvent::CustomerDeleted(result)); + }); + }); + } + + fn load_suppliers(&mut self) { + self.spawn_master_load("/api/v1/suppliers", |result| { + AdminEvent::SuppliersLoaded(result) + }); + } + + fn load_number_ranges(&mut self) { + self.spawn_master_load("/api/v1/number-ranges", |result| { + AdminEvent::NumberRangesLoaded(result) + }); + } + + fn load_cash_discount_terms(&mut self) { + self.spawn_master_load("/api/v1/cash-discount-terms", |result| { + AdminEvent::CashDiscountTermsLoaded(result) + }); + } + + fn load_items(&mut self) { + self.spawn_master_load("/api/v1/items", |result| AdminEvent::ItemsLoaded(result)); + } + + fn load_item_price_history(&mut self, item_id: String) { + let path = format!("/api/v1/items/{item_id}/prices"); + self.spawn_owned_master_load(path, |result| AdminEvent::ItemPriceHistoryLoaded(result)); + } + + fn load_activities(&mut self) { + self.spawn_master_load("/api/v1/activities", |result| { + AdminEvent::ActivitiesLoaded(result) + }); + } + + fn load_quotes(&mut self) { + self.spawn_master_load("/api/v1/quotes", |result| AdminEvent::QuotesLoaded(result)); + } + + fn load_outgoing_invoices(&mut self) { + self.spawn_master_load("/api/v1/outgoing-invoices", |result| { + AdminEvent::OutgoingInvoicesLoaded(result) + }); + } + + fn load_incoming_invoices(&mut self) { + self.spawn_master_load("/api/v1/incoming-invoices", |result| { + AdminEvent::IncomingInvoicesLoaded(result) + }); + } + + fn reserve_next_number(&mut self, code: &'static str) { + let Some(access_token) = self.access_token.clone() else { + self.set_number_status(code, "Anmeldung erforderlich.".to_string()); + return; + }; + let api_base_url = self.api_base_url.clone(); + let sender = self.admin_sender.clone(); + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async move { + let result = reserve_next_number(&api_base_url, &access_token, code).await; + let _ = sender.send(AdminEvent::NextNumberReserved { + code: code.to_string(), + result, + }); + }); + }); + } + + fn apply_reserved_number(&mut self, code: &str, number: String) { + match code { + "customers" => { + if self.selected_customer_id.is_none() { + self.customer_form.customer_number = number; + self.customers_status = "Neue Kundennummer reserviert.".to_string(); + } + } + "suppliers" => { + if self.selected_supplier_id.is_none() { + self.supplier_form.supplier_number = number; + self.suppliers_status = "Neue Lieferantennummer reserviert.".to_string(); + } + } + "items" => { + if self.selected_item_id.is_none() { + self.item_form.item_number = number; + self.items_status = "Neue Artikelnummer reserviert.".to_string(); + } + } + "activities" => { + if self.selected_activity_id.is_none() { + self.activity_form.activity_number = Some(number); + self.activities_status = "Neue Aktivitätsnummer reserviert.".to_string(); + } + } + "quotes" => { + if self.selected_quote_id.is_none() { + self.quote_form.quote_number = number; + self.quotes_status = "Neue Angebotsnummer reserviert.".to_string(); + } + } + "outgoing_invoices" => { + if self.selected_outgoing_invoice_id.is_none() { + self.outgoing_invoice_form.invoice_number = number; + self.outgoing_invoices_status = "Neue Rechnungsnummer reserviert.".to_string(); + } + } + "incoming_invoices" => { + if self.selected_incoming_invoice_id.is_none() { + self.incoming_invoice_form.invoice_number = number; + self.incoming_invoices_status = + "Neue Eingangsrechnungsnummer reserviert.".to_string(); + } + } + _ => {} + } + } + + fn set_number_status(&mut self, code: &str, message: String) { + match code { + "customers" => self.customers_status = message, + "suppliers" => self.suppliers_status = message, + "items" => self.items_status = message, + "activities" => self.activities_status = message, + "quotes" => self.quotes_status = message, + "outgoing_invoices" => self.outgoing_invoices_status = message, + "incoming_invoices" => self.incoming_invoices_status = message, + _ => self.number_ranges_status = message, + } + } + + fn load_api_connectors(&mut self) { + self.spawn_master_load("/api/v1/api-connectors", |result| { + AdminEvent::ApiConnectorsLoaded(result) + }); + } + + fn load_price_rules(&mut self) { + self.spawn_master_load("/api/v1/price-rules", |result| { + AdminEvent::PriceRulesLoaded(result) + }); + } + + fn spawn_master_load(&mut self, path: &'static str, event: F) + where + T: for<'de> Deserialize<'de> + Send + 'static, + F: FnOnce(Result, String>) -> AdminEvent + Send + 'static, + { + let Some(access_token) = self.access_token.clone() else { + return; + }; + let api_base_url = self.api_base_url.clone(); + let sender = self.admin_sender.clone(); + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async move { + let result = list_master_records(&api_base_url, &access_token, path).await; + let _ = sender.send(event(result)); + }); + }); + } + + fn spawn_owned_master_load(&mut self, path: String, event: F) + where + T: for<'de> Deserialize<'de> + Send + 'static, + F: FnOnce(Result, String>) -> AdminEvent + Send + 'static, + { + let Some(access_token) = self.access_token.clone() else { + return; + }; + let api_base_url = self.api_base_url.clone(); + let sender = self.admin_sender.clone(); + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async move { + let result = list_master_records(&api_base_url, &access_token, &path).await; + let _ = sender.send(event(result)); + }); + }); + } + + fn save_supplier(&mut self) { + let Some(token) = self.access_token.clone() else { + return; + }; + let base = self.api_base_url.clone(); + let id = self.selected_supplier_id.clone(); + let form = self.supplier_form.clone(); + let sender = self.admin_sender.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async move { + let _ = sender.send(AdminEvent::SupplierSaved( + save_master_record(&base, &token, "/api/v1/suppliers", id, form).await, + )); + }); + }); + } + + fn save_cash_discount_term(&mut self) { + let Some(token) = self.access_token.clone() else { + return; + }; + let base = self.api_base_url.clone(); + let id = self.selected_cash_discount_term_id.clone(); + let form = self.cash_discount_term_form.clone(); + let sender = self.admin_sender.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async move { + let _ = sender.send(AdminEvent::CashDiscountTermSaved( + save_master_record(&base, &token, "/api/v1/cash-discount-terms", id, form) + .await, + )); + }); + }); + } + + fn save_number_range(&mut self) { + let Some(token) = self.access_token.clone() else { + return; + }; + let Some(code) = self.selected_number_range_code.clone() else { + return; + }; + let base = self.api_base_url.clone(); + let form = self.number_range_form.clone(); + let sender = self.admin_sender.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async move { + let path = format!("/api/v1/number-ranges/{code}"); + let _ = sender.send(AdminEvent::NumberRangeSaved( + save_master_record_without_id(&base, &token, &path, form).await, + )); + }); + }); + } + + fn save_item(&mut self) { + let Some(token) = self.access_token.clone() else { + return; + }; + let base = self.api_base_url.clone(); + let id = self.selected_item_id.clone(); + let form = self.item_form.clone(); + let sender = self.admin_sender.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async move { + let _ = sender.send(AdminEvent::ItemSaved( + save_master_record(&base, &token, "/api/v1/items", id, form).await, + )); + }); + }); + } + + fn save_activity(&mut self) { + let Some(token) = self.access_token.clone() else { + return; + }; + let base = self.api_base_url.clone(); + let id = self.selected_activity_id.clone(); + let form = self.activity_form.clone(); + let sender = self.admin_sender.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async move { + let _ = sender.send(AdminEvent::ActivitySaved( + save_master_record(&base, &token, "/api/v1/activities", id, form).await, + )); + }); + }); + } + + fn preview_price_import(&mut self) { + let Some(token) = self.access_token.clone() else { + self.price_import_status = "Anmeldung erforderlich.".to_string(); + return; + }; + let base = self.api_base_url.clone(); + let form = self.price_import_form.clone(); + let sender = self.admin_sender.clone(); + self.price_import_status = "Prüfe Preisliste...".to_string(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async move { + let _ = sender.send(AdminEvent::PriceImportPreviewed( + post_master_record(&base, &token, "/api/v1/imports/price-list/preview", form) + .await, + )); + }); + }); + } + + fn apply_price_import(&mut self) { + let Some(token) = self.access_token.clone() else { + self.price_import_status = "Anmeldung erforderlich.".to_string(); + return; + }; + let base = self.api_base_url.clone(); + let form = self.price_import_form.clone(); + let sender = self.admin_sender.clone(); + self.price_import_status = "Importiere Preisliste...".to_string(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async move { + let _ = sender.send(AdminEvent::PriceImportApplied( + post_master_record(&base, &token, "/api/v1/imports/price-list/apply", form) + .await, + )); + }); + }); + } + + fn save_api_connector(&mut self) { + let Some(token) = self.access_token.clone() else { + self.api_connectors_status = "Anmeldung erforderlich.".to_string(); + return; + }; + let Ok(payload) = self.api_connector_form.payload() else { + self.api_connectors_status = "Konfiguration ist kein gültiges JSON.".to_string(); + return; + }; + let base = self.api_base_url.clone(); + let id = self.selected_api_connector_id.clone(); + let sender = self.admin_sender.clone(); + self.api_connectors_status = "Speichere Preis-API...".to_string(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async move { + let _ = sender.send(AdminEvent::ApiConnectorSaved( + save_master_record(&base, &token, "/api/v1/api-connectors", id, payload).await, + )); + }); + }); + } + + fn sync_api_connector(&mut self) { + let Some(token) = self.access_token.clone() else { + self.api_connectors_status = "Anmeldung erforderlich.".to_string(); + return; + }; + let Some(id) = self.selected_api_connector_id.clone() else { + return; + }; + let base = self.api_base_url.clone(); + let sender = self.admin_sender.clone(); + self.api_connectors_status = "Führe Preisabgleich aus...".to_string(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async move { + let path = format!("/api/v1/api-connectors/{id}/sync"); + let _ = sender.send(AdminEvent::ApiConnectorSynced( + post_master_record(&base, &token, &path, serde_json::json!({})).await, + )); + }); + }); + } + + fn save_price_rule(&mut self) { + let Some(token) = self.access_token.clone() else { + self.price_rules_status = "Anmeldung erforderlich.".to_string(); + return; + }; + let base = self.api_base_url.clone(); + let id = self.selected_price_rule_id.clone(); + let mut form = self.price_rule_form.clone(); + if form + .source_id + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + form.source_id = None; + } + let sender = self.admin_sender.clone(); + self.price_rules_status = "Speichere Preisregel...".to_string(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async move { + let _ = sender.send(AdminEvent::PriceRuleSaved( + save_master_record(&base, &token, "/api/v1/price-rules", id, form).await, + )); + }); + }); + } + + fn save_quote(&mut self) { + let Some(token) = self.access_token.clone() else { + return; + }; + let base = self.api_base_url.clone(); + let id = self.selected_quote_id.clone(); + let form = self.quote_form.clone(); + let sender = self.admin_sender.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async move { + let _ = sender.send(AdminEvent::QuoteSaved( + save_master_record(&base, &token, "/api/v1/quotes", id, form).await, + )); + }); + }); + } + + fn save_outgoing_invoice(&mut self) { + let Some(token) = self.access_token.clone() else { + return; + }; + let base = self.api_base_url.clone(); + let id = self.selected_outgoing_invoice_id.clone(); + let form = self.outgoing_invoice_form.clone(); + let sender = self.admin_sender.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async move { + let _ = sender.send(AdminEvent::OutgoingInvoiceSaved( + save_master_record(&base, &token, "/api/v1/outgoing-invoices", id, form).await, + )); + }); + }); + } + + fn save_incoming_invoice(&mut self) { + let Some(token) = self.access_token.clone() else { + return; + }; + let base = self.api_base_url.clone(); + let id = self.selected_incoming_invoice_id.clone(); + let form = self.incoming_invoice_form.clone(); + let sender = self.admin_sender.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async move { + let _ = sender.send(AdminEvent::IncomingInvoiceSaved( + save_master_record(&base, &token, "/api/v1/incoming-invoices", id, form).await, + )); + }); + }); + } + + fn delete_supplier(&mut self) { + if let Some(id) = self.selected_supplier_id.clone() { + self.spawn_master_delete("/api/v1/suppliers", id, AdminEvent::SupplierDeleted); + } + } + fn delete_cash_discount_term(&mut self) { + if let Some(id) = self.selected_cash_discount_term_id.clone() { + self.spawn_master_delete( + "/api/v1/cash-discount-terms", + id, + AdminEvent::CashDiscountTermDeleted, + ); + } + } + fn delete_item(&mut self) { + if let Some(id) = self.selected_item_id.clone() { + self.spawn_master_delete("/api/v1/items", id, AdminEvent::ItemDeleted); + } + } + fn delete_quote(&mut self) { + if let Some(id) = self.selected_quote_id.clone() { + self.spawn_master_delete("/api/v1/quotes", id, AdminEvent::QuoteDeleted); + } + } + fn delete_outgoing_invoice(&mut self) { + if let Some(id) = self.selected_outgoing_invoice_id.clone() { + self.spawn_master_delete( + "/api/v1/outgoing-invoices", + id, + AdminEvent::OutgoingInvoiceDeleted, + ); + } + } + fn finalize_outgoing_invoice(&mut self) { + let Some(token) = self.access_token.clone() else { + return; + }; + let Some(id) = self.selected_outgoing_invoice_id.clone() else { + return; + }; + let base = self.api_base_url.clone(); + let sender = self.admin_sender.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async move { + let path = format!("/api/v1/outgoing-invoices/{id}/finalize"); + let _ = sender.send(AdminEvent::OutgoingInvoiceFinalized( + post_empty_action(&base, &token, &path).await, + )); + }); + }); + } + fn delete_incoming_invoice(&mut self) { + if let Some(id) = self.selected_incoming_invoice_id.clone() { + self.spawn_master_delete( + "/api/v1/incoming-invoices", + id, + AdminEvent::IncomingInvoiceDeleted, + ); + } + } + fn delete_activity(&mut self) { + if let Some(id) = self.selected_activity_id.clone() { + self.spawn_master_delete("/api/v1/activities", id, AdminEvent::ActivityDeleted); + } + } + fn delete_api_connector(&mut self) { + if let Some(id) = self.selected_api_connector_id.clone() { + self.spawn_master_delete( + "/api/v1/api-connectors", + id, + AdminEvent::ApiConnectorDeleted, + ); + } + } + fn delete_price_rule(&mut self) { + if let Some(id) = self.selected_price_rule_id.clone() { + self.spawn_master_delete("/api/v1/price-rules", id, AdminEvent::PriceRuleDeleted); + } + } + + fn open_dashboard(&mut self) { + self.view = AppView::Status; + } + + fn open_outgoing_invoices_window(&mut self) { + self.restore_window("outgoing_invoices"); + self.outgoing_invoices_window_open = true; + self.load_outgoing_invoices(); + self.load_customers(); + self.load_items(); + } + + fn open_quotes_window(&mut self) { + self.restore_window("quotes"); + self.quotes_window_open = true; + self.load_quotes(); + self.load_customers(); + self.load_items(); + } + + fn open_incoming_invoices_window(&mut self) { + self.restore_window("incoming_invoices"); + self.incoming_invoices_window_open = true; + self.load_incoming_invoices(); + self.load_suppliers(); + self.load_items(); + } + + fn open_customers_window(&mut self) { + self.restore_window("customers"); + self.customers_window_open = true; + self.load_customers(); + self.load_cash_discount_terms(); + } + + fn open_suppliers_window(&mut self) { + self.restore_window("suppliers"); + self.suppliers_window_open = true; + self.load_suppliers(); + self.load_cash_discount_terms(); + } + + fn open_items_window(&mut self) { + self.restore_window("items"); + self.items_window_open = true; + self.load_items(); + } + + fn open_activities_window(&mut self) { + self.restore_window("activities"); + self.activities_window_open = true; + self.load_activities(); + } + + fn open_price_import_window(&mut self) { + self.restore_window("price_imports"); + self.price_import_window_open = true; + } + + fn open_price_rules_window(&mut self) { + self.restore_window("price_rules"); + self.price_rules_window_open = true; + self.load_price_rules(); + } + + fn open_api_connectors_window(&mut self) { + self.restore_window("api_connectors"); + self.api_connectors_window_open = true; + self.load_api_connectors(); + } + + fn open_organization_setup_window(&mut self) { + self.restore_window("organization_setup"); + self.organization_setup_window_open = true; + self.load_organization_setup(); + } + + fn open_users_window(&mut self) { + self.restore_window("users"); + self.users_window_open = true; + if self.users.is_empty() { + self.load_users(); + } + } + + fn open_number_ranges_window(&mut self) { + self.restore_window("number_ranges"); + self.number_ranges_window_open = true; + self.load_number_ranges(); + } + + fn open_cash_discount_terms_window(&mut self) { + self.restore_window("cash_discount_terms"); + self.cash_discount_terms_window_open = true; + self.load_cash_discount_terms(); + } + + fn open_admin_registrations_window(&mut self) { + self.restore_window("admin_registrations"); + self.admin_registrations_window_open = true; + if self.registrations.is_empty() { + self.load_registrations(); + } + } + + fn minimize_window(&mut self, key: &'static str) { + self.minimized_windows.insert(key); + } + + fn restore_window(&mut self, key: &'static str) { + self.minimized_windows.remove(key); + } + + fn window_is_minimized(&self, key: &'static str) -> bool { + self.minimized_windows.contains(key) + } + + fn clear_minimized_if_closed(&mut self, key: &'static str, open: bool) { + if !open { + self.minimized_windows.remove(key); + } + } + + fn nav_dropdown( + &mut self, + ui: &mut egui::Ui, + label: &str, + items: &[(&str, fn(&mut CompanyToolApp))], + add_top_space: bool, + ) { + if add_top_space { + ui.add_space(12.0); + } + ui.scope(|ui| { + let visuals = ui.visuals_mut(); + visuals.widgets.inactive.bg_fill = accent_soft_color(); + visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, accent_dark_color()); + visuals.widgets.hovered.bg_fill = egui::Color32::from_rgb(199, 235, 230); + visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, accent_dark_color()); + visuals.widgets.active.bg_fill = accent_soft_color(); + visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, accent_dark_color()); + visuals.widgets.open.bg_fill = accent_soft_color(); + visuals.widgets.open.fg_stroke = egui::Stroke::new(1.0, accent_dark_color()); + ui.set_min_width(200.0); + ui.menu_button( + egui::RichText::new(label) + .color(accent_dark_color()) + .strong(), + |ui| { + let visuals = ui.visuals_mut(); + visuals.widgets.inactive.bg_fill = surface_color(); + visuals.widgets.inactive.fg_stroke = + egui::Stroke::new(1.0, accent_dark_color()); + visuals.widgets.hovered.bg_fill = accent_soft_color(); + visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, accent_dark_color()); + visuals.widgets.active.bg_fill = accent_color(); + visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE); + ui.set_min_width(220.0); + for (item_label, action) in items { + let (rect, response) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), 24.0), + egui::Sense::click(), + ); + let active = response.hovered() || response.has_focus(); + let bg = if active { + accent_dark_color() + } else { + surface_color() + }; + let fg = if active { + egui::Color32::WHITE + } else { + accent_dark_color() + }; + ui.painter().rect_filled(rect, 4.0, bg); + ui.painter().text( + rect.left_center() + egui::vec2(8.0, 0.0), + egui::Align2::LEFT_CENTER, + *item_label, + egui::FontId::proportional(13.0), + fg, + ); + if response.clicked() { + action(self); + ui.close_menu(); + } + } + }, + ); + }); + } + + fn render_taskbar(&mut self, ui: &mut egui::Ui) { + ui.horizontal_wrapped(|ui| { + self.taskbar_button(ui, "users", self.users_window_open, "Benutzerrechte"); + self.taskbar_button( + ui, + "organization_setup", + self.organization_setup_window_open, + "Firmendaten", + ); + self.taskbar_button( + ui, + "number_ranges", + self.number_ranges_window_open, + "Nummernkreise", + ); + self.taskbar_button( + ui, + "admin_registrations", + self.admin_registrations_window_open, + "Freischaltung", + ); + self.taskbar_button(ui, "customers", self.customers_window_open, "Kunden"); + self.taskbar_button(ui, "suppliers", self.suppliers_window_open, "Lieferanten"); + self.taskbar_button(ui, "items", self.items_window_open, "Artikel"); + self.taskbar_button( + ui, + "price_imports", + self.price_import_window_open, + "Preislisten", + ); + self.taskbar_button( + ui, + "api_connectors", + self.api_connectors_window_open, + "Preis-APIs", + ); + self.taskbar_button( + ui, + "price_rules", + self.price_rules_window_open, + "Preisregeln", + ); + self.taskbar_button( + ui, + "cash_discount_terms", + self.cash_discount_terms_window_open, + "Skonto", + ); + self.taskbar_button(ui, "quotes", self.quotes_window_open, "Angebote"); + self.taskbar_button( + ui, + "outgoing_invoices", + self.outgoing_invoices_window_open, + "Ausgangsrechnungen", + ); + self.taskbar_button( + ui, + "incoming_invoices", + self.incoming_invoices_window_open, + "Eingangsrechnungen", + ); + self.taskbar_button(ui, "activities", self.activities_window_open, "Aktivitäten"); + }); + } + + fn taskbar_button(&mut self, ui: &mut egui::Ui, key: &'static str, open: bool, label: &str) { + if !open { + return; + } + let minimized = self.window_is_minimized(key); + let button = egui::Button::new( + egui::RichText::new(label) + .color(if minimized { + accent_dark_color() + } else { + egui::Color32::WHITE + }) + .size(12.0) + .strong(), + ) + .fill(if minimized { + accent_soft_color() + } else { + accent_dark_color() + }) + .rounding(6.0); + if ui.add_sized([170.0, 30.0], button).clicked() { + self.restore_window(key); + } + } + + fn spawn_master_delete(&mut self, path: &'static str, id: String, event: F) + where + F: FnOnce(EmptyResult) -> AdminEvent + Send + 'static, + { + let Some(token) = self.access_token.clone() else { + return; + }; + let base = self.api_base_url.clone(); + let sender = self.admin_sender.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async move { + let _ = sender.send(event(delete_master_record(&base, &token, path, &id).await)); + }); + }); + } +} + +impl eframe::App for CompanyToolApp { + fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) { + apply_app_style(ctx); + self.drain_messages(); + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + + egui::SidePanel::left("navigation") + .exact_width(244.0) + .frame(egui::Frame::side_top_panel(&ctx.style()).fill(surface_color())) + .show(ctx, |ui| { + ui.add_space(10.0); + brand(ui, self.logo_texture.as_ref()); + ui.add_space(20.0); + + let nav_height = if self.authenticated_email.is_some() { + (ui.available_height() - 170.0).max(180.0) + } else { + ui.available_height() + }; + let nav_button_height = self.navigation_mode.button_height(); + + egui::ScrollArea::vertical() + .id_salt("navigation_menu") + .max_height(nav_height) + .auto_shrink([false, false]) + .show(ui, |ui| { + if self.authenticated_email.is_some() { + if self.navigation_mode == NavigationMode::Groups { + self.nav_dropdown( + ui, + "Vorgänge", + &[ + ("Dashboard", CompanyToolApp::open_dashboard), + ( + "Ausgangsrechnungen", + CompanyToolApp::open_outgoing_invoices_window, + ), + ("Angebote", CompanyToolApp::open_quotes_window), + ( + "Eingangsrechnungen", + CompanyToolApp::open_incoming_invoices_window, + ), + ], + false, + ); + self.nav_dropdown( + ui, + "Stammdaten", + &[ + ("Kunden", CompanyToolApp::open_customers_window), + ("Lieferanten", CompanyToolApp::open_suppliers_window), + ("Artikel", CompanyToolApp::open_items_window), + ("Aktivitäten", CompanyToolApp::open_activities_window), + ], + true, + ); + self.nav_dropdown( + ui, + "Arbeitsdaten", + &[("Preislisten", CompanyToolApp::open_price_import_window)], + true, + ); + self.nav_dropdown( + ui, + "Einstellungen", + &[ + ("Preisregeln", CompanyToolApp::open_price_rules_window), + ("Preis-APIs", CompanyToolApp::open_api_connectors_window), + ( + "Firmendaten", + CompanyToolApp::open_organization_setup_window, + ), + ("Benutzerrechte", CompanyToolApp::open_users_window), + ( + "Nummernkreise", + CompanyToolApp::open_number_ranges_window, + ), + ("Skonto", CompanyToolApp::open_cash_discount_terms_window), + ( + "Freischaltung", + CompanyToolApp::open_admin_registrations_window, + ), + ], + true, + ); + } else { + nav_group_label(ui, "Vorgänge"); + self.nav_button(ui, AppView::Status, "Dashboard"); + if ui + .add_sized( + [200.0, nav_button_height], + sidebar_action_button("Ausgangsrechnungen"), + ) + .clicked() + { + self.open_outgoing_invoices_window(); + } + if ui + .add_sized( + [200.0, nav_button_height], + sidebar_action_button("Angebote"), + ) + .clicked() + { + self.open_quotes_window(); + } + if ui + .add_sized( + [200.0, nav_button_height], + sidebar_action_button("Eingangsrechnungen"), + ) + .clicked() + { + self.open_incoming_invoices_window(); + } + + nav_group_label(ui, "Stammdaten"); + if ui + .add_sized( + [200.0, nav_button_height], + sidebar_action_button("Kunden"), + ) + .clicked() + { + self.open_customers_window(); + } + if ui + .add_sized( + [200.0, nav_button_height], + sidebar_action_button("Lieferanten"), + ) + .clicked() + { + self.open_suppliers_window(); + } + if ui + .add_sized( + [200.0, nav_button_height], + sidebar_action_button("Artikel"), + ) + .clicked() + { + self.open_items_window(); + } + if ui + .add_sized( + [200.0, nav_button_height], + sidebar_action_button("Aktivitäten"), + ) + .clicked() + { + self.open_activities_window(); + } + + nav_group_label(ui, "Arbeitsdaten"); + if ui + .add_sized( + [200.0, nav_button_height], + sidebar_action_button("Preislisten"), + ) + .clicked() + { + self.open_price_import_window(); + } + + nav_group_label(ui, "Einstellungen"); + if ui + .add_sized( + [200.0, nav_button_height], + sidebar_action_button("Preisregeln"), + ) + .clicked() + { + self.open_price_rules_window(); + } + if ui + .add_sized( + [200.0, nav_button_height], + sidebar_action_button("Preis-APIs"), + ) + .clicked() + { + self.open_api_connectors_window(); + } + if ui + .add_sized( + [200.0, nav_button_height], + sidebar_action_button("Firmendaten"), + ) + .clicked() + { + self.open_organization_setup_window(); + } + if ui + .add_sized( + [200.0, nav_button_height], + sidebar_action_button("Benutzerrechte"), + ) + .clicked() + { + self.open_users_window(); + } + if ui + .add_sized( + [200.0, nav_button_height], + sidebar_action_button("Nummernkreise"), + ) + .clicked() + { + self.open_number_ranges_window(); + } + if ui + .add_sized( + [200.0, nav_button_height], + sidebar_action_button("Skonto"), + ) + .clicked() + { + self.open_cash_discount_terms_window(); + } + if ui + .add_sized( + [200.0, nav_button_height], + sidebar_action_button("Freischaltung"), + ) + .clicked() + { + self.open_admin_registrations_window(); + } + } + } else { + self.nav_button(ui, AppView::Login, "Login"); + self.nav_button(ui, AppView::Register, "Registrierung"); + } + }); + + if let Some(email) = self.authenticated_email.clone() { + ui.add_space(22.0); + ui.separator(); + ui.add_space(12.0); + let mut selected_mode = self.navigation_mode; + ui.scope(|ui| { + let visuals = ui.visuals_mut(); + visuals.widgets.inactive.bg_fill = accent_soft_color(); + visuals.widgets.inactive.fg_stroke = + egui::Stroke::new(1.0, accent_dark_color()); + visuals.widgets.hovered.bg_fill = egui::Color32::from_rgb(199, 235, 230); + visuals.widgets.hovered.fg_stroke = + egui::Stroke::new(1.0, accent_dark_color()); + visuals.widgets.active.bg_fill = accent_soft_color(); + visuals.widgets.active.fg_stroke = + egui::Stroke::new(1.0, accent_dark_color()); + visuals.widgets.open.bg_fill = accent_soft_color(); + visuals.widgets.open.fg_stroke = + egui::Stroke::new(1.0, accent_dark_color()); + egui::ComboBox::from_id_salt("navigation_mode") + .selected_text( + egui::RichText::new(selected_mode.label()) + .color(accent_dark_color()) + .strong(), + ) + .show_ui(ui, |ui| { + let visuals = ui.visuals_mut(); + visuals.widgets.inactive.bg_fill = surface_color(); + visuals.widgets.inactive.fg_stroke = + egui::Stroke::new(1.0, accent_dark_color()); + visuals.widgets.hovered.bg_fill = accent_soft_color(); + visuals.widgets.hovered.fg_stroke = + egui::Stroke::new(1.0, accent_dark_color()); + visuals.widgets.active.bg_fill = accent_color(); + visuals.widgets.active.fg_stroke = + egui::Stroke::new(1.0, egui::Color32::WHITE); + ui.selectable_value( + &mut selected_mode, + NavigationMode::Scroll, + egui::RichText::new(NavigationMode::Scroll.label()) + .color(accent_dark_color()) + .strong(), + ); + ui.selectable_value( + &mut selected_mode, + NavigationMode::Groups, + egui::RichText::new(NavigationMode::Groups.label()) + .color(accent_dark_color()) + .strong(), + ); + }); + }); + if selected_mode != self.navigation_mode { + self.save_navigation_mode(selected_mode); + } + if !self.navigation_settings_status.is_empty() { + ui.label( + egui::RichText::new(&self.navigation_settings_status) + .color(muted_text_color()) + .size(12.0), + ); + } + ui.add_space(8.0); + ui.label( + egui::RichText::new(email) + .color(muted_text_color()) + .size(13.0) + .strong(), + ); + ui.add_space(8.0); + if ui + .add_sized([180.0, 34.0], sidebar_action_button("Abmelden")) + .clicked() + { + self.logout(); + } + } + }); + + egui::TopBottomPanel::bottom("window_taskbar") + .exact_height(46.0) + .frame(egui::Frame::side_top_panel(&ctx.style()).fill(surface_color())) + .show(ctx, |ui| { + ui.add_space(6.0); + self.render_taskbar(ui); + }); + + egui::CentralPanel::default() + .frame(egui::Frame::central_panel(&ctx.style()).fill(background_color())) + .show(ctx, |ui| { + ui.add_space(12.0); + egui::ScrollArea::vertical().show(ui, |ui| match self.view { + AppView::Login => self.login_view(ui), + AppView::Status => self.status_view(ui), + AppView::Register => self.registration_view(ui), + }); + }); + + macro_rules! managed_window { + ($key:literal, $field:ident, $title:literal, $body:expr $(, $method:ident($($arg:expr),*))* $(,)?) => { + if self.$field && !self.window_is_minimized($key) { + let mut open = self.$field; + let mut minimize = false; + let screen = ctx.screen_rect(); + let window_area = egui::Rect::from_min_max( + screen.min, + egui::pos2(screen.max.x, screen.max.y - 46.0), + ); + egui::Window::new($title) + .open(&mut open) + .collapsible(false) + .constrain_to(window_area) + $( + .$method($($arg),*) + )* + .show(ctx, |ui| { + if window_minimize_control(ui) { + minimize = true; + } + egui::ScrollArea::vertical().show(ui, |ui| $body(self, ui)); + }); + self.$field = open; + self.clear_minimized_if_closed($key, open); + if minimize { + self.minimize_window($key); + } + } + }; + } + + managed_window!( + "users", + users_window_open, + "Benutzerrechte", + |app: &mut CompanyToolApp, ui| app.users_view(ui), + min_width(760.0), + default_width(900.0), + default_height(620.0), + resizable(true) + ); + managed_window!( + "organization_setup", + organization_setup_window_open, + "Firmendaten", + |app: &mut CompanyToolApp, ui| app.organization_setup_view(ui), + min_width(760.0), + default_width(900.0), + default_height(620.0), + resizable(true) + ); + managed_window!( + "number_ranges", + number_ranges_window_open, + "Nummernkreise", + |app: &mut CompanyToolApp, ui| app.number_ranges_view(ui), + min_width(760.0), + default_width(900.0), + default_height(620.0), + resizable(true) + ); + managed_window!( + "admin_registrations", + admin_registrations_window_open, + "Freischaltung", + |app: &mut CompanyToolApp, ui| app.registrations_view(ui), + min_width(760.0), + default_width(900.0), + default_height(620.0), + resizable(true) + ); + managed_window!( + "customers", + customers_window_open, + "Kunden", + |app: &mut CompanyToolApp, ui| app.customers_view(ui), + min_width(820.0), + default_width(980.0), + default_height(700.0), + resizable(true) + ); + managed_window!( + "suppliers", + suppliers_window_open, + "Lieferanten", + |app: &mut CompanyToolApp, ui| app.suppliers_view(ui), + default_width(980.0) + ); + managed_window!( + "items", + items_window_open, + "Artikel", + |app: &mut CompanyToolApp, ui| app.items_view(ui), + default_width(900.0) + ); + managed_window!( + "price_imports", + price_import_window_open, + "Preislisten", + |app: &mut CompanyToolApp, ui| app.price_imports_view(ui), + default_width(980.0), + default_height(720.0) + ); + managed_window!( + "api_connectors", + api_connectors_window_open, + "Preis-APIs", + |app: &mut CompanyToolApp, ui| app.api_connectors_view(ui), + default_width(980.0), + default_height(720.0) + ); + managed_window!( + "price_rules", + price_rules_window_open, + "Preisregeln", + |app: &mut CompanyToolApp, ui| app.price_rules_view(ui), + default_width(940.0), + default_height(680.0) + ); + managed_window!( + "cash_discount_terms", + cash_discount_terms_window_open, + "Skonto", + |app: &mut CompanyToolApp, ui| app.cash_discount_terms_view(ui), + default_width(900.0) + ); + managed_window!( + "quotes", + quotes_window_open, + "Angebote", + |app: &mut CompanyToolApp, ui| app.quotes_view(ui), + default_width(1080.0), + default_height(760.0) + ); + managed_window!( + "outgoing_invoices", + outgoing_invoices_window_open, + "Rechnungen", + |app: &mut CompanyToolApp, ui| app.outgoing_invoices_view(ui), + default_width(1080.0), + default_height(760.0) + ); + managed_window!( + "incoming_invoices", + incoming_invoices_window_open, + "Eingangsrechnungen", + |app: &mut CompanyToolApp, ui| app.incoming_invoices_view(ui), + default_width(1080.0), + default_height(760.0) + ); + managed_window!( + "activities", + activities_window_open, + "Aktivitäten", + |app: &mut CompanyToolApp, ui| app.activities_view(ui), + default_width(900.0) + ); + } +} + +impl CompanyToolApp { + fn nav_button(&mut self, ui: &mut egui::Ui, view: AppView, label: &str) { + let selected = self.view == view; + let button = egui::Button::new( + egui::RichText::new(label) + .color(if selected { + accent_dark_color() + } else { + egui::Color32::WHITE + }) + .strong(), + ) + .fill(if selected { + accent_soft_color() + } else { + accent_dark_color() + }) + .stroke(egui::Stroke::NONE) + .rounding(6.0); + + if ui + .add_sized([200.0, self.navigation_mode.button_height()], button) + .clicked() + { + self.view = view; + } + ui.add_space(4.0); + } + + fn login_view(&mut self, ui: &mut egui::Ui) { + page_header(ui, "Login", "Anmeldung mit E-Mail-Adresse und Passwort."); + + panel(ui, |ui| { + ui.set_max_width(620.0); + form_row(ui, "E-Mail-Adresse", |ui| { + ui.text_edit_singleline(&mut self.login_email); + }); + form_row(ui, "Passwort", |ui| { + ui.add(egui::TextEdit::singleline(&mut self.login_password).password(true)); + }); + + ui.add_space(12.0); + let submit = ui.add_enabled(!self.login_pending, primary_action_button("Einloggen")); + if submit.clicked() { + self.submit_login(); + } + + if !self.login_status.is_empty() { + ui.add_space(10.0); + ui.label(egui::RichText::new(&self.login_status).color(muted_text_color())); + } + }); + + panel(ui, |ui| { + ui.set_max_width(620.0); + ui.heading("Dev-Bootstrap"); + ui.label( + egui::RichText::new( + "Lokale Testfirma mit erstem User anlegen, ohne E-Mail-Versand.", + ) + .color(muted_text_color()), + ); + ui.add_space(10.0); + form_row(ui, "Firmenname", |ui| { + ui.text_edit_singleline(&mut self.dev_organization_name); + }); + form_row(ui, "E-Mail-Adresse", |ui| { + ui.text_edit_singleline(&mut self.dev_email); + }); + let submit = ui.add_enabled( + !self.dev_pending, + primary_action_button("Testfirma und User anlegen"), + ); + if submit.clicked() { + self.submit_dev_bootstrap(); + } + if !self.dev_status.is_empty() { + ui.add_space(10.0); + ui.label(egui::RichText::new(&self.dev_status).color(muted_text_color())); + } + if let Some(credentials) = &self.dev_credentials { + ui.add_space(10.0); + ui.monospace(format!("Login: {}", credentials.email)); + ui.monospace(format!("Passwort: {}", credentials.password)); + ui.monospace(format!("User: {}", credentials.user_id)); + ui.monospace(format!("Firma: {}", credentials.organization_id)); + ui.monospace(format!("Schema: {}", credentials.schema_name)); + ui.monospace(format!("Dev-Modus: {}", credentials.dev_mode)); + } + }); + } + + fn status_view(&mut self, ui: &mut egui::Ui) { + page_header( + ui, + "Dashboard", + "Übersicht über Backend-Verbindung und aktuelle Vorgänge.", + ); + + panel(ui, |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.heading("Live-Verbindung"); + ui.label(egui::RichText::new(&self.status).color(muted_text_color())); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.monospace(&self.ws_url); + }); + }); + }); + + panel(ui, |ui| { + ui.heading("Aktuelle Datensätze"); + ui.add_space(10.0); + egui::Grid::new("records") + .striped(true) + .spacing([28.0, 8.0]) + .show(ui, |ui| { + ui.strong("Titel"); + ui.strong("Aktualisiert"); + ui.end_row(); + + for record in &self.records { + ui.label(&record.title); + ui.label(record.updated_at.to_rfc3339()); + ui.end_row(); + } + }); + }); + } + + fn registration_view(&mut self, ui: &mut egui::Ui) { + page_header( + ui, + "Firma registrieren", + "Neue Organization für den SaaS-Betrieb vormerken.", + ); + + panel(ui, |ui| { + ui.set_max_width(620.0); + form_row(ui, "Firmenname", |ui| { + ui.text_edit_singleline(&mut self.organization_name); + }); + form_row(ui, "E-Mail-Adresse", |ui| { + ui.text_edit_singleline(&mut self.registration_email); + }); + ui.checkbox(&mut self.accept_terms, "Nutzungsbedingungen akzeptieren"); + ui.add_space(12.0); + + let submit = ui.add_enabled( + !self.registration_pending, + primary_action_button("Registrierung absenden"), + ); + if submit.clicked() { + self.submit_registration(); + } + + if !self.registration_status.is_empty() { + ui.add_space(10.0); + ui.label(egui::RichText::new(&self.registration_status).color(muted_text_color())); + } + }); + } + + fn users_view(&mut self, ui: &mut egui::Ui) { + page_header( + ui, + "Benutzerrechte", + "Benutzer einladen und Rollen verwalten.", + ); + + panel(ui, |ui| { + ui.set_max_width(720.0); + form_row(ui, "E-Mail-Adresse", |ui| { + ui.text_edit_singleline(&mut self.invite_email); + }); + ui.label( + egui::RichText::new("Rollen") + .strong() + .color(muted_text_color()), + ); + role_checkboxes(ui, &mut self.invite_roles); + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui + .add_enabled( + !self.users_pending, + primary_action_button("Einladung senden"), + ) + .clicked() + { + self.invite_user(); + } + if ui + .add_enabled(!self.users_pending, egui::Button::new("Benutzer laden")) + .clicked() + { + self.load_users(); + } + }); + ui.add_space(10.0); + ui.label(egui::RichText::new(&self.users_status).color(muted_text_color())); + ui.label(egui::RichText::new(&self.live_update_status).color(muted_text_color())); + }); + + panel(ui, |ui| { + ui.heading("Benutzerliste"); + ui.add_space(10.0); + if self.users.is_empty() { + ui.label(egui::RichText::new(&self.users_status).color(muted_text_color())); + return; + } + + ui.horizontal(|ui| { + ui.add_sized( + [220.0, 22.0], + egui::Label::new(egui::RichText::new("E-Mail").strong()), + ); + ui.add_sized( + [80.0, 22.0], + egui::Label::new(egui::RichText::new("Status").strong()), + ); + ui.add_sized( + [300.0, 22.0], + egui::Label::new(egui::RichText::new("Rollen").strong()), + ); + ui.add_sized( + [100.0, 22.0], + egui::Label::new(egui::RichText::new("Aktion").strong()), + ); + }); + ui.separator(); + + for index in 0..self.users.len() { + ui.horizontal(|ui| { + ui.add_sized([220.0, 24.0], egui::Label::new(&self.users[index].email)); + ui.add_sized([80.0, 24.0], egui::Label::new(&self.users[index].status)); + ui.vertical(|ui| { + ui.set_min_width(300.0); + role_checkboxes(ui, &mut self.users[index].roles); + }); + let user_id = self.users[index].user_id.clone(); + let roles = self.users[index].roles.clone(); + let saving = self.saving_user_id.as_deref() == Some(user_id.as_str()); + if ui + .add_enabled(!saving, egui::Button::new("Speichern")) + .clicked() + { + self.save_user_roles(user_id, roles); + } + }); + ui.add_space(6.0); + ui.separator(); + ui.add_space(6.0); + } + }); + } + + fn organization_setup_view(&mut self, ui: &mut egui::Ui) { + page_header( + ui, + "Firmendaten", + "Stammdaten der aktiven Organization erfassen.", + ); + panel(ui, |ui| { + ui.set_max_width(720.0); + form_row(ui, "Firmenname", |ui| { + ui.text_edit_singleline(&mut self.organization_setup.display_name); + }); + form_row(ui, "Rechtsform", |ui| { + ui.text_edit_singleline(&mut self.organization_setup.legal_form); + }); + form_row(ui, "Straße und Hausnummer", |ui| { + ui.text_edit_singleline(&mut self.organization_setup.street); + }); + form_row(ui, "PLZ", |ui| { + ui.text_edit_singleline(&mut self.organization_setup.postal_code); + }); + form_row(ui, "Ort", |ui| { + ui.text_edit_singleline(&mut self.organization_setup.city); + }); + form_row(ui, "Land", |ui| { + ui.text_edit_singleline(&mut self.organization_setup.country); + }); + form_row(ui, "USt-IdNr.", |ui| { + ui.text_edit_singleline(&mut self.organization_setup.vat_id); + }); + form_row(ui, "E-Mail der Firma", |ui| { + ui.text_edit_singleline(&mut self.organization_setup.email); + }); + form_row(ui, "Telefon", |ui| { + ui.text_edit_singleline(&mut self.organization_setup.phone); + }); + form_row(ui, "Standard-Steuersatz", |ui| { + ui.text_edit_singleline(&mut self.organization_setup.default_tax_rate); + }); + form_row(ui, "Standard-Zahlungsziel", |ui| { + ui.text_edit_singleline(&mut self.organization_setup.default_payment_days); + }); + if ui + .add_enabled( + !self.organization_setup_pending, + primary_action_button("Firmendaten speichern"), + ) + .clicked() + { + self.save_organization_setup(); + } + if !self.organization_setup_status.is_empty() { + ui.add_space(10.0); + ui.label( + egui::RichText::new(&self.organization_setup_status).color(muted_text_color()), + ); + } + ui.label(egui::RichText::new(&self.live_update_status).color(muted_text_color())); + }); + } + + fn registrations_view(&mut self, ui: &mut egui::Ui) { + page_header( + ui, + "Freischaltungen", + "Offene und entschiedene Organization-Registrierungen.", + ); + panel(ui, |ui| { + if ui + .add_enabled( + !self.registrations_pending, + primary_action_button("Aktualisieren"), + ) + .clicked() + { + self.load_registrations(); + } + ui.add_space(10.0); + ui.label(egui::RichText::new(&self.registrations_status).color(muted_text_color())); + ui.label(egui::RichText::new(&self.live_update_status).color(muted_text_color())); + }); + + panel(ui, |ui| { + ui.heading("Registrierungen"); + ui.add_space(10.0); + if self.registrations.is_empty() { + ui.label(egui::RichText::new(&self.registrations_status).color(muted_text_color())); + return; + } + + egui::Grid::new("registrations_grid") + .striped(true) + .spacing([18.0, 8.0]) + .show(ui, |ui| { + ui.strong("Firma"); + ui.strong("E-Mail"); + ui.strong("Status"); + ui.strong("Eingegangen"); + ui.strong("Aktion"); + ui.end_row(); + + for registration in self.registrations.clone() { + ui.label(registration.organization_name); + ui.label(registration.email); + ui.label(registration.status.clone()); + ui.label(registration.requested_at); + if ui + .add_enabled( + !self.registrations_pending + && registration.status == "pending_approval", + egui::Button::new("Freischalten"), + ) + .clicked() + { + self.approve_registration(registration.id); + } + ui.end_row(); + } + }); + }); + } + + fn number_ranges_view(&mut self, ui: &mut egui::Ui) { + page_header( + ui, + "Nummernkreise", + "Individuelle Nummern für Stammdaten, Angebote und Rechnungen.", + ); + master_detail!( + ui, + { + panel(ui, |ui| { + ui.set_min_width(280.0); + for range in self.number_ranges.clone() { + if ui + .selectable_label( + self.selected_number_range_code.as_deref() == Some(&range.code), + format!( + "{} {} {}", + number_range_label(&range.code), + range.pattern, + &range.id[..range.id.len().min(8)] + ), + ) + .clicked() + { + self.selected_number_range_code = Some(range.code.clone()); + self.number_range_form = NumberRangeForm::from(&range); + } + } + }); + }, + { + panel(ui, |ui| { + ui.set_min_width(520.0); + form_row(ui, "Muster", |ui| { + ui.text_edit_singleline(&mut self.number_range_form.pattern); + }); + form_row(ui, "Zählerstand", |ui| { + ui.add( + egui::DragValue::new(&mut self.number_range_form.counter_value) + .range(0..=i64::MAX), + ); + }); + form_row(ui, "Zählerlänge", |ui| { + ui.add( + egui::DragValue::new(&mut self.number_range_form.counter_padding) + .range(1..=18), + ); + }); + form_row(ui, "Reset-Regel", |ui| { + ui.text_edit_singleline( + self.number_range_form + .reset_rule + .get_or_insert(String::new()), + ); + }); + ui.checkbox(&mut self.number_range_form.is_active, "Aktiv"); + ui.label("Das Muster muss {counter} enthalten."); + if ui + .add_enabled( + self.selected_number_range_code.is_some(), + primary_action_button("Speichern"), + ) + .clicked() + { + self.save_number_range(); + } + ui.label(&self.number_ranges_status); + }); + }, + ); + } + + fn customers_view(&mut self, ui: &mut egui::Ui) { + page_header(ui, "Kunden", "Kundenstamm, Rabatt und Kontaktdaten."); + master_detail!( + ui, + { + panel(ui, |ui| { + ui.set_min_width(250.0); + ui.horizontal(|ui| { + ui.heading("Kundenliste"); + if ui.button("Neu").clicked() { + self.new_customer(); + } + }); + ui.add_space(8.0); + ui.text_edit_singleline(&mut self.customer_list_search); + ui.add_space(6.0); + let filtered_customers: Vec = self + .customers + .iter() + .filter(|customer| { + matches_number_or_name( + &customer.customer_number, + &customer.name, + &self.customer_list_search, + ) + }) + .cloned() + .collect(); + for customer in filtered_customers { + if ui + .selectable_label( + self.selected_customer_id.as_deref() == Some(&customer.id), + format!("{} {}", customer.customer_number, customer.name), + ) + .clicked() + { + self.selected_customer_id = Some(customer.id.clone()); + self.customer_form = CustomerForm::from(&customer); + } + } + if self.customers.is_empty() { + ui.label("Keine Kunden vorhanden."); + } else if self.customers.iter().all(|customer| { + !matches_number_or_name( + &customer.customer_number, + &customer.name, + &self.customer_list_search, + ) + }) { + ui.label("Keine Treffer."); + } + }); + }, + { + panel(ui, |ui| { + ui.set_min_width(480.0); + form_row(ui, "Kundennummer", |ui| { + ui.label(number_or_pending(&self.customer_form.customer_number)); + }); + form_row(ui, "Name", |ui| { + ui.text_edit_singleline(&mut self.customer_form.name); + }); + form_row(ui, "Status", |ui| { + egui::ComboBox::from_id_salt("customer_status") + .selected_text(&self.customer_form.status) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.customer_form.status, + "active".to_string(), + "Aktiv", + ); + ui.selectable_value( + &mut self.customer_form.status, + "inactive".to_string(), + "Inaktiv", + ); + ui.selectable_value( + &mut self.customer_form.status, + "blocked".to_string(), + "Gesperrt", + ); + }); + }); + form_row(ui, "Standardrabatt %", |ui| { + ui.text_edit_singleline(&mut self.customer_form.standard_discount_percent); + }); + let terms = self.cash_discount_terms.clone(); + form_row(ui, "Skonto-Regel", |ui| { + cash_discount_combo( + ui, + "customer_cash_discount", + &mut self.customer_form.cash_discount_term_id, + &terms, + ); + }); + form_row(ui, "Straße", |ui| { + ui.text_edit_singleline(&mut self.customer_form.details.street); + }); + form_row(ui, "PLZ", |ui| { + ui.text_edit_singleline(&mut self.customer_form.details.postal_code); + }); + form_row(ui, "Ort", |ui| { + ui.text_edit_singleline(&mut self.customer_form.details.city); + }); + form_row(ui, "Land", |ui| { + ui.text_edit_singleline(&mut self.customer_form.details.country); + }); + form_row(ui, "E-Mail", |ui| { + ui.text_edit_singleline(&mut self.customer_form.details.email); + }); + form_row(ui, "Telefon", |ui| { + ui.text_edit_singleline(&mut self.customer_form.details.phone); + }); + ui.horizontal(|ui| { + if ui + .add_enabled( + !self.customers_pending, + primary_action_button("Speichern"), + ) + .clicked() + { + self.save_customer(); + } + if ui + .add_enabled( + !self.customers_pending && self.selected_customer_id.is_some(), + egui::Button::new("Deaktivieren"), + ) + .clicked() + { + self.deactivate_customer(); + } + }); + ui.add_space(8.0); + ui.label(egui::RichText::new(&self.customers_status).color(muted_text_color())); + }); + }, + ); + } + + fn suppliers_view(&mut self, ui: &mut egui::Ui) { + page_header( + ui, + "Lieferanten", + "Lieferantenstamm und Zahlungskonditionen.", + ); + master_detail!( + ui, + { + panel(ui, |ui| { + ui.set_min_width(250.0); + if ui.button("Neuer Lieferant").clicked() { + self.selected_supplier_id = None; + self.supplier_form = SupplierForm::default(); + self.suppliers_status = "Lieferantennummer wird reserviert.".to_string(); + self.reserve_next_number("suppliers"); + } + ui.text_edit_singleline(&mut self.supplier_list_search); + ui.add_space(6.0); + let filtered_suppliers: Vec = self + .suppliers + .iter() + .filter(|supplier| { + matches_number_or_name( + &supplier.supplier_number, + &supplier.name, + &self.supplier_list_search, + ) + }) + .cloned() + .collect(); + for supplier in filtered_suppliers { + if ui + .selectable_label( + self.selected_supplier_id.as_deref() == Some(&supplier.id), + format!("{} {}", supplier.supplier_number, supplier.name), + ) + .clicked() + { + self.selected_supplier_id = Some(supplier.id.clone()); + self.supplier_form = SupplierForm::from(&supplier); + } + } + }); + }, + { + panel(ui, |ui| { + ui.set_min_width(480.0); + form_row(ui, "Lieferantennummer", |ui| { + ui.label(number_or_pending(&self.supplier_form.supplier_number)); + }); + form_row(ui, "Name", |ui| { + ui.text_edit_singleline(&mut self.supplier_form.name); + }); + form_row(ui, "Rabatt %", |ui| { + ui.text_edit_singleline(&mut self.supplier_form.standard_discount_percent); + }); + let terms = self.cash_discount_terms.clone(); + form_row(ui, "Skonto-Regel", |ui| { + cash_discount_combo( + ui, + "supplier_cash_discount", + &mut self.supplier_form.cash_discount_term_id, + &terms, + ); + }); + form_row(ui, "Zahlungsziel Tage", |ui| { + ui.add( + egui::DragValue::new(self.supplier_form.payment_days.get_or_insert(14)) + .range(0..=365), + ); + }); + form_row(ui, "Straße", |ui| { + ui.text_edit_singleline(&mut self.supplier_form.details.street); + }); + form_row(ui, "Ort", |ui| { + ui.text_edit_singleline(&mut self.supplier_form.details.city); + }); + ui.horizontal(|ui| { + if ui.button("Speichern").clicked() { + self.save_supplier(); + } + if ui + .add_enabled( + self.selected_supplier_id.is_some(), + egui::Button::new("Deaktivieren"), + ) + .clicked() + { + self.delete_supplier(); + } + }); + ui.label(&self.suppliers_status); + }); + }, + ); + } + + fn items_view(&mut self, ui: &mut egui::Ui) { + page_header(ui, "Artikel", "Artikelstamm und Standardpreise."); + master_detail!( + ui, + { + panel(ui, |ui| { + ui.set_min_width(250.0); + if ui.button("Neuer Artikel").clicked() { + self.selected_item_id = None; + self.item_form = ItemForm::default(); + self.items_status = "Artikelnummer wird reserviert.".to_string(); + self.reserve_next_number("items"); + } + ui.text_edit_singleline(&mut self.item_list_search); + ui.add_space(6.0); + let filtered_items: Vec = self + .items + .iter() + .filter(|item| { + matches_number_or_name( + &item.item_number, + &item.name, + &self.item_list_search, + ) + }) + .cloned() + .collect(); + for item in filtered_items { + if ui + .selectable_label( + self.selected_item_id.as_deref() == Some(&item.id), + format!("{} {}", item.item_number, item.name), + ) + .clicked() + { + self.selected_item_id = Some(item.id.clone()); + self.item_form = ItemForm::from(&item); + self.load_item_price_history(item.id); + } + } + }); + }, + { + panel(ui, |ui| { + ui.set_min_width(440.0); + form_row(ui, "Artikelnummer", |ui| { + ui.label(number_or_pending(&self.item_form.item_number)); + }); + form_row(ui, "Bezeichnung", |ui| { + ui.text_edit_singleline(&mut self.item_form.name); + }); + form_row(ui, "Einheit", |ui| { + ui.text_edit_singleline(&mut self.item_form.unit); + }); + form_row(ui, "Steuersatz %", |ui| { + ui.text_edit_singleline(&mut self.item_form.tax_rate); + }); + form_row(ui, "Einkaufspreis", |ui| { + ui.text_edit_singleline(&mut self.item_form.default_purchase_price); + }); + form_row(ui, "Verkaufspreis", |ui| { + ui.text_edit_singleline(&mut self.item_form.default_sales_price); + }); + ui.horizontal(|ui| { + if ui.button("Speichern").clicked() { + self.save_item(); + } + if ui + .add_enabled( + self.selected_item_id.is_some(), + egui::Button::new("Deaktivieren"), + ) + .clicked() + { + self.delete_item(); + } + }); + ui.label(&self.items_status); + ui.add_space(12.0); + ui.heading("Preishistorie"); + if self.item_price_history.is_empty() { + ui.label("Noch keine Preisänderung vorhanden."); + } + for entry in &self.item_price_history { + ui.horizontal(|ui| { + ui.label(&entry.valid_from); + ui.label(format!("ID {}", &entry.id[..entry.id.len().min(8)])); + ui.label(format!( + "EK {}", + entry.purchase_price.as_deref().unwrap_or("-") + )); + ui.label(format!( + "VK {}", + entry.sales_price.as_deref().unwrap_or("-") + )); + ui.label(&entry.source); + ui.label(format!( + "Item {}", + &entry.item_id[..entry.item_id.len().min(8)] + )); + ui.label(format!("Erfasst {}", entry.created_at)); + if let Some(user_id) = &entry.created_by_user_id { + ui.label(format!("User {}", &user_id[..user_id.len().min(8)])); + } + }); + } + }); + }, + ); + } + + fn price_imports_view(&mut self, ui: &mut egui::Ui) { + page_header( + ui, + "Preislistenimport", + "CSV-Preislisten prüfen und Artikelpreise historisiert übernehmen.", + ); + panel(ui, |ui| { + form_row(ui, "Quelle", |ui| { + ui.text_edit_singleline(&mut self.price_import_form.source_name); + }); + form_row(ui, "Trennzeichen", |ui| { + ui.text_edit_singleline( + self.price_import_form + .delimiter + .get_or_insert(";".to_string()), + ); + }); + form_row(ui, "CSV-Inhalt", |ui| { + ui.add( + egui::TextEdit::multiline(&mut self.price_import_form.content) + .desired_rows(12) + .desired_width(860.0), + ); + }); + ui.horizontal(|ui| { + if ui.button("Vorschau").clicked() { + self.preview_price_import(); + } + if ui.button("Importieren").clicked() { + self.apply_price_import(); + } + }); + ui.label(&self.price_import_status); + }); + if let Some(preview) = &self.price_import_preview { + panel(ui, |ui| { + ui.heading("Vorschau"); + ui.label(format!( + "{} Zeilen, {} gültig, {} Fehler", + preview.total_rows, preview.valid_rows, preview.error_rows + )); + ui.separator(); + egui::Grid::new("price_import_preview_grid") + .striped(true) + .show(ui, |ui| { + ui.label("Zeile"); + ui.label("Aktion"); + ui.label("Artikelnummer"); + ui.label("Name"); + ui.label("Einheit"); + ui.label("Steuer"); + ui.label("EK"); + ui.label("VK"); + ui.label("Fehler"); + ui.end_row(); + for row in &preview.rows { + ui.label(row.row_number.to_string()); + ui.label(&row.action); + ui.label(&row.item_number); + ui.label(&row.name); + ui.label(&row.unit); + ui.label(&row.tax_rate); + ui.label(row.purchase_price.as_deref().unwrap_or("-")); + ui.label(row.sales_price.as_deref().unwrap_or("-")); + ui.label(row.error.as_deref().unwrap_or("-")); + ui.end_row(); + } + }); + }); + } + } + + fn api_connectors_view(&mut self, ui: &mut egui::Ui) { + page_header( + ui, + "Preis-APIs", + "Externe Preisquellen konfigurieren und manuell abgleichen.", + ); + master_detail!( + ui, + { + panel(ui, |ui| { + ui.set_min_width(260.0); + if ui.button("Neue Preis-API").clicked() { + self.selected_api_connector_id = None; + self.api_connector_form = ApiConnectorForm::default(); + } + for connector in self.api_connectors.clone() { + if ui + .selectable_label( + self.selected_api_connector_id.as_deref() == Some(&connector.id), + format!("{} {}", connector.code, connector.name), + ) + .clicked() + { + self.selected_api_connector_id = Some(connector.id.clone()); + self.api_connector_form = ApiConnectorForm::from(&connector); + } + if let Some(last_sync_at) = &connector.last_sync_at { + ui.label( + egui::RichText::new(format!("zuletzt: {last_sync_at}")) + .color(muted_text_color()) + .size(12.0), + ); + } + } + }); + }, + { + panel(ui, |ui| { + ui.set_min_width(560.0); + form_row(ui, "Code", |ui| { + ui.text_edit_singleline(&mut self.api_connector_form.code); + }); + form_row(ui, "Name", |ui| { + ui.text_edit_singleline(&mut self.api_connector_form.name); + }); + form_row(ui, "Typ", |ui| { + ui.text_edit_singleline(&mut self.api_connector_form.connector_type); + }); + form_row(ui, "Intervall Minuten", |ui| { + ui.add( + egui::DragValue::new( + self.api_connector_form + .sync_interval_minutes + .get_or_insert(1440), + ) + .range(1..=525_600), + ); + }); + ui.checkbox(&mut self.api_connector_form.is_active, "Aktiv"); + form_row(ui, "Konfiguration JSON", |ui| { + ui.add( + egui::TextEdit::multiline(&mut self.api_connector_form.config) + .desired_rows(10) + .desired_width(520.0), + ); + }); + ui.horizontal(|ui| { + if ui.button("Speichern").clicked() { + self.save_api_connector(); + } + if ui + .add_enabled( + self.selected_api_connector_id.is_some(), + egui::Button::new("Abgleichen"), + ) + .clicked() + { + self.sync_api_connector(); + } + if ui + .add_enabled( + self.selected_api_connector_id.is_some(), + egui::Button::new("Deaktivieren"), + ) + .clicked() + { + self.delete_api_connector(); + } + }); + ui.label(&self.api_connectors_status); + }); + }, + ); + } + + fn price_rules_view(&mut self, ui: &mut egui::Ui) { + page_header( + ui, + "Preisregeln", + "Aufschläge und Rundung je Preisquelle festlegen.", + ); + master_detail!( + ui, + { + panel(ui, |ui| { + ui.set_min_width(260.0); + if ui.button("Neue Preisregel").clicked() { + self.selected_price_rule_id = None; + self.price_rule_form = PriceRuleForm::default(); + } + for rule in self.price_rules.clone() { + if ui + .selectable_label( + self.selected_price_rule_id.as_deref() == Some(&rule.id), + format!("{} {}", rule.code, rule.name), + ) + .clicked() + { + self.selected_price_rule_id = Some(rule.id.clone()); + self.price_rule_form = PriceRuleForm::from(&rule); + } + } + }); + }, + { + panel(ui, |ui| { + ui.set_min_width(520.0); + form_row(ui, "Code", |ui| { + ui.text_edit_singleline(&mut self.price_rule_form.code); + }); + form_row(ui, "Name", |ui| { + ui.text_edit_singleline(&mut self.price_rule_form.name); + }); + form_row(ui, "Quellentyp", |ui| { + egui::ComboBox::from_id_salt("price_rule_source_type") + .selected_text(&self.price_rule_form.source_type) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.price_rule_form.source_type, + "import".to_string(), + "Import", + ); + ui.selectable_value( + &mut self.price_rule_form.source_type, + "api".to_string(), + "API", + ); + ui.selectable_value( + &mut self.price_rule_form.source_type, + "supplier".to_string(), + "Lieferant", + ); + }); + }); + form_row(ui, "Quell-ID", |ui| { + ui.text_edit_singleline( + self.price_rule_form.source_id.get_or_insert(String::new()), + ); + }); + form_row(ui, "Aufschlag %", |ui| { + ui.text_edit_singleline(&mut self.price_rule_form.markup_percent); + }); + form_row(ui, "Rundung", |ui| { + egui::ComboBox::from_id_salt("price_rule_rounding") + .selected_text(&self.price_rule_form.rounding_mode) + .show_ui(ui, |ui| { + for (code, label) in [ + ("none", "Keine"), + ("cent", "Cent"), + ("five_cent", "5 Cent"), + ("ten_cent", "10 Cent"), + ("whole", "Ganze Beträge"), + ] { + ui.selectable_value( + &mut self.price_rule_form.rounding_mode, + code.to_string(), + label, + ); + } + }); + }); + ui.checkbox(&mut self.price_rule_form.is_active, "Aktiv"); + ui.horizontal(|ui| { + if ui.button("Speichern").clicked() { + self.save_price_rule(); + } + if ui + .add_enabled( + self.selected_price_rule_id.is_some(), + egui::Button::new("Deaktivieren"), + ) + .clicked() + { + self.delete_price_rule(); + } + }); + ui.label(&self.price_rules_status); + }); + }, + ); + } + + fn cash_discount_terms_view(&mut self, ui: &mut egui::Ui) { + page_header( + ui, + "Skonto", + "Zahlungsbedingungen für Kunden, Lieferanten und Belege.", + ); + master_detail!( + ui, + { + panel(ui, |ui| { + ui.set_min_width(250.0); + if ui.button("Neue Skonto-Regel").clicked() { + self.selected_cash_discount_term_id = None; + self.cash_discount_term_form = CashDiscountTermForm::default(); + } + for term in self.cash_discount_terms.clone() { + if ui + .selectable_label( + self.selected_cash_discount_term_id.as_deref() == Some(&term.id), + format!("{} {}", term.code, term.name), + ) + .clicked() + { + self.selected_cash_discount_term_id = Some(term.id.clone()); + self.cash_discount_term_form = CashDiscountTermForm::from(&term); + } + } + }); + }, + { + panel(ui, |ui| { + ui.set_min_width(520.0); + form_row(ui, "Code", |ui| { + ui.text_edit_singleline(&mut self.cash_discount_term_form.code); + }); + form_row(ui, "Name", |ui| { + ui.text_edit_singleline(&mut self.cash_discount_term_form.name); + }); + form_row(ui, "Skonto %", |ui| { + ui.text_edit_singleline(&mut self.cash_discount_term_form.discount_percent); + }); + form_row(ui, "Skontofrist Tage", |ui| { + ui.add( + egui::DragValue::new(&mut self.cash_discount_term_form.discount_days) + .range(0..=365), + ); + }); + form_row(ui, "Nettoziel Tage", |ui| { + ui.add( + egui::DragValue::new( + self.cash_discount_term_form.net_days.get_or_insert(30), + ) + .range(0..=365), + ); + }); + form_row(ui, "Gültig ab", |ui| { + ui.text_edit_singleline( + self.cash_discount_term_form + .valid_from + .get_or_insert(String::new()), + ); + }); + form_row(ui, "Gültig bis", |ui| { + ui.text_edit_singleline( + self.cash_discount_term_form + .valid_until + .get_or_insert(String::new()), + ); + }); + ui.checkbox( + &mut self.cash_discount_term_form.is_default_customer_term, + "Standard für Kunden", + ); + ui.checkbox( + &mut self.cash_discount_term_form.is_default_supplier_term, + "Standard für Lieferanten", + ); + ui.checkbox(&mut self.cash_discount_term_form.is_active, "Aktiv"); + ui.horizontal(|ui| { + if ui.button("Speichern").clicked() { + self.save_cash_discount_term(); + } + if ui + .add_enabled( + self.selected_cash_discount_term_id.is_some(), + egui::Button::new("Deaktivieren"), + ) + .clicked() + { + self.delete_cash_discount_term(); + } + }); + ui.label(&self.cash_discount_terms_status); + }); + }, + ); + } + + fn quotes_view(&mut self, ui: &mut egui::Ui) { + page_header( + ui, + "Angebote", + "Angebote mit festen Artikelpositionen und individuellen Preisen.", + ); + master_detail!( + ui, + { + panel(ui, |ui| { + ui.set_min_width(300.0); + if ui.button("Neues Angebot").clicked() { + self.selected_quote_id = None; + self.quote_form = QuoteForm::default(); + self.quotes_status = "Angebotsnummer wird reserviert.".to_string(); + self.reserve_next_number("quotes"); + } + ui.text_edit_singleline(&mut self.quote_list_search); + ui.add_space(6.0); + let quote_query = self.quote_list_search.trim().to_lowercase(); + let filtered_quotes: Vec = self + .quotes + .iter() + .filter(|quote| { + let customer = self + .customers + .iter() + .find(|customer| customer.id == quote.customer_id) + .map(|customer| customer.name.as_str()) + .unwrap_or("-"); + quote_query.is_empty() + || format!("{} {} {}", quote.quote_number, customer, quote.status) + .to_lowercase() + .contains("e_query) + }) + .cloned() + .collect(); + for quote in filtered_quotes { + let customer = self + .customers + .iter() + .find(|customer| customer.id == quote.customer_id) + .map(|customer| customer.name.as_str()) + .unwrap_or("-"); + if ui + .selectable_label( + self.selected_quote_id.as_deref() == Some("e.id), + format!("{} {} {}", quote.quote_number, customer, quote.status), + ) + .clicked() + { + self.selected_quote_id = Some(quote.id.clone()); + self.quote_form = QuoteForm::from("e); + } + } + }); + }, + { + panel(ui, |ui| { + ui.set_min_width(680.0); + form_row(ui, "Angebotsnummer", |ui| { + ui.label(number_or_pending(&self.quote_form.quote_number)); + }); + form_row(ui, "Kunde", |ui| { + customer_combo( + ui, + &mut self.quote_form.customer_id, + &self.customers, + &mut self.customer_lookup_search, + ); + }); + form_row(ui, "Status", |ui| { + egui::ComboBox::from_id_salt("quote_status") + .selected_text(&self.quote_form.status) + .show_ui(ui, |ui| { + for (code, label) in [ + ("draft", "Entwurf"), + ("sent", "Gesendet"), + ("accepted", "Angenommen"), + ("rejected", "Abgelehnt"), + ("expired", "Abgelaufen"), + ("cancelled", "Storniert"), + ] { + ui.selectable_value( + &mut self.quote_form.status, + code.to_string(), + label, + ); + } + }); + }); + form_row(ui, "Gültig bis", |ui| { + ui.text_edit_singleline( + self.quote_form.valid_until.get_or_insert(String::new()), + ); + }); + form_row(ui, "Kundenrabatt %", |ui| { + ui.text_edit_singleline(&mut self.quote_form.customer_discount_percent); + }); + form_row(ui, "Notizen", |ui| { + ui.text_edit_multiline(&mut self.quote_form.notes); + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.heading("Positionen"); + if ui.button("Position hinzufügen").clicked() { + self.quote_form.items.push(QuoteItemForm::default()); + } + }); + let mut remove_index = None; + let items = self.items.clone(); + let can_remove_quote_item = self.quote_form.items.len() > 1; + for (index, line) in self.quote_form.items.iter_mut().enumerate() { + ui.separator(); + ui.label(format!("Position {}", index + 1)); + form_row(ui, "Artikel", |ui| { + item_combo(ui, &mut line.item_id, &items, &mut self.item_lookup_search); + }); + if ui.button("Artikelwerte übernehmen").clicked() { + apply_quote_item_defaults(line, &items); + } + form_row(ui, "Beschreibung", |ui| { + ui.text_edit_singleline(&mut line.description); + }); + egui::Grid::new(format!("quote_item_fields_{index}")) + .num_columns(2) + .spacing([14.0, 8.0]) + .show(ui, |ui| { + ui.label("Menge"); + ui.text_edit_singleline(&mut line.quantity); + ui.end_row(); + ui.label("Preis"); + ui.text_edit_singleline(&mut line.unit_price); + ui.end_row(); + ui.label("Original"); + ui.text_edit_singleline( + line.original_unit_price.get_or_insert(String::new()), + ); + ui.end_row(); + ui.label("Rabatt %"); + ui.text_edit_singleline(&mut line.discount_percent); + ui.end_row(); + ui.label("Steuer %"); + ui.text_edit_singleline(&mut line.tax_rate); + ui.end_row(); + }); + if can_remove_quote_item && ui.button("Position entfernen").clicked() { + remove_index = Some(index); + } + } + if let Some(index) = remove_index { + self.quote_form.items.remove(index); + } + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui.button("Speichern").clicked() { + self.save_quote(); + } + if ui + .add_enabled( + self.selected_quote_id.is_some(), + egui::Button::new("Stornieren"), + ) + .clicked() + { + self.delete_quote(); + } + }); + ui.label(&self.quotes_status); + }); + }, + ); + } + + fn outgoing_invoices_view(&mut self, ui: &mut egui::Ui) { + page_header( + ui, + "Rechnungen", + "Ausgangsrechnungen erstellen und abschließen.", + ); + master_detail!( + ui, + { + panel(ui, |ui| { + ui.set_min_width(300.0); + if ui.button("Neue Rechnung").clicked() { + self.selected_outgoing_invoice_id = None; + self.outgoing_invoice_form = OutgoingInvoiceForm::default(); + self.outgoing_invoices_status = + "Rechnungsnummer wird reserviert.".to_string(); + self.reserve_next_number("outgoing_invoices"); + } + ui.text_edit_singleline(&mut self.outgoing_invoice_list_search); + ui.add_space(6.0); + let invoice_query = self.outgoing_invoice_list_search.trim().to_lowercase(); + let filtered_invoices: Vec = self + .outgoing_invoices + .iter() + .filter(|invoice| { + let customer = self + .customers + .iter() + .find(|customer| customer.id == invoice.customer_id) + .map(|customer| customer.name.as_str()) + .unwrap_or("-"); + invoice_query.is_empty() + || format!( + "{} {} {}", + invoice.invoice_number, customer, invoice.status + ) + .to_lowercase() + .contains(&invoice_query) + }) + .cloned() + .collect(); + for invoice in filtered_invoices { + let customer = self + .customers + .iter() + .find(|customer| customer.id == invoice.customer_id) + .map(|customer| customer.name.as_str()) + .unwrap_or("-"); + if ui + .selectable_label( + self.selected_outgoing_invoice_id.as_deref() == Some(&invoice.id), + format!( + "{} {} {}{}", + invoice.invoice_number, + customer, + invoice.status, + invoice + .finalized_at + .as_deref() + .map(|_| " abgeschlossen") + .unwrap_or("") + ), + ) + .clicked() + { + self.selected_outgoing_invoice_id = Some(invoice.id.clone()); + self.outgoing_invoice_form = OutgoingInvoiceForm::from(&invoice); + } + } + }); + }, + { + panel(ui, |ui| { + ui.set_min_width(680.0); + form_row(ui, "Rechnungsnummer", |ui| { + ui.label(number_or_pending( + &self.outgoing_invoice_form.invoice_number, + )); + }); + form_row(ui, "Kunde", |ui| { + customer_combo( + ui, + &mut self.outgoing_invoice_form.customer_id, + &self.customers, + &mut self.customer_lookup_search, + ); + }); + form_row(ui, "Status", |ui| { + ui.text_edit_singleline(&mut self.outgoing_invoice_form.status); + }); + form_row(ui, "Ausgestellt", |ui| { + ui.text_edit_singleline( + self.outgoing_invoice_form + .issued_at + .get_or_insert(String::new()), + ); + }); + form_row(ui, "Fällig", |ui| { + ui.text_edit_singleline( + self.outgoing_invoice_form + .due_at + .get_or_insert(String::new()), + ); + }); + form_row(ui, "Kundenrabatt %", |ui| { + ui.text_edit_singleline( + &mut self.outgoing_invoice_form.customer_discount_percent, + ); + }); + invoice_items_editor( + ui, + &mut self.outgoing_invoice_form.items, + &self.items, + &mut self.item_lookup_search, + ); + ui.horizontal(|ui| { + if ui.button("Speichern").clicked() { + self.save_outgoing_invoice(); + } + if ui + .add_enabled( + self.selected_outgoing_invoice_id.is_some(), + egui::Button::new("Abschließen"), + ) + .clicked() + { + self.finalize_outgoing_invoice(); + } + if ui + .add_enabled( + self.selected_outgoing_invoice_id.is_some(), + egui::Button::new("Stornieren"), + ) + .clicked() + { + self.delete_outgoing_invoice(); + } + }); + ui.label(&self.outgoing_invoices_status); + }); + }, + ); + } + + fn incoming_invoices_view(&mut self, ui: &mut egui::Ui) { + page_header(ui, "Eingangsrechnungen", "Lieferantenrechnungen erfassen."); + master_detail!( + ui, + { + panel(ui, |ui| { + ui.set_min_width(300.0); + if ui.button("Neue Eingangsrechnung").clicked() { + self.selected_incoming_invoice_id = None; + self.incoming_invoice_form = IncomingInvoiceForm::default(); + self.incoming_invoices_status = + "Eingangsrechnungsnummer wird reserviert.".to_string(); + self.reserve_next_number("incoming_invoices"); + } + ui.text_edit_singleline(&mut self.incoming_invoice_list_search); + ui.add_space(6.0); + let invoice_query = self.incoming_invoice_list_search.trim().to_lowercase(); + let filtered_invoices: Vec = self + .incoming_invoices + .iter() + .filter(|invoice| { + let supplier = self + .suppliers + .iter() + .find(|supplier| supplier.id == invoice.supplier_id) + .map(|supplier| supplier.name.as_str()) + .unwrap_or("-"); + invoice_query.is_empty() + || format!( + "{} {} {}", + invoice.invoice_number, supplier, invoice.status + ) + .to_lowercase() + .contains(&invoice_query) + }) + .cloned() + .collect(); + for invoice in filtered_invoices { + let supplier = self + .suppliers + .iter() + .find(|supplier| supplier.id == invoice.supplier_id) + .map(|supplier| supplier.name.as_str()) + .unwrap_or("-"); + if ui + .selectable_label( + self.selected_incoming_invoice_id.as_deref() == Some(&invoice.id), + format!( + "{} {} {}", + invoice.invoice_number, supplier, invoice.status + ), + ) + .clicked() + { + self.selected_incoming_invoice_id = Some(invoice.id.clone()); + self.incoming_invoice_form = IncomingInvoiceForm::from(&invoice); + } + } + }); + }, + { + panel(ui, |ui| { + ui.set_min_width(680.0); + form_row(ui, "Rechnungsnummer", |ui| { + ui.label(number_or_pending( + &self.incoming_invoice_form.invoice_number, + )); + }); + form_row(ui, "Lieferant", |ui| { + supplier_combo( + ui, + &mut self.incoming_invoice_form.supplier_id, + &self.suppliers, + &mut self.supplier_lookup_search, + ); + }); + form_row(ui, "Status", |ui| { + ui.text_edit_singleline(&mut self.incoming_invoice_form.status); + }); + form_row(ui, "Datum", |ui| { + ui.text_edit_singleline( + self.incoming_invoice_form + .invoice_date + .get_or_insert(String::new()), + ); + }); + form_row(ui, "Fällig", |ui| { + ui.text_edit_singleline( + self.incoming_invoice_form + .due_at + .get_or_insert(String::new()), + ); + }); + incoming_invoice_items_editor( + ui, + &mut self.incoming_invoice_form.items, + &self.items, + &mut self.item_lookup_search, + ); + ui.horizontal(|ui| { + if ui.button("Speichern").clicked() { + self.save_incoming_invoice(); + } + if ui + .add_enabled( + self.selected_incoming_invoice_id.is_some(), + egui::Button::new("Stornieren"), + ) + .clicked() + { + self.delete_incoming_invoice(); + } + }); + ui.label(&self.incoming_invoices_status); + }); + }, + ); + } + + fn activities_view(&mut self, ui: &mut egui::Ui) { + page_header(ui, "Aktivitäten", "Aufgaben und Notizen."); + master_detail!( + ui, + { + panel(ui, |ui| { + ui.set_min_width(250.0); + if ui.button("Neue Aktivität").clicked() { + self.selected_activity_id = None; + self.activity_form = ActivityForm::default(); + self.activities_status = "Aktivitätsnummer wird reserviert.".to_string(); + self.reserve_next_number("activities"); + } + ui.text_edit_singleline(&mut self.activity_list_search); + ui.add_space(6.0); + let filtered_activities: Vec = self + .activities + .iter() + .filter(|record| { + matches_number_or_name( + record + .activity_number + .as_deref() + .unwrap_or(&record.activity_type), + &record.title, + &self.activity_list_search, + ) + }) + .cloned() + .collect(); + for record in filtered_activities { + if ui + .selectable_label( + self.selected_activity_id.as_deref() == Some(&record.id), + format!( + "{} {}", + record + .activity_number + .as_deref() + .unwrap_or(&record.activity_type), + record.title + ), + ) + .clicked() + { + self.selected_activity_id = Some(record.id.clone()); + self.activity_form = ActivityForm::from(&record); + } + } + }); + }, + { + panel(ui, |ui| { + ui.set_min_width(450.0); + form_row(ui, "Titel", |ui| { + ui.text_edit_singleline(&mut self.activity_form.title); + }); + form_row(ui, "Aktivitätsnummer", |ui| { + ui.label(number_or_pending( + self.activity_form.activity_number.as_deref().unwrap_or(""), + )); + }); + form_row(ui, "Typ", |ui| { + ui.text_edit_singleline(&mut self.activity_form.activity_type); + }); + form_row(ui, "Status", |ui| { + ui.text_edit_singleline(&mut self.activity_form.status); + }); + form_row(ui, "Priorität", |ui| { + ui.text_edit_singleline(&mut self.activity_form.priority); + }); + form_row(ui, "Beschreibung", |ui| { + ui.text_edit_multiline(&mut self.activity_form.body); + }); + ui.horizontal(|ui| { + if ui.button("Speichern").clicked() { + self.save_activity(); + } + if ui + .add_enabled( + self.selected_activity_id.is_some(), + egui::Button::new("Stornieren"), + ) + .clicked() + { + self.delete_activity(); + } + }); + ui.label(&self.activities_status); + }); + }, + ); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AppView { + Login, + Status, + Register, +} + +type RegistrationResult = Result; +type LoginResult = Result; +type DevBootstrapResult = Result; +type UsersResult = Result, String>; +type RegistrationsResult = Result, String>; +type NumberRangesResult = Result, String>; +type CustomersResult = Result, String>; +type SuppliersResult = Result, String>; +type CashDiscountTermsResult = Result, String>; +type ItemsResult = Result, String>; +type ItemPriceHistoryResult = Result, String>; +type QuotesResult = Result, String>; +type OutgoingInvoicesResult = Result, String>; +type IncomingInvoicesResult = Result, String>; +type ActivitiesResult = Result, String>; +type ApiConnectorsResult = Result, String>; +type PriceRulesResult = Result, String>; +type EmptyResult = Result<(), String>; + +#[derive(Debug, Clone)] +enum UserAdminEvent { + UsersLoaded(UsersResult), + InvitationSent(Result), + RolesSaved { + user_id: String, + result: EmptyResult, + }, +} + +#[derive(Debug, Clone)] +enum AdminEvent { + NavigationSettingsLoaded(Result), + NavigationSettingsSaved(Result), + OrganizationSetupSaved(EmptyResult), + OrganizationSetupLoaded(Result, String>), + RegistrationsLoaded(RegistrationsResult), + RegistrationActionDone(Result), + NumberRangesLoaded(NumberRangesResult), + NumberRangeSaved(Result), + NextNumberReserved { + code: String, + result: Result, + }, + CustomersLoaded(CustomersResult), + CustomerSaved(Result), + CustomerDeleted(EmptyResult), + SuppliersLoaded(SuppliersResult), + SupplierSaved(Result), + SupplierDeleted(EmptyResult), + CashDiscountTermsLoaded(CashDiscountTermsResult), + CashDiscountTermSaved(Result), + CashDiscountTermDeleted(EmptyResult), + ItemsLoaded(ItemsResult), + ItemSaved(Result), + ItemDeleted(EmptyResult), + ItemPriceHistoryLoaded(ItemPriceHistoryResult), + QuotesLoaded(QuotesResult), + QuoteSaved(Result), + QuoteDeleted(EmptyResult), + OutgoingInvoicesLoaded(OutgoingInvoicesResult), + OutgoingInvoiceSaved(Result), + OutgoingInvoiceDeleted(EmptyResult), + OutgoingInvoiceFinalized(EmptyResult), + IncomingInvoicesLoaded(IncomingInvoicesResult), + IncomingInvoiceSaved(Result), + IncomingInvoiceDeleted(EmptyResult), + ActivitiesLoaded(ActivitiesResult), + ActivitySaved(Result), + ActivityDeleted(EmptyResult), + PriceImportPreviewed(Result), + PriceImportApplied(Result), + ApiConnectorsLoaded(ApiConnectorsResult), + ApiConnectorSaved(Result), + ApiConnectorDeleted(EmptyResult), + ApiConnectorSynced(Result), + PriceRulesLoaded(PriceRulesResult), + PriceRuleSaved(Result), + PriceRuleDeleted(EmptyResult), +} + +#[derive(Debug, Clone, Deserialize)] +struct LoginResponse { + user_id: String, + access_token: String, + organization_id: Option, + must_change_password: bool, + organizations: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct LoginOrganization { + id: String, + schema_name: Option, + status: String, +} + +#[derive(Debug, Serialize)] +struct LoginRequest { + email: String, + password: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct UserNavigationSettings { + mode: NavigationMode, +} + +#[derive(Debug, Clone, Deserialize)] +struct DevBootstrapResponse { + organization_id: String, + schema_name: String, + user_id: String, + email: String, + password: String, + dev_mode: bool, +} + +#[derive(Debug, Serialize)] +struct DevBootstrapRequest { + organization_name: String, + email: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct RegistrationResponse { + id: String, + status: String, +} + +#[derive(Debug, Serialize)] +struct RegistrationRequest { + organization_name: String, + email: String, + accept_terms: bool, +} + +#[derive(Debug, Clone, Deserialize)] +struct OrganizationUser { + user_id: String, + email: String, + status: String, + roles: Vec, +} + +#[derive(Debug, Serialize)] +struct InviteUserRequest { + email: String, + roles: Vec, +} + +#[derive(Debug, Serialize)] +struct UpdateUserRolesRequest { + roles: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct OrganizationSetupForm { + display_name: String, + legal_form: String, + street: String, + postal_code: String, + city: String, + country: String, + vat_id: String, + email: String, + phone: String, + default_tax_rate: String, + default_payment_days: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct OrganizationSetupResponse { + setup: Option, +} + +impl Default for OrganizationSetupForm { + fn default() -> Self { + Self { + display_name: String::new(), + legal_form: String::new(), + street: String::new(), + postal_code: String::new(), + city: String::new(), + country: "Deutschland".to_string(), + vat_id: String::new(), + email: String::new(), + phone: String::new(), + default_tax_rate: "19".to_string(), + default_payment_days: "14".to_string(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +struct OrganizationRegistration { + id: String, + organization_name: String, + email: String, + status: String, + requested_at: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct ApproveRegistrationResponse { + dev_initial_password: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct NumberRange { + id: String, + code: String, + pattern: String, + counter_value: i64, + counter_padding: i32, + reset_rule: Option, + is_active: bool, +} + +#[derive(Debug, Clone, Deserialize)] +struct NextNumberResponse { + number: String, +} + +#[derive(Debug, Clone, Serialize)] +struct NumberRangeForm { + pattern: String, + counter_value: i64, + counter_padding: i32, + reset_rule: Option, + is_active: bool, +} + +impl Default for NumberRangeForm { + fn default() -> Self { + Self { + pattern: "{counter}".to_string(), + counter_value: 0, + counter_padding: 9, + reset_rule: None, + is_active: true, + } + } +} + +impl From<&NumberRange> for NumberRangeForm { + fn from(value: &NumberRange) -> Self { + Self { + pattern: value.pattern.clone(), + counter_value: value.counter_value, + counter_padding: value.counter_padding, + reset_rule: value.reset_rule.clone(), + is_active: value.is_active, + } + } +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +struct CustomerDetails { + street: String, + postal_code: String, + city: String, + country: String, + email: String, + phone: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct Customer { + id: String, + customer_number: String, + name: String, + status: String, + details: CustomerDetails, + standard_discount_percent: String, + cash_discount_term_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct CustomerForm { + customer_number: String, + name: String, + status: String, + details: CustomerDetails, + standard_discount_percent: String, + cash_discount_term_id: Option, +} + +impl Default for CustomerForm { + fn default() -> Self { + Self { + customer_number: String::new(), + name: String::new(), + status: "active".to_string(), + details: CustomerDetails { + country: "Deutschland".to_string(), + ..Default::default() + }, + standard_discount_percent: "0".to_string(), + cash_discount_term_id: None, + } + } +} + +impl From<&Customer> for CustomerForm { + fn from(customer: &Customer) -> Self { + Self { + customer_number: customer.customer_number.clone(), + name: customer.name.clone(), + status: customer.status.clone(), + details: customer.details.clone(), + standard_discount_percent: customer.standard_discount_percent.clone(), + cash_discount_term_id: customer.cash_discount_term_id.clone(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +struct Supplier { + id: String, + supplier_number: String, + name: String, + status: String, + details: CustomerDetails, + standard_discount_percent: String, + cash_discount_term_id: Option, + payment_days: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct SupplierForm { + supplier_number: String, + name: String, + status: String, + details: CustomerDetails, + standard_discount_percent: String, + cash_discount_term_id: Option, + payment_days: Option, +} + +impl Default for SupplierForm { + fn default() -> Self { + Self { + supplier_number: String::new(), + name: String::new(), + status: "active".to_string(), + details: CustomerDetails { + country: "Deutschland".to_string(), + ..Default::default() + }, + standard_discount_percent: "0".to_string(), + cash_discount_term_id: None, + payment_days: Some(14), + } + } +} +impl From<&Supplier> for SupplierForm { + fn from(value: &Supplier) -> Self { + Self { + supplier_number: value.supplier_number.clone(), + name: value.name.clone(), + status: value.status.clone(), + details: value.details.clone(), + standard_discount_percent: value.standard_discount_percent.clone(), + cash_discount_term_id: value.cash_discount_term_id.clone(), + payment_days: value.payment_days, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +struct CashDiscountTerm { + id: String, + code: String, + name: String, + discount_percent: String, + discount_days: i32, + net_days: Option, + valid_from: Option, + valid_until: Option, + is_default_customer_term: bool, + is_default_supplier_term: bool, + is_active: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct CashDiscountTermForm { + code: String, + name: String, + discount_percent: String, + discount_days: i32, + net_days: Option, + valid_from: Option, + valid_until: Option, + is_default_customer_term: bool, + is_default_supplier_term: bool, + is_active: bool, +} + +impl Default for CashDiscountTermForm { + fn default() -> Self { + Self { + code: String::new(), + name: String::new(), + discount_percent: "2".to_string(), + discount_days: 10, + net_days: Some(30), + valid_from: None, + valid_until: None, + is_default_customer_term: false, + is_default_supplier_term: false, + is_active: true, + } + } +} + +impl From<&CashDiscountTerm> for CashDiscountTermForm { + fn from(value: &CashDiscountTerm) -> Self { + Self { + code: value.code.clone(), + name: value.name.clone(), + discount_percent: value.discount_percent.clone(), + discount_days: value.discount_days, + net_days: value.net_days, + valid_from: value.valid_from.clone(), + valid_until: value.valid_until.clone(), + is_default_customer_term: value.is_default_customer_term, + is_default_supplier_term: value.is_default_supplier_term, + is_active: value.is_active, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +struct Item { + id: String, + item_number: String, + name: String, + unit: String, + tax_rate: String, + default_purchase_price: Option, + default_sales_price: Option, + status: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct ItemPriceHistory { + id: String, + item_id: String, + purchase_price: Option, + sales_price: Option, + source: String, + valid_from: String, + created_by_user_id: Option, + created_at: String, +} +#[derive(Debug, Clone, Serialize)] +struct ItemForm { + item_number: String, + name: String, + unit: String, + tax_rate: String, + default_purchase_price: String, + default_sales_price: String, + status: String, +} +impl Default for ItemForm { + fn default() -> Self { + Self { + item_number: String::new(), + name: String::new(), + unit: "Stk".to_string(), + tax_rate: "19".to_string(), + default_purchase_price: "0".to_string(), + default_sales_price: "0".to_string(), + status: "active".to_string(), + } + } +} +impl From<&Item> for ItemForm { + fn from(value: &Item) -> Self { + Self { + item_number: value.item_number.clone(), + name: value.name.clone(), + unit: value.unit.clone(), + tax_rate: value.tax_rate.clone(), + default_purchase_price: value.default_purchase_price.clone().unwrap_or_default(), + default_sales_price: value.default_sales_price.clone().unwrap_or_default(), + status: value.status.clone(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +struct Quote { + id: String, + quote_number: String, + customer_id: String, + status: String, + valid_until: Option, + cash_discount_term_id: Option, + customer_discount_percent: String, + notes: String, + items: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +struct QuoteItem { + id: String, + line_number: i32, + item_id: String, + description: String, + quantity: String, + unit_price: String, + original_unit_price: Option, + discount_percent: String, + tax_rate: String, + price_overridden: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct QuoteForm { + quote_number: String, + customer_id: String, + status: String, + valid_until: Option, + cash_discount_term_id: Option, + customer_discount_percent: String, + notes: String, + items: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct QuoteItemForm { + item_id: String, + description: String, + quantity: String, + unit_price: String, + original_unit_price: Option, + discount_percent: String, + tax_rate: String, +} + +impl Default for QuoteForm { + fn default() -> Self { + Self { + quote_number: String::new(), + customer_id: String::new(), + status: "draft".to_string(), + valid_until: None, + cash_discount_term_id: None, + customer_discount_percent: "0".to_string(), + notes: String::new(), + items: vec![QuoteItemForm::default()], + } + } +} + +impl Default for QuoteItemForm { + fn default() -> Self { + Self { + item_id: String::new(), + description: String::new(), + quantity: "1".to_string(), + unit_price: "0".to_string(), + original_unit_price: None, + discount_percent: "0".to_string(), + tax_rate: "19".to_string(), + } + } +} + +impl From<&Quote> for QuoteForm { + fn from(value: &Quote) -> Self { + Self { + quote_number: value.quote_number.clone(), + customer_id: value.customer_id.clone(), + status: value.status.clone(), + valid_until: value.valid_until.clone(), + cash_discount_term_id: value.cash_discount_term_id.clone(), + customer_discount_percent: value.customer_discount_percent.clone(), + notes: value.notes.clone(), + items: value.items.iter().map(QuoteItemForm::from).collect(), + } + } +} + +impl From<&QuoteItem> for QuoteItemForm { + fn from(value: &QuoteItem) -> Self { + Self { + item_id: value.item_id.clone(), + description: value.description.clone(), + quantity: value.quantity.clone(), + unit_price: value.unit_price.clone(), + original_unit_price: value.original_unit_price.clone(), + discount_percent: value.discount_percent.clone(), + tax_rate: value.tax_rate.clone(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +struct OutgoingInvoice { + id: String, + invoice_number: String, + customer_id: String, + status: String, + cash_discount_term_id: Option, + customer_discount_percent: String, + issued_at: Option, + due_at: Option, + source_quote_id: Option, + finalized_at: Option, + items: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +struct OutgoingInvoiceItem { + id: String, + line_number: i32, + item_id: String, + description: String, + quantity: String, + unit_price: String, + original_unit_price: Option, + discount_percent: String, + tax_rate: String, + price_overridden: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct OutgoingInvoiceForm { + invoice_number: String, + customer_id: String, + status: String, + cash_discount_term_id: Option, + customer_discount_percent: String, + issued_at: Option, + due_at: Option, + source_quote_id: Option, + items: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct OutgoingInvoiceItemForm { + item_id: String, + description: String, + quantity: String, + unit_price: String, + original_unit_price: Option, + discount_percent: String, + tax_rate: String, +} + +impl Default for OutgoingInvoiceForm { + fn default() -> Self { + Self { + invoice_number: String::new(), + customer_id: String::new(), + status: "draft".to_string(), + cash_discount_term_id: None, + customer_discount_percent: "0".to_string(), + issued_at: None, + due_at: None, + source_quote_id: None, + items: vec![OutgoingInvoiceItemForm::default()], + } + } +} + +impl Default for OutgoingInvoiceItemForm { + fn default() -> Self { + Self { + item_id: String::new(), + description: String::new(), + quantity: "1".to_string(), + unit_price: "0".to_string(), + original_unit_price: None, + discount_percent: "0".to_string(), + tax_rate: "19".to_string(), + } + } +} + +impl From<&OutgoingInvoice> for OutgoingInvoiceForm { + fn from(value: &OutgoingInvoice) -> Self { + Self { + invoice_number: value.invoice_number.clone(), + customer_id: value.customer_id.clone(), + status: value.status.clone(), + cash_discount_term_id: value.cash_discount_term_id.clone(), + customer_discount_percent: value.customer_discount_percent.clone(), + issued_at: value.issued_at.clone(), + due_at: value.due_at.clone(), + source_quote_id: value.source_quote_id.clone(), + items: value + .items + .iter() + .map(OutgoingInvoiceItemForm::from) + .collect(), + } + } +} + +impl From<&OutgoingInvoiceItem> for OutgoingInvoiceItemForm { + fn from(value: &OutgoingInvoiceItem) -> Self { + Self { + item_id: value.item_id.clone(), + description: value.description.clone(), + quantity: value.quantity.clone(), + unit_price: value.unit_price.clone(), + original_unit_price: value.original_unit_price.clone(), + discount_percent: value.discount_percent.clone(), + tax_rate: value.tax_rate.clone(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +struct IncomingInvoice { + id: String, + invoice_number: String, + supplier_id: String, + status: String, + cash_discount_term_id: Option, + invoice_date: Option, + due_at: Option, + items: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +struct IncomingInvoiceItem { + id: String, + line_number: i32, + item_id: Option, + description: String, + quantity: String, + unit_price: String, + tax_rate: String, +} + +#[derive(Debug, Clone, Serialize)] +struct IncomingInvoiceForm { + invoice_number: String, + supplier_id: String, + status: String, + cash_discount_term_id: Option, + invoice_date: Option, + due_at: Option, + items: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct IncomingInvoiceItemForm { + item_id: Option, + description: String, + quantity: String, + unit_price: String, + tax_rate: String, +} + +impl Default for IncomingInvoiceForm { + fn default() -> Self { + Self { + invoice_number: String::new(), + supplier_id: String::new(), + status: "received".to_string(), + cash_discount_term_id: None, + invoice_date: None, + due_at: None, + items: vec![IncomingInvoiceItemForm::default()], + } + } +} + +impl Default for IncomingInvoiceItemForm { + fn default() -> Self { + Self { + item_id: None, + description: String::new(), + quantity: "1".to_string(), + unit_price: "0".to_string(), + tax_rate: "19".to_string(), + } + } +} + +impl From<&IncomingInvoice> for IncomingInvoiceForm { + fn from(value: &IncomingInvoice) -> Self { + Self { + invoice_number: value.invoice_number.clone(), + supplier_id: value.supplier_id.clone(), + status: value.status.clone(), + cash_discount_term_id: value.cash_discount_term_id.clone(), + invoice_date: value.invoice_date.clone(), + due_at: value.due_at.clone(), + items: value + .items + .iter() + .map(IncomingInvoiceItemForm::from) + .collect(), + } + } +} + +impl From<&IncomingInvoiceItem> for IncomingInvoiceItemForm { + fn from(value: &IncomingInvoiceItem) -> Self { + Self { + item_id: value.item_id.clone(), + description: value.description.clone(), + quantity: value.quantity.clone(), + unit_price: value.unit_price.clone(), + tax_rate: value.tax_rate.clone(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +struct Activity { + id: String, + activity_number: Option, + activity_type: String, + title: String, + body: String, + status: String, + priority: String, + due_at: Option, +} +#[derive(Debug, Clone, Serialize)] +struct ActivityForm { + activity_number: Option, + activity_type: String, + title: String, + body: String, + status: String, + priority: String, + due_at: Option, +} +impl Default for ActivityForm { + fn default() -> Self { + Self { + activity_number: None, + activity_type: "task".to_string(), + title: String::new(), + body: String::new(), + status: "open".to_string(), + priority: "normal".to_string(), + due_at: None, + } + } +} +impl From<&Activity> for ActivityForm { + fn from(value: &Activity) -> Self { + Self { + activity_number: value.activity_number.clone(), + activity_type: value.activity_type.clone(), + title: value.title.clone(), + body: value.body.clone(), + status: value.status.clone(), + priority: value.priority.clone(), + due_at: value.due_at.clone(), + } + } +} + +#[derive(Debug, Clone, Serialize)] +struct PriceListImportForm { + source_name: String, + content: String, + delimiter: Option, +} + +impl Default for PriceListImportForm { + fn default() -> Self { + Self { + source_name: "Preisliste.csv".to_string(), + content: "item_number;name;unit;tax_rate;purchase_price;sales_price\nAR-IMPORT-1;Importartikel;Stk;19;10.00;25.00".to_string(), + delimiter: Some(";".to_string()), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +struct PriceListImportPreview { + rows: Vec, + total_rows: usize, + valid_rows: usize, + error_rows: usize, +} + +#[derive(Debug, Clone, Deserialize)] +struct PriceListImportRow { + row_number: usize, + item_number: String, + name: String, + unit: String, + tax_rate: String, + purchase_price: Option, + sales_price: Option, + action: String, + error: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct PriceListImportApplyResponse { + import_id: String, + applied_rows: usize, + error_rows: usize, +} + +#[derive(Debug, Clone, Deserialize)] +struct ApiConnector { + id: String, + code: String, + name: String, + connector_type: String, + config: serde_json::Value, + is_active: bool, + sync_interval_minutes: Option, + last_sync_at: Option, +} + +#[derive(Debug, Clone)] +struct ApiConnectorForm { + code: String, + name: String, + connector_type: String, + config: String, + is_active: bool, + sync_interval_minutes: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct ApiConnectorPayload { + code: String, + name: String, + connector_type: String, + config: serde_json::Value, + is_active: bool, + sync_interval_minutes: Option, +} + +impl Default for ApiConnectorForm { + fn default() -> Self { + Self { + code: String::new(), + name: String::new(), + connector_type: "generic_price_api".to_string(), + config: "{\n \"base_url\": \"https://example.invalid\",\n \"api_key\": \"dev\"\n}" + .to_string(), + is_active: true, + sync_interval_minutes: Some(1440), + } + } +} + +impl ApiConnectorForm { + fn payload(&self) -> Result { + Ok(ApiConnectorPayload { + code: self.code.clone(), + name: self.name.clone(), + connector_type: self.connector_type.clone(), + config: serde_json::from_str(self.config.trim().if_empty("{}"))?, + is_active: self.is_active, + sync_interval_minutes: self.sync_interval_minutes, + }) + } +} + +trait EmptyStrFallback { + fn if_empty<'a>(&'a self, fallback: &'a str) -> &'a str; +} + +impl EmptyStrFallback for str { + fn if_empty<'a>(&'a self, fallback: &'a str) -> &'a str { + if self.is_empty() { + fallback + } else { + self + } + } +} + +impl From<&ApiConnector> for ApiConnectorForm { + fn from(value: &ApiConnector) -> Self { + Self { + code: value.code.clone(), + name: value.name.clone(), + connector_type: value.connector_type.clone(), + config: serde_json::to_string_pretty(&value.config) + .unwrap_or_else(|_| "{}".to_string()), + is_active: value.is_active, + sync_interval_minutes: value.sync_interval_minutes, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +struct ApiConnectorSyncResponse { + synced: bool, + id: String, + applied_rows: usize, + error_rows: usize, +} + +#[derive(Debug, Clone, Deserialize)] +struct PriceRule { + id: String, + code: String, + name: String, + source_type: String, + source_id: Option, + markup_percent: String, + rounding_mode: String, + is_active: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct PriceRuleForm { + code: String, + name: String, + source_type: String, + source_id: Option, + markup_percent: String, + rounding_mode: String, + is_active: bool, +} + +impl Default for PriceRuleForm { + fn default() -> Self { + Self { + code: String::new(), + name: String::new(), + source_type: "import".to_string(), + source_id: None, + markup_percent: "0.00".to_string(), + rounding_mode: "none".to_string(), + is_active: true, + } + } +} + +impl From<&PriceRule> for PriceRuleForm { + fn from(value: &PriceRule) -> Self { + Self { + code: value.code.clone(), + name: value.name.clone(), + source_type: value.source_type.clone(), + source_id: value.source_id.clone(), + markup_percent: value.markup_percent.clone(), + rounding_mode: value.rounding_mode.clone(), + is_active: value.is_active, + } + } +} + +async fn login_user(api_base_url: &str, email: String, password: String) -> LoginResult { + let url = format!("{}/api/v1/auth/login", api_base_url.trim_end_matches('/')); + let response = reqwest::Client::new() + .post(url) + .json(&LoginRequest { email, password }) + .send() + .await + .map_err(|error| format!("Login fehlgeschlagen: {error}"))?; + + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + + if !status.is_success() { + let message = serde_json::from_str::(&text) + .ok() + .and_then(|value| { + value + .get("message") + .and_then(|item| item.as_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| format!("HTTP {status}")); + return Err(message); + } + + serde_json::from_str(&text).map_err(|error| format!("Antwort ist ungültig: {error}")) +} + +async fn get_user_navigation_settings( + api_base_url: &str, + access_token: &str, +) -> Result { + let url = format!( + "{}/api/v1/users/me/settings/navigation", + api_base_url.trim_end_matches('/') + ); + let response = reqwest::Client::new() + .get(url) + .bearer_auth(access_token) + .send() + .await + .map_err(|error| format!("Menüeinstellung konnte nicht geladen werden: {error}"))?; + + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + + if !status.is_success() { + return Err(error_message_from_response(status, &text)); + } + + serde_json::from_str(&text).map_err(|error| format!("Antwort ist ungültig: {error}")) +} + +async fn put_user_navigation_settings( + api_base_url: &str, + access_token: &str, + settings: UserNavigationSettings, +) -> Result { + let url = format!( + "{}/api/v1/users/me/settings/navigation", + api_base_url.trim_end_matches('/') + ); + let response = reqwest::Client::new() + .put(url) + .bearer_auth(access_token) + .json(&settings) + .send() + .await + .map_err(|error| format!("Menüeinstellung konnte nicht gespeichert werden: {error}"))?; + + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + + if !status.is_success() { + return Err(error_message_from_response(status, &text)); + } + + serde_json::from_str(&text).map_err(|error| format!("Antwort ist ungültig: {error}")) +} + +async fn dev_bootstrap_local( + api_base_url: &str, + organization_name: String, + email: String, +) -> DevBootstrapResult { + let url = format!( + "{}/api/v1/dev/bootstrap-local", + api_base_url.trim_end_matches('/') + ); + let response = reqwest::Client::new() + .post(url) + .json(&DevBootstrapRequest { + organization_name, + email, + }) + .send() + .await + .map_err(|error| format!("Dev-Bootstrap fehlgeschlagen: {error}"))?; + + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + + if !status.is_success() { + let message = serde_json::from_str::(&text) + .ok() + .and_then(|value| { + value + .get("message") + .and_then(|item| item.as_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| format!("HTTP {status}")); + return Err(message); + } + + serde_json::from_str(&text).map_err(|error| format!("Antwort ist ungültig: {error}")) +} + +async fn register_organization( + api_base_url: &str, + organization_name: String, + email: String, + accept_terms: bool, +) -> RegistrationResult { + let url = format!( + "{}/api/v1/registration/organization", + api_base_url.trim_end_matches('/') + ); + let response = reqwest::Client::new() + .post(url) + .json(&RegistrationRequest { + organization_name, + email, + accept_terms, + }) + .send() + .await + .map_err(|error| format!("Registrierung fehlgeschlagen: {error}"))?; + + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + + if !status.is_success() { + let message = serde_json::from_str::(&text) + .ok() + .and_then(|value| { + value + .get("message") + .and_then(|item| item.as_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| format!("HTTP {status}")); + return Err(message); + } + + serde_json::from_str(&text).map_err(|error| format!("Antwort ist ungültig: {error}")) +} + +async fn list_users(api_base_url: &str, access_token: &str) -> UsersResult { + let url = format!( + "{}/api/v1/organizations/current/users", + api_base_url.trim_end_matches('/') + ); + let response = reqwest::Client::new() + .get(url) + .bearer_auth(access_token) + .send() + .await + .map_err(|error| format!("Benutzer konnten nicht geladen werden: {error}"))?; + + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + + if !status.is_success() { + return Err(error_message_from_response(status, &text)); + } + + serde_json::from_str(&text).map_err(|error| format!("Antwort ist ungültig: {error}")) +} + +async fn invite_organization_user( + api_base_url: &str, + access_token: &str, + email: String, + roles: Vec, +) -> EmptyResult { + let url = format!( + "{}/api/v1/organizations/current/invitations", + api_base_url.trim_end_matches('/') + ); + let response = reqwest::Client::new() + .post(url) + .bearer_auth(access_token) + .json(&InviteUserRequest { email, roles }) + .send() + .await + .map_err(|error| format!("Einladung fehlgeschlagen: {error}"))?; + + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + + if status.is_success() { + Ok(()) + } else { + Err(error_message_from_response(status, &text)) + } +} + +async fn update_user_roles( + api_base_url: &str, + access_token: &str, + user_id: &str, + roles: Vec, +) -> EmptyResult { + let url = format!( + "{}/api/v1/organizations/current/users/{}/roles", + api_base_url.trim_end_matches('/'), + user_id + ); + let response = reqwest::Client::new() + .patch(url) + .bearer_auth(access_token) + .json(&UpdateUserRolesRequest { roles }) + .send() + .await + .map_err(|error| format!("Benutzerrechte konnten nicht gespeichert werden: {error}"))?; + + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + + if status.is_success() { + Ok(()) + } else { + Err(error_message_from_response(status, &text)) + } +} + +async fn save_organization_setup( + api_base_url: &str, + access_token: &str, + payload: OrganizationSetupForm, +) -> EmptyResult { + let url = format!( + "{}/api/v1/organizations/current/setup", + api_base_url.trim_end_matches('/') + ); + let response = reqwest::Client::new() + .put(url) + .bearer_auth(access_token) + .json(&payload) + .send() + .await + .map_err(|error| format!("Firmendaten konnten nicht gespeichert werden: {error}"))?; + + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + + if status.is_success() { + Ok(()) + } else { + Err(error_message_from_response(status, &text)) + } +} + +async fn load_organization_setup( + api_base_url: &str, + access_token: &str, +) -> Result, String> { + let url = format!( + "{}/api/v1/organizations/current/setup", + api_base_url.trim_end_matches('/') + ); + let response = reqwest::Client::new() + .get(url) + .bearer_auth(access_token) + .send() + .await + .map_err(|error| format!("Firmendaten konnten nicht geladen werden: {error}"))?; + + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + + if !status.is_success() { + return Err(error_message_from_response(status, &text)); + } + + let data = serde_json::from_str::(&text) + .map_err(|error| format!("Antwort ist ungültig: {error}"))?; + Ok(data.setup) +} + +async fn list_registrations(api_base_url: &str) -> RegistrationsResult { + let url = format!( + "{}/api/v1/admin/organization-registrations", + api_base_url.trim_end_matches('/') + ); + let response = reqwest::Client::new() + .get(url) + .send() + .await + .map_err(|error| format!("Registrierungen konnten nicht geladen werden: {error}"))?; + + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + + if !status.is_success() { + return Err(error_message_from_response(status, &text)); + } + + serde_json::from_str(&text).map_err(|error| format!("Antwort ist ungültig: {error}")) +} + +async fn approve_registration( + api_base_url: &str, + registration_id: &str, +) -> Result { + let url = format!( + "{}/api/v1/admin/organization-registrations/{}/approve", + api_base_url.trim_end_matches('/'), + registration_id + ); + let response = reqwest::Client::new() + .post(url) + .json(&serde_json::json!({})) + .send() + .await + .map_err(|error| format!("Freischaltung fehlgeschlagen: {error}"))?; + + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + + if !status.is_success() { + return Err(error_message_from_response(status, &text)); + } + + serde_json::from_str(&text).map_err(|error| format!("Antwort ist ungültig: {error}")) +} + +async fn list_customers(api_base_url: &str, access_token: &str) -> CustomersResult { + let url = format!("{}/api/v1/customers", api_base_url.trim_end_matches('/')); + let response = reqwest::Client::new() + .get(url) + .bearer_auth(access_token) + .send() + .await + .map_err(|error| format!("Kunden konnten nicht geladen werden: {error}"))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + if !status.is_success() { + return Err(error_message_from_response(status, &text)); + } + serde_json::from_str(&text).map_err(|error| format!("Antwort ist ungültig: {error}")) +} + +async fn save_customer( + api_base_url: &str, + access_token: &str, + customer_id: Option, + payload: CustomerForm, +) -> Result { + let client = reqwest::Client::new(); + let response = if let Some(customer_id) = customer_id { + client + .put(format!( + "{}/api/v1/customers/{}", + api_base_url.trim_end_matches('/'), + customer_id + )) + .bearer_auth(access_token) + .json(&payload) + .send() + .await + } else { + client + .post(format!( + "{}/api/v1/customers", + api_base_url.trim_end_matches('/') + )) + .bearer_auth(access_token) + .json(&payload) + .send() + .await + } + .map_err(|error| format!("Kunde konnte nicht gespeichert werden: {error}"))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + if !status.is_success() { + return Err(error_message_from_response(status, &text)); + } + serde_json::from_str(&text).map_err(|error| format!("Antwort ist ungültig: {error}")) +} + +async fn delete_customer(api_base_url: &str, access_token: &str, customer_id: &str) -> EmptyResult { + let response = reqwest::Client::new() + .delete(format!( + "{}/api/v1/customers/{}", + api_base_url.trim_end_matches('/'), + customer_id + )) + .bearer_auth(access_token) + .send() + .await + .map_err(|error| format!("Kunde konnte nicht deaktiviert werden: {error}"))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + if status.is_success() { + Ok(()) + } else { + Err(error_message_from_response(status, &text)) + } +} + +async fn list_master_records Deserialize<'de>>( + api_base_url: &str, + access_token: &str, + path: &str, +) -> Result, String> { + let response = reqwest::Client::new() + .get(format!("{}{}", api_base_url.trim_end_matches('/'), path)) + .bearer_auth(access_token) + .send() + .await + .map_err(|error| format!("Daten konnten nicht geladen werden: {error}"))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + if !status.is_success() { + return Err(error_message_from_response(status, &text)); + } + serde_json::from_str(&text).map_err(|error| format!("Antwort ist ungültig: {error}")) +} + +async fn save_master_record Deserialize<'de>>( + api_base_url: &str, + access_token: &str, + path: &str, + id: Option, + payload: P, +) -> Result { + let client = reqwest::Client::new(); + let request = if let Some(id) = id { + client.put(format!( + "{}{}/{}", + api_base_url.trim_end_matches('/'), + path, + id + )) + } else { + client.post(format!("{}{}", api_base_url.trim_end_matches('/'), path)) + }; + let response = request + .bearer_auth(access_token) + .json(&payload) + .send() + .await + .map_err(|error| format!("Datensatz konnte nicht gespeichert werden: {error}"))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + if !status.is_success() { + return Err(error_message_from_response(status, &text)); + } + serde_json::from_str(&text).map_err(|error| format!("Antwort ist ungültig: {error}")) +} + +async fn save_master_record_without_id Deserialize<'de>>( + api_base_url: &str, + access_token: &str, + path: &str, + payload: P, +) -> Result { + let response = reqwest::Client::new() + .put(format!("{}{}", api_base_url.trim_end_matches('/'), path)) + .bearer_auth(access_token) + .json(&payload) + .send() + .await + .map_err(|error| format!("Datensatz konnte nicht gespeichert werden: {error}"))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + if !status.is_success() { + return Err(error_message_from_response(status, &text)); + } + serde_json::from_str(&text).map_err(|error| format!("Antwort ist ungültig: {error}")) +} + +async fn post_master_record Deserialize<'de>>( + api_base_url: &str, + access_token: &str, + path: &str, + payload: P, +) -> Result { + let response = reqwest::Client::new() + .post(format!("{}{}", api_base_url.trim_end_matches('/'), path)) + .bearer_auth(access_token) + .json(&payload) + .send() + .await + .map_err(|error| format!("Aktion konnte nicht ausgeführt werden: {error}"))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + if !status.is_success() { + return Err(error_message_from_response(status, &text)); + } + serde_json::from_str(&text).map_err(|error| format!("Antwort ist ungültig: {error}")) +} + +async fn reserve_next_number( + api_base_url: &str, + access_token: &str, + code: &str, +) -> Result { + let path = format!("/api/v1/number-ranges/{code}/next"); + let response: NextNumberResponse = + post_master_record(api_base_url, access_token, &path, serde_json::json!({})).await?; + Ok(response.number) +} + +async fn post_empty_action(api_base_url: &str, access_token: &str, path: &str) -> EmptyResult { + let response = reqwest::Client::new() + .post(format!("{}{}", api_base_url.trim_end_matches('/'), path)) + .bearer_auth(access_token) + .json(&serde_json::json!({})) + .send() + .await + .map_err(|error| format!("Aktion konnte nicht ausgeführt werden: {error}"))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + if status.is_success() { + Ok(()) + } else { + Err(error_message_from_response(status, &text)) + } +} + +async fn delete_master_record( + api_base_url: &str, + access_token: &str, + path: &str, + id: &str, +) -> EmptyResult { + let response = reqwest::Client::new() + .delete(format!( + "{}{}/{}", + api_base_url.trim_end_matches('/'), + path, + id + )) + .bearer_auth(access_token) + .send() + .await + .map_err(|error| format!("Datensatz konnte nicht deaktiviert werden: {error}"))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("Antwort konnte nicht gelesen werden: {error}"))?; + if status.is_success() { + Ok(()) + } else { + Err(error_message_from_response(status, &text)) + } +} + +fn error_message_from_response(status: reqwest::StatusCode, text: &str) -> String { + serde_json::from_str::(text) + .ok() + .and_then(|value| { + value + .get("message") + .and_then(|item| item.as_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| format!("HTTP {status}")) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f4f64fa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: companytool + POSTGRES_PASSWORD: companytool + POSTGRES_DB: companytool + ports: + - "5432:5432" + volumes: + - companytool-postgres:/var/lib/postgresql/data + + backend: + build: + context: . + dockerfile: backend/Dockerfile + profiles: + - backend + depends_on: + - postgres + environment: + DATABASE_URL: postgres://companytool:companytool@postgres:5432/companytool + BACKEND_BIND: 0.0.0.0:8080 + COMPANYTOOL_EMAIL_TRANSPORT: outbox + COMPANYTOOL_DOCUMENT_STORAGE_DIR: /var/lib/companytool/documents + ports: + - "8080:8080" + volumes: + - companytool-documents:/var/lib/companytool/documents + +volumes: + companytool-postgres: + companytool-documents: diff --git a/images/icons/companytool-logo.png b/images/icons/companytool-logo.png new file mode 100644 index 0000000..0f73f7f Binary files /dev/null and b/images/icons/companytool-logo.png differ diff --git a/images/icons/logo.png b/images/icons/logo.png new file mode 100644 index 0000000..3f403eb Binary files /dev/null and b/images/icons/logo.png differ diff --git a/scripts/api-onboarding-test.mjs b/scripts/api-onboarding-test.mjs new file mode 100644 index 0000000..c65fd24 --- /dev/null +++ b/scripts/api-onboarding-test.mjs @@ -0,0 +1,495 @@ +#!/usr/bin/env node + +const baseUrl = process.argv[2] ?? process.env.API_BASE_URL ?? "http://127.0.0.1:8080"; +const email = `admin+${Date.now()}@example.com`; + +async function request(method, path, body, token, options = {}) { + const response = await fetch(`${baseUrl}${path}`, { + method, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + const data = text ? parseResponseBody(text) : {}; + const expectedStatus = options.expectedStatus; + + if (expectedStatus !== undefined) { + if (response.status !== expectedStatus) { + throw new Error(`${method} ${path} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(data)}`); + } + return data; + } + + if (!response.ok) { + throw new Error(`${method} ${path} failed: ${response.status} ${JSON.stringify(data)}`); + } + + return data; +} + +function parseResponseBody(text) { + try { + return JSON.parse(text); + } catch { + return { message: text }; + } +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function main() { + console.log(`testing onboarding api via ${baseUrl}`); + + const registration = await request("POST", "/api/v1/registration/organization", { + organization_name: "Muster GmbH", + email, + accept_terms: true, + }); + assert(registration.id, "registration id missing"); + console.log(`registered ${registration.id}`); + + const registrations = await request("GET", "/api/v1/admin/organization-registrations"); + assert( + registrations.some((item) => item.id === registration.id), + "registration not found in list", + ); + + const detail = await request( + "GET", + `/api/v1/admin/organization-registrations/${registration.id}`, + ); + assert(detail.organization_name === "Muster GmbH", "organization name did not decrypt"); + assert(detail.email === email, "registration email mismatch"); + + const approval = await request( + "POST", + `/api/v1/admin/organization-registrations/${registration.id}/approve`, + ); + assert(approval.organization_id, "organization id missing"); + assert(approval.schema_name?.startsWith("company_"), "schema name missing"); + assert(approval.dev_initial_password, "dev initial password missing"); + console.log(`approved ${approval.organization_id}`); + + const login = await request("POST", "/api/v1/auth/login", { + email, + password: approval.dev_initial_password, + }); + assert(login.must_change_password === true, "must_change_password should be true"); + assert(login.access_token, "access token missing"); + assert(login.organization_id, "selected organization missing"); + assert(login.organizations.length >= 1, "login organizations missing"); + const token = login.access_token; + + const selected = await request( + "POST", + "/api/v1/auth/select-organization", + { organization_id: login.organization_id }, + token, + ); + assert(selected.selected === true, "organization was not selected"); + + const users = await request("GET", "/api/v1/organizations/current/users", undefined, token); + assert(users.length >= 1, "users list is empty"); + assert(users.some((user) => user.email === email), "owner user missing"); + + const setup = await request("PUT", "/api/v1/organizations/current/setup", { + display_name: "Muster GmbH", + legal_form: "GmbH", + street: "Musterstrasse 1", + postal_code: "12345", + city: "Musterstadt", + country: "Deutschland", + vat_id: "", + email: "info@example.com", + phone: "", + default_tax_rate: "19", + default_payment_days: "14", + }, token); + assert(setup.saved === true, "organization setup was not saved"); + + const loadedSetup = await request("GET", "/api/v1/organizations/current/setup", undefined, token); + assert(loadedSetup.setup?.display_name === "Muster GmbH", "organization setup was not loaded"); + assert(loadedSetup.setup?.street === "Musterstrasse 1", "organization setup street mismatch"); + + const numberRanges = await request("GET", "/api/v1/number-ranges", undefined, token); + assert(numberRanges.some((range) => range.code === "customers" && range.pattern === "KU{counter}"), "customer number range missing"); + assert(numberRanges.some((range) => range.code === "items" && range.pattern === "AR{counter}"), "item number range missing"); + assert(numberRanges.some((range) => range.code === "activities" && range.pattern === "AK{counter}"), "activity number range missing"); + assert(numberRanges.some((range) => range.code === "outgoing_invoices" && range.pattern === "AR{counter}"), "invoice number range missing"); + + const cashDiscountTerm = await request("POST", "/api/v1/cash-discount-terms", { + code: "2-10-30", + name: "2 % Skonto bei Zahlung innerhalb von 10 Tagen", + discount_percent: "2.00", + discount_days: 10, + net_days: 30, + valid_from: null, + valid_until: null, + is_default_customer_term: true, + is_default_supplier_term: true, + is_active: true, + }, token); + assert(cashDiscountTerm.id, "cash discount term id missing"); + const cashDiscountTerms = await request("GET", "/api/v1/cash-discount-terms", undefined, token); + assert(cashDiscountTerms.some((term) => term.id === cashDiscountTerm.id), "cash discount term missing"); + + const createdCustomer = await request("POST", "/api/v1/customers", { + customer_number: "", + name: "Beispielkunde GmbH", + status: "active", + details: { + street: "Kundenstraße 4", + postal_code: "54321", + city: "Kundenstadt", + country: "Deutschland", + email: "kunde@example.com", + phone: "01234 56789", + }, + standard_discount_percent: "5.50", + cash_discount_term_id: cashDiscountTerm.id, + }, token); + assert(createdCustomer.id, "customer id missing"); + assert(/^KU\d{3}\.\d{3}\.\d{3}$/.test(createdCustomer.customer_number), "customer number was not generated"); + + const customers = await request("GET", "/api/v1/customers", undefined, token); + const listedCustomer = customers.find((customer) => customer.id === createdCustomer.id); + assert(listedCustomer?.name === "Beispielkunde GmbH", "customer name was not loaded"); + assert(listedCustomer?.details.city === "Kundenstadt", "customer details were not loaded"); + assert(listedCustomer?.standard_discount_percent.startsWith("5.5"), "customer discount missing"); + assert(listedCustomer?.cash_discount_term_id === cashDiscountTerm.id, "customer cash discount missing"); + + const updatedCustomer = await request("PUT", `/api/v1/customers/${createdCustomer.id}`, { + ...createdCustomer, + name: "Beispielkunde AG", + standard_discount_percent: "7.00", + }, token); + assert(updatedCustomer.name === "Beispielkunde AG", "customer update failed"); + + const supplier = await request("POST", "/api/v1/suppliers", { + supplier_number: "", + name: "Beispiellieferant GmbH", + status: "active", + details: { street: "Lieferweg 1", postal_code: "10115", city: "Berlin", country: "Deutschland", email: "lieferant@example.com", phone: "" }, + standard_discount_percent: "2.00", + cash_discount_term_id: cashDiscountTerm.id, + payment_days: 30, + }, token); + assert(/^LI\d{3}\.\d{3}\.\d{3}$/.test(supplier.supplier_number), "supplier number was not generated"); + const suppliers = await request("GET", "/api/v1/suppliers", undefined, token); + assert(suppliers.some((record) => record.id === supplier.id && record.details.city === "Berlin"), "supplier CRUD failed"); + assert(suppliers.some((record) => record.id === supplier.id && record.cash_discount_term_id === cashDiscountTerm.id), "supplier cash discount missing"); + + const item = await request("POST", "/api/v1/items", { + item_number: "", + name: "Montagestunde", + unit: "Std", + tax_rate: "19", + default_purchase_price: "40.00", + default_sales_price: "85.00", + status: "active", + }, token); + assert(/^AR\d{3}\.\d{3}\.\d{3}$/.test(item.item_number), "item number was not generated"); + const updatedItem = await request("PUT", `/api/v1/items/${item.id}`, { ...item, default_sales_price: "95.00" }, token); + assert(updatedItem.default_sales_price === "95.00", "item update failed"); + const priceHistory = await request("GET", `/api/v1/items/${item.id}/prices`, undefined, token); + assert(priceHistory.length >= 2, "item price history missing"); + assert(priceHistory.some((entry) => entry.sales_price?.startsWith("95")), "updated item price history missing"); + + const priceListContent = [ + "item_number;name;unit;tax_rate;purchase_price;sales_price", + "IMP-100;Importartikel;Stk;19;10.00;25.00", + `${item.item_number};Montagestunde Import;Std;19;42.00;99.00`, + ].join("\n"); + const importPreview = await request("POST", "/api/v1/imports/price-list/preview", { + source_name: "api-test-price-list.csv", + delimiter: ";", + content: priceListContent, + }, token); + assert(importPreview.total_rows === 2, "price import preview row count mismatch"); + assert(importPreview.valid_rows === 2, "price import preview valid rows mismatch"); + assert(importPreview.rows.some((row) => row.item_number === "IMP-100" && row.action === "create"), "price import create action missing"); + assert(importPreview.rows.some((row) => row.item_number === item.item_number && row.action === "update"), "price import update action missing"); + + const importApply = await request("POST", "/api/v1/imports/price-list/apply", { + source_name: "api-test-price-list.csv", + delimiter: ";", + content: priceListContent, + }, token); + assert(importApply.import_id, "price import id missing"); + assert(importApply.applied_rows === 2, "price import applied rows mismatch"); + assert(importApply.error_rows === 0, "price import errors mismatch"); + + const importedItems = await request("GET", "/api/v1/items", undefined, token); + assert(importedItems.some((record) => record.item_number === "IMP-100" && record.name === "Importartikel"), "imported item missing"); + const reloadedItem = importedItems.find((record) => record.id === item.id); + assert(reloadedItem?.default_sales_price?.startsWith("99"), "imported item price update missing"); + const importedPriceHistory = await request("GET", `/api/v1/items/${item.id}/prices`, undefined, token); + assert(importedPriceHistory.some((entry) => entry.source.startsWith("import:") && entry.sales_price?.startsWith("99")), "import price history missing"); + + const connector = await request("POST", "/api/v1/api-connectors", { + code: "demo_price_api", + name: "Demo Preis API", + connector_type: "demo", + config: { + base_url: "https://example.test/api", + token: "secret", + delimiter: ";", + price_list_csv: [ + "item_number;name;unit;tax_rate;purchase_price;sales_price", + "IMP-100;Importartikel API;Stk;19;11.00;27.00", + ].join("\n"), + }, + is_active: true, + sync_interval_minutes: 60, + }, token); + assert(connector.id, "api connector id missing"); + assert(connector.config?.token === "secret", "api connector config roundtrip failed"); + const connectors = await request("GET", "/api/v1/api-connectors", undefined, token); + assert(connectors.some((record) => record.id === connector.id && record.config?.base_url === "https://example.test/api"), "api connector list missing"); + const connectorSync = await request("POST", `/api/v1/api-connectors/${connector.id}/sync`, {}, token); + assert(connectorSync.synced === true, "api connector sync failed"); + assert(connectorSync.applied_rows === 1, "api connector price sync did not apply rows"); + const syncedItems = await request("GET", "/api/v1/items", undefined, token); + assert(syncedItems.some((record) => record.item_number === "IMP-100" && record.default_sales_price?.startsWith("27")), "api connector price update missing"); + const deletedConnector = await request("DELETE", `/api/v1/api-connectors/${connector.id}`, undefined, token); + assert(deletedConnector.deleted === true, "api connector deactivate failed"); + + const priceRule = await request("POST", "/api/v1/price-rules", { + code: "standard_import_markup", + name: "Standardaufschlag Import", + source_type: "import", + source_id: null, + markup_percent: "25.00", + rounding_mode: "cent", + is_active: true, + }, token); + assert(priceRule.id, "price rule id missing"); + const priceRules = await request("GET", "/api/v1/price-rules", undefined, token); + assert(priceRules.some((record) => record.id === priceRule.id && record.markup_percent.startsWith("25")), "price rule list missing"); + const updatedPriceRule = await request("PUT", `/api/v1/price-rules/${priceRule.id}`, { + ...priceRule, + markup_percent: "30.00", + rounding_mode: "five_cent", + }, token); + assert(updatedPriceRule.rounding_mode === "five_cent", "price rule update failed"); + const deletedPriceRule = await request("DELETE", `/api/v1/price-rules/${priceRule.id}`, undefined, token); + assert(deletedPriceRule.deleted === true, "price rule deactivate failed"); + + const quote = await request("POST", "/api/v1/quotes", { + quote_number: "", + customer_id: createdCustomer.id, + status: "draft", + valid_until: null, + cash_discount_term_id: cashDiscountTerm.id, + customer_discount_percent: "7.00", + notes: "Erstes Testangebot.", + items: [{ + item_id: item.id, + description: "Montagestunde mit Sonderpreis", + quantity: "2.00", + unit_price: "90.00", + original_unit_price: "95.00", + discount_percent: "0.00", + tax_rate: "19.00", + }], + }, token); + assert(/^AN\d{3}\.\d{3}\.\d{3}$/.test(quote.quote_number), "quote number was not generated"); + assert(quote.items.length === 1, "quote item missing"); + assert(quote.items[0].price_overridden === true, "quote price override missing"); + + const quotes = await request("GET", "/api/v1/quotes", undefined, token); + assert(quotes.some((record) => record.id === quote.id && record.notes.includes("Testangebot")), "quote was not loaded"); + const updatedQuote = await request("PUT", `/api/v1/quotes/${quote.id}`, { + ...quote, + status: "sent", + items: quote.items.map((line) => ({ ...line, quantity: "3.00" })), + }, token); + assert(updatedQuote.status === "sent", "quote update failed"); + const convertedInvoice = await request("POST", `/api/v1/quotes/${quote.id}/convert-to-invoice`, undefined, token); + assert(/^AR\d{3}\.\d{3}\.\d{3}$/.test(convertedInvoice.invoice_number), "converted invoice number missing"); + assert(convertedInvoice.source_quote_id === quote.id, "converted invoice quote link missing"); + assert(convertedInvoice.items.length === 1, "converted invoice item missing"); + const invoices = await request("GET", "/api/v1/outgoing-invoices", undefined, token); + assert(invoices.some((record) => record.id === convertedInvoice.id), "outgoing invoice was not loaded"); + const finalized = await request("POST", `/api/v1/outgoing-invoices/${convertedInvoice.id}/finalize`, undefined, token); + assert(finalized.finalized === true, "outgoing invoice finalize failed"); + const deletedQuote = await request("DELETE", `/api/v1/quotes/${quote.id}`, undefined, token); + assert(deletedQuote.deleted === true, "quote cancel failed"); + + const incomingInvoice = await request("POST", "/api/v1/incoming-invoices", { + invoice_number: "EXT-10001", + supplier_id: supplier.id, + status: "received", + cash_discount_term_id: cashDiscountTerm.id, + invoice_date: null, + due_at: null, + items: [{ + item_id: item.id, + description: "Einkauf Montagestunde", + quantity: "1.00", + unit_price: "40.00", + tax_rate: "19.00", + }], + }, token); + assert(incomingInvoice.id, "incoming invoice id missing"); + assert(incomingInvoice.cash_discount_term_id === cashDiscountTerm.id, "incoming invoice cash discount missing"); + const incomingInvoices = await request("GET", "/api/v1/incoming-invoices", undefined, token); + assert(incomingInvoices.some((record) => record.id === incomingInvoice.id), "incoming invoice was not loaded"); + const deletedIncomingInvoice = await request("DELETE", `/api/v1/incoming-invoices/${incomingInvoice.id}`, undefined, token); + assert(deletedIncomingInvoice.deleted === true, "incoming invoice cancel failed"); + + const deletedItem = await request("DELETE", `/api/v1/items/${item.id}`, undefined, token); + assert(deletedItem.deleted === true, "item deactivate failed"); + + const deletedSupplier = await request("DELETE", `/api/v1/suppliers/${supplier.id}`, undefined, token); + assert(deletedSupplier.deleted === true, "supplier deactivate failed"); + + const deletedCustomer = await request("DELETE", `/api/v1/customers/${createdCustomer.id}`, undefined, token); + assert(deletedCustomer.deleted === true, "customer deactivate failed"); + + const activity = await request("POST", "/api/v1/activities", { + activity_number: null, + activity_type: "task", + title: "Angebot prüfen", + body: "Preise mit Kunde abstimmen.", + status: "open", + priority: "high", + due_at: null, + }, token); + assert(/^AK\d{3}\.\d{3}\.\d{3}$/.test(activity.activity_number), "activity number was not generated"); + const activities = await request("GET", "/api/v1/activities", undefined, token); + assert(activities.some((record) => record.id === activity.id && record.body.includes("Preise")), "activity CRUD failed"); + const deletedActivity = await request("DELETE", `/api/v1/activities/${activity.id}`, undefined, token); + assert(deletedActivity.deleted === true, "activity cancel failed"); + + const communication = await request("POST", "/api/v1/communications", { + communication_type: "email", + direction: "outbound", + subject: "Rückfrage zum Angebot", + body: "Kunde bittet um aktualisierte Dokumente.", + status: "open", + occurred_at: null, + links: [{ entity_type: "customer", entity_id: createdCustomer.id }], + }, token); + assert(communication.id, "communication id missing"); + assert(communication.subject === "Rückfrage zum Angebot", "communication subject mismatch"); + assert(communication.links.some((link) => link.entity_id === createdCustomer.id), "communication link missing"); + const communications = await request("GET", "/api/v1/communications", undefined, token); + assert(communications.some((record) => record.id === communication.id), "communication list missing"); + + const documentContent = Buffer.from("Dokumentinhalt für Phase 6", "utf8").toString("base64"); + const documentRecord = await request("POST", "/api/v1/documents", { + title: "Testdokument", + description: "Dokument für Phase 6", + file_name: "phase-6.txt", + content_type: "text/plain", + content_base64: documentContent, + links: [{ entity_type: "communication", entity_id: communication.id }], + }, token); + assert(documentRecord.id, "document id missing"); + assert(documentRecord.latest_version?.file_name === "phase-6.txt", "document metadata missing"); + const documents = await request("GET", "/api/v1/documents", undefined, token); + assert(documents.some((record) => record.id === documentRecord.id), "document list missing"); + const downloadedDocument = await request("GET", `/api/v1/documents/${documentRecord.id}/download`, undefined, token); + assert(downloadedDocument.content_base64 === documentContent, "document download content mismatch"); + const auditLog = await request("GET", `/api/v1/documents/${documentRecord.id}/audit-log`, undefined, token); + assert(auditLog.some((entry) => entry.action === "upload"), "document upload audit missing"); + assert(auditLog.some((entry) => entry.action === "download"), "document download audit missing"); + const deletedDocument = await request("DELETE", `/api/v1/documents/${documentRecord.id}`, undefined, token); + assert(deletedDocument.deleted === true, "document archive failed"); + + const invitedEmail = `user+${Date.now()}@example.com`; + const invitation = await request("POST", "/api/v1/organizations/current/invitations", { + email: invitedEmail, + roles: ["viewer"], + }, token); + assert(invitation.id, "invitation id missing"); + assert(invitation.dev_invitation_token, "dev invitation token missing"); + + const invitedUsers = await request("GET", "/api/v1/organizations/current/users", undefined, token); + const invitedUser = invitedUsers.find((user) => user.user_id === invitation.user_id); + assert(invitedUser, "invited user missing"); + const acceptedInvitation = await request("POST", "/api/v1/auth/accept-invitation", { + token: invitation.dev_invitation_token, + new_password: "InvitePass123", + new_password_confirm: "InvitePass123", + }); + assert(acceptedInvitation.accepted === true, "invitation accept failed"); + const invitedLogin = await request("POST", "/api/v1/auth/login", { + email: invitedEmail, + password: "InvitePass123", + }); + assert(invitedLogin.access_token, "invited user login failed"); + assert(invitedLogin.organization_id, "invited user selected organization missing"); + const invitedToken = invitedLogin.access_token; + + await request( + "POST", + "/api/v1/auth/select-organization", + { organization_id: invitedLogin.organization_id }, + invitedToken, + ); + + const deniedCustomerWrite = await request("POST", "/api/v1/customers", { + customer_number: "", + name: "Nicht erlaubt GmbH", + status: "active", + details: { + street: "Sperrweg 1", + postal_code: "12345", + city: "Teststadt", + country: "Deutschland", + email: "nicht-erlaubt@example.test", + phone: "", + }, + standard_discount_percent: "0", + cash_discount_term_id: null, + }, invitedToken, { expectedStatus: 403 }); + assert(deniedCustomerWrite.message === "Berechtigung fehlt", "viewer customer write was not forbidden"); + + const deniedRoleWrite = await request( + "PATCH", + `/api/v1/organizations/current/users/${invitation.user_id}/roles`, + { roles: ["admin"] }, + invitedToken, + { expectedStatus: 403 }, + ); + assert(deniedRoleWrite.message === "Berechtigung fehlt", "viewer role write was not forbidden"); + + await request( + "PATCH", + `/api/v1/organizations/current/users/${invitation.user_id}/roles`, + { roles: ["sales", "viewer"] }, + token, + ); + const updatedUsers = await request("GET", "/api/v1/organizations/current/users", undefined, token); + const updatedUser = updatedUsers.find((user) => user.user_id === invitation.user_id); + assert(updatedUser.roles.includes("sales"), "role change was not saved"); + + const resetRequest = await request("POST", "/api/v1/auth/request-password-reset", { email }); + assert(resetRequest.queued === true, "password reset request failed"); + assert(resetRequest.dev_reset_token, "dev reset token missing"); + const resetPassword = await request("POST", "/api/v1/auth/reset-password", { + token: resetRequest.dev_reset_token, + new_password: "ResetPass123", + new_password_confirm: "ResetPass123", + }); + assert(resetPassword.changed === true, "password reset failed"); + const resetLogin = await request("POST", "/api/v1/auth/login", { + email, + password: "ResetPass123", + }); + assert(resetLogin.access_token, "login after password reset failed"); + + console.log("onboarding api test ok"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/communication-test.mjs b/scripts/communication-test.mjs new file mode 100644 index 0000000..997b510 --- /dev/null +++ b/scripts/communication-test.mjs @@ -0,0 +1,258 @@ +#!/usr/bin/env node + +import { webcrypto } from "node:crypto"; +import assert from "node:assert/strict"; + +const wsUrl = process.argv[2] ?? process.env.WS_URL ?? "ws://localhost:8080/ws"; +const apiBaseUrl = process.argv[3] ?? process.env.API_BASE_URL; +const protocolVersion = 1; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function bytesToBase64(bytes) { + return Buffer.from(bytes).toString("base64"); +} + +function base64ToBytes(value) { + return Buffer.from(value, "base64"); +} + +async function createSession() { + const key = await webcrypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"], + ); + const rawKey = await webcrypto.subtle.exportKey("raw", key); + + return { + key, + keyId: webcrypto.randomUUID(), + exportedKey: bytesToBase64(new Uint8Array(rawKey)), + }; +} + +async function encryptMessage(session, message) { + const nonce = webcrypto.getRandomValues(new Uint8Array(12)); + const plaintext = encoder.encode(JSON.stringify(message)); + const ciphertext = await webcrypto.subtle.encrypt( + { name: "AES-GCM", iv: nonce }, + session.key, + plaintext, + ); + + return { + enc: `aes-256-gcm-v${protocolVersion}`, + key_id: session.keyId, + nonce: bytesToBase64(nonce), + ciphertext: bytesToBase64(new Uint8Array(ciphertext)), + }; +} + +async function decryptMessage(session, envelope) { + const plaintext = await webcrypto.subtle.decrypt( + { name: "AES-GCM", iv: base64ToBytes(envelope.nonce) }, + session.key, + base64ToBytes(envelope.ciphertext), + ); + + return JSON.parse(decoder.decode(plaintext)); +} + +function waitForOpen(socket) { + return new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); + }); +} + +function waitForRawMessage(socket, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + socket.removeEventListener("message", onMessage); + reject(new Error(`timeout after ${timeoutMs}ms`)); + }, timeoutMs); + + function onMessage(event) { + clearTimeout(timeout); + resolve(String(event.data)); + } + + socket.addEventListener("message", onMessage, { once: true }); + }); +} + +async function waitForDecryptedType(client, expectedType, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const raw = await waitForRawMessage(client.socket, Math.max(100, deadline - Date.now())); + const wire = JSON.parse(raw); + + assert.equal(wire.type, "encrypted", `${client.name}: expected encrypted wire message`); + assert.equal(wire.payload.key_id, client.session.keyId, `${client.name}: key id mismatch`); + assertNoPlaintext(raw, client.name); + + const message = await decryptMessage(client.session, wire.payload); + if (message.type === expectedType) { + return message; + } + } + + throw new Error(`${client.name}: did not receive ${expectedType}`); +} + +async function waitForRecordChanged(client, expectedTitle, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const message = await waitForDecryptedType( + client, + "record_changed", + Math.max(100, deadline - Date.now()), + ); + if (!expectedTitle || message.payload.record?.title === expectedTitle) { + return message; + } + } + + throw new Error(`${client.name}: did not receive record_changed ${expectedTitle}`); +} + +function assertNoPlaintext(raw, clientName) { + for (const forbidden of ["snapshot", "record_changed", "Erster Datensatz", "records"]) { + assert.equal( + raw.includes(forbidden), + false, + `${clientName}: raw frame leaked plaintext marker ${forbidden}`, + ); + } +} + +async function connectClient(name) { + const session = await createSession(); + const socket = new WebSocket(wsUrl); + + await waitForOpen(socket); + socket.send(JSON.stringify({ + type: "hello", + payload: { + protocol_version: protocolVersion, + key_id: session.keyId, + session_key: session.exportedKey, + }, + })); + + const ack = JSON.parse(await waitForRawMessage(socket)); + assert.equal(ack.type, "hello_ack", `${name}: expected hello_ack`); + assert.equal(ack.payload.protocol_version, protocolVersion, `${name}: protocol mismatch`); + assert.equal(ack.payload.key_id, session.keyId, `${name}: ack key mismatch`); + + const client = { name, socket, session }; + const snapshot = await waitForDecryptedType(client, "snapshot"); + assert.ok(Array.isArray(snapshot.payload.records), `${name}: snapshot records missing`); + + await sendEncrypted(client, { + type: "subscribe", + payload: { topic: "records" }, + }); + + return client; +} + +async function sendEncrypted(client, message) { + const envelope = await encryptMessage(client.session, message); + client.socket.send(JSON.stringify({ type: "encrypted", payload: envelope })); +} + +function closeClient(client) { + if (client.socket.readyState === WebSocket.OPEN) { + client.socket.close(); + } +} + +async function request(method, path, body, token) { + const response = await fetch(`${apiBaseUrl}${path}`, { + method, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + const data = text ? JSON.parse(text) : {}; + + if (!response.ok) { + throw new Error(`${method} ${path} failed: ${response.status} ${JSON.stringify(data)}`); + } + + return data; +} + +async function createLiveEventViaApi() { + const email = `live-event-${Date.now()}@example.test`; + const bootstrap = await request("POST", "/api/v1/dev/bootstrap-local", { + organization_name: "Live Event Test GmbH", + email, + }); + const login = await request("POST", "/api/v1/auth/login", { + email, + password: bootstrap.password, + }); + const token = login.access_token; + await request("POST", "/api/v1/auth/select-organization", { + organization_id: login.organization_id, + }, token); + + await request("POST", "/api/v1/activities", { + activity_number: null, + activity_type: "task", + title: "Live-Event testen", + body: "Änderung muss an alle Clients gehen.", + status: "open", + priority: "normal", + due_at: null, + }, token); +} + +async function main() { + console.log(`testing encrypted communication via ${wsUrl}`); + + const clientA = await connectClient("client-a"); + console.log("client-a handshake, encrypted snapshot and subscribe ok"); + + const clientB = await connectClient("client-b"); + console.log("client-b handshake, encrypted snapshot and subscribe ok"); + + await sendEncrypted(clientA, { type: "ping" }); + const [pongA, pongB] = await Promise.all([ + waitForDecryptedType(clientA, "pong"), + waitForDecryptedType(clientB, "pong"), + ]); + + assert.equal(pongA.type, "pong"); + assert.equal(pongB.type, "pong"); + assert.notEqual(clientA.session.keyId, clientB.session.keyId, "clients must use different keys"); + + if (apiBaseUrl) { + console.log(`testing api-triggered live event via ${apiBaseUrl}`); + const waitA = waitForRecordChanged(clientA, "Aktivität angelegt"); + const waitB = waitForRecordChanged(clientB, "Aktivität angelegt"); + await createLiveEventViaApi(); + await Promise.all([waitA, waitB]); + console.log("api-triggered live event reached both clients"); + } + + closeClient(clientA); + closeClient(clientB); + + console.log("encrypted multi-client communication test ok"); + process.exit(0); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/dev-seed.mjs b/scripts/dev-seed.mjs new file mode 100644 index 0000000..4b0ead6 --- /dev/null +++ b/scripts/dev-seed.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node + +const baseUrl = process.argv[2] ?? process.env.API_BASE_URL ?? "http://127.0.0.1:8080"; +const stamp = Date.now(); +const email = process.env.DEV_SEED_EMAIL ?? `seed-admin-${stamp}@example.test`; + +async function request(method, path, body, token) { + const response = await fetch(`${baseUrl}${path}`, { + method, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + const data = text ? JSON.parse(text) : {}; + + if (!response.ok) { + throw new Error(`${method} ${path} failed: ${response.status} ${JSON.stringify(data)}`); + } + + return data; +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function main() { + console.log(`creating development seed data via ${baseUrl}`); + + const bootstrap = await request("POST", "/api/v1/dev/bootstrap-local", { + organization_name: `Seed Firma ${stamp}`, + email, + }); + assert(bootstrap.password, "dev bootstrap password missing"); + + const login = await request("POST", "/api/v1/auth/login", { + email, + password: bootstrap.password, + }); + const token = login.access_token; + assert(token, "login token missing"); + + await request("POST", "/api/v1/auth/select-organization", { + organization_id: login.organization_id, + }, token); + + const cashDiscountTerm = await request("POST", "/api/v1/cash-discount-terms", { + code: `SEED-${stamp}`, + name: "2 % Skonto, 30 Tage netto", + discount_percent: "2.00", + discount_days: 10, + net_days: 30, + valid_from: null, + valid_until: null, + is_default_customer_term: true, + is_default_supplier_term: true, + is_active: true, + }, token); + + const customer = await request("POST", "/api/v1/customers", { + customer_number: "", + name: "Seed Kunde GmbH", + status: "active", + details: { + street: "Kundenweg 10", + postal_code: "60311", + city: "Frankfurt", + country: "Deutschland", + email: "kunde@example.test", + phone: "", + }, + standard_discount_percent: "5.00", + cash_discount_term_id: cashDiscountTerm.id, + }, token); + + const supplier = await request("POST", "/api/v1/suppliers", { + supplier_number: "", + name: "Seed Lieferant GmbH", + status: "active", + details: { + street: "Lieferstraße 8", + postal_code: "10115", + city: "Berlin", + country: "Deutschland", + email: "lieferant@example.test", + phone: "", + }, + standard_discount_percent: "0.00", + cash_discount_term_id: cashDiscountTerm.id, + payment_days: 30, + }, token); + + const item = await request("POST", "/api/v1/items", { + item_number: "", + name: "Seed Montagestunde", + unit: "Std", + tax_rate: "19.00", + default_purchase_price: "40.00", + default_sales_price: "85.00", + status: "active", + }, token); + + const activity = await request("POST", "/api/v1/activities", { + activity_number: null, + activity_type: "task", + title: "Seed Aktivität", + body: "Testdaten für lokale Entwicklung.", + status: "open", + priority: "normal", + due_at: null, + }, token); + + console.log(JSON.stringify({ + email, + password: bootstrap.password, + organization_id: login.organization_id, + customer_number: customer.customer_number, + supplier_number: supplier.supplier_number, + item_number: item.item_number, + activity_number: activity.activity_number, + }, null, 2)); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/schema-migration-test.mjs b/scripts/schema-migration-test.mjs new file mode 100644 index 0000000..98f31df --- /dev/null +++ b/scripts/schema-migration-test.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node + +const baseUrl = process.argv[2] ?? process.env.API_BASE_URL ?? "http://127.0.0.1:8080"; +const email = `schema-migration-${Date.now()}@example.test`; + +async function request(method, path, body, token) { + const response = await fetch(`${baseUrl}${path}`, { + method, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + const data = text ? JSON.parse(text) : {}; + + if (!response.ok) { + throw new Error(`${method} ${path} failed: ${response.status} ${JSON.stringify(data)}`); + } + + return data; +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function main() { + console.log(`testing schema migration idempotency via ${baseUrl}`); + + const registration = await request("POST", "/api/v1/registration/organization", { + organization_name: "Migrationstest GmbH", + email, + accept_terms: true, + }); + + const approval = await request( + "POST", + `/api/v1/admin/organization-registrations/${registration.id}/approve`, + ); + assert(approval.schema_name, "schema name missing after approval"); + + const retry = await request( + "POST", + `/api/v1/admin/organization-registrations/${registration.id}/retry-provisioning`, + ); + assert(retry.provisioned === true, "retry provisioning did not report success"); + assert(retry.schema_name === approval.schema_name, "retry provisioning schema mismatch"); + + const login = await request("POST", "/api/v1/auth/login", { + email, + password: approval.dev_initial_password, + }); + const token = login.access_token; + await request("POST", "/api/v1/auth/select-organization", { + organization_id: login.organization_id, + }, token); + + const ranges = await request("GET", "/api/v1/number-ranges", undefined, token); + for (const code of ["customers", "suppliers", "items", "activities", "outgoing_invoices", "incoming_invoices", "quotes"]) { + assert(ranges.some((range) => range.code === code), `number range missing after retry: ${code}`); + } + + const users = await request("GET", "/api/v1/organizations/current/users", undefined, token); + const owner = users.find((user) => user.email === email); + assert(owner?.roles.includes("owner"), "owner role missing after retry"); + assert(owner?.roles.includes("admin"), "admin role missing after retry"); + + const communication = await request("POST", "/api/v1/communications", { + communication_type: "internal_note", + direction: "internal", + subject: "Migrationstest", + body: "Kommunikationstabellen sind vorhanden.", + status: "open", + occurred_at: null, + links: [], + }, token); + assert(communication.id, "communication insert failed after retry"); + + const navigationSettings = await request("PUT", "/api/v1/users/me/settings/navigation", { + mode: "groups", + }, token); + assert(navigationSettings.mode === "groups", "navigation user setting was not saved"); + + console.log("schema migration idempotency test ok"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/standard-check.sh b/scripts/standard-check.sh new file mode 100644 index 0000000..735acf1 --- /dev/null +++ b/scripts/standard-check.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +cd "$ROOT_DIR" + +echo "== Rust format ==" +cargo fmt --all -- --check + +echo "== Rust workspace check ==" +cargo check --workspace + +echo "== Desktopclient headless unit tests ==" +cargo test -p companytool-desktop-client + +echo "== Node script syntax ==" +node --check scripts/api-onboarding-test.mjs +node --check scripts/communication-test.mjs +node --check scripts/dev-seed.mjs +node --check scripts/schema-migration-test.mjs + +echo "== Webfrontend build and type check ==" +npm --prefix web-frontend run build + +echo "standard check ok" diff --git a/scripts/ws-smoke-test.mjs b/scripts/ws-smoke-test.mjs new file mode 100644 index 0000000..265de10 --- /dev/null +++ b/scripts/ws-smoke-test.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node + +import { webcrypto } from "node:crypto"; + +const wsUrl = process.argv[2] ?? process.env.WS_URL ?? "ws://localhost:8080/ws"; +const protocolVersion = 1; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function bytesToBase64(bytes) { + return Buffer.from(bytes).toString("base64"); +} + +function base64ToBytes(value) { + return Buffer.from(value, "base64"); +} + +async function createSession() { + const key = await webcrypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"], + ); + const rawKey = await webcrypto.subtle.exportKey("raw", key); + + return { + key, + keyId: webcrypto.randomUUID(), + exportedKey: bytesToBase64(new Uint8Array(rawKey)), + }; +} + +async function encryptMessage(session, message) { + const nonce = webcrypto.getRandomValues(new Uint8Array(12)); + const plaintext = encoder.encode(JSON.stringify(message)); + const ciphertext = await webcrypto.subtle.encrypt( + { name: "AES-GCM", iv: nonce }, + session.key, + plaintext, + ); + + return { + enc: `aes-256-gcm-v${protocolVersion}`, + key_id: session.keyId, + nonce: bytesToBase64(nonce), + ciphertext: bytesToBase64(new Uint8Array(ciphertext)), + }; +} + +async function decryptMessage(session, envelope) { + const plaintext = await webcrypto.subtle.decrypt( + { name: "AES-GCM", iv: base64ToBytes(envelope.nonce) }, + session.key, + base64ToBytes(envelope.ciphertext), + ); + + return JSON.parse(decoder.decode(plaintext)); +} + +function waitForOpen(socket) { + return new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); + }); +} + +function waitForMessage(socket, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + socket.removeEventListener("message", onMessage); + reject(new Error(`timeout after ${timeoutMs}ms`)); + }, timeoutMs); + + function onMessage(event) { + clearTimeout(timeout); + resolve(JSON.parse(event.data)); + } + + socket.addEventListener("message", onMessage, { once: true }); + }); +} + +async function main() { + const session = await createSession(); + const socket = new WebSocket(wsUrl); + + console.log(`connecting ${wsUrl}`); + await waitForOpen(socket); + console.log("socket open"); + + socket.send(JSON.stringify({ + type: "hello", + payload: { + protocol_version: protocolVersion, + key_id: session.keyId, + session_key: session.exportedKey, + }, + })); + + const ack = await waitForMessage(socket); + if (ack.type !== "hello_ack") { + throw new Error(`expected hello_ack, got ${JSON.stringify(ack)}`); + } + console.log(`hello_ack protocol=${ack.payload.protocol_version} key=${ack.payload.key_id}`); + + const firstEncrypted = await waitForMessage(socket); + if (firstEncrypted.type !== "encrypted") { + throw new Error(`expected encrypted snapshot, got ${JSON.stringify(firstEncrypted)}`); + } + const snapshot = await decryptMessage(session, firstEncrypted.payload); + console.log(`decrypted first server message: ${snapshot.type}`); + + const subscribe = await encryptMessage(session, { + type: "subscribe", + payload: { topic: "records" }, + }); + socket.send(JSON.stringify({ type: "encrypted", payload: subscribe })); + console.log("sent encrypted subscribe"); + + const ping = await encryptMessage(session, { type: "ping" }); + socket.send(JSON.stringify({ type: "encrypted", payload: ping })); + + const pongEnvelope = await waitForMessage(socket); + if (pongEnvelope.type !== "encrypted") { + throw new Error(`expected encrypted pong, got ${JSON.stringify(pongEnvelope)}`); + } + const pong = await decryptMessage(session, pongEnvelope.payload); + console.log(`decrypted ping response: ${pong.type}`); + + socket.close(); + console.log("communication smoke test ok"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); + diff --git a/shared-protocol/Cargo.toml b/shared-protocol/Cargo.toml new file mode 100644 index 0000000..ee192c0 --- /dev/null +++ b/shared-protocol/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "companytool-shared-protocol" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +aes-gcm = "0.10" +base64 = "0.22" +chrono = { version = "0.4", features = ["serde"] } +rand_core = { version = "0.6", features = ["getrandom"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["serde", "v4"] } diff --git a/shared-protocol/src/lib.rs b/shared-protocol/src/lib.rs new file mode 100644 index 0000000..45477f0 --- /dev/null +++ b/shared-protocol/src/lib.rs @@ -0,0 +1,202 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub const PROTOCOL_VERSION: u16 = 1; +pub const SESSION_KEY_BYTES: usize = 32; +pub const AES_GCM_NONCE_BYTES: usize = 12; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "payload", rename_all = "snake_case")] +pub enum WireMessage { + Hello(HelloMessage), + HelloAck(HelloAckMessage), + Encrypted(EncryptedEnvelope), + Error(ProtocolErrorMessage), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HelloMessage { + pub protocol_version: u16, + pub key_id: String, + pub session_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HelloAckMessage { + pub protocol_version: u16, + pub key_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EncryptedEnvelope { + pub enc: String, + pub key_id: String, + pub nonce: String, + pub ciphertext: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProtocolErrorMessage { + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "payload", rename_all = "snake_case")] +pub enum ClientMessage { + Subscribe { topic: String }, + Ping, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "payload", rename_all = "snake_case")] +pub enum ServerMessage { + Snapshot { records: Vec }, + RecordChanged { record: RecordSummary }, + Pong, + Error { message: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecordSummary { + pub id: Uuid, + pub title: String, + pub updated_at: DateTime, +} + +pub mod crypto { + use aes_gcm::{ + aead::{Aead, AeadCore, KeyInit, OsRng}, + Aes256Gcm, Key, Nonce, + }; + use base64::{engine::general_purpose::STANDARD, Engine}; + use rand_core::RngCore; + use serde::{de::DeserializeOwned, Serialize}; + + use crate::{EncryptedEnvelope, AES_GCM_NONCE_BYTES, PROTOCOL_VERSION, SESSION_KEY_BYTES}; + + #[derive(Debug)] + pub enum CryptoError { + InvalidBase64(base64::DecodeError), + InvalidKeyLength(usize), + InvalidNonceLength(usize), + Serialize(serde_json::Error), + Deserialize(serde_json::Error), + Encrypt, + Decrypt, + } + + impl std::fmt::Display for CryptoError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidBase64(error) => write!(formatter, "invalid base64: {error}"), + Self::InvalidKeyLength(length) => write!(formatter, "invalid key length: {length}"), + Self::InvalidNonceLength(length) => { + write!(formatter, "invalid nonce length: {length}") + } + Self::Serialize(error) => write!(formatter, "serialize failed: {error}"), + Self::Deserialize(error) => write!(formatter, "deserialize failed: {error}"), + Self::Encrypt => write!(formatter, "encryption failed"), + Self::Decrypt => write!(formatter, "decryption failed"), + } + } + } + + impl std::error::Error for CryptoError {} + + #[derive(Debug, Clone)] + pub struct SessionKey { + key_id: String, + key: [u8; SESSION_KEY_BYTES], + } + + impl SessionKey { + pub fn generate() -> Self { + let mut key = [0_u8; SESSION_KEY_BYTES]; + OsRng.fill_bytes(&mut key); + + Self { + key_id: uuid::Uuid::new_v4().to_string(), + key, + } + } + + pub fn from_base64(key_id: String, value: &str) -> Result { + let decoded = STANDARD.decode(value).map_err(CryptoError::InvalidBase64)?; + if decoded.len() != SESSION_KEY_BYTES { + return Err(CryptoError::InvalidKeyLength(decoded.len())); + } + + let mut key = [0_u8; SESSION_KEY_BYTES]; + key.copy_from_slice(&decoded); + + Ok(Self { key_id, key }) + } + + pub fn key_id(&self) -> &str { + &self.key_id + } + + pub fn to_base64(&self) -> String { + STANDARD.encode(self.key) + } + + pub fn encrypt(&self, value: &T) -> Result { + let cipher = Aes256Gcm::new(Key::::from_slice(&self.key)); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let plaintext = serde_json::to_vec(value).map_err(CryptoError::Serialize)?; + let ciphertext = cipher + .encrypt(&nonce, plaintext.as_ref()) + .map_err(|_| CryptoError::Encrypt)?; + + Ok(EncryptedEnvelope { + enc: format!("aes-256-gcm-v{PROTOCOL_VERSION}"), + key_id: self.key_id.clone(), + nonce: STANDARD.encode(nonce), + ciphertext: STANDARD.encode(ciphertext), + }) + } + + pub fn decrypt( + &self, + envelope: &EncryptedEnvelope, + ) -> Result { + let nonce_bytes = STANDARD + .decode(&envelope.nonce) + .map_err(CryptoError::InvalidBase64)?; + if nonce_bytes.len() != AES_GCM_NONCE_BYTES { + return Err(CryptoError::InvalidNonceLength(nonce_bytes.len())); + } + + let ciphertext = STANDARD + .decode(&envelope.ciphertext) + .map_err(CryptoError::InvalidBase64)?; + let cipher = Aes256Gcm::new(Key::::from_slice(&self.key)); + let plaintext = cipher + .decrypt(Nonce::from_slice(&nonce_bytes), ciphertext.as_ref()) + .map_err(|_| CryptoError::Decrypt)?; + + serde_json::from_slice(&plaintext).map_err(CryptoError::Deserialize) + } + } +} + +#[cfg(test)] +mod tests { + use super::{crypto::SessionKey, ClientMessage}; + + #[test] + fn session_key_encrypts_and_decrypts_client_message() { + let session_key = SessionKey::generate(); + let message = ClientMessage::Subscribe { + topic: "customers".to_string(), + }; + + let envelope = session_key.encrypt(&message).expect("encrypt message"); + let decrypted = session_key + .decrypt::(&envelope) + .expect("decrypt message"); + + assert_eq!(decrypted, message); + } +} diff --git a/test-zugang b/test-zugang new file mode 100644 index 0000000..bc0b2fb --- /dev/null +++ b/test-zugang @@ -0,0 +1,6 @@ +Login: admin@example.test +Passwort: 4FjYIivIPzbATWAUxN4KSmc0 +User: 43477791-1b8e-4307-adee-ec70d2a79a93 +Firma: ceb30710-10a5-4ad8-a63d-24b859652ad3 +Schema: company_ceb3071010a54ad8a63d24b859652ad3 +Dev-Modus: true \ No newline at end of file diff --git a/web-frontend/index.html b/web-frontend/index.html new file mode 100644 index 0000000..4f0dea0 --- /dev/null +++ b/web-frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Company Tool + + +
+ + + diff --git a/web-frontend/package-lock.json b/web-frontend/package-lock.json new file mode 100644 index 0000000..f88f2b1 --- /dev/null +++ b/web-frontend/package-lock.json @@ -0,0 +1,1463 @@ +{ + "name": "companytool-web-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "companytool-web-frontend", + "version": "0.1.0", + "dependencies": { + "vite": "^6.0.0", + "vue": "^3.5.0", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.0", + "typescript": "^5.7.0", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz", + "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.34", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", + "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", + "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.34", + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.14", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", + "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz", + "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz", + "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", + "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/runtime-core": "3.5.34", + "@vue/shared": "3.5.34", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", + "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "vue": "3.5.34" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", + "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-sfc": "3.5.34", + "@vue/runtime-dom": "3.5.34", + "@vue/server-renderer": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/web-frontend/package.json b/web-frontend/package.json new file mode 100644 index 0000000..26db008 --- /dev/null +++ b/web-frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "companytool-web-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "vue": "^3.5.0", + "vue-router": "^4.5.0", + "vite": "^6.0.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.0", + "typescript": "^5.7.0", + "vue-tsc": "^2.2.0" + } +} diff --git a/web-frontend/public/companytool-logo.png b/web-frontend/public/companytool-logo.png new file mode 100644 index 0000000..0f73f7f Binary files /dev/null and b/web-frontend/public/companytool-logo.png differ diff --git a/web-frontend/src/App.vue b/web-frontend/src/App.vue new file mode 100644 index 0000000..ec5e100 --- /dev/null +++ b/web-frontend/src/App.vue @@ -0,0 +1,399 @@ + + + diff --git a/web-frontend/src/api.ts b/web-frontend/src/api.ts new file mode 100644 index 0000000..5644af5 --- /dev/null +++ b/web-frontend/src/api.ts @@ -0,0 +1,51 @@ +import type { ApiResult } from "./types"; +import { authState } from "./auth"; + +const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? ""; + +export function apiGet(path: string): Promise> { + return apiRequest("GET", path); +} + +export function apiPost(path: string, body: unknown): Promise> { + return apiRequest("POST", path, body); +} + +export function apiPut(path: string, body: unknown): Promise> { + return apiRequest("PUT", path, body); +} + +export function apiPatch(path: string, body: unknown): Promise> { + return apiRequest("PATCH", path, body); +} + +export function apiDelete(path: string): Promise> { + return apiRequest("DELETE", path); +} + +async function apiRequest(method: string, path: string, body?: unknown): Promise> { + try { + const response = await fetch(`${apiBaseUrl}${path}`, { + method, + headers: { + "Content-Type": "application/json", + ...(authState.session?.accessToken ? { Authorization: `Bearer ${authState.session.accessToken}` } : {}) + }, + body: body === undefined ? undefined : JSON.stringify(body) + }); + + const text = await response.text(); + const data = text ? JSON.parse(text) : {}; + + if (!response.ok) { + return { ok: false, message: data.message ?? `HTTP ${response.status}` }; + } + + return { ok: true, data }; + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : "Unbekannter Fehler" + }; + } +} diff --git a/web-frontend/src/auth.ts b/web-frontend/src/auth.ts new file mode 100644 index 0000000..142d3af --- /dev/null +++ b/web-frontend/src/auth.ts @@ -0,0 +1,44 @@ +import { reactive } from "vue"; +import type { AuthSession } from "./types"; + +const authStorageKey = "companytool.auth"; + +export const authState = reactive<{ + session: AuthSession | null; +}>({ + session: loadAuthSession() +}); + +export function setAuthSession(session: AuthSession) { + authState.session = session; + window.localStorage.setItem(authStorageKey, JSON.stringify(session)); +} + +export function updateAuthSession(partial: Partial) { + if (!authState.session) return; + setAuthSession({ ...authState.session, ...partial }); +} + +export function clearAuthSession() { + authState.session = null; + window.localStorage.removeItem(authStorageKey); +} + +function loadAuthSession(): AuthSession | null { + try { + const raw = window.localStorage.getItem(authStorageKey); + if (!raw) return null; + const session = JSON.parse(raw) as Partial; + if (!session.email || !session.userId) return null; + if (!session.accessToken) return null; + return { + email: session.email, + userId: session.userId, + accessToken: session.accessToken, + organizationId: session.organizationId ?? null, + mustChangePassword: session.mustChangePassword === true + }; + } catch { + return null; + } +} diff --git a/web-frontend/src/components/FormStatus.vue b/web-frontend/src/components/FormStatus.vue new file mode 100644 index 0000000..b4e4979 --- /dev/null +++ b/web-frontend/src/components/FormStatus.vue @@ -0,0 +1,10 @@ + + + diff --git a/web-frontend/src/components/PageHeader.vue b/web-frontend/src/components/PageHeader.vue new file mode 100644 index 0000000..9474d1c --- /dev/null +++ b/web-frontend/src/components/PageHeader.vue @@ -0,0 +1,15 @@ + + + diff --git a/web-frontend/src/components/SearchSelect.vue b/web-frontend/src/components/SearchSelect.vue new file mode 100644 index 0000000..3f812fb --- /dev/null +++ b/web-frontend/src/components/SearchSelect.vue @@ -0,0 +1,75 @@ + + + diff --git a/web-frontend/src/main.ts b/web-frontend/src/main.ts new file mode 100644 index 0000000..ad69c6b --- /dev/null +++ b/web-frontend/src/main.ts @@ -0,0 +1,6 @@ +import { createApp } from "vue"; +import "./styles.css"; +import App from "./App.vue"; +import { router } from "./router"; + +createApp(App).use(router).mount("#app"); diff --git a/web-frontend/src/number-ranges.ts b/web-frontend/src/number-ranges.ts new file mode 100644 index 0000000..68267e6 --- /dev/null +++ b/web-frontend/src/number-ranges.ts @@ -0,0 +1,17 @@ +import { apiPost } from "./api"; + +type NextNumberResponse = { + code: string; + number: string; +}; + +export async function reserveNextNumber(code: string): Promise { + const result = await apiPost(`/api/v1/number-ranges/${encodeURIComponent(code)}/next`, {}); + return result.ok ? result.data.number : null; +} + +export function matchesObjectSearch(number: string | null | undefined, title: string | null | undefined, query: string) { + const needle = query.trim().toLowerCase(); + if (!needle) return true; + return `${number ?? ""} ${title ?? ""}`.toLowerCase().includes(needle); +} diff --git a/web-frontend/src/realtime.ts b/web-frontend/src/realtime.ts new file mode 100644 index 0000000..b0812e0 --- /dev/null +++ b/web-frontend/src/realtime.ts @@ -0,0 +1,179 @@ +import { reactive } from "vue"; +import type { + ClientMessage, + EncryptedEnvelope, + RecordSummary, + ServerMessage, + SessionCrypto, + WireMessage +} from "./types"; + +const protocolVersion = 1; +export const wsUrl = import.meta.env.VITE_WS_URL ?? "ws://localhost:8080/ws"; + +export const connectionState = reactive<{ + records: RecordSummary[]; + status: string; +}>({ + records: [], + status: "Nicht verbunden" +}); + +export const liveUpdateState = reactive<{ + revision: number; + lastTitle: string; + lastUpdatedAt: string | null; +}>({ + revision: 0, + lastTitle: "", + lastUpdatedAt: null +}); + +let connectionStarted = false; +let reconnectTimer: number | undefined; + +export function ensureConnection() { + if (connectionStarted) return; + connectionStarted = true; + connect(); +} + +export function stopConnection() { + connectionStarted = false; + if (reconnectTimer !== undefined) { + window.clearTimeout(reconnectTimer); + reconnectTimer = undefined; + } + connectionState.records = []; + connectionState.status = "Nicht verbunden"; +} + +function connect() { + const socket = new WebSocket(wsUrl); + let session: SessionCrypto | null = null; + + socket.addEventListener("open", async () => { + session = await createSessionCrypto(); + connectionState.status = "Handshake..."; + socket.send(JSON.stringify({ + type: "hello", + payload: { + protocol_version: protocolVersion, + key_id: session.keyId, + session_key: session.exportedKey + } + } satisfies WireMessage)); + }); + + socket.addEventListener("message", async (event) => { + const wireMessage = JSON.parse(event.data) as WireMessage; + if (wireMessage.type === "hello_ack") { + connectionState.status = "Verbunden"; + if (session) { + const envelope = await encryptMessage(session, { + type: "subscribe", + payload: { topic: "records" } + }); + socket.send(JSON.stringify({ type: "encrypted", payload: envelope } satisfies WireMessage)); + } + return; + } + + if (wireMessage.type === "encrypted" && session) { + const message = await decryptMessage(session, wireMessage.payload); + applyMessage(message); + return; + } + + if (wireMessage.type === "error") { + connectionState.status = wireMessage.payload.message; + } + }); + + socket.addEventListener("close", () => { + connectionStarted = false; + connectionState.status = "Verbindung getrennt, neuer Versuch..."; + reconnectTimer = window.setTimeout(ensureConnection, 1500); + }); + + socket.addEventListener("error", () => { + connectionState.status = "Socket-Fehler"; + }); +} + +async function createSessionCrypto(): Promise { + const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [ + "encrypt", + "decrypt" + ]); + const rawKey = await crypto.subtle.exportKey("raw", key); + + return { + key, + keyId: crypto.randomUUID(), + exportedKey: bytesToBase64(new Uint8Array(rawKey)) + }; +} + +async function encryptMessage(session: SessionCrypto, message: ClientMessage) { + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode(JSON.stringify(message)); + const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, session.key, encoded); + + return { + enc: `aes-256-gcm-v${protocolVersion}`, + key_id: session.keyId, + nonce: bytesToBase64(nonce), + ciphertext: bytesToBase64(new Uint8Array(ciphertext)) + } satisfies EncryptedEnvelope; +} + +async function decryptMessage(session: SessionCrypto, envelope: EncryptedEnvelope): Promise { + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: base64ToBytes(envelope.nonce) }, + session.key, + base64ToBytes(envelope.ciphertext) + ); + return JSON.parse(new TextDecoder().decode(plaintext)) as T; +} + +function bytesToBase64(bytes: Uint8Array) { + let binary = ""; + bytes.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + return btoa(binary); +} + +function base64ToBytes(value: string) { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function applyMessage(message: ServerMessage) { + if (message.type === "snapshot") { + connectionState.records = message.payload.records; + connectionState.status = "Verbunden"; + } + + if (message.type === "record_changed") { + const index = connectionState.records.findIndex((record) => record.id === message.payload.record.id); + if (index >= 0) { + connectionState.records[index] = message.payload.record; + } else { + connectionState.records.unshift(message.payload.record); + } + connectionState.status = "Aktualisiert"; + liveUpdateState.revision += 1; + liveUpdateState.lastTitle = message.payload.record.title; + liveUpdateState.lastUpdatedAt = message.payload.record.updated_at; + } + + if (message.type === "error") { + connectionState.status = message.payload.message; + } +} diff --git a/web-frontend/src/router.ts b/web-frontend/src/router.ts new file mode 100644 index 0000000..25b8781 --- /dev/null +++ b/web-frontend/src/router.ts @@ -0,0 +1,105 @@ +import { createRouter, createWebHistory } from "vue-router"; +import { authState } from "./auth"; +import DashboardPage from "./views/DashboardPage.vue"; +import LoginPage from "./views/LoginPage.vue"; +import RegisterPage from "./views/RegisterPage.vue"; +import ChangeInitialPasswordPage from "./views/ChangeInitialPasswordPage.vue"; +import PasswordResetPage from "./views/PasswordResetPage.vue"; +import AcceptInvitationPage from "./views/AcceptInvitationPage.vue"; +import AdminRegistrationsPage from "./views/AdminRegistrationsPage.vue"; +import AdminRegistrationDetailPage from "./views/AdminRegistrationDetailPage.vue"; +import OrganizationSetupPage from "./views/OrganizationSetupPage.vue"; +import UsersPage from "./views/UsersPage.vue"; +import CustomersPage from "./views/CustomersPage.vue"; +import SuppliersPage from "./views/SuppliersPage.vue"; +import ItemsPage from "./views/ItemsPage.vue"; +import ActivitiesPage from "./views/ActivitiesPage.vue"; +import CashDiscountTermsPage from "./views/CashDiscountTermsPage.vue"; +import NumberRangesPage from "./views/NumberRangesPage.vue"; +import QuotesPage from "./views/QuotesPage.vue"; +import OutgoingInvoicesPage from "./views/OutgoingInvoicesPage.vue"; +import IncomingInvoicesPage from "./views/IncomingInvoicesPage.vue"; +import PriceImportsPage from "./views/PriceImportsPage.vue"; +import ApiConnectorsPage from "./views/ApiConnectorsPage.vue"; +import PriceRulesPage from "./views/PriceRulesPage.vue"; +import CommunicationsPage from "./views/CommunicationsPage.vue"; +import DocumentsPage from "./views/DocumentsPage.vue"; + +export const publicRoutes = [ + { path: "/login", label: "Login" }, + { path: "/register", label: "Registrierung" }, + { path: "/password-reset", label: "Passwort zurücksetzen" }, + { path: "/accept-invitation", label: "Einladung annehmen" } +]; + +export const privateRoutes = [ + { path: "/", label: "Dashboard", group: "Vorgänge" }, + { path: "/outgoing-invoices", label: "Ausgangsrechnungen", group: "Vorgänge" }, + { path: "/quotes", label: "Angebote", group: "Vorgänge" }, + { path: "/incoming-invoices", label: "Eingangsrechnungen", group: "Vorgänge" }, + { path: "/master-data/customers", label: "Kunden", group: "Stammdaten" }, + { path: "/master-data/suppliers", label: "Lieferanten", group: "Stammdaten" }, + { path: "/master-data/items", label: "Artikel", group: "Stammdaten" }, + { path: "/activities", label: "Aktivitäten", group: "Stammdaten" }, + { path: "/imports/price-lists", label: "Preislisten", group: "Arbeitsdaten" }, + { path: "/communications", label: "Kommunikation", group: "Arbeitsdaten" }, + { path: "/documents", label: "Dokumente", group: "Arbeitsdaten" }, + { path: "/settings/price-rules", label: "Preisregeln", group: "Einstellungen" }, + { path: "/settings/api-connectors", label: "Preis-APIs", group: "Einstellungen" }, + { path: "/setup/organization", label: "Firmendaten", group: "Einstellungen" }, + { path: "/settings/users", label: "Benutzerrechte", group: "Einstellungen" }, + { path: "/settings/number-ranges", label: "Nummernkreise", group: "Einstellungen" }, + { path: "/settings/cash-discount-terms", label: "Skonto", group: "Einstellungen" }, + { path: "/admin/organization-registrations", label: "Freischaltung", group: "Einstellungen" } +]; + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: "/", component: DashboardPage, meta: { requiresAuth: true } }, + { path: "/login", component: LoginPage, meta: { publicOnly: true } }, + { path: "/register", component: RegisterPage, meta: { publicOnly: true } }, + { path: "/password-reset", component: PasswordResetPage, meta: { publicOnly: true } }, + { path: "/accept-invitation", component: AcceptInvitationPage, meta: { publicOnly: true } }, + { + path: "/change-initial-password", + component: ChangeInitialPasswordPage, + meta: { requiresAuth: true, passwordChange: true } + }, + { + path: "/admin/organization-registrations", + component: AdminRegistrationsPage, + meta: { requiresAuth: true } + }, + { + path: "/admin/organization-registrations/:id", + component: AdminRegistrationDetailPage, + meta: { requiresAuth: true } + }, + { path: "/setup/organization", component: OrganizationSetupPage, meta: { requiresAuth: true } }, + { path: "/settings/users", component: UsersPage, meta: { requiresAuth: true } }, + { path: "/settings/number-ranges", component: NumberRangesPage, meta: { requiresAuth: true } }, + { path: "/master-data/customers", component: CustomersPage, meta: { requiresAuth: true } }, + { path: "/master-data/suppliers", component: SuppliersPage, meta: { requiresAuth: true } }, + { path: "/master-data/items", component: ItemsPage, meta: { requiresAuth: true } }, + { path: "/settings/cash-discount-terms", component: CashDiscountTermsPage, meta: { requiresAuth: true } }, + { path: "/quotes", component: QuotesPage, meta: { requiresAuth: true } }, + { path: "/outgoing-invoices", component: OutgoingInvoicesPage, meta: { requiresAuth: true } }, + { path: "/incoming-invoices", component: IncomingInvoicesPage, meta: { requiresAuth: true } }, + { path: "/imports/price-lists", component: PriceImportsPage, meta: { requiresAuth: true } }, + { path: "/settings/api-connectors", component: ApiConnectorsPage, meta: { requiresAuth: true } }, + { path: "/settings/price-rules", component: PriceRulesPage, meta: { requiresAuth: true } }, + { path: "/communications", component: CommunicationsPage, meta: { requiresAuth: true } }, + { path: "/documents", component: DocumentsPage, meta: { requiresAuth: true } }, + { path: "/activities", component: ActivitiesPage, meta: { requiresAuth: true } }, + { path: "/:pathMatch(.*)*", redirect: "/login" } + ] +}); + +router.beforeEach((to) => { + const session = authState.session; + if (!session && to.meta.requiresAuth) return "/login"; + if (session?.mustChangePassword && !to.meta.passwordChange) return "/change-initial-password"; + if (session && to.meta.publicOnly) return "/"; + return true; +}); diff --git a/web-frontend/src/styles.css b/web-frontend/src/styles.css new file mode 100644 index 0000000..d1f087d --- /dev/null +++ b/web-frontend/src/styles.css @@ -0,0 +1,788 @@ +:root { + color: #172026; + background: #f6faf9; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + background: #118a7f; + border: 1px solid #118a7f; + border-radius: 6px; + color: #ffffff; + cursor: pointer; + font-weight: 700; + min-height: 38px; + padding: 8px 13px; +} + +button.secondary { + background: #ffffff; + border-color: #c9d3d6; + color: #26343b; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +button:hover { + filter: brightness(0.96); +} + +a { + color: #118a7f; + text-decoration: none; +} + +code { + background: #e8edef; + border: 1px solid #ccd6d9; + border-radius: 6px; + color: #26343b; + padding: 4px 7px; + overflow-wrap: anywhere; +} + +.app-shell { + display: grid; + grid-template-columns: 244px minmax(0, 1fr); + height: 100vh; + min-height: 0; + overflow: hidden; +} + +.sidebar { + background: #ffffff; + border-right: 1px solid #dbe3e6; + display: flex; + flex-direction: column; + min-height: 0; + padding: 22px 16px; +} + +.brand { + align-items: center; + display: flex; + flex-shrink: 0; + gap: 10px; + margin-bottom: 26px; +} + +.brand-mark { + background: #ffffff; + border-radius: 6px; + display: block; + height: 34px; + object-fit: contain; + width: 34px; +} + +.brand strong, +.brand span { + display: block; +} + +.brand span { + color: #6a787d; + font-size: 13px; +} + +.brand strong { + color: #172026; +} + +.nav { + display: grid; + gap: 0; + min-height: 0; + overflow-y: auto; + padding-right: 4px; +} + +.nav-group-title { + color: #6a787d; + font-size: 11px; + font-weight: 800; + letter-spacing: 0; + margin: 0 0 0 0; +} + +.nav-link + .nav-link { + margin-top: 4px; +} + +.nav-link + .nav-group-title { + margin-top: 12px; +} + +.nav-link { + border-radius: 6px; + color: #334349; + font-weight: 650; + line-height: 1.2; + padding: 9px 10px; +} + +.nav-button { + background: #10545c; + border: 1px solid #10545c; + color: #eefbf8; + font-size: 12px; + font-weight: 500; + min-height: 36px; + padding: 8px 10px; + text-align: center; +} + +.nav-button:hover { + background: #118a7f; + border-color: #118a7f; + color: #ffffff; +} + +.nav-button.active { + background: #def4f0; + border-color: #def4f0; + color: #10545c; + font-weight: 500; +} + +.nav-groups { + overflow: visible; +} + +.nav-group-toggle { + align-items: center; + background: #f4f7f8; + border-color: #d8e2e5; + color: #334349; + display: flex; + font-size: 12px; + justify-content: space-between; + margin-top: 0; + min-height: 32px; + padding: 6px 9px; + width: 100%; +} + +.nav-dropdown { + position: relative; +} + +.nav-groups .nav-dropdown:not(:first-child) { + margin-top: 12px; +} + +.nav-dropdown-menu { + background: #ffffff; + border: 1px solid #cfd9dc; + border-radius: 6px; + box-shadow: 0 14px 34px rgb(28 43 48 / 16%); + display: grid; + gap: 2px; + left: 0; + min-width: 190px; + padding: 6px; + position: absolute; + top: calc(100% + 4px); + z-index: 80; +} + +.nav-dropdown-menu .nav-link { + font-size: 13px; + font-weight: 750; + line-height: 1.2; + min-height: 30px; + padding: 6px 8px; + white-space: nowrap; +} + +.nav-link.active, +.nav-link:hover { + background: #def4f0; + color: #10545c; +} + +.session-box { + border-top: 1px solid #dbe3e6; + display: grid; + flex-shrink: 0; + gap: 10px; + margin-top: 22px; + padding-top: 16px; +} + +.session-box span { + color: #435258; + font-size: 13px; + font-weight: 700; + overflow-wrap: anywhere; +} + +.session-box button { + background: #10545c; + border-color: #10545c; + color: #eefbf8; + font-size: 12px; + font-weight: 500; + min-height: 34px; + width: 100%; +} + +.settings-field { + gap: 5px; +} + +.settings-field span, +.session-box small { + color: #65757b; + font-size: 12px; + font-weight: 700; +} + +.settings-field select { + background: #10545c; + border: 1px solid #10545c; + border-radius: 6px; + color: #ffffff; + font-size: 12px; + min-height: 34px; + padding: 4px 8px; +} + +.main { + overflow: auto; + min-width: 0; + padding: 28px 28px 64px; + position: relative; +} + +.app-window { + background: #ffffff; + border: 1px solid #cfd9dc; + border-radius: 8px; + box-shadow: 0 18px 50px rgb(28 43 48 / 18%); + overflow: hidden; + position: fixed; +} + +.app-window-title { + align-items: center; + background: #eef8f6; + border-bottom: 1px solid #dbe3e6; + cursor: move; + display: flex; + justify-content: space-between; + padding: 9px 12px; + touch-action: none; +} + +.app-window-actions { + display: flex; + gap: 6px; +} + +.app-window-body { + height: calc(100% - 49px); + overflow: auto; + padding: 18px; +} + +.app-window-resize { + border-bottom: 12px solid #8fa0a6; + border-left: 12px solid transparent; + bottom: 5px; + cursor: nwse-resize; + height: 0; + position: absolute; + right: 5px; + touch-action: none; + width: 0; +} + +.icon-button { + min-height: 30px; + padding: 2px 10px; +} + +.taskbar { + align-items: center; + background: #ffffff; + border-top: 1px solid #cfd9dc; + bottom: 0; + display: flex; + gap: 8px; + left: 244px; + min-height: 48px; + overflow-x: auto; + padding: 7px 12px; + position: fixed; + right: 0; + z-index: 2000; +} + +.taskbar-button { + background: #10545c; + border-color: #10545c; + color: #ffffff; + flex: 0 0 auto; + font-size: 12px; + font-weight: 600; + min-height: 32px; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.taskbar-button.minimized { + background: #def4f0; + border-color: #c8ebe6; + color: #10545c; +} + +.page-header { + align-items: flex-start; + display: flex; + justify-content: space-between; + margin-bottom: 18px; +} + +.page-header h1 { + font-size: 28px; + line-height: 1.15; + margin: 0 0 6px; +} + +.page-header p, +.muted { + color: #65757b; + margin: 0; +} + +.panel { + background: #ffffff; + border: 1px solid #d9e1e4; + border-radius: 8px; + margin-bottom: 18px; + padding: 18px; +} + +.panel.compact { + padding: 15px 18px; +} + +.sub-panel { + border-top: 1px solid #dbe3e6; + display: grid; + gap: 8px; + margin-top: 18px; + padding-top: 16px; +} + +.sub-panel h2 { + font-size: 18px; + margin: 0; +} + +.data-row { + align-items: center; + border: 1px solid #dbe3e6; + border-radius: 6px; + display: grid; + gap: 8px; + grid-template-columns: minmax(180px, 1.2fr) repeat(3, minmax(70px, 0.6fr)); + padding: 9px 10px; +} + +.data-row small { + color: #65757b; +} + +.quote-line { + border: 1px solid #dbe3e6; + border-radius: 8px; + display: grid; + gap: 12px; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + padding: 12px; +} + +.form-panel { + max-width: 720px; +} + +.form-panel.wide { + max-width: 1040px; +} + +.workspace-split { + display: grid; + gap: 16px; + grid-template-columns: 280px minmax(420px, 1fr); +} + +.list-panel, +.detail-panel { + margin-bottom: 0; +} + +.list-row { + align-items: flex-start; + background: #ffffff; + border: 1px solid transparent; + color: #26343b; + display: grid; + font-weight: 400; + gap: 3px; + margin-bottom: 5px; + padding: 10px; + text-align: left; + width: 100%; +} + +.list-row:hover, +.list-row.selected { + background: #def4f0; + border-color: #c6d9d8; + filter: none; +} + +.list-row strong { + font-size: 13px; +} + +.list-row span { + font-weight: 650; +} + +.list-row small { + color: #65757b; +} + +.split-row, +.section-title, +.button-row, +.form-actions { + align-items: center; + display: flex; + gap: 12px; +} + +.split-row, +.section-title { + justify-content: space-between; +} + +.section-title { + margin-bottom: 14px; +} + +.section-title h2, +.split-row h2 { + font-size: 18px; + margin: 0 0 4px; +} + +form { + display: grid; + gap: 14px; +} + +.form-grid { + display: grid; + gap: 14px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.field { + display: grid; + gap: 6px; +} + +.field span, +.check-row span { + color: #435258; + font-size: 14px; + font-weight: 700; +} + +input, +select, +textarea { + background: #ffffff; + border: 1px solid #cbd5d9; + border-radius: 6px; + color: #172026; + min-height: 38px; + padding: 8px 10px; + width: 100%; +} + +.readonly-value { + align-items: center; + background: #f6faf9; + border: 1px solid #cbd5d9; + border-radius: 6px; + color: #172026; + display: flex; + min-height: 38px; + padding: 8px 10px; +} + +.list-search { + margin-bottom: 10px; +} + +textarea { + resize: vertical; +} + +.field.full-width { + grid-column: 1 / -1; +} + +select[multiple] { + min-height: 128px; +} + +.search-select { + display: grid; + gap: 8px; +} + +.search-select-current { + color: #435258; + font-size: 13px; +} + +.search-select-options { + border: 1px solid #dbe3e6; + border-radius: 6px; + display: grid; + gap: 4px; + max-height: 220px; + overflow: auto; + padding: 6px; +} + +.search-option { + background: #ffffff; + border: 1px solid transparent; + color: #26343b; + display: grid; + gap: 2px; + min-height: 0; + padding: 7px 8px; + text-align: left; +} + +.search-option span { + color: #65757b; + font-size: 12px; +} + +.search-option strong { + font-size: 13px; +} + +.search-option:hover, +.search-option.selected { + background: #def4f0; + border-color: #c6d9d8; + filter: none; +} + +.check-row { + align-items: center; + display: flex; + gap: 9px; +} + +.check-row input { + min-height: 16px; + width: 16px; +} + +.check-row.compact { + gap: 6px; +} + +.check-row.compact span { + font-size: 13px; +} + +.role-checks { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px 12px; +} + +.form-actions, +.button-row { + flex-wrap: wrap; + margin-top: 4px; +} + +.form-status { + border-radius: 6px; + display: block; + min-height: 20px; + padding: 0; +} + +.form-status.info, +.form-status.success, +.form-status.error { + padding: 10px 12px; +} + +.form-status.info { + background: #eef8f6; + color: #334349; +} + +.form-status.success { + background: #e6f4ea; + color: #1f5d38; +} + +.form-status.error { + background: #fdeceb; + color: #8a2521; +} + +.data-table { + border: 1px solid #e0e7ea; + border-radius: 8px; + display: grid; + overflow: hidden; +} + +.data-table.two-cols { + grid-template-columns: minmax(0, 1fr) 220px; +} + +.data-table.registrations { + grid-template-columns: minmax(140px, 1.2fr) minmax(170px, 1fr) 140px 180px; +} + +.data-table.users { + grid-template-columns: minmax(180px, 1fr) 140px minmax(260px, 1.2fr) 130px; +} + +.data-table > * { + border-bottom: 1px solid #edf1f3; + min-width: 0; + padding: 12px 14px; +} + +.table-head { + background: #eef8f6; + color: #526268; + font-size: 13px; + font-weight: 800; + text-transform: uppercase; +} + +.status-pill { + background: #def4f0; + border-radius: 999px; + color: #118a7f; + display: inline-block; + font-size: 13px; + font-weight: 800; + height: 28px; + line-height: 28px; + margin: 8px 12px; + padding: 0 10px; +} + +.detail-grid { + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + margin: 0 0 16px; +} + +.detail-grid dt, +.detail-grid dd { + border-bottom: 1px solid #edf1f3; + margin: 0; + padding: 10px 0; +} + +.detail-grid dt { + color: #65757b; + font-weight: 800; +} + +.empty { + background: #f7f9fa; + border: 1px dashed #cbd5d9; + border-radius: 8px; + color: #65757b; + margin: 0; + padding: 18px; +} + +@media (max-width: 860px) { + .app-shell { + grid-template-columns: 1fr; + height: auto; + min-height: 100vh; + overflow: visible; + } + + .sidebar { + border-bottom: 1px solid #dbe3e6; + border-right: 0; + max-height: none; + } + + .nav { + grid-template-columns: repeat(2, minmax(0, 1fr)); + overflow-y: visible; + } + + .main { + padding: 18px; + } + + .taskbar { + left: 0; + } + + .form-grid, + .workspace-split, + .data-table.two-cols, + .data-table.registrations, + .data-table.users, + .detail-grid { + grid-template-columns: 1fr; + } + + .split-row, + .section-title { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/web-frontend/src/types.ts b/web-frontend/src/types.ts new file mode 100644 index 0000000..4f8fde0 --- /dev/null +++ b/web-frontend/src/types.ts @@ -0,0 +1,348 @@ +export type ApiResult = + | { ok: true; data: T } + | { ok: false; message: string }; + +export type OrganizationRegistration = { + id: string; + organization_name?: string; + email: string; + status: string; + requested_at: string; + decided_at?: string | null; + decided_by_user_id?: string | null; +}; + +export type OrganizationRegistrationDetail = OrganizationRegistration & { + organization_id?: string | null; + schema_name?: string | null; + provisioning_error?: string | null; + decision_note?: string | null; +}; + +export type OrganizationUser = { + user_id: string; + email: string; + status: string; + roles: string[]; +}; + +export type Customer = { + id: string; + customer_number: string; + name: string; + status: string; + details: { + street: string; + postal_code: string; + city: string; + country: string; + email: string; + phone: string; + }; + standard_discount_percent: string; + cash_discount_term_id?: string | null; +}; + +export type Supplier = Omit & { + supplier_number: string; + payment_days?: number | null; +}; + +export type Item = { + id: string; + item_number: string; + name: string; + unit: string; + tax_rate: string; + default_purchase_price?: string | null; + default_sales_price?: string | null; + status: string; +}; + +export type ItemPriceHistory = { + id: string; + item_id: string; + purchase_price?: string | null; + sales_price?: string | null; + source: string; + valid_from: string; + created_by_user_id?: string | null; + created_at: string; +}; + +export type CashDiscountTerm = { + id: string; + code: string; + name: string; + discount_percent: string; + discount_days: number; + net_days?: number | null; + valid_from?: string | null; + valid_until?: string | null; + is_default_customer_term: boolean; + is_default_supplier_term: boolean; + is_active: boolean; +}; + +export type Activity = { + id: string; + activity_number?: string | null; + activity_type: string; + title: string; + body: string; + status: string; + priority: string; + due_at?: string | null; +}; + +export type NumberRange = { + id: string; + code: string; + pattern: string; + counter_value: number; + counter_padding: number; + reset_rule?: string | null; + is_active: boolean; +}; + +export type QuoteItem = { + id?: string; + line_number?: number; + item_id: string; + description: string; + quantity: string; + unit_price: string; + original_unit_price?: string | null; + discount_percent: string; + tax_rate: string; + price_overridden?: boolean; +}; + +export type Quote = { + id: string; + quote_number: string; + customer_id: string; + status: string; + valid_until?: string | null; + cash_discount_term_id?: string | null; + customer_discount_percent: string; + notes: string; + items: QuoteItem[]; +}; + +export type OutgoingInvoiceItem = QuoteItem; + +export type OutgoingInvoice = { + id: string; + invoice_number: string; + customer_id: string; + status: string; + cash_discount_term_id?: string | null; + customer_discount_percent: string; + issued_at?: string | null; + due_at?: string | null; + source_quote_id?: string | null; + finalized_at?: string | null; + items: OutgoingInvoiceItem[]; +}; + +export type IncomingInvoiceItem = { + id?: string; + line_number?: number; + item_id?: string | null; + description: string; + quantity: string; + unit_price: string; + tax_rate: string; +}; + +export type IncomingInvoice = { + id: string; + invoice_number: string; + supplier_id: string; + status: string; + cash_discount_term_id?: string | null; + invoice_date?: string | null; + due_at?: string | null; + items: IncomingInvoiceItem[]; +}; + +export type PriceListImportRow = { + row_number: number; + item_number: string; + name: string; + unit: string; + tax_rate: string; + purchase_price?: string | null; + sales_price?: string | null; + action: string; + error?: string | null; +}; + +export type PriceListImportPreview = { + rows: PriceListImportRow[]; + total_rows: number; + valid_rows: number; + error_rows: number; +}; + +export type PriceListImportApplyResponse = { + import_id: string; + applied_rows: number; + error_rows: number; +}; + +export type ApiConnector = { + id: string; + code: string; + name: string; + connector_type: string; + config: Record; + is_active: boolean; + sync_interval_minutes?: number | null; + last_sync_at?: string | null; +}; + +export type PriceRule = { + id: string; + code: string; + name: string; + source_type: "import" | "api" | "supplier"; + source_id?: string | null; + markup_percent: string; + rounding_mode: "none" | "cent" | "five_cent" | "ten_cent" | "whole"; + is_active: boolean; +}; + +export type EntityLink = { + entity_type: string; + entity_id: string; +}; + +export type Communication = { + id: string; + communication_type: string; + direction: string; + subject: string; + body: string; + status: string; + occurred_at?: string | null; + links: EntityLink[]; +}; + +export type DocumentVersion = { + id: string; + version_no: number; + file_name: string; + content_type: string; + file_size: number; + checksum_sha256: string; + created_at: string; +}; + +export type DocumentRecord = { + id: string; + title: string; + description: string; + status: string; + latest_version?: DocumentVersion | null; + links: EntityLink[]; +}; + +export type DocumentDownload = { + document_id: string; + version_id: string; + file_name: string; + content_type: string; + content_base64: string; +}; + +export type DocumentAuditLogEntry = { + id: string; + document_id: string; + version_id?: string | null; + action: string; + user_id?: string | null; + created_at: string; +}; + +export type LoginResponse = { + user_id: string; + access_token: string; + organization_id?: string | null; + must_change_password: boolean; + organizations: Array<{ + id: string; + schema_name?: string | null; + status: string; + }>; +}; + +export type AuthSession = { + email: string; + userId: string; + accessToken: string; + organizationId?: string | null; + mustChangePassword: boolean; +}; + +export type NavigationMode = "scroll" | "groups"; + +export type UserNavigationSettings = { + mode: NavigationMode; +}; + +export type RecordSummary = { + id: string; + title: string; + updated_at: string; +}; + +export type ServerMessage = + | { type: "snapshot"; payload: { records: RecordSummary[] } } + | { type: "record_changed"; payload: { record: RecordSummary } } + | { type: "pong" } + | { type: "error"; payload: { message: string } }; + +export type ClientMessage = + | { type: "subscribe"; payload: { topic: string } } + | { type: "ping" }; + +export type WireMessage = + | { type: "hello"; payload: HelloMessage } + | { type: "hello_ack"; payload: HelloAckMessage } + | { type: "encrypted"; payload: EncryptedEnvelope } + | { type: "error"; payload: { message: string } }; + +export type HelloMessage = { + protocol_version: number; + key_id: string; + session_key: string; +}; + +export type HelloAckMessage = { + protocol_version: number; + key_id: string; +}; + +export type EncryptedEnvelope = { + enc: string; + key_id: string; + nonce: string; + ciphertext: string; +}; + +export type SessionCrypto = { + keyId: string; + key: CryptoKey; + exportedKey: string; +}; + +export type DevBootstrapResponse = { + organization_id: string; + schema_name: string; + user_id: string; + email: string; + password: string; + dev_mode: boolean; +}; diff --git a/web-frontend/src/user-settings.ts b/web-frontend/src/user-settings.ts new file mode 100644 index 0000000..a9b4289 --- /dev/null +++ b/web-frontend/src/user-settings.ts @@ -0,0 +1,32 @@ +import { reactive } from "vue"; +import { apiGet, apiPut } from "./api"; +import type { NavigationMode, UserNavigationSettings } from "./types"; + +export const userSettings = reactive({ + navigationMode: "scroll" as NavigationMode, + status: "", + loaded: false +}); + +export async function loadUserSettings() { + const result = await apiGet("/api/v1/users/me/settings/navigation"); + userSettings.loaded = true; + if (result.ok) { + userSettings.navigationMode = result.data.mode; + userSettings.status = ""; + } else { + userSettings.status = result.message; + } +} + +export async function saveNavigationMode(mode: NavigationMode) { + userSettings.navigationMode = mode; + userSettings.status = "Speichere Menü..."; + const result = await apiPut("/api/v1/users/me/settings/navigation", { mode }); + if (result.ok) { + userSettings.navigationMode = result.data.mode; + userSettings.status = "Menü gespeichert."; + } else { + userSettings.status = result.message; + } +} diff --git a/web-frontend/src/utils.ts b/web-frontend/src/utils.ts new file mode 100644 index 0000000..ee49cc5 --- /dev/null +++ b/web-frontend/src/utils.ts @@ -0,0 +1,4 @@ +export function formatDate(value?: string | null) { + if (!value) return "-"; + return new Date(value).toLocaleString("de-DE"); +} diff --git a/web-frontend/src/views/AcceptInvitationPage.vue b/web-frontend/src/views/AcceptInvitationPage.vue new file mode 100644 index 0000000..f4fbdd9 --- /dev/null +++ b/web-frontend/src/views/AcceptInvitationPage.vue @@ -0,0 +1,32 @@ + + + diff --git a/web-frontend/src/views/ActivitiesPage.vue b/web-frontend/src/views/ActivitiesPage.vue new file mode 100644 index 0000000..8b7093f --- /dev/null +++ b/web-frontend/src/views/ActivitiesPage.vue @@ -0,0 +1,39 @@ + +