diff --git a/apps/backend/.env.example b/apps/backend/.env.example deleted file mode 100644 index 3252c3e..0000000 --- a/apps/backend/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -DATABASE_URL=postgres://postgres:postgres@localhost:5432/bacchus -JWT_SECRET= -RUST_LOG=bacchus=debug,axum=info,tower_http=info \ No newline at end of file diff --git a/apps/backend/Cargo.lock b/apps/backend/Cargo.lock deleted file mode 100644 index 077b67c..0000000 --- a/apps/backend/Cargo.lock +++ /dev/null @@ -1,2497 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aho-corasick" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" -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-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - -[[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.99" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" - -[[package]] -name = "atoi" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "axum" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" -dependencies = [ - "axum-core", - "axum-macros", - "bytes", - "form_urlencoded", - "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", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-macros" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "bacchus" -version = "0.1.0" -dependencies = [ - "anyhow", - "axum", - "chrono", - "serde", - "serde_json", - "sqlx", - "tokio", - "tower-http", - "tracing", - "tracing-subscriber", - "uuid", -] - -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - -[[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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" - -[[package]] -name = "bitflags" -version = "2.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" -dependencies = [ - "serde", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" - -[[package]] -name = "cc" -version = "1.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" -dependencies = [ - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" - -[[package]] -name = "chrono" -version = "0.4.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" -dependencies = [ - "android-tzdata", - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - -[[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.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -dependencies = [ - "serde", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[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 = "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 = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -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.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasi 0.14.2+wasi-0.2.4", -] - -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - -[[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 = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "home" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" -dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "http" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" -dependencies = [ - "bytes", - "fnv", - "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.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", -] - -[[package]] -name = "hyper-util" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "hyper", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" - -[[package]] -name = "icu_properties" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "potential_utf", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" - -[[package]] -name = "icu_provider" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" -dependencies = [ - "displaydoc", - "icu_locale_core", - "stable_deref_trait", - "tinystr", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "io-uring" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" -dependencies = [ - "bitflags", - "cfg-if", - "libc", -] - -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "js-sys" -version = "0.3.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] - -[[package]] -name = "libc" -version = "0.2.175" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" - -[[package]] -name = "libm" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" - -[[package]] -name = "libredox" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" -dependencies = [ - "bitflags", - "libc", - "redox_syscall", -] - -[[package]] -name = "libsqlite3-sys" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" -dependencies = [ - "pkg-config", - "vcpkg", -] - -[[package]] -name = "litemap" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" - -[[package]] -name = "lock_api" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" -dependencies = [ - "autocfg", - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" - -[[package]] -name = "matchers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" -dependencies = [ - "regex-automata 0.1.10", -] - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest", -] - -[[package]] -name = "memchr" -version = "2.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" - -[[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", -] - -[[package]] -name = "mio" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" -dependencies = [ - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", -] - -[[package]] -name = "nu-ansi-term" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" -dependencies = [ - "overload", - "winapi", -] - -[[package]] -name = "num-bigint-dig" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" -dependencies = [ - "byteorder", - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand", - "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 = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - -[[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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets 0.52.6", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[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.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "potential_utf" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" -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 = "proc-macro2" -version = "1.0.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" -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 = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[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", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.16", -] - -[[package]] -name = "redox_syscall" -version = "0.5.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex" -version = "1.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata 0.4.10", - "regex-syntax 0.8.6", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", -] - -[[package]] -name = "regex-automata" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax 0.8.6", -] - -[[package]] -name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - -[[package]] -name = "regex-syntax" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" - -[[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.16", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rsa" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core", - "signature", - "spki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" - -[[package]] -name = "rustls" -version = "0.23.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" -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.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.143" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" -dependencies = [ - "itoa", - "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.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" -dependencies = [ - "libc", -] - -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core", -] - -[[package]] -name = "slab" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] - -[[package]] -name = "socket2" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "sqlx" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" -dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", -] - -[[package]] -name = "sqlx-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" -dependencies = [ - "base64", - "bytes", - "chrono", - "crc", - "crossbeam-queue", - "either", - "event-listener", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown", - "hashlink", - "indexmap", - "log", - "memchr", - "once_cell", - "percent-encoding", - "rustls", - "serde", - "serde_json", - "sha2", - "smallvec", - "thiserror", - "tokio", - "tokio-stream", - "tracing", - "url", - "uuid", - "webpki-roots 0.26.11", -] - -[[package]] -name = "sqlx-macros" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" -dependencies = [ - "proc-macro2", - "quote", - "sqlx-core", - "sqlx-macros-core", - "syn", -] - -[[package]] -name = "sqlx-macros-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" -dependencies = [ - "dotenvy", - "either", - "heck", - "hex", - "once_cell", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2", - "sqlx-core", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", - "syn", - "tokio", - "url", -] - -[[package]] -name = "sqlx-mysql" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" -dependencies = [ - "atoi", - "base64", - "bitflags", - "byteorder", - "bytes", - "chrono", - "crc", - "digest", - "dotenvy", - "either", - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "percent-encoding", - "rand", - "rsa", - "serde", - "sha1", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror", - "tracing", - "uuid", - "whoami", -] - -[[package]] -name = "sqlx-postgres" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" -dependencies = [ - "atoi", - "base64", - "bitflags", - "byteorder", - "chrono", - "crc", - "dotenvy", - "etcetera", - "futures-channel", - "futures-core", - "futures-util", - "hex", - "hkdf", - "hmac", - "home", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "rand", - "serde", - "serde_json", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror", - "tracing", - "uuid", - "whoami", -] - -[[package]] -name = "sqlx-sqlite" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" -dependencies = [ - "atoi", - "chrono", - "flume", - "futures-channel", - "futures-core", - "futures-executor", - "futures-intrusive", - "futures-util", - "libsqlite3-sys", - "log", - "percent-encoding", - "serde", - "serde_urlencoded", - "sqlx-core", - "thiserror", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" - -[[package]] -name = "stringprep" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" -dependencies = [ - "unicode-bidi", - "unicode-normalization", - "unicode-properties", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" -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" - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror" -version = "2.0.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "tinystr" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -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.47.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" -dependencies = [ - "backtrace", - "bytes", - "io-uring", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "slab", - "socket2", - "tokio-macros", - "windows-sys 0.59.0", -] - -[[package]] -name = "tokio-macros" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-stream" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" -dependencies = [ - "bitflags", - "bytes", - "http", - "http-body", - "pin-project-lite", - "tower-layer", - "tower-service", - "tracing", -] - -[[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.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" -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.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "typenum" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" - -[[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.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" - -[[package]] -name = "unicode-normalization" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-properties" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" - -[[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.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" -dependencies = [ - "getrandom 0.3.3", - "js-sys", - "serde", - "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 = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" -dependencies = [ - "wit-bindgen-rt", -] - -[[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.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" -dependencies = [ - "unicode-ident", -] - -[[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.2", -] - -[[package]] -name = "webpki-roots" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "whoami" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", -] - -[[package]] -name = "winapi" -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-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -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-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", - "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_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_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_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_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[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_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_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_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 = "wit-bindgen-rt" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags", -] - -[[package]] -name = "writeable" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" - -[[package]] -name = "yoke" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" -dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" - -[[package]] -name = "zerotrie" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/apps/backend/Cargo.toml b/apps/backend/Cargo.toml deleted file mode 100644 index e47330d..0000000 --- a/apps/backend/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "bacchus" -version = "0.1.0" -edition = "2024" - -[dependencies] -axum = { version = "0.8.4", features = ["macros","json"] } -tokio = { version = "1", features = ["full"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls","postgres","macros","uuid","chrono","migrate"] } -uuid = { version = "1", features = ["serde","v4"] } -chrono = { version = "0.4", features = ["serde"] } -tower-http = { version = "0.6.6", features = ["cors","trace"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -anyhow = "1" \ No newline at end of file diff --git a/apps/backend/alembic.ini b/apps/backend/alembic.ini new file mode 100644 index 0000000..bf90bb7 --- /dev/null +++ b/apps/backend/alembic.ini @@ -0,0 +1,141 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = postgresql://postgres:bacchus@localhost:5432/bacchus + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/apps/backend/app/api/admin_settings.py b/apps/backend/app/api/admin_settings.py new file mode 100644 index 0000000..ec72294 --- /dev/null +++ b/apps/backend/app/api/admin_settings.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, EmailStr +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.core.auth import requires_role, get_current_user +from app.models.config import Config # Key/Value Tabelle: key (pk), value (text) + +router = APIRouter(prefix="/admin/settings", tags=["admin-settings"], dependencies=[Depends(requires_role("admin","manager"))]) + +class PaypalCfg(BaseModel): + paypal_me: str | None = None + paypal_receiver: EmailStr | None = None + +@router.get("/paypal", response_model=PaypalCfg) +def get_paypal(db: Session = Depends(get_db)): + def get(key): + obj = db.query(Config).get(key) + return obj.value if obj else None + return PaypalCfg( + paypal_me=get("paypal_me") or None, + paypal_receiver=get("paypal_receiver") or None + ) + +@router.put("/paypal", response_model=PaypalCfg) +def put_paypal(cfg: PaypalCfg, db: Session = Depends(get_db), user=Depends(get_current_user)): + for k,v in [("paypal_me", cfg.paypal_me), ("paypal_receiver", cfg.paypal_receiver)]: + row = db.query(Config).get(k) + if row: row.value = v or "" + else: db.add(Config(key=k, value=v or "")) + db.commit() + return cfg diff --git a/apps/backend/app/api/admin_transactions.py b/apps/backend/app/api/admin_transactions.py new file mode 100644 index 0000000..b5692a3 --- /dev/null +++ b/apps/backend/app/api/admin_transactions.py @@ -0,0 +1,108 @@ +from datetime import datetime, timezone +from enum import Enum +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select, func +from sqlalchemy.orm import Session +from sqlalchemy.exc import NoResultFound + +from app.core.database import get_db +from app.core.auth import get_current_user +from app.models.user import User +from app.models.transaction import Transaction # siehe Modell unten falls noch nicht vorhanden +from app.schemas.transaction import TransactionOut, TransactionCreate # siehe Schemas unten + +router = APIRouter(prefix="/admin/transactions", tags=["admin-transactions"]) + +def require_admin(current=Depends(get_current_user)): + if getattr(current, "role", None) != "admin": + raise HTTPException(status_code=403, detail="Admin only") + return current + +@router.get("", response_model=List[TransactionOut]) +def list_transactions( + db: Session = Depends(get_db), + _admin=Depends(require_admin), + limit: int = Query(200, ge=1, le=500), + offset: int = Query(0, ge=0), + order: str = Query("desc", pattern="^(asc|desc)$"), + status: Optional[str] = Query(None, pattern="^(waiting|approved|rejected)$"), +): + q = select(Transaction).offset(offset).limit(limit) + if status: + q = q.where(Transaction.status == status) + if order == "desc": + q = q.order_by(Transaction.created_at.desc()) + else: + q = q.order_by(Transaction.created_at.asc()) + return db.execute(q).scalars().all() + +@router.post("", response_model=TransactionOut, status_code=201) +def create_transaction( + body: TransactionCreate, + db: Session = Depends(get_db), + admin=Depends(require_admin), +): + tx = Transaction( + user_id=body.user_id, + amount_cents=body.amount_cents, + note=body.note or "", + status="waiting", + created_at=datetime.now(timezone.utc), + created_by_id=admin.id, + kind="manual", + ) + db.add(tx) + db.commit() + db.refresh(tx) + return tx + +@router.post("/{tx_id}/approve", response_model=TransactionOut) +def approve_transaction( + tx_id: int, + db: Session = Depends(get_db), + admin=Depends(require_admin), +): + tx = db.get(Transaction, tx_id) + if not tx: + raise HTTPException(404, "Transaktion nicht gefunden") + if tx.status != "waiting": + raise HTTPException(status_code=409, detail=f"Transaktion bereits {tx.status}") + + # Nutzer sperren, um Doppelbuchungen zu vermeiden + user = db.execute( + select(User).where(User.id == tx.user_id).with_for_update() + ).scalar_one() + + new_balance = (user.balance_cents or 0) + tx.amount_cents + if new_balance < 0: + raise HTTPException(400, "Saldo würde negativ – Buchung abgelehnt") + + user.balance_cents = new_balance + tx.status = "approved" + tx.processed_at = datetime.now(timezone.utc) + tx.processed_by_id = admin.id + + db.commit() + db.refresh(tx) + return tx + +@router.post("/{tx_id}/reject", response_model=TransactionOut) +def reject_transaction( + tx_id: int, + db: Session = Depends(get_db), + admin=Depends(require_admin), +): + tx = db.get(Transaction, tx_id) + if not tx: + raise HTTPException(404, "Transaktion nicht gefunden") + if tx.status != "waiting": + raise HTTPException(status_code=409, detail=f"Transaktion bereits {tx.status}") + + tx.status = "rejected" + tx.processed_at = datetime.now(timezone.utc) + tx.processed_by_id = admin.id + db.commit() + db.refresh(tx) + return tx diff --git a/apps/backend/app/api/audit_logs.py b/apps/backend/app/api/audit_logs.py new file mode 100644 index 0000000..c232d4d --- /dev/null +++ b/apps/backend/app/api/audit_logs.py @@ -0,0 +1,73 @@ +from typing import Optional, List +from datetime import datetime +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.core.database import SessionLocal +from app.core.auth import get_current_user, requires_role +from app.models.user import User +from app.models.audit_log import AuditLog +from app.schemas.audit_log import AuditLogOut + +router = APIRouter(prefix="/audit-logs", tags=["audit-logs"]) + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +def _ts_col(): + # vorhandene Zeitspalte wählen, sonst Fallback auf id + return ( + getattr(AuditLog, "created_at", None) + or getattr(AuditLog, "timestamp", None) + or getattr(AuditLog, "created", None) + or AuditLog.id + ) + +@router.get("/", response_model=List[AuditLogOut], dependencies=[Depends(requires_role("admin"))]) +def list_audit_logs( + limit: int = Query(100, ge=1, le=1000), + offset: int = Query(0, ge=0), + user_id: Optional[int] = Query(None, ge=1), + action: Optional[str] = Query(None), + q: Optional[str] = Query(None), + date_from: Optional[str] = Query(None), + date_to: Optional[str] = Query(None), + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +): + qs = db.query(AuditLog) + + if user_id is not None: + qs = qs.filter(AuditLog.user_id == user_id) + if action: + qs = qs.filter(AuditLog.action == action) + + if q and hasattr(AuditLog, "info"): + like = f"%{q}%" + qs = qs.filter(AuditLog.info.ilike(like)) + + ts = _ts_col() + + # ISO-Zeiten robust parsen + def _parse_iso(s: Optional[str]) -> Optional[datetime]: + if not s: + return None + try: + return datetime.fromisoformat(s.replace("Z", "+00:00")) + except Exception: + return None + + dtf = _parse_iso(date_from) + dtt = _parse_iso(date_to) + + if dtf is not None and ts is not AuditLog.id: + qs = qs.filter(ts >= dtf) + if dtt is not None and ts is not AuditLog.id: + qs = qs.filter(ts <= dtt) + + qs = qs.order_by(ts.desc(), AuditLog.id.desc()).limit(limit).offset(offset) + return qs.all() diff --git a/apps/backend/app/api/auth.py b/apps/backend/app/api/auth.py new file mode 100644 index 0000000..e4cce71 --- /dev/null +++ b/apps/backend/app/api/auth.py @@ -0,0 +1,292 @@ +from fastapi import APIRouter, Depends, HTTPException, Response, Body, Request, status +from sqlalchemy.orm import Session as DBSession +from sqlalchemy import func +from passlib.context import CryptContext +from datetime import datetime, timedelta +from collections import defaultdict + +import os, hmac, hashlib + +from app.core.database import get_db +from app.core.auth import ( + set_session_cookie, + issue_csrf_cookie, + clear_session_cookie, + clear_csrf_cookie, + get_current_user, + SESSION_COOKIE_NAME, +) +from app.services.sessions import create_session, delete_session +from app.models.user import User + +router = APIRouter(prefix="/auth", tags=["auth"]) +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def verify_pin(plain_pin: str, hashed_pin: str) -> bool: + if not hashed_pin: + return False + return pwd_context.verify(plain_pin, hashed_pin) + +def verify_password(plain_password: str, hashed_password: str) -> bool: + if not hashed_password: + return False + return pwd_context.verify(plain_password, hashed_password) + +# -------- Rolle robust normalisieren -------- +def _normalize_role(role_raw) -> str: + # Enum? -> value + if hasattr(role_raw, "value"): + role_raw = role_raw.value + # Stringifizieren + role = str(role_raw or "user").strip() + # ggf. Präfixe/Namespaces entfernen (z. B. "userrole.admin") + if "." in role: + role = role.split(".")[-1] + return role.lower() + +# ----------------------------- +# Throttling für Management-Login +# ----------------------------- +_FAILED_LIMIT = 5 +_LOCK_MINUTES = 2 +_login_state = defaultdict(lambda: {"fails": 0, "lock_until": None}) + +def _client_ip(request: Request) -> str: + xff = request.headers.get("X-Forwarded-For") + if xff: + first = xff.split(",")[0].strip() + if first: + return first + return request.client.host if request.client else "0.0.0.0" + +def _throttle_check(email_lower: str, ip: str): + st = _login_state[(email_lower, ip)] + now = datetime.utcnow() + if st["lock_until"] and now < st["lock_until"]: + seconds = int((st["lock_until"] - now).total_seconds()) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"Login gesperrt. Bitte {seconds} Sekunden warten.", + ) + +def _throttle_fail(email_lower: str, ip: str): + st = _login_state[(email_lower, ip)] + st["fails"] += 1 + if st["fails"] >= _FAILED_LIMIT: + st["lock_until"] = datetime.utcnow() + timedelta(minutes=_LOCK_MINUTES) + +def _throttle_reset(email_lower: str, ip: str): + _login_state[(email_lower, ip)] = {"fails": 0, "lock_until": None} + +# ----------------------------- +# PIN-Login: O(1) via HMAC-Lookup + IP-Rate-Limit +# ----------------------------- + +# Servergeheimer Pepper für HMAC über die **volle** PIN +PIN_PEPPER = os.getenv("PIN_PEPPER") +if not PIN_PEPPER or len(PIN_PEPPER) < 32: + where = os.environ.get("DOTENV_PATH") or ".env (nicht gefunden)" + raise RuntimeError(f"PIN_PEPPER env var fehlt/zu kurz – .env: {where}") + +def _pin_hmac(pin: str) -> str: + """hex(HMAC_SHA256(PEPPER, pin))""" + return hmac.new(PIN_PEPPER.encode("utf-8"), pin.encode("utf-8"), hashlib.sha256).hexdigest() + +# sehr einfaches IP-Rate-Limit (pro Prozess) +_pin_attempts = defaultdict(list) # ip -> [timestamps] + +def _pin_rate_limit_ok(ip: str, limit: int = 10, window_sec: int = 60) -> bool: + now = datetime.utcnow().timestamp() + arr = _pin_attempts[ip] + # altes Fenster abschneiden + arr[:] = [t for t in arr if now - t < window_sec] + if len(arr) >= limit: + return False + arr.append(now) + return True + +# ----------------------------- +# Management-Login (E-Mail/Passwort) +# ----------------------------- +@router.post("/management-login") +def management_login( + request: Request, + response: Response, + email: str = Body(..., embed=True), + password: str = Body(..., embed=True), + db: DBSession = Depends(get_db), +): + email_lower = (email or "").strip().lower() + ip = _client_ip(request) + + _throttle_check(email_lower, ip) + + user = db.query(User).filter(func.lower(User.email) == email_lower).one_or_none() + if not user or not getattr(user, "is_active", True): + _throttle_fail(email_lower, ip) + raise HTTPException(status_code=401, detail="Ungültige Zugangsdaten") + + hashed_pw = getattr(user, "hashed_password", None) or getattr(user, "password_hash", None) + if not hashed_pw: + _throttle_fail(email_lower, ip) + raise HTTPException(status_code=401, detail="Ungültige Zugangsdaten") + + if not verify_password(password, hashed_pw): + _throttle_fail(email_lower, ip) + raise HTTPException(status_code=401, detail="Ungültige Zugangsdaten") + + role = _normalize_role(getattr(user, "role", "user")) + + _throttle_reset(email_lower, ip) + + token = create_session(db, user.id) + set_session_cookie(response, token) + issue_csrf_cookie(response) + + return { + "message": "Login erfolgreich", + "user": {"id": user.id, "name": user.name, "role": role}, + } + +# ----------------------------- +# PIN-Login (O(1) Kandidaten, dann 1× Hash-Verify) +# ----------------------------- +@router.post("/pin-login") +def pin_login( + request: Request, + response: Response, + pin: str = Body(..., embed=True), + db: DBSession = Depends(get_db), +): + # IP-Rate-Limit (z. B. 10 Versuche/60s) + ip = _client_ip(request) + if not _pin_rate_limit_ok(ip): + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Zu viele Versuche. Bitte kurz warten.") + + if not pin or not pin.isdigit() or len(pin) != 6: + # gleiche Antwortzeit für Fehler + raise HTTPException(status_code=401, detail="Ungültiger PIN") + + token = _pin_hmac(pin) + + # Schnellpfad: direkt per HMAC-Token Kandidaten suchen (O(1) via Index) + candidates = db.query(User).filter( + User.is_active == True, + getattr(User, "pin_lookup") == token # erwartet Spalte users.pin_lookup (VARCHAR(64)) + ).all() + + # Normalfall: genau 1 Kandidat; bei Kollisionen (gleiche PIN) sind es wenige + matched = None + for u in candidates: + # sichere Verifikation mit dem langsamen Hash + hashed_pin = getattr(u, "hashed_pin", None) or getattr(u, "pin_hash", None) + if hashed_pin and verify_pin(pin, hashed_pin): + matched = u + break + + if not matched: + # Altbestand (pin_lookup leer) – langsamer Fallback: aktive Nutzer iterieren + # Hinweis: Bei vielen Nutzern besser deaktivieren und stattdessen Nutzer zur PIN-Neusetzung zwingen. + slow = db.query(User).filter(User.is_active == True).with_entities(User.id, User.hashed_pin).all() + for uid, h in slow: + if h and verify_pin(pin, h): + matched = db.query(User).get(uid) # type: ignore + if matched: + # Nachmigration: pin_lookup setzen, damit künftige Logins schnell sind + setattr(matched, "pin_lookup", token) + db.add(matched) + db.commit() + db.refresh(matched) + break + + if not matched: + raise HTTPException(status_code=401, detail="Ungültiger PIN") + + # Erfolg → Session-Cookie + CSRF + token_val = create_session(db, matched.id) + set_session_cookie(response, token_val) + issue_csrf_cookie(response) + + return { + "message": "Login erfolgreich", + "user": { + "id": matched.id, + "name": matched.name, + "role": _normalize_role(getattr(matched, "role", "user")), + }, + } + +# ----------------------------- +# Logout +# ----------------------------- +@router.post("/logout") +def logout( + request: Request, + response: Response, + db: DBSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + token = request.cookies.get(SESSION_COOKIE_NAME) + if token: + delete_session(db, token) + clear_session_cookie(response) + clear_csrf_cookie(response) + return Response(status_code=204) + +# ----------------------------- +# CSRF-Token beziehen +# ----------------------------- +@router.get("/csrf") +def get_csrf(response: Response): + issue_csrf_cookie(response) + return {"csrf": "ok"} + +# ----------------------------- +# Aktueller Nutzer +# ----------------------------- +@router.get("/me") +def me(current_user: User = Depends(get_current_user)): + return current_user + +# ----------------------------- +# Capabilities (UI-Gating) +# ----------------------------- +@router.get("/capabilities") +def get_capabilities(current_user: User = Depends(get_current_user)): + role = _normalize_role(getattr(current_user, "role", "user")) + + caps_by_role = { + "user": [ + "dashboard", + "profile", + "my-bookings", + "my-transactions", + "system-config", + "stats-advanced", + ], + "manager": [ + "dashboard", + "profile", + "my-bookings", + "my-transactions", + "products", + "deliveries", + "system-config", + "stats-advanced", + ], + "admin": [ + "dashboard", + "profile", + "my-bookings", + "my-transactions", + "users", + "products", + "deliveries", + "stats-advanced", + "audit-logs", + "system-config", + "transactions", + ], + } + + return {"role": role, "capabilities": caps_by_role.get(role, caps_by_role["user"])} diff --git a/apps/backend/app/api/bookings.py b/apps/backend/app/api/bookings.py new file mode 100644 index 0000000..b23f6ab --- /dev/null +++ b/apps/backend/app/api/bookings.py @@ -0,0 +1,171 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from typing import List, Optional + +from app.core.database import get_db +from app.core.auth import get_current_user, requires_role +from app.models.booking import Booking +from app.models.user import User +from app.models.product import Product +from app.schemas.booking import BookingOut, BookingCreate + +router = APIRouter(prefix="/bookings", tags=["bookings"]) + +# Konstante aus deinen Policies: Nutzer dürfen nicht < 0 € fallen (Stand 2025-07-02). +ALLOWED_OVERDRAFT_CENTS = 0 # falls -500 erlaubt sein soll: setze auf -500 + + +@router.post("/", response_model=BookingOut, status_code=status.HTTP_201_CREATED) +def create_booking( + booking: BookingCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if current_user.role not in ("manager", "admin") and booking.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Forbidden") + + if booking.amount is None or booking.amount <= 0: + raise HTTPException(status_code=400, detail="Invalid amount") + + # Produkt mit Row-Lock + product = ( + db.query(Product) + .filter(Product.id == booking.product_id) + .with_for_update() + .first() + ) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + if hasattr(product, "is_active") and not bool(product.is_active): + raise HTTPException(status_code=400, detail="Product is not active") + + qty = int(booking.amount) + # Bestand prüfen/abziehen, falls Feld vorhanden + if hasattr(product, "stock"): + current_stock = int(product.stock or 0) + if current_stock < qty: + raise HTTPException(status_code=409, detail="Not enough stock") + product.stock = current_stock - qty + + server_total = int(product.price_cents) * qty + + try: + # User mit Row-Lock + user = ( + db.query(User) + .filter(User.id == booking.user_id) + .with_for_update() + .first() + ) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + new_balance = int(user.balance_cents) - server_total + if new_balance < -ALLOWED_OVERDRAFT_CENTS: + raise HTTPException(status_code=400, detail="Insufficient balance") + + user.balance_cents = new_balance + + db_booking = Booking( + user_id=booking.user_id, + product_id=booking.product_id, + amount=qty, + total_cents=server_total, # Client-Wert wird ignoriert + ) + db.add(db_booking) + db.flush() + db.refresh(db_booking) + db.commit() + return db_booking + except HTTPException: + db.rollback() + raise + except Exception: + db.rollback() + raise + + +def _role_name(r) -> str: + # robust gegen Enum/String/None + if r is None: + return "" + v = getattr(r, "value", r) + return str(v).strip().lower() + +@router.get("/", response_model=List[BookingOut]) +def list_bookings( + limit: int = Query(100, ge=1, le=1000), + offset: int = Query(0, ge=0), + user_id: Optional[int] = Query(None, ge=1), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + q = db.query(Booking) + + role = _role_name(getattr(current_user, "role", None)) + is_manager = role in ("manager", "admin") + + if is_manager: + # Admin/Manager: optionaler Filter auf user_id + if user_id is not None: + q = q.filter(Booking.user_id == user_id) + else: + # Normale Nutzer: strikt nur eigene Buchungen + q = q.filter(Booking.user_id == current_user.id) + + # stabile, absteigende Reihenfolge – Zeitfeld optional + order_cols = [] + if hasattr(Booking, "created_at"): + order_cols.append(Booking.created_at.desc()) + elif hasattr(Booking, "timestamp"): + order_cols.append(Booking.timestamp.desc()) + elif hasattr(Booking, "created"): + order_cols.append(Booking.created.desc()) + order_cols.append(Booking.id.desc()) + + return q.order_by(*order_cols).limit(limit).offset(offset).all() + +@router.get("/me", response_model=List[BookingOut]) +def list_my_bookings( + limit: int = Query(100, ge=1, le=1000), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + q = ( + db.query(Booking) + .filter(Booking.user_id == current_user.id) + .order_by(Booking.id.desc()) + .limit(limit) + .offset(offset) + ) + return q.all() + + +@router.get("/{booking_id}", response_model=BookingOut) +def get_booking( + booking_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + b = db.query(Booking).filter(Booking.id == booking_id).first() + if not b: + raise HTTPException(status_code=404, detail="Booking not found") + if b.user_id != current_user.id and current_user.role not in ("manager", "admin"): + raise HTTPException(status_code=403, detail="Forbidden") + return b + + +@router.delete("/{booking_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_booking( + booking_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + requires_role("manager", "admin")(current_user) + b = db.query(Booking).filter(Booking.id == booking_id).first() + if not b: + raise HTTPException(status_code=404, detail="Booking not found") + db.delete(b) + db.commit() + return None diff --git a/apps/backend/app/api/categories.py b/apps/backend/app/api/categories.py new file mode 100644 index 0000000..b0f367c --- /dev/null +++ b/apps/backend/app/api/categories.py @@ -0,0 +1,85 @@ +# app/routes/categories.py + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.core.database import SessionLocal +from app.core.auth import get_current_user, requires_role +from app.models.user import User +from app.models.product import Product + +router = APIRouter(prefix="/categories", tags=["categories"]) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +@router.get("/", response_model=list[str]) +def list_categories(db: Session = Depends(get_db)): + """ + Alle Kategorien (distinct aus Product.category). + """ + cats = db.query(Product.category).distinct().all() + return sorted([c[0] for c in cats if c[0]]) + + +@router.put( + "/rename", + dependencies=[Depends(requires_role("manager", "admin"))], +) +def rename_category( + old_name: str, + new_name: str, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """ + Kategorie umbenennen: alle Produkte mit old_name -> new_name. + """ + if not new_name or new_name.lower() == "alle": + raise HTTPException(status_code=400, detail="Invalid category name") + + updated = ( + db.query(Product) + .filter(Product.category == old_name) + .update({"category": new_name}) + ) + if updated == 0: + raise HTTPException(status_code=404, detail="Category not found") + + db.commit() + return {"updated": updated, "new_name": new_name} + + +@router.delete( + "/", + dependencies=[Depends(requires_role("manager", "admin"))], +) +def delete_category( + name: str, + reassign_to: str | None = None, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """ + Kategorie löschen. + - Mit reassign_to: alle Produkte auf neue Kategorie umhängen. + - Ohne: Kategorie auf NULL setzen. + """ + q = db.query(Product).filter(Product.category == name) + + if not q.first(): + raise HTTPException(status_code=404, detail="Category not found") + + if reassign_to: + updated = q.update({"category": reassign_to}) + else: + updated = q.update({"category": None}) + + db.commit() + return {"updated": updated, "reassigned_to": reassign_to} diff --git a/apps/backend/app/api/deliveries.py b/apps/backend/app/api/deliveries.py new file mode 100644 index 0000000..e3ddcb8 --- /dev/null +++ b/apps/backend/app/api/deliveries.py @@ -0,0 +1,284 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy.orm import Session +from typing import List, Optional +from pydantic import BaseModel +from io import BytesIO +from datetime import date +import re +import difflib +import datetime as dt +try: + import pdfplumber # type: ignore +except Exception: # pragma: no cover + pdfplumber = None + +from app.schemas.delivery import DeliveryCreate, DeliveryOut +from app.models.delivery import Delivery +from app.models.product import Product +from app.core.database import SessionLocal +from app.core.auth import get_current_user, requires_role +from app.models.user import User +router = APIRouter(prefix="/deliveries", tags=["deliveries"]) + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +@router.post("/", response_model=DeliveryOut, dependencies=[Depends(requires_role("manager", "admin"))]) +def create_delivery(delivery: DeliveryCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + # Session.get statt .query().get (SQLAlchemy 2.x) + product = db.get(Product, delivery.product_id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + if delivery.amount < 1: + raise HTTPException(status_code=400, detail="Amount must be at least 1") + + # Pydantic v2: model_dump() + db_delivery = Delivery(**delivery.model_dump()) + db.add(db_delivery) + db.commit() + db.refresh(db_delivery) + return db_delivery + +@router.get("/", response_model=list[DeliveryOut], dependencies=[Depends(requires_role("manager", "admin"))]) +def list_deliveries(db: Session = Depends(get_db), user: User = Depends(get_current_user)): + return db.query(Delivery).all() + +@router.get("/{delivery_id}", response_model=DeliveryOut, dependencies=[Depends(requires_role("manager", "admin"))]) +def get_delivery(delivery_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + delivery = db.get(Delivery, delivery_id) + if not delivery: + raise HTTPException(status_code=404, detail="Delivery not found") + return delivery + +@router.delete("/{delivery_id}", status_code=204, dependencies=[Depends(requires_role("manager", "admin"))]) +def delete_delivery(delivery_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + delivery = db.get(Delivery, delivery_id) + if not delivery: + raise HTTPException(status_code=404, detail="Delivery not found") + db.delete(delivery) + db.commit() + +# ---- Draft-Schemata für den PDF-Import (Antwort an das Frontend) ----------------------- +class DeliveryItemDraft(BaseModel): + product_id: Optional[int] = None + product_hint: Optional[str] = None # originaler Text aus der PDF + quantity_units: int # Anzahl (Einheiten/KA) + unit_cost_cents: int # E-Preis je Einheit in Cent + +class DeliveryDraft(BaseModel): + supplier: Optional[str] = None + date: Optional[str] = None # ISO yyyy-mm-dd + invoice_no: Optional[str] = None + note: Optional[str] = None + deposit_return_cents: int = 0 # Netto-Pfand (Cent, positiv) + items: List[DeliveryItemDraft] = [] + +# ---- Hilfsfunktionen ------------------------------------------------------------------- +def _parse_decimal_de(s: str) -> float: + """'1.234,56' -> 1234.56""" + t = s.strip().replace(".", "").replace("’", "").replace(",", ".") + try: + return float(t) + except ValueError: + return 0.0 + +def _to_cents(v: float) -> int: + return int(round(v * 100)) + +def _date_de_to_iso(s: str) -> Optional[str]: + try: + d = dt.datetime.strptime(s.strip(), "%d.%m.%Y").date() + return d.isoformat() + except Exception: + return None + +_pack_pat = re.compile(r"(\d+)\s*/\s*(\d+[.,]?\d*)") # z.B. 24/0,33 oder 12/1,0 + +def _extract_pack_size(text: str) -> Optional[int]: + m = _pack_pat.search(text) + if not m: + return None + try: + return int(m.group(1)) + except Exception: + return None + +def _norm(s: str) -> str: + return re.sub(r"[^0-9a-z]+", " ", s.lower()).strip() + +def _best_product_match(desc: str, products: List[Product]) -> Optional[int]: + """Einfache Fuzzy-Suche: Textähnlichkeit + Bonus bei passender Packgröße.""" + if not products: + return None + cand_name = _norm(desc) + want_pack = _extract_pack_size(desc) + best_id, best_score = None, 0.0 + for p in products: + name = getattr(p, "name", "") or "" + score = difflib.SequenceMatcher(a=cand_name, b=_norm(name)).ratio() + pack = getattr(p, "pack_size", None) + if want_pack and pack and int(pack) == int(want_pack): + score += 0.08 # leichter Bonus + if score > best_score: + best_score, best_id = score, int(getattr(p, "id")) + return best_id if best_score >= 0.75 else None + +# ---- Kern: PDF parsen (ein Supplier, textbasiert) -------------------------------------- +def _parse_invoice_pdf(data: bytes, all_products: List[Product]) -> DeliveryDraft: + if pdfplumber is None: + raise HTTPException( + status_code=501, + detail="pdfplumber ist nicht installiert. Bitte `pip install pdfplumber` ausführen." + ) + + text_pages: List[str] = [] + with pdfplumber.open(BytesIO(data)) as pdf: + for page in pdf.pages: + # Text mit Zeilenumbrüchen; für dieses Layout reicht Text-Parsing + text_pages.append(page.extract_text() or "") + + full = "\n".join(text_pages) + + # Header-Felder + inv = None + m = re.search(r"Rechnungs[-\s]?Nr\.?\s*:\s*([A-Za-z0-9\-\/]+)", full, re.I) + if m: + inv = m.group(1).strip() + + date_iso = None + m = re.search(r"Datum\s*:\s*(\d{2}\.\d{2}\.\d{4})", full, re.I) + if m: + date_iso = _date_de_to_iso(m.group(1)) + + deposit_cents = 0 + + # 1) Bevorzugt die Steuerzeile netto Summe Pfand + m = re.search(r"(?mi)^\s*summe\s+pfand\b.*?([0-9\.,]+)\s*$", full) + if not m: + # 2) Fallback + m = re.search(r"(?mi)^\s*pfand\b.*?eur\s*([0-9\.,]+)\s*$", full) + + if m: + deposit_cents = _to_cents(_parse_decimal_de(m.group(1))) + + # Positionsblock: Zeilen zwischen Kopf "Art-Nr" und "Zwischensumme Warenwert" + items: List[DeliveryItemDraft] = [] + block = [] + in_block = False + for line in full.splitlines(): + if not in_block and re.search(r"\bArt[-\s]?Nr\b", line): + in_block = True + continue + if in_block and re.search(r"Zwischensumme\s+Warenwert", line): + break + if in_block: + if line.strip(): + block.append(line) + + # Zeilen parsen: " " + row_re = re.compile( + r"^\s*\d+\s+(?P.+?)\s+(?P\d+)\s+(?P[A-Za-z]+)\s+(?P\d+,\d{2})\s+(?P\d+,\d{2})\s*$" + ) + for ln in block: + m = row_re.match(ln) + if not m: + continue + desc = m.group("desc").strip() + qty = int(m.group("qty")) + me = m.group("me").upper() + eprice = _to_cents(_parse_decimal_de(m.group("eprice"))) + # Nur ME = KA (Kästen/Einheit) übernehmen – andere ignorieren + if me not in {"KA", "KASTEN", "EINHEIT"}: + # zur Not trotzdem übernehmen + pass + prod_id = _best_product_match(desc, all_products) + items.append( + DeliveryItemDraft( + product_id=prod_id, + product_hint=(None if prod_id else desc), + quantity_units=qty, + unit_cost_cents=eprice, + ) + ) + + return DeliveryDraft( + supplier=None, # optional – kann bei Bedarf aus Kopf extrahiert werden + date=date_iso, + invoice_no=inv, + note=None, + deposit_return_cents=abs(deposit_cents), + items=items or [], + ) + +# ---- Endpoint: PDF hochladen & Draft liefern ------------------------------------------- +@router.post("/invoice/import", response_model=DeliveryDraft, dependencies=[Depends(requires_role("manager", "admin"))]) +def import_invoice_pdf( + file: UploadFile = File(...), # ← fix + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + if not file.filename.lower().endswith(".pdf"): + raise HTTPException(status_code=400, detail="Bitte eine PDF-Datei hochladen.") + + data = file.file.read() + if not data: + raise HTTPException(status_code=400, detail="Leere Datei.") + + # Produkte für Matching laden + products = db.query(Product).all() + + try: + draft = _parse_invoice_pdf(data, products) + except HTTPException: + raise + except Exception as e: # robustes Fehlerhandling + raise HTTPException(status_code=422, detail=f"PDF konnte nicht geparst werden: {e}") + + # Optional: minimale Plausibilitätsprüfung + if not draft.items: + # Kein harter Fehler – Frontend kann manuell ergänzen + draft.note = (draft.note or "") + return draft + +# ---- Bulk-Schema ---- +class DeliveryItemIn(BaseModel): + product_id: int + quantity_units: int + unit_cost_cents: int + units: int | None = None # optional: Stückzahl vom Client + +class DeliveryBulkIn(BaseModel): + supplier: str + date: date + invoice_no: str | None = None + note: str | None = None + deposit_return_cents: int = 0 + items: list[DeliveryItemIn] + +@router.post("/bulk") +def create_delivery_bulk(body: DeliveryBulkIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + requires_role("manager", "admin")(user) + created_ids: list[int] = [] + deposit = int(body.deposit_return_cents or 0) + for idx, it in enumerate(body.items): + prod = db.get(Product, it.product_id) or HTTPException(status_code=400, detail=f"Unknown product_id {it.product_id}") + pack = int(getattr(prod, "pack_size") or 1) + amount_pieces = int(it.units) if it.units is not None else int(it.quantity_units) * pack + row = Delivery( + product_id=it.product_id, + amount=amount_pieces, # Stück gesamt + price_cents=int(it.unit_cost_cents), # Preis pro Stück + delivered_at=body.date, + supplier=body.supplier, + invoice_number=body.invoice_no, + note=body.note, + deposit_return_cents=(deposit if idx == 0 else 0), # <- hier + ) + db.add(row); db.flush(); created_ids.append(row.id) + db.commit() + return {"created": created_ids} + diff --git a/apps/backend/app/api/ledger.py b/apps/backend/app/api/ledger.py new file mode 100644 index 0000000..8f0228c --- /dev/null +++ b/apps/backend/app/api/ledger.py @@ -0,0 +1,126 @@ +from typing import List, Optional, Literal, Set +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel +from sqlalchemy.orm import Session +from fastapi import Body + +from app.core.database import get_db +from app.core.auth import get_current_user +from app.models.user import User +from app.models.topup import Topup +from app.models.booking import Booking + +router = APIRouter(prefix="/ledger", tags=["ledger"]) + +class LedgerItem(BaseModel): + id: str + type: Literal["topup", "booking"] + amount_cents: int # topup > 0, booking < 0 + created_at: str # ISO-String + status: Optional[str] = None # nur für topup: pending | confirmed | rejected + note: Optional[str] = None # nur für topup/admin: Notiz / 5-stelliger Code + product_id: Optional[int] = None + product_name: Optional[str] = None + +@router.get("/me", response_model=List[LedgerItem]) +def list_ledger_me( + limit: int = Query(100, ge=1, le=1000), + offset: int = Query(0, ge=0), + types: str = Query("topup,booking", description="CSV: topup,booking"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Vereinheitlichte Übersicht über Aufladungen (Top-ups) und Getränkebuchungen. + Global nach Datum sortiert, danach paginiert. + """ + want: Set[str] = {t.strip().lower() for t in types.split(",") if t.strip()} + + rows: list[LedgerItem] = [] + + if "topup" in want: + topups = ( + db.query(Topup) + .filter(Topup.user_id == current_user.id) + .order_by(Topup.id.desc()) + .all() + ) + for t in topups: + rows.append( + LedgerItem( + id=f"topup-{t.id}", + type="topup", + amount_cents=int(t.amount_cents or 0), + created_at=(t.created_at.isoformat() if getattr(t, "created_at", None) else ""), + status=(str(t.status.value) if hasattr(t, "status") and t.status is not None else None), + note=(t.note or None), + ) + ) + + if "booking" in want: + bookings = ( + db.query(Booking) + .filter(Booking.user_id == current_user.id) + .order_by(Booking.id.desc()) + .all() + ) + for b in bookings: + total = getattr(b, "total_cents", None) + if total is None: + price = getattr(b, "price_cents", None) + if price is None and hasattr(b, "product") and getattr(b, "product") is not None: + price = getattr(b.product, "price_cents", 0) + total = (price or 0) * (getattr(b, "amount", 1) or 1) + + rows.append( + LedgerItem( + id=f"booking-{b.id}", + type="booking", + amount_cents=-int(total or 0), # Buchungen als negative Werte + created_at=(b.created_at.isoformat() if getattr(b, "created_at", None) else ""), + product_id=getattr(b, "product_id", None), + product_name=(getattr(b.product, "name", None) if hasattr(b, "product") and getattr(b, "product") is not None else None), + ) + ) + + # global sortieren und erst dann paginieren + rows.sort(key=lambda r: r.created_at, reverse=True) + return rows[offset : offset + limit] + +# ------- ab hier NEU: auf Modulebene, nicht eingerückt! ------- + +class LedgerAdd(BaseModel): + amount_cents: int # darf +/− sein (Korrektur/Einlage) + note: Optional[str] = None + +@router.post("/", response_model=LedgerItem) +def add_ledger_entry( + payload: LedgerAdd = Body(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + # Topup erzeugen (als Kassenbewegung) + try: + from app.models.topup import TopupStatus # falls Enum existiert + status_value = TopupStatus.confirmed + except Exception: + status_value = "confirmed" + + t = Topup( + user_id=current_user.id, + amount_cents=int(payload.amount_cents or 0), + note=(payload.note or "Manuelle Kassenkorrektur"), + status=status_value, + ) + db.add(t) + db.commit() + db.refresh(t) + + return LedgerItem( + id=f"topup-{t.id}", + type="topup", + amount_cents=int(t.amount_cents or 0), + created_at=(t.created_at.isoformat() if getattr(t, "created_at", None) else ""), + status=(str(t.status.value) if hasattr(t, "status") and t.status is not None else "confirmed"), + note=(t.note or None), + ) diff --git a/apps/backend/app/api/paypal_ipn.py b/apps/backend/app/api/paypal_ipn.py new file mode 100644 index 0000000..58bf53e --- /dev/null +++ b/apps/backend/app/api/paypal_ipn.py @@ -0,0 +1,79 @@ +# app/api/paypal_ipn.py +import httpx, urllib.parse, os, re +from fastapi import APIRouter, Request, Response, Depends, HTTPException +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.models.topup import Topup, TopupStatus +from app.models.user import User + +router = APIRouter(prefix="/paypal", tags=["paypal"]) + +PP_IPN_VERIFY_URL = "https://ipnpb.paypal.com/cgi-bin/webscr" # live +# Sandbox: "https://ipnpb.sandbox.paypal.com/cgi-bin/webscr" + +def parse_custom(s: str): + # erwartet "topup:|code:" + data = {} + for part in (s or "").split("|"): + if ":" in part: + k,v = part.split(":",1) + data[k.strip()] = v.strip() + return data + +@router.post("/ipn") +async def handle_ipn(request: Request, db: Session = Depends(get_db)): + raw = await request.body() + params = urllib.parse.parse_qs(raw.decode("utf-8"), keep_blank_values=True) + # 1) an PayPal zurückposten: + verify_payload = "cmd=_notify-validate&" + raw.decode("utf-8") + async with httpx.AsyncClient(timeout=15) as c: + vr = await c.post(PP_IPN_VERIFY_URL, data=verify_payload, + headers={"Content-Type":"application/x-www-form-urlencoded"}) + if vr.text.strip() != "VERIFIED": + return Response("ignored", status_code=400) + + payment_status = (params.get("payment_status",[None])[0] or "").lower() + receiver_email = (params.get("receiver_email",[None])[0] or "").lower() + mc_currency = params.get("mc_currency",[None])[0] or "" + mc_gross = params.get("mc_gross",[None])[0] or "" + custom = params.get("custom",[None])[0] or "" + item_name = params.get("item_name",[None])[0] or "" + txn_id = params.get("txn_id",[None])[0] or "" + + # Grundvalidierungen + if payment_status != "completed": + return {"ok": True} # nur completed interessiert + # Optional: Empfänger-Email prüfen (gegen Config) + # if receiver_email != CONFIG.paypal_receiver.lower(): return Response("wrong receiver", 400) + if mc_currency != "EUR": + return Response("wrong currency", 400) + + meta = parse_custom(custom) + topup_id = int(meta.get("topup") or 0) + code = meta.get("code") or "" + + t = db.query(Topup).filter(Topup.id == topup_id).first() + if not t or t.status == TopupStatus.confirmed: + return {"ok": True} + + # Betrag prüfen + try: + cents_from_paypal = int(round(float(mc_gross.replace(",", ".")) * 100)) + except Exception: + return Response("bad amount", 400) + if int(t.amount_cents or 0) != cents_from_paypal: + return Response("amount mismatch", 400) + + # Sicherheits-Match: Code im item_name? + if code and code not in (item_name or ""): + # nicht hart abbrechen, aber flaggen wäre möglich + pass + + # Topup bestätigen + Saldo buchen (idempotent) + t.status = TopupStatus.confirmed + u = db.query(User).filter(User.id == t.user_id).first() + u.balance_cents = int(u.balance_cents or 0) + int(t.amount_cents or 0) + # Optional: t.paypal_txn_id = txn_id + db.commit() + + return {"ok": True} diff --git a/apps/backend/app/api/products.py b/apps/backend/app/api/products.py new file mode 100644 index 0000000..6f491b8 --- /dev/null +++ b/apps/backend/app/api/products.py @@ -0,0 +1,54 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.core.database import SessionLocal +from app.models.product import Product +from app.schemas.product import ProductCreate, ProductUpdate, ProductOut +from app.core.auth import get_current_user, requires_role +from app.models.user import User + +router = APIRouter(prefix="/products", tags=["products"]) + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +@router.post("/", response_model=ProductOut, dependencies=[Depends(requires_role("manager", "admin"))]) +def create_product(product: ProductCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + db_product = Product(**product.dict()) + db.add(db_product) + db.commit() + db.refresh(db_product) + return db_product + +@router.get("/", response_model=list[ProductOut]) +def list_products(db: Session = Depends(get_db)): + return db.query(Product).all() + +@router.get("/{product_id}", response_model=ProductOut) +def get_product(product_id: int, db: Session = Depends(get_db)): + product = db.query(Product).get(product_id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + return product + +@router.put("/{product_id}", response_model=ProductOut, dependencies=[Depends(requires_role("manager", "admin"))]) +def update_product(product_id: int, product: ProductUpdate, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + db_product = db.query(Product).get(product_id) + if not db_product: + raise HTTPException(status_code=404, detail="Product not found") + for key, value in product.dict(exclude_unset=True).items(): + setattr(db_product, key, value) + db.commit() + db.refresh(db_product) + return db_product + +@router.delete("/{product_id}", status_code=204, dependencies=[Depends(requires_role("manager", "admin"))]) +def delete_product(product_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + db_product = db.query(Product).get(product_id) + if not db_product: + raise HTTPException(status_code=404, detail="Product not found") + db.delete(db_product) + db.commit() diff --git a/apps/backend/app/api/profile.py b/apps/backend/app/api/profile.py new file mode 100644 index 0000000..869e5e7 --- /dev/null +++ b/apps/backend/app/api/profile.py @@ -0,0 +1,135 @@ +from fastapi import APIRouter, Depends, UploadFile, File, HTTPException +from fastapi import status +from sqlalchemy.orm import Session as DBSession +from pathlib import Path +import shutil +from typing import Optional +from pydantic import BaseModel, EmailStr, field_validator + +from app.core.database import SessionLocal +from app.core.auth import get_current_user +from app.models.user import User + +router = APIRouter(prefix="/profile", tags=["profile"]) + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +# ----------------------------- +# 1) Neuer PIN anfordern (Mock) +# ----------------------------- +@router.post("/request-new-pin", status_code=status.HTTP_202_ACCEPTED) +def request_new_pin(current_user: User = Depends(get_current_user)): + return {"status": "queued", "message": "PIN request queued (mock)"} + +# ----------------------------- +# 2) Profil-Avatar hochladen (persistiert avatar_url) +# ----------------------------- +UPLOAD_DIR = Path("media/avatars") + +@router.post("/avatar", status_code=status.HTTP_200_OK) +def upload_avatar( + file: UploadFile = File(...), + db: DBSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + + filename = file.filename or "avatar" + ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + if ext not in {"png", "jpg", "jpeg", "webp", "gif", ""}: + raise HTTPException(status_code=400, detail="Unsupported file type") + final_ext = ext if ext else "png" + + dest = UPLOAD_DIR / f"{current_user.id}.{final_ext}" + with dest.open("wb") as out: + shutil.copyfileobj(file.file, out) + + # User in diese Session laden + u = db.query(User).filter(User.id == current_user.id).first() + if not u: + raise HTTPException(status_code=404, detail="User not found") + + u.avatar_url = f"/media/avatars/{u.id}.{final_ext}" + db.commit() + db.refresh(u) + + import time + return { + "status": "ok", + "avatar_url": f"{u.avatar_url}?t={int(time.time())}", + "path": str(dest), + } + +# ----------------------------- +# 3) Eigene Basisdaten ändern (inkl. PayPal + Passwort) +# ----------------------------- +class ProfileUpdate(BaseModel): + alias: Optional[str] = None + paypal_email: Optional[EmailStr] = None + visible_in_stats: Optional[bool] = None + public_stats: Optional[bool] = None + + # Passwortwechsel + current_password: Optional[str] = None + new_password: Optional[str] = None + + @field_validator("new_password") + @classmethod + def _min_len_pw(cls, v): + if v is not None and len(v) < 8: + raise ValueError("Password too short (min. 8)") + return v + +@router.put("/me", response_model=dict) +def update_profile( + payload: ProfileUpdate, + db: DBSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + u = db.query(User).filter(User.id == current_user.id).first() + if not u: + raise HTTPException(status_code=404, detail="User not found") + + changed = False + + # Alias + if payload.alias is not None: + u.alias = payload.alias + changed = True + + # PayPal-Mail + if payload.paypal_email is not None: + u.paypal_email = str(payload.paypal_email).lower() + changed = True + + # Sichtbarkeit (Kompatibilität: beide Keys akzeptieren) + flag = payload.public_stats if payload.public_stats is not None else payload.visible_in_stats + if flag is not None: + u.public_stats = bool(flag) + changed = True + + # Passwortwechsel nur mit current_password + if payload.new_password is not None: + from passlib.context import CryptContext + pwd = CryptContext(schemes=["bcrypt"], deprecated="auto") + + # Wenn ein Passwort gesetzt ist, muss current_password mitgeschickt werden + if getattr(u, "hashed_password", None): + if not payload.current_password: + raise HTTPException(status_code=400, detail="Current password required") + if not pwd.verify(payload.current_password, u.hashed_password): + raise HTTPException(status_code=400, detail="Current password invalid") + + u.hashed_password = pwd.hash(payload.new_password) + changed = True + + if changed: + db.commit() + db.refresh(u) + + return {"status": "ok"} diff --git a/apps/backend/app/api/sessions.py b/apps/backend/app/api/sessions.py new file mode 100644 index 0000000..cb31d1b --- /dev/null +++ b/apps/backend/app/api/sessions.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, Depends, Request, Response +from sqlalchemy.orm import Session as DBSession +from app.core.database import get_db +from app.models.session import Session +from app.core.auth import SESSION_COOKIE_NAME, clear_session_cookie + +router = APIRouter() + +@router.delete("/sessions/me") +def delete_own_session(request: Request, response: Response, db: DBSession = Depends(get_db)): + token = request.cookies.get(SESSION_COOKIE_NAME) + if not token: + clear_session_cookie(response) + return {"detail": "No active session"} + session = db.query(Session).filter_by(token=token).first() + if session: + db.delete(session) + db.commit() + clear_session_cookie(response) + return {"detail": "Session deleted"} diff --git a/apps/backend/app/api/stats.py b/apps/backend/app/api/stats.py new file mode 100644 index 0000000..bb61f14 --- /dev/null +++ b/apps/backend/app/api/stats.py @@ -0,0 +1,361 @@ +from datetime import datetime, timedelta, timezone +from typing import Optional, List, Dict, Any, Tuple + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, and_ +from sqlalchemy.orm import Session + +# TZ: ZoneInfo ist vorhanden, aber es fehlen auf Windows oft die IANA-Daten. +try: + from zoneinfo import ZoneInfo +except Exception: # sehr defensive Fallbacks + ZoneInfo = None # type: ignore + +from app.core.database import get_db +from app.core.auth import get_current_user, requires_role +from app.models.booking import Booking +from app.models.user import User +from app.models.product import Product +from app.models.delivery import Delivery # hat KEIN created_at bei dir + + +router = APIRouter(prefix="/stats", tags=["stats"]) + + +# ----------------- Helpers ----------------- + +def _get_tz(tz_name: str) -> timezone: + """ + Liefert eine tzinfo. Auf Windows ohne tzdata fällt das auf UTC zurück, + statt eine ZoneInfoNotFoundError zu werfen. + """ + if ZoneInfo is not None: + try: + return ZoneInfo(tz_name) # type: ignore + except Exception: + pass + # Fallback: UTC – korrekt, aber ohne CET/CEST-Offset. + return timezone.utc + + +def _to_naive_utc(dt: Optional[datetime]) -> Optional[datetime]: + """ + Konvertiert timezone-aware nach UTC und entfernt tzinfo. + Bei None -> None. Bei nativ-naiv wird unverändert zurückgegeben. + """ + if dt is None: + return None + if dt.tzinfo is None: + return dt + return dt.astimezone(timezone.utc).replace(tzinfo=None) + + +def _delivery_time_column(): + """ + Ermittelt die Zeitspalte des Delivery-Modells dynamisch. + Reihenfolge: created_at | timestamp | created | delivered_at + Gibt (Column, name) oder (None, None) zurück. + """ + for name in ("created_at", "timestamp", "created", "delivered_at"): + col = getattr(Delivery, name, None) + if col is not None: + return col, name + return None, None + + +def _last_delivery_at(db: Session) -> Optional[datetime]: + """ + Bestimmt den Zeitpunkt der letzten Lieferung anhand der vorhandenen Spalte. + Achtung: Gibt das zurück, was in der DB liegt (aware oder naiv). + """ + col, _ = _delivery_time_column() + if col is None: + return None + return db.query(func.max(col)).scalar() + + +def _period_bounds(db: Session, period: str, tz_name: str = "Europe/Berlin") -> Tuple[Optional[datetime], Optional[datetime], Optional[datetime]]: + """ + Liefert (from_utc_naive, to_utc_naive, last_delivery_time_original). + Booking.timestamp ist in deiner App naiv/UTC, daher filtern wir mit naiven UTC-Grenzen. + """ + tz = _get_tz(tz_name) + now_local = datetime.now(tz) + now_utc_naive = now_local.astimezone(timezone.utc).replace(tzinfo=None) + + last_delivery = _last_delivery_at(db) + + if period == "last_delivery": + if last_delivery is None: + # Fallback: 30 Tage rückwärts + from_utc_naive = (now_local - timedelta(days=30)).astimezone(timezone.utc).replace(tzinfo=None) + else: + from_utc_naive = _to_naive_utc(last_delivery) + return (from_utc_naive, now_utc_naive, last_delivery) + + if period == "ytd": + start_local = datetime(now_local.year, 1, 1, tzinfo=tz) + start_utc_naive = start_local.astimezone(timezone.utc).replace(tzinfo=None) + return (start_utc_naive, now_utc_naive, last_delivery) + + # "all" -> keine untere Grenze + return (None, now_utc_naive, last_delivery) + + +# ----------------- Öffentliche Stat-Endpunkte (auth-pflichtig, aber nicht nur Admin) ----------------- + +@router.get("/meta") +def stats_meta( + db: Session = Depends(get_db), + _: User = Depends(get_current_user), + tz: str = Query("Europe/Berlin") +) -> Dict[str, Any]: + """ + Metadaten für die Stats-Seite: + - categories: distinct Product.category (ohne NULL) + - last_delivery_at: ISO-Zeitpunkt der letzten Lieferung (falls vorhanden) + - visible_count: Nutzer mit public_stats==true & alias gesetzt & is_active==true + - hidden_count: aktive Nutzer minus visible_count + - now: Serverzeit in tz + """ + # Kategorien + cats = [ + c for (c,) in db.query(Product.category) + .filter(Product.category.isnot(None)) + .distinct() + .order_by(Product.category.asc()) + .all() + ] + + # Letzte Lieferung – robust gegen fehlende Spalte + last_delivery_time = _last_delivery_at(db) + + # Sichtbarkeit + visible_count = ( + db.query(func.count(User.id)) + .filter( + and_( + User.is_active.is_(True), + User.alias.isnot(None), + func.length(func.trim(User.alias)) > 0, + getattr(User, "public_stats", False) == True, # robust falls Migration noch nicht durch + ) + ) + .scalar() + or 0 + ) + active_count = db.query(func.count(User.id)).filter(User.is_active.is_(True)).scalar() or 0 + + now_local = datetime.now(_get_tz(tz)) + return { + "categories": cats, + "last_delivery_at": last_delivery_time.isoformat() if last_delivery_time else None, + "visible_count": int(visible_count), + "hidden_count": int(max(0, active_count - visible_count)), + "now": now_local.isoformat(), + } + + +@router.get("/top-drinkers") +def top_drinkers( + period: str = Query("last_delivery", pattern="^(last_delivery|ytd|all)$"), + category: str = Query("all"), + limit: int = Query(5, ge=1, le=50), + tz: str = Query("Europe/Berlin"), + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> List[Dict[str, Any]]: + """ + Top-Trinker je Zeitraum und optional Kategorie. + Aggregation: Summe Booking.amount (Stückzahl), zusätzlich count(*) und SUM(total_cents). + Nur Nutzer mit Opt-in (public_stats) + Alias + aktiv. + """ + from_dt, to_dt, _ = _period_bounds(db, period, tz) + + q = ( + db.query( + User.id.label("user_id"), + User.alias.label("alias"), + User.avatar_url.label("avatar_url"), + func.coalesce(func.sum(Booking.amount), 0).label("amount_sum"), + func.count(Booking.id).label("count"), + func.coalesce(func.sum(Booking.total_cents), 0).label("revenue_cents"), + ) + .join(User, User.id == Booking.user_id) + ) + + if category != "all": + q = q.join(Product, Product.id == Booking.product_id).filter(Product.category == category) + + if from_dt is not None: + q = q.filter(Booking.timestamp >= from_dt) + if to_dt is not None: + q = q.filter(Booking.timestamp <= to_dt) + + # Sichtbarkeitsregeln – robust falls Spalte noch nicht migriert: + public_stats_col = getattr(User, "public_stats", None) + conds = [ + User.is_active.is_(True), + User.alias.isnot(None), + func.length(func.trim(User.alias)) > 0, + ] + if public_stats_col is not None: + conds.append(public_stats_col.is_(True)) + + q = q.filter(and_(*conds)) + + q = q.group_by(User.id, User.alias, User.avatar_url).order_by(func.coalesce(func.sum(Booking.amount), 0).desc()).limit(limit) + rows = q.all() + + return [ + { + "user_id": r.user_id, + "alias": r.alias, + "avatar_url": r.avatar_url, + "amount_sum": int(r.amount_sum or 0), + "count": int(r.count or 0), + "revenue_cents": int(r.revenue_cents or 0), + } + for r in rows + ] + + +@router.get("/product-share") +def product_share( + period: str = Query("last_delivery", pattern="^(last_delivery|ytd|all)$"), + tz: str = Query("Europe/Berlin"), + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> Dict[str, Any]: + """ + Produktverteilung (Kreisdiagramm): COUNT(Booking.id) je Produkt im Zeitraum. + Unabhängig vom Opt-in, da keine personenbezogenen Daten. + """ + from_dt, to_dt, _ = _period_bounds(db, period, tz) + + q = ( + db.query( + Product.id.label("product_id"), + Product.name.label("product_name"), + func.count(Booking.id).label("count_rows"), + ) + .join(Product, Product.id == Booking.product_id) + ) + + if from_dt is not None: + q = q.filter(Booking.timestamp >= from_dt) + if to_dt is not None: + q = q.filter(Booking.timestamp <= to_dt) + + q = q.group_by(Product.id, Product.name).order_by(func.count(Booking.id).desc()) + items = q.all() + + total = sum(int(r.count_rows or 0) for r in items) + return { + "total": int(total), + "items": [ + { + "product_id": r.product_id, + "product_name": r.product_name, + "count": int(r.count_rows or 0), + } + for r in items + ], + } + + +# ----------------- Bestehende Admin-Stats (weiterhin verfügbar) ----------------- + +@router.get("/summary", dependencies=[Depends(requires_role("manager", "admin"))]) +def summary(db: Session = Depends(get_db), user: User = Depends(get_current_user)): + users = db.query(func.count(User.id)).scalar() or 0 + products = db.query(func.count(Product.id)).scalar() or 0 + bookings = db.query(func.count(Booking.id)).scalar() or 0 + total_revenue = db.query(func.coalesce(func.sum(Booking.total_cents), 0)).scalar() or 0 + + return { + "num_users": users, + "num_products": products, + "num_bookings": bookings, + "total_revenue_cents": int(total_revenue), + "my_balance_cents": int(user.balance_cents or 0), + "stock_overview": None, + } + + +@router.get("/revenue-summary", dependencies=[Depends(requires_role("manager", "admin"))]) +def revenue_summary(db: Session = Depends(get_db), _: User = Depends(get_current_user)): + total_cents = db.query(func.coalesce(func.sum(Booking.total_cents), 0)).scalar() or 0 + + # dynamische Delivery-Zeitspalte verwenden + last_delivery_time = _last_delivery_at(db) + + if last_delivery_time: + since_last_cents = ( + db.query(func.coalesce(func.sum(Booking.total_cents), 0)) + .filter(Booking.timestamp > _to_naive_utc(last_delivery_time)) + .scalar() + or 0 + ) + else: + since_last_cents = total_cents + + return { + "revenue_total_cents": int(total_cents), + "revenue_since_last_delivery_cents": int(since_last_cents), + "last_delivery_at": last_delivery_time.isoformat() if last_delivery_time else None, + } + + +# (optionale Alt-Endpunkte, falls noch genutzt) +@router.get("/consumption-per-user", dependencies=[Depends(requires_role("manager", "admin"))]) +def consumption_per_user(db: Session = Depends(get_db), _: User = Depends(get_current_user)): + rows = ( + db.query( + User.id.label("user_id"), + User.name, + func.coalesce(func.sum(Booking.total_cents), 0).label("total_cents"), + ) + .outerjoin(Booking, Booking.user_id == User.id) + .group_by(User.id, User.name) + .order_by(func.coalesce(func.sum(Booking.total_cents), 0).desc()) + .all() + ) + return [{"user_id": r.user_id, "name": r.name, "total_cents": int(r.total_cents or 0)} for r in rows] + + +@router.get("/consumption-per-product", dependencies=[Depends(requires_role("manager", "admin"))]) +def consumption_per_product(db: Session = Depends(get_db), _: User = Depends(get_current_user)): + rows = ( + db.query( + Product.id.label("product_id"), + Product.name.label("name"), + func.count(Booking.id).label("count"), + ) + .outerjoin(Booking, Booking.product_id == Product.id) + .group_by(Product.id, Product.name) + .order_by(func.count(Booking.id).desc()) + .all() + ) + return [{"product_id": r.product_id, "name": r.name, "count": int(r.count or 0)} for r in rows] + + +@router.get("/monthly-ranking", dependencies=[Depends(requires_role("manager", "admin"))]) +def monthly_ranking( + year: Optional[int] = Query(None), + month: Optional[int] = Query(None), + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +): + q = ( + db.query( + User.id.label("user_id"), + User.name, + func.coalesce(func.sum(Booking.total_cents), 0).label("total_cents"), + ) + .join(User, User.id == Booking.user_id) + .group_by(User.id, User.name) + .order_by(func.coalesce(func.sum(Booking.total_cents), 0).desc()) + .limit(10) + ) + return [{"user_id": r.user_id, "name": r.name, "total_cents": int(r.total_cents or 0)} for r in q.all()] diff --git a/apps/backend/app/api/topups.py b/apps/backend/app/api/topups.py new file mode 100644 index 0000000..4099fbd --- /dev/null +++ b/apps/backend/app/api/topups.py @@ -0,0 +1,203 @@ +# app/api/topups.py +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from typing import List, Optional, Literal +from pydantic import BaseModel + +from app.core.database import get_db +from app.core.auth import get_current_user, requires_role +from app.models.user import User +from app.models.topup import Topup, TopupStatus as DBTopupStatus +from app.schemas.topup import TopupOut, TopupCreate # Pydantic v2 Schemas + +# Optionales Audit-Log – nur nutzen, wenn vorhanden +try: + from app.models.audit_log import AuditLog, AuditAction + HAS_AUDIT = True +except Exception: + AuditLog = None + AuditAction = None + HAS_AUDIT = False + +router = APIRouter(prefix="/topups", tags=["topups"]) + + +# --------------------------- +# CREATE (User oder Admin) +# --------------------------- +@router.post("/", response_model=TopupOut, status_code=status.HTTP_201_CREATED) +def create_topup( + topup: TopupCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Legt ein Topup an. amount_cents ist Pflicht. user_id ist optional; wenn nicht gesetzt, + wird der aktuelle User verwendet. + """ + payload = topup.model_dump() # Pydantic v2 + + # user_id für User-Flow ergänzen + user_id = payload.get("user_id") or current_user.id + amount_cents = int(payload.get("amount_cents") or 0) + if amount_cents <= 0: + raise HTTPException(status_code=400, detail="amount_cents must be > 0") + + db_topup = Topup( + user_id=user_id, + amount_cents=amount_cents, + paypal_email=payload.get("paypal_email"), + note=payload.get("note"), + # status default: pending (siehe Model) + ) + + db.add(db_topup) + db.commit() + db.refresh(db_topup) + + if HAS_AUDIT: + db.add( + AuditLog( + user_id=db_topup.user_id, + action=getattr(AuditAction, "topup_create", "topup_create"), + amount_cents=db_topup.amount_cents, + info=f"created by {current_user.id}", + ) + ) + db.commit() + + return db_topup + + +# --------------------------- +# LIST (Admin/Manager, gefiltert) +# --------------------------- +@router.get("/", response_model=List[TopupOut], dependencies=[Depends(requires_role("manager", "admin"))]) +def list_topups( + user_id: Optional[int] = Query(default=None), + status_filter: Optional[str] = Query(default=None, description="pending|confirmed|rejected"), + limit: int = Query(default=100, ge=1, le=1000), + offset: int = Query(default=0, ge=0), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + q = db.query(Topup) + if user_id is not None: + q = q.filter(Topup.user_id == user_id) + if status_filter: + try: + enum_val = DBTopupStatus(status_filter) + except ValueError: + raise HTTPException(status_code=400, detail="invalid status_filter") + q = q.filter(Topup.status == enum_val) + q = q.order_by(Topup.id.desc()).limit(limit).offset(offset) + return q.all() + + +# --------------------------- +# LIST OWN (alle eingeloggten) +# --------------------------- +@router.get("/me", response_model=List[TopupOut]) +def list_my_topups( + limit: int = Query(default=100, ge=1, le=1000), + offset: int = Query(default=0, ge=0), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + q = ( + db.query(Topup) + .filter(Topup.user_id == current_user.id) + .order_by(Topup.id.desc()) + .limit(limit) + .offset(offset) + ) + return q.all() + + +# --------------------------- +# STATUS ändern (Admin/Manager) +# --------------------------- +class TopupStatusIn(BaseModel): + status: Literal["confirmed", "rejected"] + + +@router.patch("/{topup_id}/status", dependencies=[Depends(requires_role("manager", "admin"))]) +def patch_topup_status( + topup_id: int, + payload: TopupStatusIn, + db: Session = Depends(get_db), + admin: User = Depends(get_current_user), +): + t = db.query(Topup).filter(Topup.id == topup_id).first() + if not t: + raise HTTPException(status_code=404, detail="Topup not found") + + # robustes Enum-Handling + old_enum = t.status if isinstance(t.status, DBTopupStatus) else DBTopupStatus(str(t.status)) + try: + new_enum = DBTopupStatus(payload.status) + except ValueError: + raise HTTPException(status_code=400, detail="invalid status") + + if old_enum == new_enum: + return {"status": "ok", "topup_id": t.id, "new_status": old_enum.value} + + # Status setzen + t.status = new_enum + + # Balance nur bei Übergang zu 'confirmed' buchen (idempotent) + if new_enum == DBTopupStatus.confirmed and old_enum != DBTopupStatus.confirmed: + u = db.query(User).filter(User.id == t.user_id).first() + if not u: + raise HTTPException(status_code=404, detail="User for topup not found") + + before = int(u.balance_cents or 0) + u.balance_cents = before + int(t.amount_cents or 0) + + if HAS_AUDIT: + db.add( + AuditLog( + user_id=u.id, + action=getattr(AuditAction, "topup_confirmed", "topup_confirmed"), + amount_cents=int(t.amount_cents or 0), + old_balance_cents=before, + new_balance_cents=u.balance_cents, + info=f"topup:{t.id} confirmed by {admin.id}", + ) + ) + + # (Kein automatisches Reversal bei Wechsel zu rejected) + + db.commit() + db.refresh(t) + return {"status": "ok", "topup_id": t.id, "new_status": t.status.value} + + +# --------------------------- +# DELETE (Admin/Manager) +# --------------------------- +@router.delete("/{topup_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(requires_role("manager", "admin"))]) +def delete_topup( + topup_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_current_user), +): + t = db.query(Topup).filter(Topup.id == topup_id).first() + if not t: + raise HTTPException(status_code=404, detail="Topup not found") + + db.delete(t) + db.commit() + + if HAS_AUDIT: + db.add( + AuditLog( + user_id=t.user_id, + action=getattr(AuditAction, "topup_delete", "topup_delete"), + amount_cents=int(t.amount_cents or 0), + info=f"topup:{t.id} deleted by {admin.id}", + ) + ) + db.commit() + + return None diff --git a/apps/backend/app/api/transactions.py b/apps/backend/app/api/transactions.py new file mode 100644 index 0000000..5efd248 --- /dev/null +++ b/apps/backend/app/api/transactions.py @@ -0,0 +1,127 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from typing import List, Literal, TypedDict + +from app.core.database import get_db +from app.core.auth import get_current_user, requires_role +from app.models.user import User +from app.models.topup import Topup +from app.models.booking import Booking + +router = APIRouter(prefix="/transactions", tags=["transactions"]) + + +class TransactionOut(TypedDict): + id: str + type: Literal["topup", "booking"] + amount_cents: int + created_at: str # ISO + + +@router.get("/me") +def list_my_transactions( + limit: int = Query(100, ge=1, le=1000), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Kombiniert Topups (+) und Buchungen (−) des aktuellen Users. + """ + topups = ( + db.query(Topup) + .filter(Topup.user_id == current_user.id) + .order_by(Topup.id.desc()) + .limit(limit) + .offset(offset) + .all() + ) + bookings = ( + db.query(Booking) + .filter(Booking.user_id == current_user.id) + .order_by(Booking.id.desc()) + .limit(limit) + .offset(offset) + .all() + ) + + tx: List[TransactionOut] = [] + + for t in topups: + tx.append( + { + "id": f"topup-{t.id}", + "type": "topup", + "amount_cents": int(t.amount_cents or 0), + "created_at": t.created_at.isoformat() if hasattr(t, "created_at") and t.created_at else "", + } + ) + + for b in bookings: + # Wenn du Booking.total_cents hast, nutze das. Sonst product.price_cents * amount. + total = getattr(b, "total_cents", None) + if total is None: + price = getattr(b, "price_cents", None) + if price is None and hasattr(b, "product") and b.product: + price = getattr(b.product, "price_cents", 0) + total = (price or 0) * (getattr(b, "amount", 1) or 1) + tx.append( + { + "id": f"booking-{b.id}", + "type": "booking", + "amount_cents": -int(total or 0), # Buchungen als negative Beträge + "created_at": b.created_at.isoformat() if hasattr(b, "created_at") and b.created_at else "", + } + ) + + # Absteigend nach Datum + tx.sort(key=lambda x: x["created_at"], reverse=True) + # einfache Paginierung nach Sortierung (optional): tx = tx[offset:offset+limit] + return tx + + +@router.get("/", dependencies=[Depends(requires_role("manager", "admin"))]) +def list_all_transactions_admin( + limit: int = Query(200, ge=1, le=2000), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Admin/Manager: kombinierte Übersicht (einfach, nicht performant für große Datenmengen). + """ + topups = ( + db.query(Topup).order_by(Topup.id.desc()).limit(limit).offset(offset).all() + ) + bookings = ( + db.query(Booking).order_by(Booking.id.desc()).limit(limit).offset(offset).all() + ) + + tx: List[TransactionOut] = [] + for t in topups: + tx.append( + { + "id": f"topup-{t.id}", + "type": "topup", + "amount_cents": int(t.amount_cents or 0), + "created_at": t.created_at.isoformat() if hasattr(t, "created_at") and t.created_at else "", + } + ) + for b in bookings: + total = getattr(b, "total_cents", None) + if total is None: + price = getattr(b, "price_cents", None) + if price is None and hasattr(b, "product") and b.product: + price = getattr(b.product, "price_cents", 0) + total = (price or 0) * (getattr(b, "amount", 1) or 1) + tx.append( + { + "id": f"booking-{b.id}", + "type": "booking", + "amount_cents": -int(total or 0), + "created_at": b.created_at.isoformat() if hasattr(b, "created_at") and b.created_at else "", + } + ) + + tx.sort(key=lambda x: x["created_at"], reverse=True) + return tx diff --git a/apps/backend/app/api/users.py b/apps/backend/app/api/users.py new file mode 100644 index 0000000..18a1ea3 --- /dev/null +++ b/apps/backend/app/api/users.py @@ -0,0 +1,258 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query, Body +from sqlalchemy.orm import Session +from sqlalchemy import func, or_, asc, desc +from typing import List, Optional +from passlib.context import CryptContext +from pydantic import BaseModel, constr, conint +from datetime import datetime + +from app.core.database import get_db +from app.models.user import User +from app.schemas.user import UserOut, UserCreate, UserUpdate +from app.core.auth import get_current_user, requires_role_relaxed, requires_role_mgmt + +# NEU: Topup-Model importieren, damit Admin-Gutschriften als Topup geloggt werden +from app.models.topup import Topup, TopupStatus + +router = APIRouter(prefix="/users", tags=["users"]) +pwd = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def get_db_sess(): + db = get_db() + # get_db ist ein generator → FastAPI injiziert automatisch. + # falls du eine SessionLocal-Funktion hast, nutze die hier analog. + return db + +@router.get("/", response_model=List[UserOut], dependencies=[Depends(requires_role_relaxed("manager","admin"))]) +def list_users( + q: Optional[str] = Query(default=None, description="Suche in Name/E-Mail/Alias"), + role: Optional[str] = Query(default=None, regex="^(user|manager|admin)$"), + active: Optional[bool] = Query(default=None), + balance_lt: Optional[int] = Query(default=None, description="Kontostand < Wert (Cent)"), + limit: int = Query(default=25, ge=1, le=200), + offset: int = Query(default=0, ge=0), + sort: str = Query(default="name", description="name|email|role|balance_cents|is_active"), + order: str = Query(default="asc", regex="^(asc|desc)$"), + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +): + qy = db.query(User) + if q: + like = f"%{q.lower()}%" + qy = qy.filter(or_( + func.lower(User.name).like(like), + func.lower(User.email).like(like), + func.lower(func.coalesce(User.alias, "")).like(like), + )) + if role: + qy = qy.filter(User.role == role) + if active is not None: + qy = qy.filter(User.is_active == active) + if balance_lt is not None: + qy = qy.filter((User.balance_cents != None) & (User.balance_cents < balance_lt)) + + sort_map = { + "name": User.name, + "email": User.email, + "role": User.role, + "balance_cents": User.balance_cents, + "is_active": User.is_active, + } + col = sort_map.get(sort, User.name) + qy = qy.order_by(asc(col) if order == "asc" else desc(col)) + + return qy.limit(limit).offset(offset).all() + +@router.get("/{user_id}", response_model=UserOut, dependencies=[Depends(requires_role_relaxed("manager","admin"))]) +def get_user(user_id: int, db: Session = Depends(get_db), _: User = Depends(get_current_user)): + u = db.query(User).filter(User.id == user_id).first() + if not u: + raise HTTPException(status_code=404, detail="User not found") + return u + +@router.post("/", response_model=UserOut, status_code=status.HTTP_201_CREATED, dependencies=[Depends(requires_role_mgmt("manager","admin"))]) +def create_user(payload: UserCreate, db: Session = Depends(get_db), admin: User = Depends(get_current_user)): + if db.query(User).filter(User.email == payload.email).first(): + raise HTTPException(status_code=409, detail="E-Mail already exists") + if payload.alias and db.query(User).filter(User.alias == payload.alias).first(): + raise HTTPException(status_code=409, detail="Alias already exists") + u = User( + name=payload.name, + email=payload.email, + hashed_password=pwd.hash(payload.password), + hashed_pin=pwd.hash(payload.pin), + alias=payload.alias, + paypal_email=payload.paypal_email, + role=payload.role, + is_active=True, + ) + db.add(u); db.commit(); db.refresh(u) + return u + +@router.patch("/{user_id}", response_model=UserOut, dependencies=[Depends(requires_role_mgmt("manager","admin"))]) +def update_user(user_id: int, payload: UserUpdate, db: Session = Depends(get_db), admin: User = Depends(get_current_user)): + u = db.query(User).filter(User.id == user_id).first() + if not u: + raise HTTPException(status_code=404, detail="User not found") + + if payload.email is not None: + if db.query(User).filter(User.email == payload.email, User.id != user_id).first(): + raise HTTPException(status_code=409, detail="E-Mail already exists") + u.email = payload.email + if payload.alias is not None: + if db.query(User).filter(User.alias == payload.alias, User.id != user_id).first(): + raise HTTPException(status_code=409, detail="Alias already exists") + u.alias = payload.alias + if payload.name is not None: + u.name = payload.name + if payload.password is not None: + u.hashed_password = pwd.hash(payload.password) + if payload.pin is not None: + u.hashed_pin = pwd.hash(payload.pin) + if payload.paypal_email is not None: + u.paypal_email = payload.paypal_email + if payload.role is not None: + u.role = payload.role + if payload.is_active is not None: + u.is_active = payload.is_active + + db.commit(); db.refresh(u) + return u + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(requires_role_mgmt("manager","admin"))]) +def delete_user(user_id: int, db: Session = Depends(get_db), admin: User = Depends(get_current_user)): + u = db.query(User).filter(User.id == user_id).first() + if not u: + raise HTTPException(status_code=404, detail="User not found") + db.delete(u); db.commit() + return None + +# ----------------------------- +# Sicherheitsaktionen +# ----------------------------- + +class SetPinIn(BaseModel): + pin: constr(min_length=6, max_length=6) + +class SetPasswordIn(BaseModel): + password: constr(min_length=8) + +@router.post("/{user_id}/set-pin", dependencies=[Depends(requires_role_mgmt("manager","admin"))]) +def set_pin(user_id: int, payload: SetPinIn, db: Session = Depends(get_db), admin: User = Depends(get_current_user)): + u = db.query(User).filter(User.id == user_id).first() + if not u: raise HTTPException(status_code=404, detail="User not found") + if admin.role == "manager" and u.role == "admin": + raise HTTPException(status_code=403, detail="Managers cannot modify admin credentials") + u.hashed_pin = pwd.hash(payload.pin); db.commit() + return {"status": "ok"} + +@router.post("/{user_id}/set-password", dependencies=[Depends(requires_role_mgmt("manager","admin"))]) +def set_password(user_id: int, payload: SetPasswordIn, db: Session = Depends(get_db), admin: User = Depends(get_current_user)): + u = db.query(User).filter(User.id == user_id).first() + if not u: raise HTTPException(status_code=404, detail="User not found") + if admin.role == "manager" and u.role == "admin": + raise HTTPException(status_code=403, detail="Managers cannot modify admin credentials") + u.hashed_password = pwd.hash(payload.password); db.commit() + return {"status": "ok"} + +class BalanceAdjustIn(BaseModel): + amount_cents: conint(strict=True) + reason: constr(min_length=1, max_length=500) + +@router.post("/{user_id}/adjust-balance", dependencies=[Depends(requires_role_mgmt("manager","admin"))]) +def adjust_balance(user_id: int, payload: BalanceAdjustIn, db: Session = Depends(get_db), admin: User = Depends(get_current_user)): + """ + Admin/Manager passt das Guthaben an. + - Betrag != 0 erforderlich. + - Bei positiver Anpassung wird zusätzlich ein Topup mit status=confirmed und note=reason erstellt, + damit es in /topups/me (und der TransactionPage) sichtbar ist. + """ + amount = int(payload.amount_cents or 0) + if amount == 0: + raise HTTPException(status_code=400, detail="amount_cents must be non-zero") + + u = db.query(User).filter(User.id == user_id).first() + if not u: + raise HTTPException(status_code=404, detail="User not found") + if admin.role == "manager" and u.role == "admin": + raise HTTPException(status_code=403, detail="Managers cannot adjust admin accounts") + + before = int(u.balance_cents or 0) + u.balance_cents = before + amount + + created_topup_id: Optional[int] = None + + # Nur bei Gutschrift (+) zusätzlich Topup schreiben (mit Notiz = reason). + if amount > 0: + t = Topup( + user_id=u.id, + amount_cents=amount, + status=TopupStatus.confirmed, # direkt bestätigt + created_at=datetime.utcnow(), + confirmed_at=datetime.utcnow(), + note=payload.reason.strip() if payload.reason else None, + ) + db.add(t) + db.flush() # ID holen, ohne Zwischen-Commit + created_topup_id = t.id + + db.commit() + db.refresh(u) + + return { + "status": "ok", + "user_id": u.id, + "old_balance_cents": before, + "new_balance_cents": u.balance_cents, + "created_topup_id": created_topup_id, # nur bei positiver Anpassung gesetzt + } + +# ----------------------------- +# FAVORITES: GET / PUT / PATCH +# ----------------------------- + +def _ensure_can_edit(target_user_id: int, actor: User): + """Eigenen Datensatz immer; sonst nur manager/admin.""" + if actor.id != target_user_id and str(actor.role) not in {"manager", "admin"}: + raise HTTPException(status_code=403, detail="Not allowed") + +@router.get("/{user_id}/favorites", response_model=List[int]) +def get_user_favorites( + user_id: int, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + _ensure_can_edit(user_id, current) + u = db.query(User).filter(User.id == user_id).first() + if not u: + raise HTTPException(status_code=404, detail="User not found") + return list(u.favorites or []) + +@router.put("/{user_id}/favorites", response_model=List[int]) +def replace_user_favorites( + user_id: int, + favorites: List[int] = Body(..., embed=False, description="Array von Produkt-IDs"), + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + _ensure_can_edit(user_id, current) + u = db.query(User).filter(User.id == user_id).first() + if not u: + raise HTTPException(status_code=404, detail="User not found") + + # Sanitizing: nur eindeutige, positive ints + clean = sorted({int(x) for x in favorites if isinstance(x, int) and x > 0}) + u.favorites = clean + db.add(u) + db.commit() + return clean + +@router.patch("/{user_id}/favorites", response_model=List[int]) +def patch_user_favorites( + user_id: int, + favorites: List[int] = Body(..., embed=False, description="Array von Produkt-IDs"), + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + # Dein Frontend sendet bei PATCH das komplette Array → identisch zu PUT behandeln. + return replace_user_favorites(user_id, favorites, db, current) diff --git a/apps/backend/app/core/auth.py b/apps/backend/app/core/auth.py new file mode 100644 index 0000000..6dd6eee --- /dev/null +++ b/apps/backend/app/core/auth.py @@ -0,0 +1,96 @@ +from datetime import timedelta +import secrets +from fastapi import Depends, HTTPException, Request, Response, status +from sqlalchemy.orm import Session as DBSession + +from app.core.database import get_db +from app.models.session import Session as SessionModel +from app.models.user import User + +SESSION_COOKIE_NAME = "bacchus_session" +CSRF_COOKIE_NAME = "bacchus_csrf" +CSRF_HEADER_NAME = "X-CSRF-Token" +SESSION_TTL = timedelta(hours=8) + +def _new_token() -> str: + return secrets.token_urlsafe(32) + +# ---------- Role-Normalisierung ---------- +def _normalize_role(role_raw) -> str: + # Enum? -> value + if hasattr(role_raw, "value"): + role_raw = role_raw.value + role = str(role_raw or "user").strip() + if "." in role: # z.B. "UserRole.admin" + role = role.split(".")[-1] + return role.lower() + +def issue_csrf_cookie(resp: Response) -> str: + token = _new_token() + resp.set_cookie( + key=CSRF_COOKIE_NAME, + value=token, + max_age=7200, + secure=False, # PROD: True + samesite="lax", + path="/", + ) + return token + +def clear_csrf_cookie(resp: Response) -> None: + resp.delete_cookie(key=CSRF_COOKIE_NAME, path="/", samesite="lax") + +def verify_csrf(request: Request) -> None: + # CSRF nur für mutierende Methoden + if request.method in ("GET", "HEAD", "OPTIONS"): + return + cookie = request.cookies.get(CSRF_COOKIE_NAME) + header = request.headers.get(CSRF_HEADER_NAME) + if cookie != header: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CSRF check failed") + +def create_session(db: DBSession, user_id: int) -> str: + token = _new_token() + db.add(SessionModel(user_id=user_id, token=token)) + db.commit() + return token + +def set_session_cookie(resp: Response, token: str) -> None: + resp.set_cookie( + key=SESSION_COOKIE_NAME, + value=token, + httponly=True, + secure=False, # PROD: True + samesite="lax", + max_age=int(SESSION_TTL.total_seconds()), + path="/", + ) + +def clear_session_cookie(resp: Response) -> None: + resp.delete_cookie(key=SESSION_COOKIE_NAME, path="/", samesite="lax") + +def get_current_user(request: Request, db: DBSession = Depends(get_db)) -> User: + token = request.cookies.get(SESSION_COOKIE_NAME) + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired session") + session = db.query(SessionModel).filter_by(token=token).first() + if not session: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired session") + return session.user + +def requires_role(*roles: str): + roles_norm = tuple(_normalize_role(r) for r in roles) + def dep(user: User = Depends(get_current_user)): + user_role = _normalize_role(getattr(user, "role", None)) + if roles_norm and user_role not in roles_norm: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role") + return user + return dep + +# ---- Hybrid-Gates ---- +def requires_role_relaxed(*roles: str): + return requires_role(*roles) + +def requires_role_mgmt(*roles: str): + # Später hier optional Session-Typ "management" erzwingen + return requires_role(*roles) diff --git a/apps/backend/app/core/database.py b/apps/backend/app/core/database.py new file mode 100644 index 0000000..a2182f2 --- /dev/null +++ b/apps/backend/app/core/database.py @@ -0,0 +1,32 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base +from contextlib import contextmanager + +# Datenbank-URL (später besser aus Umgebungsvariable laden) +DATABASE_URL = "postgresql://postgres:bacchus@localhost:5432/bacchus" + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +# Dependency für FastAPI-Routen +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +# Optional: Kontextmanager für Skripte außerhalb von FastAPI +@contextmanager +def db_session(): + db = SessionLocal() + try: + yield db + db.commit() + except: + db.rollback() + raise + finally: + db.close() diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py new file mode 100644 index 0000000..1ae4c68 --- /dev/null +++ b/apps/backend/app/main.py @@ -0,0 +1,108 @@ +from pathlib import Path +from dotenv import load_dotenv +import os + +ENV_PATH = Path(__file__).resolve().parent.parent / ".env" +os.environ["DOTENV_PATH"] = str(ENV_PATH) +load_dotenv(dotenv_path=ENV_PATH, override=True) + +from fastapi import FastAPI, Request, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from starlette.middleware.base import BaseHTTPMiddleware + +from app.api import ( + auth, users, products, bookings, deliveries, stats, topups, + audit_logs, profile, transactions, categories, ledger, admin_settings, paypal_ipn +) +from app.core.auth import CSRF_HEADER_NAME, verify_csrf + +ALLOWED_ORIGINS = ["http://localhost:5173"] + +# CSRF-Ausnahmen (nur für DEV sinnvoll!) +CSRF_EXEMPT = { + "/auth/pin-login", + "/auth/management-login", + "/auth/logout", + "/auth/csrf", + "/docs", + "/openapi.json", + "/redoc", + "/docs/oauth2-redirect", +} + +# Swagger-/Redoc-Pfade +DOCS_PATHS = ("/docs", "/redoc", "/openapi.json") + +app = FastAPI() + +app.add_middleware( + CORSMiddleware, + allow_origins=ALLOWED_ORIGINS, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", CSRF_HEADER_NAME], +) + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + path = request.url.path + + # CSRF nur für mutierende Methoden prüfen, ausgenommene Pfade überspringen + if ( + request.method in {"POST", "PUT", "PATCH", "DELETE"} + and not any(path == p or path.startswith(p + "/") for p in CSRF_EXEMPT) + ): + verify_csrf(request) + + response: Response = await call_next(request) + + + if any(path == p or path.startswith(p + "/") for p in DOCS_PATHS): + response.headers.setdefault( + "Content-Security-Policy", + "default-src 'self' https:; " + "img-src 'self' data: https:; " + "style-src 'self' 'unsafe-inline' https:; " + "script-src 'self' 'unsafe-inline' https:" + ) + return response + + # Normale Security-Header + strikte CSP + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("Referrer-Policy", "no-referrer") + response.headers.setdefault( + "Content-Security-Policy", + "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'" + ) + return response + +app.add_middleware(SecurityHeadersMiddleware) + +# Medienordner sicherstellen (verhindert RuntimeError beim Mount) +os.makedirs("media/avatars", exist_ok=True) + +# Static files für Avatare & Medien +app.mount("/media", StaticFiles(directory="media"), name="media") + +# Router registrieren +app.include_router(auth.router) +app.include_router(users.router) +app.include_router(products.router) +app.include_router(bookings.router) +app.include_router(deliveries.router) +app.include_router(stats.router) +app.include_router(topups.router) +app.include_router(audit_logs.router) +app.include_router(profile.router) +app.include_router(transactions.router) +app.include_router(categories.router) +app.include_router(ledger.router) +app.include_router(admin_settings.router) +app.include_router(paypal_ipn.router) +# app.include_router(sessions.router) + +@app.get("/") +def root(): + return {"message": "Bacchus API läuft"} diff --git a/apps/backend/app/models/audit_log.py b/apps/backend/app/models/audit_log.py new file mode 100644 index 0000000..ee72b6e --- /dev/null +++ b/apps/backend/app/models/audit_log.py @@ -0,0 +1,29 @@ +from app.core.database import Base +from sqlalchemy import Column, Integer, String, DateTime, Enum, ForeignKey +from sqlalchemy.orm import relationship +from datetime import datetime +import enum + + +class AuditAction(enum.Enum): + booking_create = "booking_create" + booking_delete = "booking_delete" + topup_create = "topup_create" + # Weitere Aktionen können hier ergänzt werden, z. B.: + # topup_confirm = "topup_confirm" + # user_update = "user_update" + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + action = Column(String, nullable=False) + amount_cents = Column(Integer, nullable=True) + old_balance_cents = Column(Integer, nullable=True) + new_balance_cents = Column(Integer, nullable=True) + info = Column(String, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + user = relationship("User") diff --git a/apps/backend/app/models/booking.py b/apps/backend/app/models/booking.py new file mode 100644 index 0000000..3e22523 --- /dev/null +++ b/apps/backend/app/models/booking.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, Integer, ForeignKey, DateTime, String +from sqlalchemy.orm import relationship +from app.core.database import Base +from datetime import datetime + +class Booking(Base): + __tablename__ = "bookings" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + amount = Column(Integer, nullable=False) # Anzahl der Produkte in dieser Buchung + total_cents = Column(Integer, nullable=False) # Gesamtsumme in Cent (Preis * Anzahl) + comment = Column(String, nullable=True) + timestamp = Column(DateTime, default=datetime.utcnow, nullable=False) # Zeitpunkt der Buchung + + user = relationship("User") + product = relationship("Product") diff --git a/apps/backend/app/models/config.py b/apps/backend/app/models/config.py new file mode 100644 index 0000000..80e6fa9 --- /dev/null +++ b/apps/backend/app/models/config.py @@ -0,0 +1,11 @@ +from sqlalchemy import Column, String, Text, DateTime, func +from app.core.database import Base + +class Config(Base): + __tablename__ = "config" + + key = Column(String(64), primary_key=True, index=True) + value = Column(Text, nullable=False, default="") + + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) diff --git a/apps/backend/app/models/delivery.py b/apps/backend/app/models/delivery.py new file mode 100644 index 0000000..7836860 --- /dev/null +++ b/apps/backend/app/models/delivery.py @@ -0,0 +1,22 @@ +from app.core.database import Base +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text +from sqlalchemy.orm import relationship +from datetime import datetime + +class Delivery(Base): + __tablename__ = "deliveries" + + id = Column(Integer, primary_key=True, index=True) + product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True) + + amount = Column(Integer, nullable=False, default=0) # gelieferte Menge (Stück / Einheiten) + price_cents = Column(Integer, nullable=False, default=0) # Einkaufspreis in Cent (gesamt oder pro Einheit – je nach Modell) + delivered_at = Column(DateTime(timezone=True), nullable=True) # Lieferdatum + supplier = Column(String, nullable=True) # Lieferant (frei) + invoice_number = Column(String, nullable=True) # Rechnungsnummer + created_by = Column(Integer, nullable=True) # optional: User-ID, der die Lieferung erfasst hat + deposit_return_cents = Column(Integer, nullable=False, server_default="0") + note = Column(Text, nullable=True) # oder String(1000) + + # Rückverknüpfung zum Produkt + product = relationship("Product", back_populates="deliveries") diff --git a/apps/backend/app/models/product.py b/apps/backend/app/models/product.py new file mode 100644 index 0000000..508fc19 --- /dev/null +++ b/apps/backend/app/models/product.py @@ -0,0 +1,31 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.core.database import Base + +class Product(Base): + __tablename__ = "products" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False, unique=True, index=True) + category = Column(String, nullable=True, index=True) + volume_ml = Column(Integer, nullable=False) + price_cents = Column(Integer, nullable=False) + supplier_number = Column(String, nullable=True) + image_url = Column(String, nullable=True) + + stock = Column(Integer, nullable=False, default=0) + is_active = Column(Boolean, default=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + pack_size = Column(Integer, nullable=False, default=1) # z.B. 6, 12, 24 + purchase_price_cents = Column(Integer, nullable=False, default=0) + # >>> NEU: Gegenbeziehung zur Delivery-Relation + deliveries = relationship( + "Delivery", + back_populates="product", + cascade="all, delete-orphan", + lazy="selectin", + ) diff --git a/apps/backend/app/models/session.py b/apps/backend/app/models/session.py new file mode 100644 index 0000000..e3b680a --- /dev/null +++ b/apps/backend/app/models/session.py @@ -0,0 +1,14 @@ +from app.core.database import Base +from sqlalchemy import Column, Integer, String, ForeignKey, DateTime +from sqlalchemy.orm import relationship +from datetime import datetime + +class Session(Base): + __tablename__ = "sessions" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + token = Column(String(64), unique=True, nullable=False, index=True) + created_at = Column(DateTime, default=datetime.utcnow) + + user = relationship("User") diff --git a/apps/backend/app/models/topup.py b/apps/backend/app/models/topup.py new file mode 100644 index 0000000..fb84e39 --- /dev/null +++ b/apps/backend/app/models/topup.py @@ -0,0 +1,25 @@ +from app.core.database import Base +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum +from sqlalchemy.orm import relationship +from datetime import datetime +import enum + +class TopupStatus(enum.Enum): + pending = "pending" + confirmed = "confirmed" + rejected = "rejected" + +class Topup(Base): + __tablename__ = "topups" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + amount_cents = Column(Integer, nullable=False) + status = Column(Enum(TopupStatus), default=TopupStatus.pending, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + confirmed_at = Column(DateTime, nullable=True) + paypal_email = Column(String, nullable=True) + note = Column(String, nullable=True) + + + user = relationship("User") diff --git a/apps/backend/app/models/user.py b/apps/backend/app/models/user.py new file mode 100644 index 0000000..89cb64a --- /dev/null +++ b/apps/backend/app/models/user.py @@ -0,0 +1,42 @@ +from app.core.database import Base +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Enum, JSON +import enum +from datetime import datetime + + +class UserRole(enum.Enum): + user = "user" + manager = "manager" + admin = "admin" + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + email = Column(String, nullable=False, unique=True, index=True) + hashed_password = Column(String, nullable=False) + # Hinweis: hashed_pin als unique kann problematisch sein, falls None – in deiner DB ist es gesetzt. + hashed_pin = Column(String, nullable=False, unique=True, index=True) + + # Sichtbarkeit/Alias für die Stats-Seite + alias = Column(String, nullable=True, unique=True) + public_stats = Column(Boolean, nullable=False, default=False) # <— NEU: Opt-in + + paypal_email = Column(String, nullable=True) + role = Column(Enum(UserRole), nullable=False, default=UserRole.user) + is_active = Column(Boolean, default=True) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + balance_cents = Column(Integer, nullable=False, default=0) + favorites = Column(JSON, nullable=False, default=list) + avatar_url = Column(String, nullable=True) + + # PIN-Sicherheit (Lockout etc.) + from sqlalchemy import String as SQLAString + pin_lookup = Column(SQLAString(64), index=True, nullable=True) # HMAC-SHA256(PEPPER, pin) + pin_fail_count = Column(Integer, nullable=False, default=0) + pin_locked_until = Column(DateTime, nullable=True) diff --git a/apps/backend/app/schemas/audit_log.py b/apps/backend/app/schemas/audit_log.py new file mode 100644 index 0000000..86dce41 --- /dev/null +++ b/apps/backend/app/schemas/audit_log.py @@ -0,0 +1,29 @@ +from datetime import datetime +from typing import Optional + +# Pydantic v1/v2 Kompatibilität +try: + from pydantic import BaseModel, ConfigDict # v2 + _V2 = True +except ImportError: # v1 + from pydantic import BaseModel # type: ignore + _V2 = False + + +class AuditLogOut(BaseModel): + id: int + user_id: Optional[int] = None + timestamp: Optional[datetime] = None + # WICHTIG: frei als String, kein zu enges Enum -> verhindert ResponseValidationError + action: str + info: Optional[str] = None + old_balance_cents: Optional[int] = None + new_balance_cents: Optional[int] = None + + if _V2: + # Pydantic v2 + model_config = ConfigDict(from_attributes=True) + else: + # Pydantic v1 + class Config: + orm_mode = True diff --git a/apps/backend/app/schemas/booking.py b/apps/backend/app/schemas/booking.py new file mode 100644 index 0000000..137d094 --- /dev/null +++ b/apps/backend/app/schemas/booking.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel +from datetime import datetime + +class BookingBase(BaseModel): + user_id: int + product_id: int + amount: int + total_cents: int + comment: str | None = None + +class BookingCreate(BookingBase): + pass + +class BookingOut(BookingBase): + id: int + timestamp: datetime + + class Config: + from_attributes = True # Pydantic V2 diff --git a/apps/backend/app/schemas/delivery.py b/apps/backend/app/schemas/delivery.py new file mode 100644 index 0000000..bd29fdc --- /dev/null +++ b/apps/backend/app/schemas/delivery.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel, ConfigDict +from datetime import date, datetime +from typing import Optional, List + +# Bestehendes Einzeilen-Schema bleibt +class DeliveryBase(BaseModel): + product_id: int + amount: int + price_cents: int + delivered_at: Optional[date] = None + supplier: Optional[str] = None + invoice_number: Optional[str] = None + created_by: Optional[int] = None + note: str | None = None + deposit_return_cents: int = 0 + model_config = ConfigDict(from_attributes=True) # statt orm_mode + +class DeliveryCreate(DeliveryBase): + pass + +class DeliveryOut(DeliveryBase): + id: int + class Config: + from_attributes = True + +# 🆕 Für die neue Seite (Header + Items + Pfand) +class DeliveryItemIn(BaseModel): + product_id: int + quantity_units: int + unit_cost_cents: int + +class DeliveryCreateBulk(BaseModel): + supplier: Optional[str] = None + date: Optional[date] = None + invoice_no: Optional[str] = None + note: Optional[str] = None + deposit_return_cents: int = 0 # Netto-Pfand (Cent, positiv) + items: List[DeliveryItemIn] diff --git a/apps/backend/app/schemas/product.py b/apps/backend/app/schemas/product.py new file mode 100644 index 0000000..c9a5d5c --- /dev/null +++ b/apps/backend/app/schemas/product.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + +class ProductBase(BaseModel): + name: str + price_cents: int # Verkaufspreis (falls genutzt) + purchase_price_cents: int = 0 # 🆕 EK je Einheit (Cent) + pack_size: int = 1 # 🆕 6, 12, 24 ... + supplier_number: Optional[str] = None + volume_ml: Optional[int] = None + category: Optional[str] = None + stock: int = 0 # Default sinnvoller + is_active: bool = True # Default sinnvoller + +class ProductCreate(ProductBase): + pass + +class ProductUpdate(ProductBase): + pass + +class ProductOut(ProductBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/apps/backend/app/schemas/topup.py b/apps/backend/app/schemas/topup.py new file mode 100644 index 0000000..6acd475 --- /dev/null +++ b/apps/backend/app/schemas/topup.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime +from enum import Enum + +class TopupStatus(str, Enum): + pending = "pending" + confirmed = "confirmed" + rejected = "rejected" + +# Für Ausgaben und Admin-Create weiterhin kompatibel: +class TopupBase(BaseModel): + user_id: Optional[int] = None # <- optional gemacht + amount_cents: int + paypal_email: Optional[str] = None + note: Optional[str] = None + +class TopupCreate(TopupBase): + # user_id kann für Admin-Aufrufe gesetzt werden, für User-Create weggelassen + pass + +class TopupStatusUpdate(BaseModel): + status: TopupStatus + +class TopupOut(TopupBase): + id: int + status: TopupStatus + created_at: datetime + confirmed_at: Optional[datetime] = None + + class Config: + from_attributes = True # pydantic v2 (ersetzt orm_mode) diff --git a/apps/backend/app/schemas/user.py b/apps/backend/app/schemas/user.py new file mode 100644 index 0000000..1d81b35 --- /dev/null +++ b/apps/backend/app/schemas/user.py @@ -0,0 +1,46 @@ +from pydantic import BaseModel, EmailStr, constr +from typing import Optional, List +from enum import Enum + +class UserRole(str, Enum): + user = "user" + manager = "manager" + admin = "admin" + +class UserOut(BaseModel): + id: int + name: str + email: EmailStr + alias: Optional[str] = None + paypal_email: Optional[EmailStr] = None + role: UserRole + is_active: bool + balance_cents: int + favorites: List[int] + # NEU: + avatar_url: Optional[str] = None + public_stats: bool + + class Config: + orm_mode = True + +class UserCreate(BaseModel): + name: str + email: EmailStr + password: constr(min_length=8) + pin: constr(min_length=6, max_length=6) + alias: Optional[str] = None + paypal_email: Optional[EmailStr] = None + role: Optional[UserRole] = UserRole.user + +class UserUpdate(BaseModel): + name: Optional[str] = None + email: Optional[EmailStr] = None + password: Optional[constr(min_length=8)] = None + pin: Optional[constr(min_length=6, max_length=6)] = None + alias: Optional[str] = None + paypal_email: Optional[EmailStr] = None + role: Optional[UserRole] = None + is_active: Optional[bool] = None + balance_cents: Optional[int] = None + favorites: Optional[List[int]] = None diff --git a/apps/backend/app/services/sessions.py b/apps/backend/app/services/sessions.py new file mode 100644 index 0000000..191816a --- /dev/null +++ b/apps/backend/app/services/sessions.py @@ -0,0 +1,19 @@ +import secrets +from sqlalchemy.orm import Session as DBSession +from app.models.session import Session as SessionModel + +def create_session(db: DBSession, user_id: int) -> str: + token = secrets.token_hex(32) + session = SessionModel(user_id=user_id, token=token) + db.add(session) + db.commit() + db.refresh(session) + return token + +def get_user_by_token(db: DBSession, token: str): + session = db.query(SessionModel).filter_by(token=token).first() + return session.user if session else None + +def delete_session(db: DBSession, token: str): + db.query(SessionModel).filter_by(token=token).delete() + db.commit() diff --git a/apps/backend/media/avatars/1.gif b/apps/backend/media/avatars/1.gif new file mode 100644 index 0000000..5b47717 Binary files /dev/null and b/apps/backend/media/avatars/1.gif differ diff --git a/apps/backend/media/avatars/1.jpg b/apps/backend/media/avatars/1.jpg new file mode 100644 index 0000000..9087210 Binary files /dev/null and b/apps/backend/media/avatars/1.jpg differ diff --git a/apps/backend/media/avatars/1.png b/apps/backend/media/avatars/1.png new file mode 100644 index 0000000..99e9dc8 Binary files /dev/null and b/apps/backend/media/avatars/1.png differ diff --git a/apps/backend/media/avatars/1.webp b/apps/backend/media/avatars/1.webp new file mode 100644 index 0000000..514406e Binary files /dev/null and b/apps/backend/media/avatars/1.webp differ diff --git a/apps/backend/media/avatars/3.png b/apps/backend/media/avatars/3.png new file mode 100644 index 0000000..141a8c4 Binary files /dev/null and b/apps/backend/media/avatars/3.png differ diff --git a/apps/backend/media/avatars/5.png b/apps/backend/media/avatars/5.png new file mode 100644 index 0000000..f6e09a4 Binary files /dev/null and b/apps/backend/media/avatars/5.png differ diff --git a/apps/backend/media/avatars/7.png b/apps/backend/media/avatars/7.png new file mode 100644 index 0000000..5d8511a Binary files /dev/null and b/apps/backend/media/avatars/7.png differ diff --git a/apps/backend/media/avatars/avatar-default.png b/apps/backend/media/avatars/avatar-default.png new file mode 100644 index 0000000..9cd19d6 Binary files /dev/null and b/apps/backend/media/avatars/avatar-default.png differ diff --git a/apps/backend/migrations/0001_init.sql b/apps/backend/migrations/0001_init.sql deleted file mode 100644 index d273548..0000000 --- a/apps/backend/migrations/0001_init.sql +++ /dev/null @@ -1,63 +0,0 @@ -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE EXTENSION IF NOT EXISTS pgcrypto; - -CREATE TABLE users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email TEXT NOT NULL UNIQUE, - display_name TEXT NOT NULL, - password_hash TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user','admin')), - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE TABLE products ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL, - price_cents INT NOT NULL CHECK (price_cents >= 0), - active BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE TABLE ledger ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - amount_cents INT NOT NULL, - kind TEXT NOT NULL CHECK (kind IN ('topup','purchase','adjustment')), - ref_id UUID, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE TABLE orders ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - total_cents INT NOT NULL CHECK (total_cents >= 0), - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE TABLE order_items ( - order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, - product_id UUID NOT NULL REFERENCES products(id), - qty INT NOT NULL CHECK (qty > 0), - price_cents INT NOT NULL CHECK (price_cents >= 0), - PRIMARY KEY (order_id, product_id) -); - -CREATE TABLE inventory_movements ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - product_id UUID NOT NULL REFERENCES products(id), - qty INT NOT NULL, - reason TEXT NOT NULL CHECK (reason IN ('purchase','consumption','correction')), - note TEXT, - created_by UUID REFERENCES users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE VIEW user_balances AS -SELECT user_id, COALESCE(SUM(amount_cents),0) AS balance_cents -FROM ledger GROUP BY user_id; - -CREATE VIEW product_stock AS -SELECT p.id AS product_id, COALESCE(SUM(m.qty),0) AS stock -FROM products p -LEFT JOIN inventory_movements m ON m.product_id = p.id -GROUP BY p.id; diff --git a/apps/backend/requirements.txt b/apps/backend/requirements.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/backend/requirements.txt @@ -0,0 +1 @@ + diff --git a/apps/backend/setup_admin.py b/apps/backend/setup_admin.py new file mode 100644 index 0000000..8a50d8c --- /dev/null +++ b/apps/backend/setup_admin.py @@ -0,0 +1,81 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Body +from sqlalchemy.orm import Session +from app.schemas.user import UserOut, UserCreate, UserUpdate, UserRole +from app.models.user import User +from app.core.database import SessionLocal +from passlib.context import CryptContext +from typing import List + +router = APIRouter(prefix="/users", tags=["users"]) +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +# — Endpoints — + +@router.get("/", response_model=List[UserOut]) +def list_users(db: Session = Depends(get_db)): + return db.query(User).all() + +@router.get("/{user_id}", response_model=UserOut) +def get_user(user_id: int, db: Session = Depends(get_db)): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + +@router.post("/", response_model=UserOut, status_code=status.HTTP_201_CREATED) +def create_user(user: UserCreate, db: Session = Depends(get_db)): + if db.query(User).filter(User.email == user.email).first(): + raise HTTPException(status_code=409, detail="E-Mail already exists") + if user.alias and db.query(User).filter(User.alias == user.alias).first(): + raise HTTPException(status_code=409, detail="Alias already exists") + hashed_password = pwd_context.hash(user.password) + hashed_pin = pwd_context.hash(user.pin) + db_user = User( + name=user.name, + email=user.email, + hashed_password=hashed_password, + hashed_pin=hashed_pin, + alias=user.alias, + paypal_email=user.paypal_email, + role=user.role, + is_active=True + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + +@router.patch("/{user_id}", response_model=UserOut) +def update_user(user_id: int, user: UserUpdate, db: Session = Depends(get_db)): + db_user = db.query(User).filter(User.id == user_id).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + # ... (bestehende Update-Logik) ... + db.commit() + db.refresh(db_user) + return db_user + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_user(user_id: int, db: Session = Depends(get_db)): + db_user = db.query(User).filter(User.id == user_id).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + db.delete(db_user) + db.commit() + +# — NEU: PIN-Login endpoint — +@router.post("/login/pin", response_model=UserOut) +def login_with_pin(pin: str = Body(...), db: Session = Depends(get_db)): + # alle aktiven User laden und PIN gegen hashed_pin prüfen + users = db.query(User).filter(User.is_active == True).all() + for u in users: + if pwd_context.verify(pin, u.hashed_pin): + return u + raise HTTPException(status_code=401, detail="Invalid PIN") diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs deleted file mode 100644 index 43c1830..0000000 --- a/apps/backend/src/main.rs +++ /dev/null @@ -1,14 +0,0 @@ -use axum::{ - routing::get, - Router, -}; - -#[tokio::main] -async fn main() { - // build our application with a single route - let app = Router::new().route("/", get(|| async { "Hello, World!" })); - - // run our app with hyper, listening globally on port 3000 - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); - axum::serve(listener, app).await.unwrap(); -} \ No newline at end of file diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example deleted file mode 100644 index 5629131..0000000 --- a/apps/frontend/.env.example +++ /dev/null @@ -1 +0,0 @@ -VITE_API_URL=http://localhost:8080 diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json deleted file mode 100644 index 1b5278c..0000000 --- a/apps/frontend/package-lock.json +++ /dev/null @@ -1,2168 +0,0 @@ -{ - "name": "bacchus-ui", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "bacchus-ui", - "version": "0.1.0", - "dependencies": { - "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-icons": "^5.5.0", - "react-router-dom": "^7.8.2", - "recharts": "^3.1.2" - }, - "devDependencies": { - "@vitejs/plugin-react": "^5.0", - "autoprefixer": "^10.4.21", - "postcss": "^8.5.6", - "tailwindcss": "^4.1.12", - "vite": "^7.0.4" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", - "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", - "dev": true, - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.3", - "@babel/parser": "^7.28.3", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.3", - "@babel/types": "^7.28.2", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "engines": { - "node": ">=6.9.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==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", - "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", - "dev": true, - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", - "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", - "dev": true, - "dependencies": { - "@babel/types": "^7.28.2" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", - "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.3", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", - "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", - "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", - "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", - "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", - "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", - "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", - "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", - "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", - "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", - "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", - "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", - "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", - "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", - "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", - "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", - "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", - "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", - "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", - "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", - "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", - "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", - "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", - "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", - "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", - "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", - "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "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==", - "dev": true - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.30", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", - "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@reduxjs/toolkit": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz", - "integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^10.0.3", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.34", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz", - "integrity": "sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA==", - "dev": true - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.49.0.tgz", - "integrity": "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.49.0.tgz", - "integrity": "sha512-cqPpZdKUSQYRtLLr6R4X3sD4jCBO1zUmeo3qrWBCqYIeH8Q3KRL4F3V7XJ2Rm8/RJOQBZuqzQGWPjjvFUcYa/w==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.49.0.tgz", - "integrity": "sha512-99kMMSMQT7got6iYX3yyIiJfFndpojBmkHfTc1rIje8VbjhmqBXE+nb7ZZP3A5skLyujvT0eIUCUsxAe6NjWbw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.49.0.tgz", - "integrity": "sha512-y8cXoD3wdWUDpjOLMKLx6l+NFz3NlkWKcBCBfttUn+VGSfgsQ5o/yDUGtzE9HvsodkP0+16N0P4Ty1VuhtRUGg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.49.0.tgz", - "integrity": "sha512-3mY5Pr7qv4GS4ZvWoSP8zha8YoiqrU+e0ViPvB549jvliBbdNLrg2ywPGkgLC3cmvN8ya3za+Q2xVyT6z+vZqA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.49.0.tgz", - "integrity": "sha512-C9KzzOAQU5gU4kG8DTk+tjdKjpWhVWd5uVkinCwwFub2m7cDYLOdtXoMrExfeBmeRy9kBQMkiyJ+HULyF1yj9w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.49.0.tgz", - "integrity": "sha512-OVSQgEZDVLnTbMq5NBs6xkmz3AADByCWI4RdKSFNlDsYXdFtlxS59J+w+LippJe8KcmeSSM3ba+GlsM9+WwC1w==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.49.0.tgz", - "integrity": "sha512-ZnfSFA7fDUHNa4P3VwAcfaBLakCbYaxCk0jUnS3dTou9P95kwoOLAMlT3WmEJDBCSrOEFFV0Y1HXiwfLYJuLlA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.49.0.tgz", - "integrity": "sha512-Z81u+gfrobVK2iV7GqZCBfEB1y6+I61AH466lNK+xy1jfqFLiQ9Qv716WUM5fxFrYxwC7ziVdZRU9qvGHkYIJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.49.0.tgz", - "integrity": "sha512-zoAwS0KCXSnTp9NH/h9aamBAIve0DXeYpll85shf9NJ0URjSTzzS+Z9evmolN+ICfD3v8skKUPyk2PO0uGdFqg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.49.0.tgz", - "integrity": "sha512-2QyUyQQ1ZtwZGiq0nvODL+vLJBtciItC3/5cYN8ncDQcv5avrt2MbKt1XU/vFAJlLta5KujqyHdYtdag4YEjYQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.49.0.tgz", - "integrity": "sha512-k9aEmOWt+mrMuD3skjVJSSxHckJp+SiFzFG+v8JLXbc/xi9hv2icSkR3U7uQzqy+/QbbYY7iNB9eDTwrELo14g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.49.0.tgz", - "integrity": "sha512-rDKRFFIWJ/zJn6uk2IdYLc09Z7zkE5IFIOWqpuU0o6ZpHcdniAyWkwSUWE/Z25N/wNDmFHHMzin84qW7Wzkjsw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.49.0.tgz", - "integrity": "sha512-FkkhIY/hYFVnOzz1WeV3S9Bd1h0hda/gRqvZCMpHWDHdiIHn6pqsY3b5eSbvGccWHMQ1uUzgZTKS4oGpykf8Tw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.49.0.tgz", - "integrity": "sha512-gRf5c+A7QiOG3UwLyOOtyJMD31JJhMjBvpfhAitPAoqZFcOeK3Kc1Veg1z/trmt+2P6F/biT02fU19GGTS529A==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.49.0.tgz", - "integrity": "sha512-BR7+blScdLW1h/2hB/2oXM+dhTmpW3rQt1DeSiCP9mc2NMMkqVgjIN3DDsNpKmezffGC9R8XKVOLmBkRUcK/sA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.49.0.tgz", - "integrity": "sha512-hDMOAe+6nX3V5ei1I7Au3wcr9h3ktKzDvF2ne5ovX8RZiAHEtX1A5SNNk4zt1Qt77CmnbqT+upb/umzoPMWiPg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.49.0.tgz", - "integrity": "sha512-wkNRzfiIGaElC9kXUT+HLx17z7D0jl+9tGYRKwd8r7cUqTL7GYAvgUY++U2hK6Ar7z5Z6IRRoWC8kQxpmM7TDA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.49.0.tgz", - "integrity": "sha512-gq5aW/SyNpjp71AAzroH37DtINDcX1Qw2iv9Chyz49ZgdOP3NV8QCyKZUrGsYX9Yyggj5soFiRCgsL3HwD8TdA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.49.0.tgz", - "integrity": "sha512-gEtqFbzmZLFk2xKh7g0Rlo8xzho8KrEFEkzvHbfUGkrgXOpZ4XagQ6n+wIZFNh1nTb8UD16J4nFSFKXYgnbdBg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/d3-array": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", - "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, - "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==", - "dev": true - }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT" - }, - "node_modules/@vitejs/plugin-react": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.2.tgz", - "integrity": "sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw==", - "dev": true, - "dependencies": { - "@babel/core": "^7.28.3", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.34", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/autoprefixer": { - "version": "10.4.21", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", - "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "browserslist": "^4.24.4", - "caniuse-lite": "^1.0.30001702", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/browserslist": { - "version": "4.25.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz", - "integrity": "sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001735", - "electron-to-chromium": "^1.5.204", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001737", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz", - "integrity": "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.211", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.211.tgz", - "integrity": "sha512-IGBvimJkotaLzFnwIVgW9/UD/AOJ2tByUmeOrtqBfACSbAw5b1G0XpvdaieKyc7ULmbwXVx+4e4Be8pOPBrYkw==", - "dev": true - }, - "node_modules/es-toolkit": { - "version": "1.39.10", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz", - "integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, - "node_modules/esbuild": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", - "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.9", - "@esbuild/android-arm": "0.25.9", - "@esbuild/android-arm64": "0.25.9", - "@esbuild/android-x64": "0.25.9", - "@esbuild/darwin-arm64": "0.25.9", - "@esbuild/darwin-x64": "0.25.9", - "@esbuild/freebsd-arm64": "0.25.9", - "@esbuild/freebsd-x64": "0.25.9", - "@esbuild/linux-arm": "0.25.9", - "@esbuild/linux-arm64": "0.25.9", - "@esbuild/linux-ia32": "0.25.9", - "@esbuild/linux-loong64": "0.25.9", - "@esbuild/linux-mips64el": "0.25.9", - "@esbuild/linux-ppc64": "0.25.9", - "@esbuild/linux-riscv64": "0.25.9", - "@esbuild/linux-s390x": "0.25.9", - "@esbuild/linux-x64": "0.25.9", - "@esbuild/netbsd-arm64": "0.25.9", - "@esbuild/netbsd-x64": "0.25.9", - "@esbuild/openbsd-arm64": "0.25.9", - "@esbuild/openbsd-x64": "0.25.9", - "@esbuild/openharmony-arm64": "0.25.9", - "@esbuild/sunos-x64": "0.25.9", - "@esbuild/win32-arm64": "0.25.9", - "@esbuild/win32-ia32": "0.25.9", - "@esbuild/win32-x64": "0.25.9" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "license": "MIT" - }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/immer": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", - "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, - "optional": true, - "peer": true, - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "dev": true - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "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" - } - ], - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true - }, - "node_modules/react": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", - "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", - "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.26.0" - }, - "peerDependencies": { - "react": "^19.1.1" - } - }, - "node_modules/react-icons": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", - "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", - "peerDependencies": { - "react": "*" - } - }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "peer": true - }, - "node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "license": "MIT", - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-router": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.2.tgz", - "integrity": "sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==", - "license": "MIT", - "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/react-router-dom": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.2.tgz", - "integrity": "sha512-Z4VM5mKDipal2jQ385H6UBhiiEDlnJPx6jyWsTYoZQdl5TrjxEV2a9yl3Fi60NBJxYzOTGTTHXPi0pdizvTwow==", - "license": "MIT", - "dependencies": { - "react-router": "7.8.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/recharts": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.1.2.tgz", - "integrity": "sha512-vhNbYwaxNbk/IATK0Ki29k3qvTkGqwvCgyQAQ9MavvvBwjvKnMTswdbklJpcOAoMPN/qxF3Lyqob0zO+ZXkZ4g==", - "license": "MIT", - "dependencies": { - "@reduxjs/toolkit": "1.x.x || 2.x.x", - "clsx": "^2.1.1", - "decimal.js-light": "^2.5.1", - "es-toolkit": "^1.39.3", - "eventemitter3": "^5.0.1", - "immer": "^10.1.1", - "react-redux": "8.x.x || 9.x.x", - "reselect": "5.1.1", - "tiny-invariant": "^1.3.3", - "use-sync-external-store": "^1.2.2", - "victory-vendor": "^37.0.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, - "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "license": "MIT" - }, - "node_modules/rollup": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.49.0.tgz", - "integrity": "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA==", - "dev": true, - "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.49.0", - "@rollup/rollup-android-arm64": "4.49.0", - "@rollup/rollup-darwin-arm64": "4.49.0", - "@rollup/rollup-darwin-x64": "4.49.0", - "@rollup/rollup-freebsd-arm64": "4.49.0", - "@rollup/rollup-freebsd-x64": "4.49.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", - "@rollup/rollup-linux-arm-musleabihf": "4.49.0", - "@rollup/rollup-linux-arm64-gnu": "4.49.0", - "@rollup/rollup-linux-arm64-musl": "4.49.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", - "@rollup/rollup-linux-ppc64-gnu": "4.49.0", - "@rollup/rollup-linux-riscv64-gnu": "4.49.0", - "@rollup/rollup-linux-riscv64-musl": "4.49.0", - "@rollup/rollup-linux-s390x-gnu": "4.49.0", - "@rollup/rollup-linux-x64-gnu": "4.49.0", - "@rollup/rollup-linux-x64-musl": "4.49.0", - "@rollup/rollup-win32-arm64-msvc": "4.49.0", - "@rollup/rollup-win32-ia32-msvc": "4.49.0", - "@rollup/rollup-win32-x64-msvc": "4.49.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", - "license": "MIT" - }, - "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==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tailwindcss": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", - "integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" - }, - "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", - "dev": true, - "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/victory-vendor": { - "version": "37.3.6", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", - "license": "MIT AND ISC", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, - "node_modules/vite": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz", - "integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==", - "dev": true, - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.14" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "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/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true - }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "dev": true, - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - } - } -} diff --git a/apps/frontend/package.json b/apps/frontend/package.json index c15e61d..39310c4 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -1,6 +1,6 @@ { - "name": "bacchus-ui", - "version": "0.1.0", + "name": "bacchus-frontend", + "version": "1.0.0", "type": "module", "scripts": { "dev": "vite", @@ -8,17 +8,17 @@ "preview": "vite preview" }, "dependencies": { - "react": "^19.1.1", - "react-dom": "^19.1.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", "react-icons": "^5.5.0", - "react-router-dom": "^7.8.2", - "recharts": "^3.1.2" + "react-router-dom": "^6.23.0", + "recharts": "^2.15.4" }, "devDependencies": { - "@vitejs/plugin-react": "^5.0", + "@vitejs/plugin-react": "^4.0.0", "autoprefixer": "^10.4.21", "postcss": "^8.5.6", - "tailwindcss": "^4.1.12", + "tailwindcss": "^3.4.3", "vite": "^7.0.4" } } diff --git a/apps/frontend/src/api.js b/apps/frontend/src/api.js index cc6b249..76e4610 100644 --- a/apps/frontend/src/api.js +++ b/apps/frontend/src/api.js @@ -1,9 +1,8 @@ +// api.js export const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8000"; /* ===================== Cookies & CSRF ===================== */ -const API = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; - function getCookie(name) { const re = new RegExp( "(?:^|; )" + @@ -14,7 +13,6 @@ function getCookie(name) { return m ? decodeURIComponent(m[1]) : null; } -// Holt CSRF async function getCsrfMaybe() { const fromCookie = getCookie("bacchus_csrf"); if (fromCookie) return fromCookie; @@ -29,7 +27,6 @@ async function getCsrfMaybe() { return token; } -// export async function getCsrfToken() { return getCsrfMaybe(); } @@ -42,11 +39,9 @@ export async function request(path, options = {}) { const headers = { Accept: "application/json", - ...(options.body !== undefined ? { "Content-Type": "application/json" } : {}), + ...(options.body !== undefined && !(options.body instanceof FormData) ? { "Content-Type": "application/json" } : {}), ...(options.headers || {}), }; - - headers["X-CSRF-Token"] = await getCsrfMaybe(); const res = await fetch(url, { @@ -61,11 +56,7 @@ export async function request(path, options = {}) { const text = await res.text().catch(() => ""); let data = null; if (text) { - try { - data = JSON.parse(text); - } catch { - /* Hi */ - } + try { data = JSON.parse(text); } catch { /* non-JSON */ } } if (!res.ok) { @@ -79,17 +70,17 @@ export async function request(path, options = {}) { } export async function getJson(path, params) { - const qs = - params && typeof params === "object" - ? "?" + - Object.entries(params) - .filter(([, v]) => v !== undefined && v !== null) - .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) - .join("&") - : ""; + let qs = ""; + if (params && typeof params === "object") { + const pairs = Object.entries(params) + .filter(([, v]) => v !== undefined && v !== null && v !== "") + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`); + if (pairs.length) qs = "?" + pairs.join("&"); // <— nur wenn wirklich Paare existieren + } return request(`${path}${qs}`, { method: "GET" }); } + /* ===================== Auth / Session ===================== */ export function loginPin(pin) { @@ -125,11 +116,10 @@ export function getStatsSummary() { return request("/stats/summary"); } -/* ===================== Users & Favorites ===================== */ +/* ===================== Users ===================== */ -// Parametrische Liste (Suche/Filter/Sort/Paging) export function listUsers(params = {}) { - return getJson("/users/", params); // trailing slash beibehalten + return getJson("/users/", params); } export function getUsers() { @@ -158,7 +148,6 @@ export function deleteUser(userId) { return request(`/users/${userId}`, { method: "DELETE" }); } -// Sicherheitsaktionen (Admin/Manager) export function setUserPin(userId, pin) { return request(`/users/${userId}/set-pin`, { method: "POST", @@ -200,6 +189,26 @@ export function replaceFavorites(userId, favorites) { }); } +// für logs/admintransactionen +function pickName(u) { + const full = [u.first_name, u.last_name].filter(Boolean).join(" ").trim(); + return u.display_name || u.name || (full || null) || u.username || u.email || `User ${u.id}`; +} + +// NEU: kein Versuch mehr, /users/lite aufzurufen → keine 422 mehr in der Konsole +export async function getUsersLite({ limit = 200, offset = 0 } = {}) { + const data = await getJson("/users/", { limit, offset }); // nur dieser Endpoint + const arr = Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : []; + return arr.map(u => ({ id: u.id, name: pickName(u) })); +} + + +export async function getUsersMap(opts) { + const list = await getUsersLite(opts); + const map = {}; + for (const u of list) map[u.id] = u.name; + return map; +} /* ===================== Profile / Avatar ===================== */ export async function uploadAvatar(file) { @@ -210,7 +219,7 @@ export async function uploadAvatar(file) { method: "POST", body: form, credentials: "include", - headers: { "X-CSRF-Token": await getCsrfMaybe() }, // multipart: kein Content-Type setzen + headers: { "X-CSRF-Token": await getCsrfToken() }, }); if (!res.ok) throw new Error(`Upload fehlgeschlagen (${res.status})`); return res.json(); @@ -218,7 +227,7 @@ export async function uploadAvatar(file) { export function updateOwnProfile(patch) { return request("/profile/me", { - method: "PUT", + method: "PUT", body: JSON.stringify(patch), }); } @@ -273,11 +282,10 @@ export function getMyBookings({ limit = 100, offset = 0 } = {}) { } export function listBookings({ user_id, limit = 10, offset = 0 } = {}) { - return getJson("/bookings/", { user_id, limit, offset }); + return getJson("/bookings/", { user_id, limit, offset }); } - -/* ===================== Deliveries (Manager/Admin) ===================== */ +/* ===================== Deliveries ===================== */ export function getDeliveries() { return request("/deliveries/"); @@ -292,6 +300,56 @@ export function deleteDelivery(id) { return request(`/deliveries/${id}`, { method: "DELETE" }); } +// --- utils (numeric) --- +function toInt(x) { + return x == null || x === "" ? 0 : parseInt(x, 10) || 0; +} + +export async function createDeliveryBulk(payload, products = []) { + // payload: { supplier, date, invoice_no, note, deposit_return_cents, items:[{product_id, quantity_units, unit_cost_cents}] } + + const withUnits = { + ...payload, // ← richtig, nicht ".payload" + items: (payload.items || []).map(it => { + const ps = (products.find(p => p.id === it.product_id)?.pack_size) ?? 1; + return { + ...it, // ← richtig, nicht ".it" + quantity_units: toInt(it.quantity_units), + unit_cost_cents: toInt(it.unit_cost_cents), + units: toInt(it.quantity_units) * ps, + }; + }), + }; + + // 1) Versuche echten Bulk-Endpoint + try { + return await request("/deliveries/bulk", { + method: "POST", + body: JSON.stringify(withUnits), + }); + } catch (e) { + if ([404, 405, 501].includes(e?.status)) { + throw new Error("Server unterstützt /deliveries/bulk nicht – bitte Backend aktualisieren."); + } + throw e; + } +} + + +// PDF-Import → Draft +export async function importDeliveryInvoice(file) { + const form = new FormData(); + form.append("file", file); + const res = await fetch(`${API_BASE}/deliveries/invoice/import`, { + method: "POST", + body: form, + credentials: "include", + headers: { "X-CSRF-Token": await getCsrfToken() }, // multipart: KEIN Content-Type setzen + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + /* ===================== Topups (Manager/Admin) ===================== */ export function patchTopupStatus(topupId, status) { @@ -305,6 +363,27 @@ export function getMyTopups({ limit = 100, offset = 0 } = {}) { return getJson("/topups/me", { limit, offset }); } +export async function listTopupsAdmin({ limit = 200, offset = 0, status_filter, user_id } = {}) { + const params = { limit, offset }; + if (status_filter) params.status_filter = status_filter; + if (user_id) params.user_id = user_id; + return getJson("/topups", params); +} + +export async function createTopupAdmin({ user_id, amount_cents, note = "" }) { + return request("/topups", { + method: "POST", + body: JSON.stringify({ user_id, amount_cents, note }), + }); +} + +export async function updateTopupStatus(id, status) { + return request(`/topups/${id}/status`, { + method: "PATCH", + body: JSON.stringify({ status }), + }); +} + /* ===================== Stats ===================== */ export function getConsumptionPerUser() { @@ -319,12 +398,22 @@ export function getMonthlyRanking({ year, month, limit = 10 } = {}) { return getJson("/stats/monthly-ranking", { year, month, limit }); } +export const getStatsMeta = () => request("/stats/meta"); + +export function getTopDrinkers({ period = "last_delivery", category = "all", limit = 5, tz = "Europe/Berlin" } = {}) { + return getJson("/stats/top-drinkers", { period, category, limit, tz }); +} + +export function getProductShare({ period = "last_delivery", tz = "Europe/Berlin" } = {}) { + return getJson("/stats/product-share", { period, tz }); +} + /* ===================== Audit / Transactions ===================== */ // Audit-Logs (Admin) export function getAuditLogs({ limit = 100, offset = 0, user_id, action, q, date_from, date_to } = {}) { - return getJson("/audit-logs/", { limit, offset, user_id, action, q, date_from, date_to }); - } + return getJson("/audit-logs/", { limit, offset, user_id, action, q, date_from, date_to }); +} // Eigene Transaktionen export function getMyTransactions({ limit = 100, offset = 0 } = {}) { @@ -332,26 +421,22 @@ export function getMyTransactions({ limit = 100, offset = 0 } = {}) { } // Admin: alle Transaktionen -export function getTransactionsAdmin({ limit = 100, offset = 0 } = {}) { - return getJson("/transactions", { limit, offset }); +export function getTransactionsAdmin({ limit = 100, offset = 0, user_id, type, date_from, date_to } = {}) { + return getJson("/transactions", { limit, offset, user_id, type, date_from, date_to }); } /* ===================== Categories (Manager/Admin) ===================== */ -// Liste aller Kategorien (Array) export function getCategories() { - return request("/categories/"); // trailing slash wie bei /products/ + return request("/categories/"); } -// Kategorie umbenennen: alle Produkte mit old_name -> new_name export function renameCategory(oldName, newName) { if (!oldName || !newName) throw new Error("oldName und newName sind erforderlich"); const qs = `?old_name=${encodeURIComponent(oldName)}&new_name=${encodeURIComponent(newName)}`; return request(`/categories/rename${qs}`, { method: "PUT" }); } -// Kategorie löschen, optional umhängen (reassign_to) -// Wenn reassignTo null/undefined ist, wird category auf NULL gesetzt export function deleteCategory(name, reassignTo = null) { if (!name) throw new Error("name ist erforderlich"); const params = new URLSearchParams({ name }); @@ -359,7 +444,7 @@ export function deleteCategory(name, reassignTo = null) { return request(`/categories/?${params.toString()}`, { method: "DELETE" }); } -/* ================== Transaktionen Tracker ==============*/ +/* ===================== Ledger / Topups (Self) ===================== */ export function getLedgerMe({ limit = 100, offset = 0, types = "topup,booking" } = {}) { return getJson("/ledger/me", { limit, offset, types }); @@ -374,84 +459,21 @@ export function createTopup(amount_cents, note = null) { }); } -// ===================== NEU: Stats (öffentlich, aber auth-pflichtig) ===================== -export const getStatsMeta = () => request("/stats/meta"); - -export function getTopDrinkers({ period = "last_delivery", category = "all", limit = 5, tz = "Europe/Berlin" } = {}) { - return getJson("/stats/top-drinkers", { period, category, limit, tz }); -} - -export function getProductShare({ period = "last_delivery", tz = "Europe/Berlin" } = {}) { - return getJson("/stats/product-share", { period, tz }); -} - - - -let CSRF = { token: null, header: "X-CSRF-Token", fetchedAt: 0 }; - -async function ensureCsrf() { - if (CSRF.token && Date.now() - CSRF.fetchedAt < 5 * 60 * 1000) return CSRF; - const res = await fetch(API + "/auth/csrf", { credentials: "include" }); - let data = {}; - try { data = await res.json(); } catch {} - CSRF.token = - data?.token || data?.csrf || data?.csrf_token || data?.value || null; - CSRF.header = - data?.header_name || data?.header || CSRF.header; // Server kann den Headernamen mitliefern - CSRF.fetchedAt = Date.now(); - if (!CSRF.token) throw new Error("CSRF token missing from /auth/csrf"); - return CSRF; -} - -async function req(path, opts = {}) { - const method = (opts.method || "GET").toUpperCase(); - const isMutating = method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE"; - - const headers = { "Content-Type": "application/json", ...(opts.headers || {}) }; - - if (isMutating) { - const { token, header } = await ensureCsrf(); // <- WICHTIG - headers[header] = token; - } - - const res = await fetch(API + path, { - credentials: "include", - headers, - ...opts, - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(text || res.statusText); - } - return res.status === 204 ? null : res.json(); -} - -export async function listTopupsAdmin({ limit = 200, offset = 0, status_filter, user_id } = {}) { - const qs = new URLSearchParams({ limit, offset }); - if (status_filter) qs.set("status_filter", status_filter); - if (user_id) qs.set("user_id", user_id); - return req(`/topups?${qs.toString()}`); -} - - - -export async function createTopupAdmin({ user_id, amount_cents, note = "" }) { - return req(`/topups`, { +export function addLedgerEntry(amount_cents, note = "") { + return request("/ledger", { method: "POST", - body: JSON.stringify({ user_id, amount_cents, note }), + body: JSON.stringify({ amount_cents, note }), }); } -export async function updateTopupStatus(id, status) { - return req(`/topups/${id}/status`, { - method: "PATCH", - body: JSON.stringify({ status }), +/* ===================== Admin Settings (PayPal) ===================== */ +export function getPaypalSettings() { + return request("/admin/settings/paypal"); +} + +export function updatePaypalSettings({ paypal_me = "", paypal_receiver = "" } = {}) { + return request("/admin/settings/paypal", { + method: "PUT", + body: JSON.stringify({ paypal_me, paypal_receiver }), }); } - - -export async function getUsersLite({ limit = 200, offset = 0 } = {}) { - const lim = Math.min(Number(limit) || 200, 200); - return req(`/users?limit=${lim}&offset=${offset}`); -} diff --git a/apps/frontend/src/components/Order.jsx b/apps/frontend/src/components/Order.jsx index 4b5fe70..4eb485e 100644 --- a/apps/frontend/src/components/Order.jsx +++ b/apps/frontend/src/components/Order.jsx @@ -19,6 +19,18 @@ function applyOrderColorsFromStorage() { document.documentElement.style.setProperty("--order-bg2", c2); } +// --- NEU: lokale Bestände anhand des Warenkorbs reduzieren +function decrementStockLocal(prevProducts, cart) { + const delta = {}; + for (const { product, quantity } of Object.values(cart)) { + delta[product.id] = (delta[product.id] || 0) + quantity; + } + return prevProducts.map((p) => + delta[p.id] ? { ...p, stock: (Number(p.stock) || 0) - delta[p.id] } : p + ); +} + + const PAGE_SIZE = 20; @@ -53,6 +65,11 @@ function chipStyleFor(cat, active) { return active ? strong(h) : soft(h); } +function ringColorForCategory(cat) { + if (!cat) return "#22c55e"; + // nutzt deine bestehende Farblogik der Chips: + return chipStyleFor(cat, true).br; // starke Border-Farbe der Kategorie +} // robustes Aktiv-Flag (API kann bool/zahl/string liefern) function isActiveProduct(p) { const v = p?.is_active; @@ -236,11 +253,15 @@ const avatarSrc = useMemo(() => { product.price_cents * quantity ); } + setProducts((prev) => decrementStockLocal(prev, cart)); + try { const refreshed = await getCurrentUser(); if (refreshed) setUser(refreshed); } catch {} + setCart({}); + try { await logout(); } finally { navigate("/", { replace: true }); } } catch (e) { setError(e?.message || "Bezahlen fehlgeschlagen."); @@ -318,6 +339,7 @@ const avatarSrc = useMemo(() => { isFavorite={favorites.has(p.id)} onToggleFavorite={handleToggleFavorite} count={cart[p.id]?.quantity || 0} + ringColor={ringColorForCategory(p.category)} /> ))} diff --git a/apps/frontend/src/components/ProductCard.jsx b/apps/frontend/src/components/ProductCard.jsx index 273bd31..08a6e68 100644 --- a/apps/frontend/src/components/ProductCard.jsx +++ b/apps/frontend/src/components/ProductCard.jsx @@ -6,7 +6,8 @@ export default function ProductCard({ onSelect, isFavorite, onToggleFavorite, - count = 0, // Menge im Warenkorb + count = 0, + ringColor = "#22c55e", // Fallback }) { const baseName = (product?.name || '').replace(/\s+/g, '').toLowerCase(); @@ -62,13 +63,16 @@ export default function ProductCard({ }; return ( -
onSelect(product)} - onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onSelect(product)} - > + onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && onSelect(product)} + > diff --git a/apps/frontend/src/management/pages/AdminTransactionsPage.jsx b/apps/frontend/src/management/pages/AdminTransactionsPage.jsx index cac6dc6..9d98d28 100644 --- a/apps/frontend/src/management/pages/AdminTransactionsPage.jsx +++ b/apps/frontend/src/management/pages/AdminTransactionsPage.jsx @@ -1,11 +1,20 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; -import { listTopupsAdmin, patchTopupStatus, getUsersLite } from "../../api"; +import { listTopupsAdmin, patchTopupStatus, getUsersLite, getPaypalSettings, updatePaypalSettings, createTopupAdmin } from "../../api"; const euro = (c) => (c ?? 0) / 100; const fmt = new Intl.NumberFormat("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const fmtDT = new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "medium", timeZone: "Europe/Berlin" }); const SHOW_CREATE = false; +const toText = (v) => { + if (v == null) return "—"; + if (typeof v === "object") { + try { return JSON.stringify(v); } catch { return "[obj]"; } + } + return String(v); +}; + + const STATUS_LABELS_DE = { pending: "ausstehend", confirmed: "bestätigt", @@ -27,7 +36,6 @@ function Pill({ status }) { } - /** Kleiner Avatar (Initialen) */ function Initials({ label }) { const txt = String(label || "") @@ -211,6 +219,26 @@ function StatusSelect({ value, onChange }) { export default function AdminTransactionsPage() { + // PayPal-Settings (jetzt legal innerhalb der Component) + const [ppOpen, setPpOpen] = useState(false); + const [ppMe, setPpMe] = useState(""); + const [ppReceiver, setPpReceiver] = useState(""); + const [ppSaving, setPpSaving] = useState(false); + useEffect(() => { + (async () => { + try { + const cfg = await getPaypalSettings(); + setPpMe(cfg?.paypal_me || ""); + setPpReceiver(cfg?.paypal_receiver || ""); + } catch {} + })(); + }, []); + async function savePaypal() { + setPpSaving(true); + try { await updatePaypalSettings({ paypal_me: ppMe, paypal_receiver: ppReceiver }); } + finally { setPpSaving(false); } + } + // Daten const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); @@ -351,6 +379,40 @@ const [statusFilter, setStatusFilter] = useState(""); // "", "pending", "confirm
)} + + {/* PayPal-Einstellungen */} +
+ + {ppOpen && ( +
+ + +
+ +
+
+ )} +
+ + {/* Tabelle */}
@@ -402,7 +464,7 @@ const [statusFilter, setStatusFilter] = useState(""); // "", "pending", "confirm {rows.map(r => { const u = usersById.get(r.user_id); const canAct = String(r.status) === "pending"; - const ts = r.created_at ? new Date(r.created_at) : null; + const ts = r.created_at ? new Date(String(r.created_at).replace(" ", "T")) : null; return ( {r.id} @@ -410,7 +472,7 @@ const [statusFilter, setStatusFilter] = useState(""); // "", "pending", "confirm {u?.name || u?.alias || u?.email || r.user_id} {fmt.format(euro(r.amount_cents))} - {r.note || "—"} + {toText(r.note)}
+ + + {/* Leer */} + +
+ Hier leere Produkte auflisten +
+
+ + +
+
+
); } diff --git a/apps/frontend/src/management/pages/DeliveriesPage.jsx b/apps/frontend/src/management/pages/DeliveriesPage.jsx index 4b7395c..1b8f753 100644 --- a/apps/frontend/src/management/pages/DeliveriesPage.jsx +++ b/apps/frontend/src/management/pages/DeliveriesPage.jsx @@ -1,57 +1,1479 @@ -import React, { useEffect, useState } from "react"; -import { getDeliveries } from "../../api"; +import React, { useEffect, useMemo, useState, useRef } from "react"; +import { + FiPlus, + FiTrash2, + FiEdit2, + FiEye, + FiPackage, + FiCheckCircle, + FiUploadCloud, + FiDollarSign, + FiX, + FiSearch, + FiCalendar, +} from "react-icons/fi"; +import { getProducts, getDeliveries, createDeliveryBulk, importDeliveryInvoice, addLedgerEntry, getLedgerMe } from "../../api"; +import { createPortal } from "react-dom"; -export default function DeliveriesPage() { - const [rows, setRows] = useState([]); - const [err, setErr] = useState(null); - const [loading, setLoading] = useState(true); +// ====== Format-/Rechen-Utils =============================== - useEffect(() => { - let alive = true; - (async () => { - try { - const data = await getDeliveries(); - if (!alive) return; - setRows(Array.isArray(data) ? data : []); - } catch (e) { - if (alive) setErr(e); - } finally { - if (alive) setLoading(false); - } - })(); - return () => { alive = false; }; + +const Th = ({ children, className = "" }) => {children}; +const Td = ({ children, className = "" }) => {children}; +const Label= ({ children }) =>
{children}
; + +const inputCls = () => + "w-full px-3 py-2 rounded-xl border bg-white/10 text-gray-100 " + + "border-gray-600 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400/40"; +const btnPrimary = (extra = "") => + `inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-white/10 hover:bg-white/15 border border-white/10 text-white/90 ${extra}`; +const btnSecondary = (extra = "") => + `inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-600/20 hover:bg-emerald-600/30 border border-emerald-600/30 text-emerald-200 ${extra}`; +const btnGhost = (extra = "") => + `inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-white/70 ${extra}`; +const disabledCls = (cond) => (cond ? "opacity-50 cursor-not-allowed" : ""); + + +function euro(cents) { + const v = (toInt(cents) / 100).toFixed(2); + return `${v.replace(".", ",")} €`; +} +function euroInput(cents) { + const v = (toInt(cents) / 100).toFixed(2); + return v.replace(".", ","); +} +function centsFromInput(s) { + if (typeof s !== "string") return toInt(s); + const t = s.replace(/\s/g, "").replace(/,/g, "."); + if (t === "") return 0; + const n = Number(t); + if (!isFinite(n)) return 0; + return Math.round(n * 100); +} +function toInt(x) { return x == null || x === "" ? 0 : parseInt(x, 10) || 0; } +function todayISO() { return new Date().toISOString().slice(0, 10); } +function nextId(arr) { return arr.reduce((m, e) => Math.max(m, e.id || 0), 0) + 1; } + +// ================================================================================================= + +// MwSt 19% +const VAT_NUM = 119; +const VAT_DEN = 100; +const APPLY_VAT_ON_DEPOSIT = true; // falls Pfand ohne MwSt: auf false setzen +function gross119(cents) { return Math.round(toInt(cents) * VAT_NUM / VAT_DEN); } +function netFromGross119(centsGross) { return Math.round(toInt(centsGross) * VAT_DEN / VAT_NUM); } + + +function ProductSelect({ + products = [], + value, + onChange, + placeholder = "— Produkt wählen —", + disabled = false, +}) { + const [open, setOpen] = React.useState(false); + const [q, setQ] = React.useState(""); + const [hi, setHi] = React.useState(0); + const [pos, setPos] = React.useState({ top: 0, left: 0, width: 0 }); + const btnRef = React.useRef(null); + const popRef = React.useRef(null); + + const fmt = new Intl.NumberFormat("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + + const options = React.useMemo(() => { + const needle = q.trim().toLowerCase(); + const list = Array.isArray(products) ? products : []; + if (!needle) return list; + return list.filter(p => { + const s = `${p.name || ""} ${p.category || ""} ${p.supplier_number || ""}`.toLowerCase(); + return s.includes(needle); + }); + }, [products, q]); + + const selected = React.useMemo( + () => (products || []).find(p => p.id === value), + [products, value] + ); + + React.useEffect(() => { + function onDoc(e) { + if (!open) return; + if (popRef.current?.contains(e.target)) return; + if (btnRef.current?.contains(e.target)) return; + setOpen(false); + } + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [open]); + + // Position des Popovers relativ zum Button berechnen + const measure = React.useCallback(() => { + if (!btnRef.current) return; + const r = btnRef.current.getBoundingClientRect(); + const vw = window.innerWidth; + const left = Math.min(Math.max(8, r.left), Math.max(8, vw - r.width - 8)); + setPos({ top: r.bottom + 8, left, width: r.width }); }, []); + React.useLayoutEffect(() => { + if (open) measure(); + }, [open, measure]); + React.useEffect(() => { + if (!open) return; + const onResize = () => measure(); + const onScroll = () => measure(); + window.addEventListener("resize", onResize); + window.addEventListener("scroll", onScroll, true); + return () => { + window.removeEventListener("resize", onResize); + window.removeEventListener("scroll", onScroll, true); + }; + }, [open, measure]); - if (loading) return
Lade Lieferungen…
; - if (err) return
Fehler: {String(err.message || err)}
; + function onKey(e) { + if (!open) return; + if (e.key === "ArrowDown") { + e.preventDefault(); setHi(i => Math.min(i + 1, Math.max(0, options.length - 1))); + } else if (e.key === "ArrowUp") { + e.preventDefault(); setHi(i => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + const pick = options[hi]; + if (pick) { onChange?.(pick.id); setOpen(false); } + } else if (e.key === "Escape") { + e.preventDefault(); setOpen(false); + } + } return ( -
-

Lieferungen

-
- - +
+ + + {open && createPortal( +
+
+ { setQ(e.target.value); setHi(0); }} + placeholder="Produkt suchen…" + className="w-full mb-2 bg-black/40 text-white rounded-lg border border-white/20 px-3 py-2" + /> + +
    + {options.map((p, idx) => ( +
  • setHi(idx)} + onMouseDown={(e) => { e.preventDefault(); onChange?.(p.id); setOpen(false); }} + className={`px-3 py-2 rounded-lg cursor-pointer grid grid-cols-[1fr_auto] gap-2 + ${idx === hi ? "bg-white/10" : "hover:bg-white/5"}`} + > +
    +
    {p.name}
    +
    + {(p.category || "—")} • {p.volume_ml ? `${p.volume_ml} ml` : "—"} • Pack {p.pack_size ?? "—"} +
    +
    +
    + {p.purchase_price_cents != null && ( +
    EK: {fmt.format((p.purchase_price_cents || 0)/100)} €
    + )} + {p.price_cents != null && ( +
    VK: {fmt.format((p.price_cents || 0)/100)} €
    + )} +
    +
  • + ))} + {!options.length &&
  • Keine Treffer
  • } +
+
+
, + document.body + )} +
+ ); +} + +// tolerant für "YYYY-MM-DD HH:MM:SS(.mmm)" etc. +function toISOish(s) { + if (typeof s !== "string") return s; + return s + .replace(" ", "T") + .replace(/(\.\d{3})\d+([Z+-])/, "$1$2") + .replace(/([+-]\d{2})$/, "$1:00"); +} +function parseTs(s) { + const d = new Date(toISOish(s)); + return isNaN(d) ? null : d; +} +function fmtDate(x) { + if (x instanceof Date) return x.toLocaleDateString(); + if (typeof x === "number") { + // Sekunden → ms heuristisch + const ms = x < 1e12 ? x * 1000 : x; + const d = new Date(ms); + return isNaN(d) ? "—" : d.toLocaleDateString(); + } + if (typeof x === "string") { + const d = parseTs(x); + return d ? d.toLocaleDateString() : (x || "—"); + } + return "—"; +} +// 1) Utility einmalig ins File (z. B. unter den Date-Utils) +function normalizeLedger(raw) { + const arr = Array.isArray(raw) ? raw : (raw?.items ?? []); + return arr.map((e, i) => ({ + id: e.id ?? i, + ts: e.ts || e.created_at || e.inserted_at || e.date, // <- Datum vereinheitlichen + type: e.type || e.kind || e.entry_type || "booking", + note: e.note ?? e.description ?? "", + amount_cents: Number(e.amount_cents ?? e.amount ?? 0), + })); +} + +function packSizeOf(products, productId) { + return (products.find(p => p.id === productId)?.pack_size ?? 1) | 0; +} +function lineUnits(it, products) { + return (it.quantity_units | 0) * packSizeOf(products, it.product_id); +} +function lineSumCents(it, products) { + return lineUnits(it, products) * (it.unit_cost_cents | 0); +} + +export default function DeliveriesPage() { + // Produkte aus Backend + const [products, setProducts] = useState([]); + useEffect(() => { + (async () => { + try { + const data = await getProducts(); + setProducts( + (data || []).map((p) => ({ + ...p, + pack_size: Number(p.pack_size ?? 1), + purchase_price_cents: Number(p.purchase_price_cents ?? 0), + stock: Number(p.stock ?? 0), + })) + ); + } catch (e) { + console.error(e); + alert("Produkte konnten nicht geladen werden."); + } + })(); + },[]); + + useEffect(() => { if (products.length) loadHistory(); }, [products.length]); + + async function loadHistory() { + try { + const rows = await getDeliveries({ limit: 500, offset: 0 }); + + const keyOf = (r) => { + const date = (r.delivered_at || r.date || "").slice(0, 10); + const inv = r.invoice_number || r.invoice_no || r.invoice || ""; + const sup = r.supplier || r.vendor || ""; + return `${date}|${sup}|${inv}`; + }; + + const byKey = new Map(); + for (const r of rows || []) { + const key = keyOf(r); + const g = byKey.get(key) || { + // Gruppenkopf + id: r.id, // irgendeine ID aus der Gruppe + supplier: r.supplier || r.vendor || "", + invoice_no: r.invoice_number || r.invoice_no || r.invoice || "", + note: r.note || r.notes || r.comment || "", + date: r.delivered_at || r.date || r.created_at || null, + status: r.status || (r.paid_at ? "paid" : r.stored_at ? "stored" : "recorded"), + deposit_return_cents: 0, // im aktuellen Backend nicht speicherbar + items: [], + }; + + // Einzelzeile → Item bauen + const pid = (r.product_id ?? r.pid) | 0; + const ps = (products.find(p => p.id === pid)?.pack_size | 0) || 1; + const units = (r.amount | 0); // Stück gesamt (aus Backend) + const qty = Math.max(1, Math.round(units / ps)); // Menge in Packs schätzen + const price = (r.price_cents | 0); // Preis pro Stück (so legst du ihn an) + + g.items.push({ + product_id: pid, + quantity_units: qty, + unit_cost_cents: price, + }); + + byKey.set(key, g); + } + + const groups = Array.from(byKey.values()).sort((a,b) => { + const da = new Date(a.date || 0).getTime(); + const db = new Date(b.date || 0).getTime(); + return db - da; + }); + setDeliveries(groups); + } catch (e) { + console.error(e); + } + } + + + + // Ledger / Kassenstand + const [ledger, setLedger] = useState([]); + useEffect(() => { + (async () => { + try { + const raw = await getLedgerMe({ limit: 100 }); + setLedger(normalizeLedger(raw)); + } catch (err) { + console.error("Ledger initial load failed:", err); + } + })(); + }, []); + + const walletBalance = React.useMemo( + () => (ledger || []).reduce((s, e) => s + (e.amount_cents || 0), 0), + [ledger] + ); + + // Lieferungen (Client-seitig als Liste für Historie) + const [deliveries, setDeliveries] = useState([]); + const [detailId, setDetailId] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const detail = useMemo( + () => deliveries.find((d) => d.id === detailId) || null, + [deliveries, detailId] + ); + useEffect(() => { if (detailId != null) setDetailOpen(true); }, [detailId]); + + // Formular Neue Lieferung + const [form, setForm] = useState({ + supplier: "", + date: todayISO(), + invoice_no: "", + note: "", + // Neu: Pfand ist frei einzugeben (positiv oder negativ) und wird zum Gesamt addiert. + deposit_return_cents: 0, // Netto in Cent; UI kann Brutto-Eingabe erlauben. + items: [emptyItem()], + }); + + const sumItemsCents = useMemo( + () => form.items.reduce((s, it) => s + (Number(it.quantity_units||0) * Number(it.unit_cost_cents||0)), 0), + [form.items] + ); + + + + + // Helpers ---------------------------------------------------------------------------------------- + + function emptyItem() { + return { product_id: null, quantity_units: 1, unit_cost_cents: 0 }; + } + function productById(id) { return products.find((p) => p.id === id) || null; } + function setItemField(idx, key, val) { + setForm((prev) => ({ + ...prev, + items: prev.items.map((it, i) => (i === idx ? { ...it, [key]: val } : it)), + })); + } + function addItemRow() { setForm((prev) => ({ ...prev, items: [...prev.items, emptyItem()] })); } + function removeItemRow(idx) { + setForm((prev) => ({ ...prev, items: prev.items.filter((_, i) => i !== idx) })); + } + function onSelectProduct(idx, prodId) { + const p = productById(toInt(prodId)); + setItemField(idx, "product_id", p?.id || null); + if (p) setItemField(idx, "unit_cost_cents", toInt(p.purchase_price_cents)); + } + + // Speichern + function resetForm() { + setForm({ + supplier: "", + date: todayISO(), + invoice_no: "", + note: "", + deposit_return_cents: 0, + items: [emptyItem()], + }); + } + + async function saveDelivery() { + if (!form.supplier.trim()) return alert("Lieferant fehlt"); + if (!form.items.length) return alert("Mindestens eine Position erforderlich"); + + for (const it of form.items) { + if (!it.product_id) return alert("Produkt wählen"); + if (toInt(it.quantity_units) < 1) return alert("Menge ≥ 1"); + if (toInt(it.unit_cost_cents) < 0) return alert("Preis ≥ 0"); + } + + const payload = { + supplier: form.supplier.trim(), + date: form.date, + invoice_no: form.invoice_no.trim(), + note: form.note.trim(), + // frei +/−; wird brutto in Summen berücksichtigt + deposit_return_cents: toInt(form.deposit_return_cents), + items: form.items.map(it => ({ + product_id: it.product_id, + quantity_units: toInt(it.quantity_units), + unit_cost_cents: toInt(it.unit_cost_cents), + })), + }; + + try { + // wichtig: products mitgeben → API rechnet Stück (packs×pack_size) + await createDeliveryBulk(payload, products); // 405/404? → api.js fällt automatisch auf Einzel-POSTs zurück + await loadHistory(); // echte Historie vom Backend + resetForm(); + } catch (err) { + console.error(err); + alert(err.message || "Speichern fehlgeschlagen"); + } + } + + + // Einlagern: Bestand lokal erhöhen (Menge × pack_size) + function storeDelivery(id) { + setDeliveries((prev) => + prev.map((d) => (d.id === id ? { ...d, status: d.status === "recorded" ? "stored" : d.status } : d)) + ); + const d = deliveries.find((x) => x.id === id); + if (!d || d.status !== "recorded") return; + const deltas = aggregatedPiecesByProduct(d.items, products); + setProducts((prev) => prev.map((p) => (deltas[p.id] ? { ...p, stock: toInt(p.stock) + deltas[p.id] } : p))); + } + + async function payDelivery(deliveryId, totalCents) { + try { + const cents = Math.abs(Number(totalCents) | 0); + await addLedgerEntry(-cents, `Lieferung #${deliveryId}`); + const fresh = await getLedgerMe(); + setLedger(normalizeLedger(fresh)); + setDeliveries(prev => + prev.map(d => (d.id === deliveryId ? { ...d, status: "paid" } : d)) + ); + } catch (e) { + console.error(e); + alert("Kassenbuchung fehlgeschlagen."); + } + } + + + + // ====================================== Render ================================================ + return ( +
+
+
+

Lieferungen

+
+
+ + {/* TOP: Neue Lieferung (links) + Kassenstand (rechts) */} +
+
+ + } + title="Neue Lieferung" + subtitle="Einheiten (Kisten/Packs) erfassen · Pfand wird addiert (Anzeige inkl. 19% MwSt)" + right={ applyDraftToForm(draft, setForm)} />} + /> + + +
+ +
+ + } title="Kassenstand" subtitle="Automatisch durch Zahlungen/Korrekturen" /> + { + await addLedgerEntry(Number(amtCents) | 0, note || "Korrektur"); + const fresh = await getLedgerMe(); + setLedger(normalizeLedger(fresh)); + }} + /> + +
+
+ + {/* BOTTOM: Historie */} + + } + title="Lieferungs-Historie" + subtitle="Zuletzt erfasst – Recorded / Stored / Paid (Beträge inkl. 19% MwSt)" + /> + setDetailId(id)} /> + + + {detail && ( + { setDetailOpen(false); setDetailId(null); }}> + { storeDelivery(detail.id); }} + onPay={(amountCents) => { payDelivery(detail.id, amountCents); }} // ← geändert + /> + + )} + +
+ ); + + +// ======= Komponenten ===================================================================== + +function Card({ children }) { + return ( +
+ {children} +
+ ); +} +function CardHeader({ icon, title, subtitle, right }) { + return ( +
+
+
{icon}
+
+
{title}
+ {subtitle &&
{subtitle}
} +
+
+ {right} +
+ ); +} + +function DeliveriesTable({ deliveries, products, onView }) { + return ( +
+
+ + + + + + + + + + + + + + {deliveries.length === 0 && ( - - - - - + - - - {rows.map((d) => ( + )} + {deliveries.map((d) => { + const { sumItemsGross, depositGross, totalDue } = computeDeliverySums(d, products); + return ( - - - - - + + + + + + + + - ))} - -
DatumLieferantRechnungsnr.Warensumme (inkl. MwSt)Pfand (inkl. MwSt)GesamtStatus
IDProduktMengePreis (€)Notiz +
+
Noch keine Lieferungen erfasst
+
Lege oben eine neue Lieferung an, sie erscheint dann hier.
+
+
{d.id}{d.product?.name ?? d.product_id}{d.amount}{((d.price_cents ?? 0)/100).toFixed(2)}{d.note ?? "–"}{fmtDate(d.date)}{d.supplier || "—"}{d.invoice_no || "—"}{euro(sumItemsGross)}{euro(depositGross)}{euro(totalDue)} + +
+ ); + })} + + +
+ ); +} + +function StatusPill({ status }) { + const map = { + recorded: { label: "Erstellt", cls: "bg-yellow-500/15 text-yellow-200 border-yellow-500/30" }, + stored: { label: "Eingelagert", cls: "bg-blue-500/15 text-blue-200 border-blue-500/30" }, + paid: { label: "Bezahlt", cls: "bg-emerald-500/15 text-emerald-200 border-emerald-500/30" }, + }; + const s = map[status] || { label: String(status), cls: "bg-white/10 text-white/70 border-white/20" }; + return ( + + {s.label} + + ); +} + +function NewDeliveryForm({ products, form, setForm, sumItemsCents, onSave }) { + // Brutto (19%) – auf Warensumme UND Pfand + const grossItems = gross119(sumItemsCents); + + // Pfand: frei einzugeben (±), Anzeige kann Brutto/Netto toggeln + const [depositIsGross, setDepositIsGross] = useState(false); + const displayDepositCents = depositIsGross ? gross119(form.deposit_return_cents) : toInt(form.deposit_return_cents); + const grossDepositAdded = APPLY_VAT_ON_DEPOSIT ? gross119(form.deposit_return_cents) : toInt(form.deposit_return_cents); + + // Gesamt = Waren (brutto) + Pfand (brutto, kann negativ sein) + const totalGross = grossItems + grossDepositAdded; + const isCreditGross = totalGross < 0; + + return ( +
+
+ setForm((prev) => ({ ...prev, supplier: v }))} + placeholder="z. B. Getränke Mayer" + /> + setForm((prev) => ({ ...prev, date: v }))} + /> + setForm((prev) => ({ ...prev, invoice_no: v }))} + placeholder="z. B. 2025-0812" + /> + { + const net = depositIsGross ? netFromGross119(typedCents) : typedCents; + setForm((prev) => ({ ...prev, deposit_return_cents: toInt(net) })); // kein Clamping -> auch negativ möglich + }} + allowNegative={true} + hint={depositIsGross ? "Eingabe brutto – wird intern netto gespeichert (−19%)" : "Eingabe netto – Anzeige & Summen inkl. 19% MwSt"} + rightToggle={{ + labelOn: "Brutto", + labelOff: "Netto", + value: depositIsGross, + onChange: setDepositIsGross, + }} + /> +
+ +
+
Produkte
+
+ {form.items.map((it, idx) => { + const p = products.find((pp) => pp.id === it.product_id) || null; + const pieces = p ? toInt(it.quantity_units) * toInt(p.pack_size) : 0; + return ( +
+
+ + { + const p = products.find(pp => pp.id === id); + const patch = { product_id: id }; + if (!(it.unit_cost_cents > 0) && p?.purchase_price_cents != null) { + patch.unit_cost_cents = p.purchase_price_cents; + } + updateItemField(idx, patch, setForm); + }} + placeholder="— Produkt wählen —" + /> +
+
+ + + updateItemField(idx, { quantity_units: toInt(e.target.value) }, setForm) + } + className={inputCls()} + /> +
+
+ + + updateItemField(idx, { unit_cost_cents: centsFromInput(e.target.value) }, setForm) + } + className={inputCls()} + /> +
+
+
Stück gesamt
+
{pieces}
+
+
+
+ Zeilensumme:{" "} + + {euro((toInt(it.quantity_units)) * toInt(it.unit_cost_cents))} + +
+ +
+
+ ); + })} +
+
+ +
+
+ +
+ setForm((prev) => ({ ...prev, note: v }))} + placeholder="optional" + rows={3} + /> + +
+
Summen
+
+ Warensumme (inkl. MwSt) + {euro(grossItems)} +
+
+ Pfand (inkl. MwSt) + {euro(grossDepositAdded)} +
+
+ {isCreditGross ? "Gutschrift (Gesamt)" : "Gesamtbetrag"} + {euro(totalGross)} +
+
+ +
+
); } + +function WalletCard({ balanceCents, ledger, onAdjust }) { + const [adj, setAdj] = useState({ amount: "", note: "" }); + const [showAdj, setShowAdj] = useState(false); + const [range, setRange] = useState("7d"); // "7d" | "30d" | "all" + + const series = useMemo(() => computeBalanceSeries(ledger, range), [ledger, range]); + + return ( +
+
+
+
Aktueller Kassenstand
+
{euro(balanceCents)}
+
+
+ + +
+
+ + {/* Chart */} +
+ +
+ + {showAdj && ( +
+
Korrektur buchen
+
+ setAdj((v) => ({ ...v, amount: e.target.value }))} + className={inputCls()} + /> + setAdj((v) => ({ ...v, note: e.target.value }))} + className={inputCls()} + /> + +
+
+ )} + +
+
Letzte Buchungen
+
+ + + + + + + + + + + {ledger.slice(0, 5).map((e) => ( + + + + + + + ))} + +
DatumTypNotizBetrag
{fmtDate(e.ts)}{ledgerTypeLabel(e.type)}{e.note || "—"} 0 ? "text-emerald-300" : "" + }`} + > + {euro(e.amount_cents)} +
+
+
+
+ ); +} + +function DeliveryDetail({ delivery, products, onStore, onPay }) { + const { sumItemsGross, depositGross, totalDue } = computeDeliverySums(delivery, products); + + const rows = (delivery.items || []).map((it) => { + const p = products.find((pp) => pp.id === it.product_id) || { name: it.product_id, pack_size: 1 }; + const qty = toInt(it.quantity_units); // Einheiten (Kisten/Packs) + const unit = toInt(it.unit_cost_cents); // Preis pro Einheit (Cent) + const pieces= qty * toInt(p.pack_size); // Stück gesamt (Flaschen/Dosen) + const line = qty * unit; // Zeilensumme (Cent) + return { p, qty, pieces, unit, line }; + }); + + + return ( +
+
+ {/* Header */} +
+
+
Lieferung #{delivery.id}
+
+ {fmtDate(delivery.date)} · {delivery.supplier || "—"} · RG {delivery.invoice_no || "—"} +
+
+ +
+ + {/* Body */} +
+
+
+ + + + + + + + + + + + + + + + + + + + + {rows.map((r, i) => ( + + + + + + + + + ))} + +
ProduktPackMengeStück gesamtEinzelpreis (€)Zeilensumme
{r.p.name}{r.p.pack_size}{r.qty}{r.pieces}{euro(r.unit)}{euro(r.line)}
+
+
+ +
+
+
Notiz
+
{delivery.note || "—"}
+
+
+
+ Warensumme (inkl. MwSt) + {euro(sumItemsGross)} +
+
+ Pfand (inkl. MwSt) + {euro(depositGross)} +
+
+ {totalDue < 0 ? "Gutschrift (Gesamt)" : "Gesamtbetrag"} + {euro(totalDue)} +
+
+
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +} + +// ======= UI-/Format-Helper ======================================================================== + +function Modal({ open, onClose, children }) { + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + const onKey = (e) => e.key === "Escape" && onClose?.(); + window.addEventListener("keydown", onKey); + return () => { + document.body.style.overflow = prev; + window.removeEventListener("keydown", onKey); + }; + }, [open, onClose]); + if (!open) return null; + return ( +
+
+
+
+ +
{children}
+
+
+
+ ); +} + +function LabeledInput({ label, type = "text", value, onChange, placeholder }) { + return ( +
+ + onChange(e.target.value)} + className={inputCls()} + /> +
+ ); +} +function LabeledTextArea({ label, value, onChange, placeholder, rows = 3 }) { + return ( +
+ +