From 1302676536b60d22a7d025c592a4e4fd5467fc04 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:07:58 +0000 Subject: [PATCH 001/139] chore(deps): bump next from 16.3.3 to 16.3.4 in the nextjs group Bumps the nextjs group with 1 update: [next](https://github.com/vercel/next.js). Updates `next` from 16.3.3 to 16.3.4 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/compare/v16.3.3...v16.3.4) --- updated-dependencies: - dependency-name: next dependency-version: 16.3.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nextjs ... Signed-off-by: dependabot[bot] --- package.json | 2 +- pnpm-lock.yaml | 110 ++++++++++++++++++++++++------------------------- 2 files changed, 56 insertions(+), 56 deletions(-) diff --git a/package.json b/package.json index e97e05da..38e67dcf 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "leaflet": "^1.9.4", "lucide-react": "^0.575.0", "marked": "^18.0.9", - "next": "16.3.3", + "next": "16.3.4", "nodemailer": "^10.0.1", "pg": "^8.20.0", "prisma": "^7.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 721d3913..a82dcfeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,7 +91,7 @@ importers: version: 11.18.0(typescript@6.0.3) better-auth: specifier: ^1.7.3 - version: 1.7.3(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(mysql2@3.24.3(@types/node@26.2.0))(next@16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.20.0)(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.7.3(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(mysql2@3.24.3(@types/node@26.2.0))(next@16.3.4(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.20.0)(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -120,8 +120,8 @@ importers: specifier: ^18.0.9 version: 18.0.9 next: - specifier: 16.3.3 - version: 16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: 16.3.4 + version: 16.3.4(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) nodemailer: specifier: ^10.0.1 version: 10.0.1 @@ -145,7 +145,7 @@ importers: version: 11.1.2(react@19.2.8) recharts: specifier: ^3.10.1 - version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1) + version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.3.0)(react@19.2.8)(redux@5.0.1) server-only: specifier: ^0.0.1 version: 0.0.1 @@ -1085,60 +1085,60 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 - '@next/env@16.3.3': - resolution: {integrity: sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==} + '@next/env@16.3.4': + resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==} '@next/eslint-plugin-next@16.3.0': resolution: {integrity: sha512-OqgJ8PN0d04KcPhDX/PTY5tJUJZxlbrt7O7FBsm4XE0XW2JDrKnDXsc9uo9WUimJGPoo2j+JRGhyXApC//mvbw==} - '@next/swc-darwin-arm64@16.3.3': - resolution: {integrity: sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==} + '@next/swc-darwin-arm64@16.3.4': + resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.3': - resolution: {integrity: sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==} + '@next/swc-darwin-x64@16.3.4': + resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.3': - resolution: {integrity: sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==} + '@next/swc-linux-arm64-gnu@16.3.4': + resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.3': - resolution: {integrity: sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==} + '@next/swc-linux-arm64-musl@16.3.4': + resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.3': - resolution: {integrity: sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==} + '@next/swc-linux-x64-gnu@16.3.4': + resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.3': - resolution: {integrity: sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==} + '@next/swc-linux-x64-musl@16.3.4': + resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.3': - resolution: {integrity: sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==} + '@next/swc-win32-arm64-msvc@16.3.4': + resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.3': - resolution: {integrity: sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==} + '@next/swc-win32-x64-msvc@16.3.4': + resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -3697,8 +3697,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@3.3.18: - resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -3714,8 +3714,8 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next@16.3.3: - resolution: {integrity: sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==} + next@16.3.4: + resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -4111,8 +4111,8 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-is@19.2.8: - resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + react-is@19.3.0: + resolution: {integrity: sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==} react-redux@9.2.0: resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} @@ -5515,7 +5515,7 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@next/env@16.3.3': {} + '@next/env@16.3.4': {} '@next/eslint-plugin-next@16.3.0(eslint@10.8.1(jiti@2.7.0))': dependencies: @@ -5524,28 +5524,28 @@ snapshots: transitivePeerDependencies: - eslint - '@next/swc-darwin-arm64@16.3.3': + '@next/swc-darwin-arm64@16.3.4': optional: true - '@next/swc-darwin-x64@16.3.3': + '@next/swc-darwin-x64@16.3.4': optional: true - '@next/swc-linux-arm64-gnu@16.3.3': + '@next/swc-linux-arm64-gnu@16.3.4': optional: true - '@next/swc-linux-arm64-musl@16.3.3': + '@next/swc-linux-arm64-musl@16.3.4': optional: true - '@next/swc-linux-x64-gnu@16.3.3': + '@next/swc-linux-x64-gnu@16.3.4': optional: true - '@next/swc-linux-x64-musl@16.3.3': + '@next/swc-linux-x64-musl@16.3.4': optional: true - '@next/swc-win32-arm64-msvc@16.3.3': + '@next/swc-win32-arm64-msvc@16.3.4': optional: true - '@next/swc-win32-x64-msvc@16.3.3': + '@next/swc-win32-x64-msvc@16.3.4': optional: true '@noble/ciphers@2.4.0': {} @@ -6771,7 +6771,7 @@ snapshots: baseline-browser-mapping@2.11.21: {} - better-auth@1.7.3(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(mysql2@3.24.3(@types/node@26.2.0))(next@16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.20.0)(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + better-auth@1.7.3(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(mysql2@3.24.3(@types/node@26.2.0))(next@16.3.4(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.20.0)(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/drizzle-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) @@ -6793,7 +6793,7 @@ snapshots: optionalDependencies: '@prisma/client': 7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3) mysql2: 3.24.3(@types/node@26.2.0) - next: 16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.4(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) pg: 8.20.0 prisma: 7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) react: 19.2.8 @@ -8221,7 +8221,7 @@ snapshots: nanoid@3.3.17: {} - nanoid@3.3.18: {} + nanoid@3.3.19: {} nanostores@1.5.3: {} @@ -8229,9 +8229,9 @@ snapshots: natural-compare@1.4.0: {} - next@16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.4(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@next/env': 16.3.3 + '@next/env': 16.3.4 '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.11.21 caniuse-lite: 1.0.30001810 @@ -8240,14 +8240,14 @@ snapshots: react-dom: 19.2.8(react@19.2.8) styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.8) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.3 - '@next/swc-darwin-x64': 16.3.3 - '@next/swc-linux-arm64-gnu': 16.3.3 - '@next/swc-linux-arm64-musl': 16.3.3 - '@next/swc-linux-x64-gnu': 16.3.3 - '@next/swc-linux-x64-musl': 16.3.3 - '@next/swc-win32-arm64-msvc': 16.3.3 - '@next/swc-win32-x64-msvc': 16.3.3 + '@next/swc-darwin-arm64': 16.3.4 + '@next/swc-darwin-x64': 16.3.4 + '@next/swc-linux-arm64-gnu': 16.3.4 + '@next/swc-linux-arm64-musl': 16.3.4 + '@next/swc-linux-x64-gnu': 16.3.4 + '@next/swc-linux-x64-musl': 16.3.4 + '@next/swc-win32-arm64-msvc': 16.3.4 + '@next/swc-win32-x64-msvc': 16.3.4 sharp: 0.35.4(@types/node@26.2.0) transitivePeerDependencies: - '@babel/core' @@ -8417,7 +8417,7 @@ snapshots: postcss@8.5.23: dependencies: - nanoid: 3.3.18 + nanoid: 3.3.19 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -8454,7 +8454,7 @@ snapshots: '@jest/schemas': 30.4.1 ansi-styles: 5.2.0 react-is-18: react-is@18.3.1 - react-is-19: react-is@19.2.8 + react-is-19: react-is@19.3.0 prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3): dependencies: @@ -8593,7 +8593,7 @@ snapshots: react-is@18.3.1: {} - react-is@19.2.8: {} + react-is@19.3.0: {} react-redux@9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1): dependencies: @@ -8628,7 +8628,7 @@ snapshots: readdirp@5.1.1: {} - recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1): + recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.3.0)(react@19.2.8)(redux@5.0.1): dependencies: '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8) clsx: 2.1.1 @@ -8638,7 +8638,7 @@ snapshots: immer: 11.1.16 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - react-is: 19.2.8 + react-is: 19.3.0 react-redux: 9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) reselect: 5.2.0 tiny-invariant: 1.3.3 From f0c7469522d51cf3a70cf29f37b82811bbee5de7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:09:18 +0000 Subject: [PATCH 002/139] chore(deps): bump the react group with 4 updates Bumps the react group with 4 updates: [react](https://github.com/react/react/tree/HEAD/packages/react), [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react), [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) and [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom). Updates `react` from 19.2.8 to 19.3.0 - [Release notes](https://github.com/react/react/releases) - [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/react/react/commits/v19.3.0/packages/react) Updates `@types/react` from 19.2.18 to 19.3.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react) Updates `react-dom` from 19.2.8 to 19.3.0 - [Release notes](https://github.com/react/react/releases) - [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/react/react/commits/v19.3.0/packages/react-dom) Updates `@types/react-dom` from 19.2.7 to 19.3.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom) Updates `@types/react` from 19.2.18 to 19.3.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react) Updates `@types/react-dom` from 19.2.7 to 19.3.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom) --- updated-dependencies: - dependency-name: react dependency-version: 19.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: react - dependency-name: "@types/react" dependency-version: 19.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: react - dependency-name: react-dom dependency-version: 19.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: react - dependency-name: "@types/react-dom" dependency-version: 19.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: react - dependency-name: "@types/react" dependency-version: 19.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: react - dependency-name: "@types/react-dom" dependency-version: 19.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: react ... Signed-off-by: dependabot[bot] --- package.json | 8 +- pnpm-lock.yaml | 478 ++++++++++++++++++++++++------------------------- 2 files changed, 243 insertions(+), 243 deletions(-) diff --git a/package.json b/package.json index e97e05da..d45bb613 100644 --- a/package.json +++ b/package.json @@ -67,8 +67,8 @@ "pg": "^8.20.0", "prisma": "^7.10.0", "qrcode": "^1.5.4", - "react": "^19.2.8", - "react-dom": "^19.2.8", + "react": "^19.3.0", + "react-dom": "^19.3.0", "react-image-crop": "^11.1.2", "recharts": "^3.10.1", "server-only": "^0.0.1", @@ -88,8 +88,8 @@ "@types/node": "^26.2.0", "@types/nodemailer": "^7.0.10", "@types/qrcode": "^1.5.6", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.7", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@types/turndown": "^5.0.6", "baseline-browser-mapping": "^2.11.21", "eslint": "^10.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 721d3913..bd8f935d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,10 +28,10 @@ importers: version: 7.10.0 '@prisma/client': specifier: ^7.10.0 - version: 7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3) + version: 7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3))(typescript@6.0.3) '@react-email/components': specifier: ^1.0.12 - version: 1.0.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.0.12(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@t3-oss/env-nextjs': specifier: ^0.13.11 version: 0.13.11(typescript@6.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3) @@ -40,10 +40,10 @@ importers: version: 0.5.20(tailwindcss@4.3.3) '@tanstack/react-query': specifier: ^5.102.8 - version: 5.102.8(react@19.2.8) + version: 5.102.8(react@19.3.0) '@tanstack/react-table': specifier: ^9.2.4 - version: 9.2.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 9.2.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@tiptap/extension-image': specifier: ^3.31.3 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) @@ -76,7 +76,7 @@ importers: version: 3.31.3 '@tiptap/react': specifier: ^3.31.3 - version: 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@tiptap/starter-kit': specifier: ^3.31.3 version: 3.31.3 @@ -85,13 +85,13 @@ importers: version: 11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3) '@trpc/react-query': specifier: ^11.18.0 - version: 11.18.0(@tanstack/react-query@5.102.8(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.2.8)(typescript@6.0.3) + version: 11.18.0(@tanstack/react-query@5.102.8(react@19.3.0))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3) '@trpc/server': specifier: ^11.18.0 version: 11.18.0(typescript@6.0.3) better-auth: specifier: ^1.7.3 - version: 1.7.3(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(mysql2@3.24.3(@types/node@26.2.0))(next@16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.20.0)(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.7.3(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3))(typescript@6.0.3))(mysql2@3.24.3(@types/node@26.2.0))(next@16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.20.0)(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -115,13 +115,13 @@ importers: version: 1.9.4 lucide-react: specifier: ^0.575.0 - version: 0.575.0(react@19.2.8) + version: 0.575.0(react@19.3.0) marked: specifier: ^18.0.9 version: 18.0.9 next: specifier: 16.3.3 - version: 16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) nodemailer: specifier: ^10.0.1 version: 10.0.1 @@ -130,22 +130,22 @@ importers: version: 8.20.0 prisma: specifier: ^7.10.0 - version: 7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + version: 7.10.0(@types/node@26.2.0)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3) qrcode: specifier: ^1.5.4 version: 1.5.4 react: - specifier: ^19.2.8 - version: 19.2.8 + specifier: ^19.3.0 + version: 19.3.0 react-dom: - specifier: ^19.2.8 - version: 19.2.8(react@19.2.8) + specifier: ^19.3.0 + version: 19.3.0(react@19.3.0) react-image-crop: specifier: ^11.1.2 - version: 11.1.2(react@19.2.8) + version: 11.1.2(react@19.3.0) recharts: specifier: ^3.10.1 - version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1) + version: 3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@19.3.0)(react@19.3.0)(redux@5.0.1) server-only: specifier: ^0.0.1 version: 0.0.1 @@ -193,11 +193,11 @@ importers: specifier: ^1.5.6 version: 1.5.6 '@types/react': - specifier: ^19.2.18 - version: 19.2.18 + specifier: ^19.3.0 + version: 19.3.0 '@types/react-dom': - specifier: ^19.2.7 - version: 19.2.7(@types/react@19.2.18) + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) '@types/turndown': specifier: ^5.0.6 version: 5.0.6 @@ -2013,13 +2013,13 @@ packages: '@types/raf@3.4.3': resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} - '@types/react-dom@19.2.7': - resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} peerDependencies: - '@types/react': ^19.2.0 + '@types/react': ^19.3.0 - '@types/react@19.2.18': - resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -4098,10 +4098,10 @@ packages: rc9@3.1.0: resolution: {integrity: sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==} - react-dom@19.2.8: - resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} peerDependencies: - react: ^19.2.8 + react: ^19.3.0 react-image-crop@11.1.2: resolution: {integrity: sha512-+0Pc2fxpwKL4u4oLmdKBw8XSwUceFbXbKEHvFOlsl/MGB1OVNic4uBlAPmEHGXYgoJIq+b63xHbc/aJMG0AVkA==} @@ -4111,8 +4111,8 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-is@19.2.8: - resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + react-is@19.3.0: + resolution: {integrity: sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==} react-redux@9.2.0: resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} @@ -4126,8 +4126,8 @@ packages: redux: optional: true - react@19.2.8: - resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} readable-stream@2.3.8: @@ -4246,8 +4246,8 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} @@ -4920,13 +4920,13 @@ snapshots: '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 - '@better-auth/prisma-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))': + '@better-auth/prisma-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3))': dependencies: '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 optionalDependencies: - '@prisma/client': 7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3) - prisma: 7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + '@prisma/client': 7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3))(typescript@6.0.3) + prisma: 7.10.0(@types/node@26.2.0)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3) '@better-auth/telemetry@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: @@ -5582,11 +5582,11 @@ snapshots: '@prisma/client-runtime-utils@7.10.0': {} - '@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3)': + '@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3))(typescript@6.0.3)': dependencies: '@prisma/client-runtime-utils': 7.10.0 optionalDependencies: - prisma: 7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + prisma: 7.10.0(@types/node@26.2.0)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3) typescript: 6.0.3 '@prisma/config@7.10.0': @@ -5658,206 +5658,206 @@ snapshots: env-paths: 3.0.0 proper-lockfile: 4.1.2 - '@prisma/studio-core@0.33.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@prisma/studio-core@0.33.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@types/react': 19.2.18 + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@types/react': 19.3.0 '@visx/curve': 4.0.1-alpha.0 '@visx/event': 4.0.1-alpha.0 - '@visx/grid': 4.0.1-alpha.0(react@19.2.8) - '@visx/group': 4.0.1-alpha.0(react@19.2.8) - '@visx/responsive': 4.0.1-alpha.0(react@19.2.8) + '@visx/grid': 4.0.1-alpha.0(react@19.3.0) + '@visx/group': 4.0.1-alpha.0(react@19.3.0) + '@visx/responsive': 4.0.1-alpha.0(react@19.3.0) '@visx/scale': 4.0.1-alpha.0 - '@visx/shape': 4.0.1-alpha.0(react@19.2.8) + '@visx/shape': 4.0.1-alpha.0(react@19.3.0) d3-array: 3.2.4 d3-shape: 3.2.0 elkjs: 0.11.1 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) transitivePeerDependencies: - '@types/react-dom' '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.3.0)(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-slot': 1.2.3(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.7(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-slot@1.2.3(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-slot@1.2.3(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.7(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.3.0)(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@react-email/body@0.3.0(react@19.2.8)': + '@react-email/body@0.3.0(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 - '@react-email/button@0.2.1(react@19.2.8)': + '@react-email/button@0.2.1(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 - '@react-email/code-block@0.2.1(react@19.2.8)': + '@react-email/code-block@0.2.1(react@19.3.0)': dependencies: prismjs: 1.30.0 - react: 19.2.8 - - '@react-email/code-inline@0.0.6(react@19.2.8)': - dependencies: - react: 19.2.8 - - '@react-email/column@0.0.14(react@19.2.8)': - dependencies: - react: 19.2.8 - - '@react-email/components@1.0.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@react-email/body': 0.3.0(react@19.2.8) - '@react-email/button': 0.2.1(react@19.2.8) - '@react-email/code-block': 0.2.1(react@19.2.8) - '@react-email/code-inline': 0.0.6(react@19.2.8) - '@react-email/column': 0.0.14(react@19.2.8) - '@react-email/container': 0.0.16(react@19.2.8) - '@react-email/font': 0.0.10(react@19.2.8) - '@react-email/head': 0.0.13(react@19.2.8) - '@react-email/heading': 0.0.16(react@19.2.8) - '@react-email/hr': 0.0.12(react@19.2.8) - '@react-email/html': 0.0.12(react@19.2.8) - '@react-email/img': 0.0.12(react@19.2.8) - '@react-email/link': 0.0.13(react@19.2.8) - '@react-email/markdown': 0.0.18(react@19.2.8) - '@react-email/preview': 0.0.14(react@19.2.8) - '@react-email/render': 2.0.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@react-email/row': 0.0.13(react@19.2.8) - '@react-email/section': 0.0.17(react@19.2.8) - '@react-email/tailwind': 2.0.7(@react-email/body@0.3.0(react@19.2.8))(@react-email/button@0.2.1(react@19.2.8))(@react-email/code-block@0.2.1(react@19.2.8))(@react-email/code-inline@0.0.6(react@19.2.8))(@react-email/container@0.0.16(react@19.2.8))(@react-email/heading@0.0.16(react@19.2.8))(@react-email/hr@0.0.12(react@19.2.8))(@react-email/img@0.0.12(react@19.2.8))(@react-email/link@0.0.13(react@19.2.8))(@react-email/preview@0.0.14(react@19.2.8))(@react-email/text@0.1.6(react@19.2.8))(react@19.2.8) - '@react-email/text': 0.1.6(react@19.2.8) - react: 19.2.8 + react: 19.3.0 + + '@react-email/code-inline@0.0.6(react@19.3.0)': + dependencies: + react: 19.3.0 + + '@react-email/column@0.0.14(react@19.3.0)': + dependencies: + react: 19.3.0 + + '@react-email/components@1.0.12(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@react-email/body': 0.3.0(react@19.3.0) + '@react-email/button': 0.2.1(react@19.3.0) + '@react-email/code-block': 0.2.1(react@19.3.0) + '@react-email/code-inline': 0.0.6(react@19.3.0) + '@react-email/column': 0.0.14(react@19.3.0) + '@react-email/container': 0.0.16(react@19.3.0) + '@react-email/font': 0.0.10(react@19.3.0) + '@react-email/head': 0.0.13(react@19.3.0) + '@react-email/heading': 0.0.16(react@19.3.0) + '@react-email/hr': 0.0.12(react@19.3.0) + '@react-email/html': 0.0.12(react@19.3.0) + '@react-email/img': 0.0.12(react@19.3.0) + '@react-email/link': 0.0.13(react@19.3.0) + '@react-email/markdown': 0.0.18(react@19.3.0) + '@react-email/preview': 0.0.14(react@19.3.0) + '@react-email/render': 2.0.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@react-email/row': 0.0.13(react@19.3.0) + '@react-email/section': 0.0.17(react@19.3.0) + '@react-email/tailwind': 2.0.7(@react-email/body@0.3.0(react@19.3.0))(@react-email/button@0.2.1(react@19.3.0))(@react-email/code-block@0.2.1(react@19.3.0))(@react-email/code-inline@0.0.6(react@19.3.0))(@react-email/container@0.0.16(react@19.3.0))(@react-email/heading@0.0.16(react@19.3.0))(@react-email/hr@0.0.12(react@19.3.0))(@react-email/img@0.0.12(react@19.3.0))(@react-email/link@0.0.13(react@19.3.0))(@react-email/preview@0.0.14(react@19.3.0))(@react-email/text@0.1.6(react@19.3.0))(react@19.3.0) + '@react-email/text': 0.1.6(react@19.3.0) + react: 19.3.0 transitivePeerDependencies: - react-dom - '@react-email/container@0.0.16(react@19.2.8)': + '@react-email/container@0.0.16(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 - '@react-email/font@0.0.10(react@19.2.8)': + '@react-email/font@0.0.10(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 - '@react-email/head@0.0.13(react@19.2.8)': + '@react-email/head@0.0.13(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 - '@react-email/heading@0.0.16(react@19.2.8)': + '@react-email/heading@0.0.16(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 - '@react-email/hr@0.0.12(react@19.2.8)': + '@react-email/hr@0.0.12(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 - '@react-email/html@0.0.12(react@19.2.8)': + '@react-email/html@0.0.12(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 - '@react-email/img@0.0.12(react@19.2.8)': + '@react-email/img@0.0.12(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 - '@react-email/link@0.0.13(react@19.2.8)': + '@react-email/link@0.0.13(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 - '@react-email/markdown@0.0.18(react@19.2.8)': + '@react-email/markdown@0.0.18(react@19.3.0)': dependencies: marked: 15.0.12 - react: 19.2.8 + react: 19.3.0 - '@react-email/preview@0.0.14(react@19.2.8)': + '@react-email/preview@0.0.14(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 - '@react-email/render@2.0.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@react-email/render@2.0.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: html-to-text: 9.0.5 prettier: 3.9.6 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) - '@react-email/row@0.0.13(react@19.2.8)': + '@react-email/row@0.0.13(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 - '@react-email/section@0.0.17(react@19.2.8)': + '@react-email/section@0.0.17(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 - '@react-email/tailwind@2.0.7(@react-email/body@0.3.0(react@19.2.8))(@react-email/button@0.2.1(react@19.2.8))(@react-email/code-block@0.2.1(react@19.2.8))(@react-email/code-inline@0.0.6(react@19.2.8))(@react-email/container@0.0.16(react@19.2.8))(@react-email/heading@0.0.16(react@19.2.8))(@react-email/hr@0.0.12(react@19.2.8))(@react-email/img@0.0.12(react@19.2.8))(@react-email/link@0.0.13(react@19.2.8))(@react-email/preview@0.0.14(react@19.2.8))(@react-email/text@0.1.6(react@19.2.8))(react@19.2.8)': + '@react-email/tailwind@2.0.7(@react-email/body@0.3.0(react@19.3.0))(@react-email/button@0.2.1(react@19.3.0))(@react-email/code-block@0.2.1(react@19.3.0))(@react-email/code-inline@0.0.6(react@19.3.0))(@react-email/container@0.0.16(react@19.3.0))(@react-email/heading@0.0.16(react@19.3.0))(@react-email/hr@0.0.12(react@19.3.0))(@react-email/img@0.0.12(react@19.3.0))(@react-email/link@0.0.13(react@19.3.0))(@react-email/preview@0.0.14(react@19.3.0))(@react-email/text@0.1.6(react@19.3.0))(react@19.3.0)': dependencies: - '@react-email/text': 0.1.6(react@19.2.8) - react: 19.2.8 + '@react-email/text': 0.1.6(react@19.3.0) + react: 19.3.0 tailwindcss: 4.2.0 optionalDependencies: - '@react-email/body': 0.3.0(react@19.2.8) - '@react-email/button': 0.2.1(react@19.2.8) - '@react-email/code-block': 0.2.1(react@19.2.8) - '@react-email/code-inline': 0.0.6(react@19.2.8) - '@react-email/container': 0.0.16(react@19.2.8) - '@react-email/heading': 0.0.16(react@19.2.8) - '@react-email/hr': 0.0.12(react@19.2.8) - '@react-email/img': 0.0.12(react@19.2.8) - '@react-email/link': 0.0.13(react@19.2.8) - '@react-email/preview': 0.0.14(react@19.2.8) + '@react-email/body': 0.3.0(react@19.3.0) + '@react-email/button': 0.2.1(react@19.3.0) + '@react-email/code-block': 0.2.1(react@19.3.0) + '@react-email/code-inline': 0.0.6(react@19.3.0) + '@react-email/container': 0.0.16(react@19.3.0) + '@react-email/heading': 0.0.16(react@19.3.0) + '@react-email/hr': 0.0.12(react@19.3.0) + '@react-email/img': 0.0.12(react@19.3.0) + '@react-email/link': 0.0.13(react@19.3.0) + '@react-email/preview': 0.0.14(react@19.3.0) - '@react-email/text@0.1.6(react@19.2.8)': + '@react-email/text@0.1.6(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 - '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)': + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1))(react@19.3.0)': dependencies: '@standard-schema/spec': 1.1.0 '@standard-schema/utils': 0.3.0 @@ -5866,8 +5866,8 @@ snapshots: redux-thunk: 3.1.0(redux@5.0.1) reselect: 5.1.1 optionalDependencies: - react: 19.2.8 - react-redux: 9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) + react: 19.3.0 + react-redux: 9.2.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1) '@selderee/plugin-htmlparser2@0.11.0': dependencies: @@ -5982,23 +5982,23 @@ snapshots: '@tanstack/query-core@5.102.8': {} - '@tanstack/react-query@5.102.8(react@19.2.8)': + '@tanstack/react-query@5.102.8(react@19.3.0)': dependencies: '@tanstack/query-core': 5.102.8 - react: 19.2.8 + react: 19.3.0 - '@tanstack/react-store@0.11.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@tanstack/react-store@0.11.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@tanstack/store': 0.11.1 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - use-sync-external-store: 1.6.0(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + use-sync-external-store: 1.6.0(react@19.3.0) - '@tanstack/react-table@9.2.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@tanstack/react-table@9.2.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@tanstack/react-store': 0.11.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/react-store': 0.11.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@tanstack/table-core': 9.2.4 - react: 19.2.8 + react: 19.3.0 transitivePeerDependencies: - react-dom @@ -6166,17 +6166,17 @@ snapshots: prosemirror-transform: 1.12.1 prosemirror-view: 1.42.3 - '@tiptap/react@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@tiptap/react@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) '@tiptap/pm': 3.31.3 - '@types/react': 19.2.18 - '@types/react-dom': 19.2.7(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) '@types/use-sync-external-store': 0.0.6 fast-equals: 5.4.2 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - use-sync-external-store: 1.6.0(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + use-sync-external-store: 1.6.0(react@19.3.0) optionalDependencies: '@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) @@ -6215,12 +6215,12 @@ snapshots: '@trpc/server': 11.18.0(typescript@6.0.3) typescript: 6.0.3 - '@trpc/react-query@11.18.0(@tanstack/react-query@5.102.8(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.2.8)(typescript@6.0.3)': + '@trpc/react-query@11.18.0(@tanstack/react-query@5.102.8(react@19.3.0))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3)': dependencies: - '@tanstack/react-query': 5.102.8(react@19.2.8) + '@tanstack/react-query': 5.102.8(react@19.3.0) '@trpc/client': 11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3) '@trpc/server': 11.18.0(typescript@6.0.3) - react: 19.2.8 + react: 19.3.0 typescript: 6.0.3 '@trpc/server@11.18.0(typescript@6.0.3)': @@ -6358,11 +6358,11 @@ snapshots: '@types/raf@3.4.3': optional: true - '@types/react-dom@19.2.7(@types/react@19.2.18)': + '@types/react-dom@19.3.0(@types/react@19.3.0)': dependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@types/react@19.2.18': + '@types/react@19.3.0': dependencies: csstype: 3.2.3 @@ -6550,50 +6550,50 @@ snapshots: '@visx/event@4.0.1-alpha.0': dependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 '@visx/point': 4.0.1-alpha.0 - '@visx/grid@4.0.1-alpha.0(react@19.2.8)': + '@visx/grid@4.0.1-alpha.0(react@19.3.0)': dependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 '@visx/curve': 4.0.1-alpha.0 - '@visx/group': 4.0.1-alpha.0(react@19.2.8) + '@visx/group': 4.0.1-alpha.0(react@19.3.0) '@visx/point': 4.0.1-alpha.0 '@visx/scale': 4.0.1-alpha.0 - '@visx/shape': 4.0.1-alpha.0(react@19.2.8) + '@visx/shape': 4.0.1-alpha.0(react@19.3.0) classnames: 2.5.1 - react: 19.2.8 + react: 19.3.0 - '@visx/group@4.0.1-alpha.0(react@19.2.8)': + '@visx/group@4.0.1-alpha.0(react@19.3.0)': dependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 classnames: 2.5.1 - react: 19.2.8 + react: 19.3.0 '@visx/point@4.0.1-alpha.0': {} - '@visx/responsive@4.0.1-alpha.0(react@19.2.8)': + '@visx/responsive@4.0.1-alpha.0(react@19.3.0)': dependencies: '@types/lodash': 4.17.25 - '@types/react': 19.2.18 + '@types/react': 19.3.0 lodash: 4.18.1 - react: 19.2.8 + react: 19.3.0 '@visx/scale@4.0.1-alpha.0': dependencies: '@visx/vendor': 4.0.0-alpha.0 - '@visx/shape@4.0.1-alpha.0(react@19.2.8)': + '@visx/shape@4.0.1-alpha.0(react@19.3.0)': dependencies: '@types/lodash': 4.17.25 - '@types/react': 19.2.18 + '@types/react': 19.3.0 '@visx/curve': 4.0.1-alpha.0 - '@visx/group': 4.0.1-alpha.0(react@19.2.8) + '@visx/group': 4.0.1-alpha.0(react@19.3.0) '@visx/scale': 4.0.1-alpha.0 '@visx/vendor': 4.0.0-alpha.0 classnames: 2.5.1 lodash: 4.18.1 - react: 19.2.8 + react: 19.3.0 '@visx/vendor@4.0.0-alpha.0': dependencies: @@ -6771,14 +6771,14 @@ snapshots: baseline-browser-mapping@2.11.21: {} - better-auth@1.7.3(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(mysql2@3.24.3(@types/node@26.2.0))(next@16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.20.0)(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + better-auth@1.7.3(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3))(typescript@6.0.3))(mysql2@3.24.3(@types/node@26.2.0))(next@16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.20.0)(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3))(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/drizzle-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) '@better-auth/kysely-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.5) '@better-auth/memory-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) '@better-auth/mongo-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3)) + '@better-auth/prisma-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3)) '@better-auth/telemetry': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -6791,13 +6791,13 @@ snapshots: nanostores: 1.5.3 zod: 4.5.4 optionalDependencies: - '@prisma/client': 7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3) + '@prisma/client': 7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3))(typescript@6.0.3) mysql2: 3.24.3(@types/node@26.2.0) - next: 16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) pg: 8.20.0 - prisma: 7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + prisma: 7.10.0(@types/node@26.2.0)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -8145,9 +8145,9 @@ snapshots: lru.min@1.1.5: {} - lucide-react@0.575.0(react@19.2.8): + lucide-react@0.575.0(react@19.3.0): dependencies: - react: 19.2.8 + react: 19.3.0 magic-string@0.30.21: dependencies: @@ -8229,16 +8229,16 @@ snapshots: natural-compare@1.4.0: {} - next@16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: '@next/env': 16.3.3 '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.11.21 caniuse-lite: 1.0.30001810 postcss: 8.5.23 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.3.0) optionalDependencies: '@next/swc-darwin-arm64': 16.3.3 '@next/swc-darwin-x64': 16.3.3 @@ -8454,14 +8454,14 @@ snapshots: '@jest/schemas': 30.4.1 ansi-styles: 5.2.0 react-is-18: react-is@18.3.1 - react-is-19: react-is@19.2.8 + react-is-19: react-is@19.3.0 - prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3): + prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3): dependencies: '@prisma/config': 7.10.0 '@prisma/dev': 0.24.17(typescript@6.0.3) '@prisma/engines': 7.10.0 - '@prisma/studio-core': 0.33.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@prisma/studio-core': 0.33.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) mysql2: 3.24.3(@types/node@26.2.0) postgres: 3.4.7 optionalDependencies: @@ -8582,29 +8582,29 @@ snapshots: defu: 6.1.7 destr: 2.0.5 - react-dom@19.2.8(react@19.2.8): + react-dom@19.3.0(react@19.3.0): dependencies: - react: 19.2.8 - scheduler: 0.27.0 + react: 19.3.0 + scheduler: 0.28.0 - react-image-crop@11.1.2(react@19.2.8): + react-image-crop@11.1.2(react@19.3.0): dependencies: - react: 19.2.8 + react: 19.3.0 react-is@18.3.1: {} - react-is@19.2.8: {} + react-is@19.3.0: {} - react-redux@9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1): + react-redux@9.2.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 - react: 19.2.8 - use-sync-external-store: 1.6.0(react@19.2.8) + react: 19.3.0 + use-sync-external-store: 1.6.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 redux: 5.0.1 - react@19.2.8: {} + react@19.3.0: {} readable-stream@2.3.8: dependencies: @@ -8628,21 +8628,21 @@ snapshots: readdirp@5.1.1: {} - recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1): + recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@19.3.0)(react@19.3.0)(redux@5.0.1): dependencies: - '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8) + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1))(react@19.3.0) clsx: 2.1.1 decimal.js-light: 2.5.1 es-toolkit: 1.44.0 eventemitter3: 5.0.4 immer: 11.1.16 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - react-is: 19.2.8 - react-redux: 9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + react-is: 19.3.0 + react-redux: 9.2.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1) reselect: 5.2.0 tiny-invariant: 1.3.3 - use-sync-external-store: 1.6.0(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.3.0) victory-vendor: 37.3.6 transitivePeerDependencies: - '@types/react' @@ -8716,7 +8716,7 @@ snapshots: dependencies: xmlchars: 2.2.0 - scheduler@0.27.0: {} + scheduler@0.28.0: {} selderee@0.11.0: dependencies: @@ -8839,10 +8839,10 @@ snapshots: strip-json-comments@3.1.1: {} - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.8): + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.3.0): dependencies: client-only: 0.0.1 - react: 19.2.8 + react: 19.3.0 optionalDependencies: '@babel/core': 7.29.0 @@ -9014,9 +9014,9 @@ snapshots: dependencies: punycode: 2.3.1 - use-sync-external-store@1.6.0(react@19.2.8): + use-sync-external-store@1.6.0(react@19.3.0): dependencies: - react: 19.2.8 + react: 19.3.0 util-deprecate@1.0.2: {} From 83d26e5a6e4560eb6a5b393f886a8e79704a661a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:10:16 +0000 Subject: [PATCH 003/139] chore(deps-dev): bump the dev-tools group with 3 updates Bumps the dev-tools group with 3 updates: [@eslint/eslintrc](https://github.com/eslint/eslintrc), [eslint](https://github.com/eslint/eslint) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint). Updates `@eslint/eslintrc` from 3.3.6 to 3.3.7 - [Release notes](https://github.com/eslint/eslintrc/releases) - [Changelog](https://github.com/eslint/eslintrc/blob/main/CHANGELOG.md) - [Commits](https://github.com/eslint/eslintrc/compare/eslintrc-v3.3.6...eslintrc-v3.3.7) Updates `eslint` from 10.8.1 to 10.10.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.8.1...v10.10.0) Updates `typescript-eslint` from 8.67.0 to 8.70.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.70.0/packages/typescript-eslint) --- updated-dependencies: - dependency-name: "@eslint/eslintrc" dependency-version: 3.3.7 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-tools - dependency-name: eslint dependency-version: 10.10.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-tools - dependency-name: typescript-eslint dependency-version: 8.70.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-tools ... Signed-off-by: dependabot[bot] --- package.json | 6 +- pnpm-lock.yaml | 332 +++++++++++++++++++++++++++++-------------------- 2 files changed, 202 insertions(+), 136 deletions(-) diff --git a/package.json b/package.json index e97e05da..d69fa71e 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "zod": "^4.4.3" }, "devDependencies": { - "@eslint/eslintrc": "^3.3.6", + "@eslint/eslintrc": "^3.3.7", "@jest/globals": "^30.4.1", "@next/eslint-plugin-next": "^16.3.0", "@tailwindcss/postcss": "^4.3.2", @@ -92,7 +92,7 @@ "@types/react-dom": "^19.2.7", "@types/turndown": "^5.0.6", "baseline-browser-mapping": "^2.11.21", - "eslint": "^10.8.1", + "eslint": "^10.10.0", "eslint-plugin-react-hooks": "^7.0.1", "jest": "^30.4.2", "postcss": "^8.5.26", @@ -100,7 +100,7 @@ "prettier-plugin-tailwindcss": "^0.8.1", "tailwindcss": "^4.3.2", "typescript": "^6.0.3", - "typescript-eslint": "^8.67.0" + "typescript-eslint": "^8.70.0" }, "ct3aMetadata": { "initVersion": "7.40.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 721d3913..c4b61e9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,7 +145,7 @@ importers: version: 11.1.2(react@19.2.8) recharts: specifier: ^3.10.1 - version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1) + version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.3.0)(react@19.2.8)(redux@5.0.1) server-only: specifier: ^0.0.1 version: 0.0.1 @@ -166,14 +166,14 @@ importers: version: 4.4.3 devDependencies: '@eslint/eslintrc': - specifier: ^3.3.6 - version: 3.3.6 + specifier: ^3.3.7 + version: 3.3.7 '@jest/globals': specifier: ^30.4.1 version: 30.4.1 '@next/eslint-plugin-next': specifier: ^16.3.0 - version: 16.3.0(eslint@10.8.1(jiti@2.7.0)) + version: 16.3.0(eslint@10.10.0(jiti@2.7.0)) '@tailwindcss/postcss': specifier: ^4.3.2 version: 4.3.3 @@ -205,11 +205,11 @@ importers: specifier: ^2.11.21 version: 2.11.21 eslint: - specifier: ^10.8.1 - version: 10.8.1(jiti@2.7.0) + specifier: ^10.10.0 + version: 10.10.0(jiti@2.7.0) eslint-plugin-react-hooks: specifier: ^7.0.1 - version: 7.0.1(eslint@10.8.1(jiti@2.7.0)) + version: 7.0.1(eslint@10.10.0(jiti@2.7.0)) jest: specifier: ^30.4.2 version: 30.4.2(@types/node@26.2.0) @@ -229,8 +229,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 typescript-eslint: - specifier: ^8.67.0 - version: 8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) + specifier: ^8.70.0 + version: 8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) packages: @@ -501,6 +501,12 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true + '@cacheable/memory@2.2.0': + resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} + + '@cacheable/utils@2.5.0': + resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + '@csstools/color-helpers@6.1.1': resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} engines: {node: '>=20.19.0'} @@ -747,16 +753,16 @@ packages: resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/eslintrc@3.3.6': - resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + '@eslint/eslintrc@3.3.7': + resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@3.0.5': resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/plugin-kit@0.7.2': - resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + '@eslint/plugin-kit@0.7.3': + resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@exodus/bytes@1.15.1': @@ -1075,6 +1081,15 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} @@ -2039,63 +2054,63 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.67.0': - resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} + '@typescript-eslint/eslint-plugin@8.70.0': + resolution: {integrity: sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.67.0 + '@typescript-eslint/parser': ^8.70.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.67.0': - resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} + '@typescript-eslint/parser@8.70.0': + resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.67.0': - resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} + '@typescript-eslint/project-service@8.70.0': + resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.67.0': - resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} + '@typescript-eslint/scope-manager@8.70.0': + resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.67.0': - resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} + '@typescript-eslint/tsconfig-utils@8.70.0': + resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.67.0': - resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} + '@typescript-eslint/type-utils@8.70.0': + resolution: {integrity: sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.67.0': - resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} + '@typescript-eslint/types@8.70.0': + resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.67.0': - resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} + '@typescript-eslint/typescript-estree@8.70.0': + resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.67.0': - resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} + '@typescript-eslint/utils@8.70.0': + resolution: {integrity: sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.67.0': - resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} + '@typescript-eslint/visitor-keys@8.70.0': + resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.3': @@ -2505,6 +2520,9 @@ packages: magicast: optional: true + cacheable@2.5.0: + resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -2869,8 +2887,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.8.1: - resolution: {integrity: sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==} + eslint@10.10.0: + resolution: {integrity: sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -2985,9 +3003,8 @@ packages: fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} + file-entry-cache@11.1.5: + resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} @@ -3005,9 +3022,8 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} + flat-cache@6.1.23: + resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} @@ -3092,12 +3108,22 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + hookified@2.2.0: + resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -3134,8 +3160,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.6: - resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + ignore@7.0.9: + resolution: {integrity: sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==} engines: {node: '>= 4'} immediate@3.0.6: @@ -3405,9 +3431,6 @@ packages: engines: {node: '>=6'} hasBin: true - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -3431,8 +3454,8 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} kysely@0.29.5: resolution: {integrity: sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ==} @@ -3889,8 +3912,8 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} pirates@4.0.7: @@ -4084,6 +4107,10 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + qified@0.10.1: + resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} + engines: {node: '>=20'} + qrcode@1.5.4: resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} engines: {node: '>=10.13.0'} @@ -4111,8 +4138,8 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-is@19.2.8: - resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + react-is@19.3.0: + resolution: {integrity: sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==} react-redux@9.2.0: resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} @@ -4491,8 +4518,8 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} - typescript-eslint@8.67.0: - resolution: {integrity: sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==} + typescript-eslint@8.70.0: + resolution: {integrity: sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4948,6 +4975,18 @@ snapshots: dependencies: css-tree: 3.2.1 + '@cacheable/memory@2.2.0': + dependencies: + '@cacheable/utils': 2.5.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.5.0': + dependencies: + hashery: 1.5.1 + keyv: 5.6.0 + '@csstools/color-helpers@6.1.1': {} '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -5081,14 +5120,14 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.8.1(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.10.1(eslint@10.10.0(jiti@2.7.0))': dependencies: - eslint: 10.8.1(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0) eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.9.1(eslint@10.8.1(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.10.0(jiti@2.7.0))': dependencies: - eslint: 10.8.1(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -5109,7 +5148,7 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.6': + '@eslint/eslintrc@3.3.7': dependencies: ajv: 6.15.0 debug: 4.4.3 @@ -5125,7 +5164,7 @@ snapshots: '@eslint/object-schema@3.0.5': {} - '@eslint/plugin-kit@0.7.2': + '@eslint/plugin-kit@0.7.3': dependencies: '@eslint/core': 1.2.1 levn: 0.4.1 @@ -5506,6 +5545,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.1 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + '@mixmark-io/domino@2.2.0': {} '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': @@ -5517,9 +5564,9 @@ snapshots: '@next/env@16.3.3': {} - '@next/eslint-plugin-next@16.3.0(eslint@10.8.1(jiti@2.7.0))': + '@next/eslint-plugin-next@16.3.0(eslint@10.10.0(jiti@2.7.0))': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.8.1(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.10.0(jiti@2.7.0)) fast-glob: 3.3.1 transitivePeerDependencies: - eslint @@ -6381,72 +6428,72 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.67.0 - '@typescript-eslint/type-utils': 8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.67.0 - eslint: 10.8.1(jiti@2.7.0) - ignore: 7.0.6 + '@typescript-eslint/parser': 8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/type-utils': 8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.70.0 + eslint: 10.10.0(jiti@2.7.0) + ignore: 7.0.9 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.67.0 - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.67.0 + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.70.0 debug: 4.4.3 - eslint: 10.8.1(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.67.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.70.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) - '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@6.0.3) + '@typescript-eslint/types': 8.70.0 debug: 4.4.3 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.67.0': + '@typescript-eslint/scope-manager@8.70.0': dependencies: - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/visitor-keys': 8.67.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/visitor-keys': 8.70.0 - '@typescript-eslint/tsconfig-utils@8.67.0(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.70.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) debug: 4.4.3 - eslint: 10.8.1(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.67.0': {} + '@typescript-eslint/types@8.70.0': {} - '@typescript-eslint/typescript-estree@8.67.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.70.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.67.0(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/visitor-keys': 8.67.0 + '@typescript-eslint/project-service': 8.70.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@6.0.3) + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/visitor-keys': 8.70.0 debug: 4.4.3 minimatch: 10.2.6 semver: 7.8.5 @@ -6456,20 +6503,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.67.0 - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) - eslint: 10.8.1(jiti@2.7.0) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) + eslint: 10.10.0(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.67.0': + '@typescript-eslint/visitor-keys@8.70.0': dependencies: - '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/types': 8.70.0 eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.3': {} @@ -6889,6 +6936,14 @@ snapshots: pkg-types: 2.3.3 rc9: 3.1.0 + cacheable@2.5.0: + dependencies: + '@cacheable/memory': 2.2.0 + '@cacheable/utils': 2.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.10.1 + callsites@3.1.0: {} camelcase@5.3.1: {} @@ -7203,11 +7258,11 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-plugin-react-hooks@7.0.1(eslint@10.8.1(jiti@2.7.0)): + eslint-plugin-react-hooks@7.0.1(eslint@10.10.0(jiti@2.7.0)): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.0 - eslint: 10.8.1(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0) hermes-parser: 0.25.1 zod: 4.4.3 zod-validation-error: 4.0.2(zod@4.4.3) @@ -7227,14 +7282,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.8.1(jiti@2.7.0): + eslint@10.10.0(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 - '@eslint/plugin-kit': 0.7.2 + '@eslint/plugin-kit': 0.7.3 '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 @@ -7249,7 +7304,7 @@ snapshots: esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 + file-entry-cache: 11.1.5 find-up: 5.0.0 glob-parent: 6.0.2 ignore: 5.3.2 @@ -7376,15 +7431,15 @@ snapshots: dependencies: bser: 2.1.1 - fdir@6.5.0(picomatch@4.0.5): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.5 + picomatch: 4.0.7 fflate@0.8.3: {} - file-entry-cache@8.0.0: + file-entry-cache@11.1.5: dependencies: - flat-cache: 4.0.1 + flat-cache: 6.1.23 fill-range@7.1.1: dependencies: @@ -7406,10 +7461,11 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - flat-cache@4.0.1: + flat-cache@6.1.23: dependencies: + cacheable: 2.5.0 flatted: 3.4.4 - keyv: 4.5.4 + hookified: 1.15.1 flatted@3.4.4: {} @@ -7484,12 +7540,20 @@ snapshots: has-flag@4.0.0: {} + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + hermes-estree@0.25.1: {} hermes-parser@0.25.1: dependencies: hermes-estree: 0.25.1 + hookified@1.15.1: {} + + hookified@2.2.0: {} + html-encoding-sniffer@6.0.0(@noble/hashes@2.4.0): dependencies: '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) @@ -7531,7 +7595,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.6: {} + ignore@7.0.9: {} immediate@3.0.6: {} @@ -7987,8 +8051,6 @@ snapshots: jsesc@3.1.0: {} - json-buffer@3.0.1: {} - json-parse-even-better-errors@2.3.1: {} json-schema-traverse@0.4.1: {} @@ -8017,9 +8079,9 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 - keyv@4.5.4: + keyv@5.6.0: dependencies: - json-buffer: 3.0.1 + '@keyv/serialize': 1.1.1 kysely@0.29.5: {} @@ -8394,7 +8456,7 @@ snapshots: picomatch@4.0.4: {} - picomatch@4.0.5: {} + picomatch@4.0.7: {} pirates@4.0.7: {} @@ -8454,7 +8516,7 @@ snapshots: '@jest/schemas': 30.4.1 ansi-styles: 5.2.0 react-is-18: react-is@18.3.1 - react-is-19: react-is@19.2.8 + react-is-19: react-is@19.3.0 prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3): dependencies: @@ -8564,6 +8626,10 @@ snapshots: pure-rand@7.0.1: {} + qified@0.10.1: + dependencies: + hookified: 2.2.0 + qrcode@1.5.4: dependencies: dijkstrajs: 1.0.3 @@ -8593,7 +8659,7 @@ snapshots: react-is@18.3.1: {} - react-is@19.2.8: {} + react-is@19.3.0: {} react-redux@9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1): dependencies: @@ -8628,7 +8694,7 @@ snapshots: readdirp@5.1.1: {} - recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1): + recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.3.0)(react@19.2.8)(redux@5.0.1): dependencies: '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8) clsx: 2.1.1 @@ -8638,7 +8704,7 @@ snapshots: immer: 11.1.16 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - react-is: 19.2.8 + react-is: 19.3.0 react-redux: 9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) reselect: 5.2.0 tiny-invariant: 1.3.3 @@ -8896,8 +8962,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tldts-core@7.4.10: {} @@ -8947,13 +9013,13 @@ snapshots: type-fest@0.21.3: {} - typescript-eslint@8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.8.1(jiti@2.7.0) + '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) + eslint: 10.10.0(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color From 0608c12b5450b81e3ab1571097c3c2707f1e3712 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:10:43 +0000 Subject: [PATCH 004/139] chore(deps): bump zod from 4.4.3 to 4.6.2 Bumps [zod](https://github.com/colinhacks/zod) from 4.4.3 to 4.6.2. - [Release notes](https://github.com/colinhacks/zod/releases) - [Commits](https://github.com/colinhacks/zod/compare/v4.4.3...v4.6.2) --- updated-dependencies: - dependency-name: zod dependency-version: 4.6.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package.json | 2 +- pnpm-lock.yaml | 101 +++++++++++++++++++++++-------------------------- 2 files changed, 49 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index e97e05da..c17d7b56 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "tsx": "^4.23.12", "turndown": "^7.2.2", "vexflow": "^5.0.0", - "zod": "^4.4.3" + "zod": "^4.6.2" }, "devDependencies": { "@eslint/eslintrc": "^3.3.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 721d3913..b7ce1496 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,7 +34,7 @@ importers: version: 1.0.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@t3-oss/env-nextjs': specifier: ^0.13.11 - version: 0.13.11(typescript@6.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3) + version: 0.13.11(typescript@6.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.6.2) '@tailwindcss/typography': specifier: ^0.5.20 version: 0.5.20(tailwindcss@4.3.3) @@ -145,7 +145,7 @@ importers: version: 11.1.2(react@19.2.8) recharts: specifier: ^3.10.1 - version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1) + version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.3.0)(react@19.2.8)(redux@5.0.1) server-only: specifier: ^0.0.1 version: 0.0.1 @@ -162,8 +162,8 @@ importers: specifier: ^5.0.0 version: 5.0.0 zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.2 + version: 4.6.2 devDependencies: '@eslint/eslintrc': specifier: ^3.3.6 @@ -4111,8 +4111,8 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-is@19.2.8: - resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + react-is@19.3.0: + resolution: {integrity: sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==} react-redux@9.2.0: resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} @@ -4670,11 +4670,8 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - - zod@4.5.4: - resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + zod@4.6.2: + resolution: {integrity: sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==} snapshots: @@ -4886,51 +4883,51 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3)': + '@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3)': dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@opentelemetry/semantic-conventions': 1.43.0 '@standard-schema/spec': 1.1.0 - better-call: 1.4.0(zod@4.5.4) + better-call: 1.4.0(zod@4.6.2) jose: 6.2.12 kysely: 0.29.5 nanostores: 1.5.3 - zod: 4.5.4 + zod: 4.6.2 - '@better-auth/drizzle-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': + '@better-auth/drizzle-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 - '@better-auth/kysely-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.5)': + '@better-auth/kysely-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.5)': dependencies: - '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 optionalDependencies: kysely: 0.29.5 - '@better-auth/memory-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': + '@better-auth/memory-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': + '@better-auth/mongo-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 - '@better-auth/prisma-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))': + '@better-auth/prisma-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))': dependencies: - '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 optionalDependencies: '@prisma/client': 7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3) prisma: 7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) - '@better-auth/telemetry@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/telemetry@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: - '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -5892,19 +5889,19 @@ snapshots: dependencies: tslib: 2.8.1 - '@t3-oss/env-core@0.13.11(typescript@6.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3)': + '@t3-oss/env-core@0.13.11(typescript@6.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.6.2)': optionalDependencies: typescript: 6.0.3 valibot: 1.4.2(typescript@6.0.3) - zod: 4.4.3 + zod: 4.6.2 - '@t3-oss/env-nextjs@0.13.11(typescript@6.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3)': + '@t3-oss/env-nextjs@0.13.11(typescript@6.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.6.2)': dependencies: - '@t3-oss/env-core': 0.13.11(typescript@6.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3) + '@t3-oss/env-core': 0.13.11(typescript@6.0.3)(valibot@1.4.2(typescript@6.0.3))(zod@4.6.2) optionalDependencies: typescript: 6.0.3 valibot: 1.4.2(typescript@6.0.3) - zod: 4.4.3 + zod: 4.6.2 '@tailwindcss/node@4.3.3': dependencies: @@ -6773,23 +6770,23 @@ snapshots: better-auth@1.7.3(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(mysql2@3.24.3(@types/node@26.2.0))(next@16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.20.0)(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) - '@better-auth/drizzle-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) - '@better-auth/kysely-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.5) - '@better-auth/memory-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3)) - '@better-auth/telemetry': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/drizzle-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) + '@better-auth/kysely-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.5) + '@better-auth/memory-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3)) + '@better-auth/telemetry': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@noble/ciphers': 2.4.0 '@noble/hashes': 2.4.0 - better-call: 1.4.0(zod@4.5.4) + better-call: 1.4.0(zod@4.6.2) defu: 6.1.7 jose: 6.2.12 kysely: 0.29.5 nanostores: 1.5.3 - zod: 4.5.4 + zod: 4.6.2 optionalDependencies: '@prisma/client': 7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3) mysql2: 3.24.3(@types/node@26.2.0) @@ -6802,14 +6799,14 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-call@1.4.0(zod@4.5.4): + better-call@1.4.0(zod@4.6.2): dependencies: '@better-auth/utils': 0.5.0 '@better-fetch/fetch': 1.3.1 rou3: 0.9.2 set-cookie-parser: 3.1.2 optionalDependencies: - zod: 4.5.4 + zod: 4.6.2 better-result@2.10.0: {} @@ -7209,8 +7206,8 @@ snapshots: '@babel/parser': 7.29.0 eslint: 10.8.1(jiti@2.7.0) hermes-parser: 0.25.1 - zod: 4.4.3 - zod-validation-error: 4.0.2(zod@4.4.3) + zod: 4.6.2 + zod-validation-error: 4.0.2(zod@4.6.2) transitivePeerDependencies: - supports-color @@ -8454,7 +8451,7 @@ snapshots: '@jest/schemas': 30.4.1 ansi-styles: 5.2.0 react-is-18: react-is@18.3.1 - react-is-19: react-is@19.2.8 + react-is-19: react-is@19.3.0 prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3): dependencies: @@ -8593,7 +8590,7 @@ snapshots: react-is@18.3.1: {} - react-is@19.2.8: {} + react-is@19.3.0: {} react-redux@9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1): dependencies: @@ -8628,7 +8625,7 @@ snapshots: readdirp@5.1.1: {} - recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1): + recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.3.0)(react@19.2.8)(redux@5.0.1): dependencies: '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8) clsx: 2.1.1 @@ -8638,7 +8635,7 @@ snapshots: immer: 11.1.16 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - react-is: 19.2.8 + react-is: 19.3.0 react-redux: 9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) reselect: 5.2.0 tiny-invariant: 1.3.3 @@ -9175,10 +9172,8 @@ snapshots: compress-commons: 4.1.2 readable-stream: 3.6.2 - zod-validation-error@4.0.2(zod@4.4.3): + zod-validation-error@4.0.2(zod@4.6.2): dependencies: - zod: 4.4.3 - - zod@4.4.3: {} + zod: 4.6.2 - zod@4.5.4: {} + zod@4.6.2: {} From 4a7e26070e9521e8b23751a9b73e70eadafe1f3f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:11:06 +0000 Subject: [PATCH 005/139] chore(deps-dev): bump @types/node from 26.2.0 to 26.5.1 Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 26.2.0 to 26.5.1. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 26.5.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package.json | 2 +- pnpm-lock.yaml | 132 ++++++++++++++++++++++++------------------------- 2 files changed, 67 insertions(+), 67 deletions(-) diff --git a/package.json b/package.json index e97e05da..b4871092 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "@tailwindcss/postcss": "^4.3.2", "@types/dompurify": "^3.2.0", "@types/leaflet": "^1.9.22", - "@types/node": "^26.2.0", + "@types/node": "^26.5.1", "@types/nodemailer": "^7.0.10", "@types/qrcode": "^1.5.6", "@types/react": "^19.2.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 721d3913..45a7d8ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 7.10.0 '@prisma/client': specifier: ^7.10.0 - version: 7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3) + version: 7.10.0(prisma@7.10.0(@types/node@26.5.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3) '@react-email/components': specifier: ^1.0.12 version: 1.0.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -91,7 +91,7 @@ importers: version: 11.18.0(typescript@6.0.3) better-auth: specifier: ^1.7.3 - version: 1.7.3(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(mysql2@3.24.3(@types/node@26.2.0))(next@16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.20.0)(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.7.3(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.5.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(mysql2@3.24.3(@types/node@26.5.1))(next@16.3.3(@babel/core@7.29.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.20.0)(prisma@7.10.0(@types/node@26.5.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -121,7 +121,7 @@ importers: version: 18.0.9 next: specifier: 16.3.3 - version: 16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.3(@babel/core@7.29.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) nodemailer: specifier: ^10.0.1 version: 10.0.1 @@ -130,7 +130,7 @@ importers: version: 8.20.0 prisma: specifier: ^7.10.0 - version: 7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + version: 7.10.0(@types/node@26.5.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -145,7 +145,7 @@ importers: version: 11.1.2(react@19.2.8) recharts: specifier: ^3.10.1 - version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1) + version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.3.0)(react@19.2.8)(redux@5.0.1) server-only: specifier: ^0.0.1 version: 0.0.1 @@ -184,8 +184,8 @@ importers: specifier: ^1.9.22 version: 1.9.22 '@types/node': - specifier: ^26.2.0 - version: 26.2.0 + specifier: ^26.5.1 + version: 26.5.1 '@types/nodemailer': specifier: ^7.0.10 version: 7.0.10 @@ -212,7 +212,7 @@ importers: version: 7.0.1(eslint@10.8.1(jiti@2.7.0)) jest: specifier: ^30.4.2 - version: 30.4.2(@types/node@26.2.0) + version: 30.4.2(@types/node@26.5.1) postcss: specifier: ^8.5.26 version: 8.5.26 @@ -1995,8 +1995,8 @@ packages: '@types/node@14.18.63': resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} - '@types/node@26.2.0': - resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + '@types/node@26.5.1': + resolution: {integrity: sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==} '@types/nodemailer@7.0.10': resolution: {integrity: sha512-tP+9WggTFN22Zxh0XFyst7239H0qwiRCogsk7v9aQS79sYAJY+WEbTHbNYcxUMaalHKmsNpxmoTe35hBEMMd6g==} @@ -4111,8 +4111,8 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-is@19.2.8: - resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + react-is@19.3.0: + resolution: {integrity: sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==} react-redux@9.2.0: resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} @@ -4503,8 +4503,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} undici@8.10.0: resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} @@ -4920,13 +4920,13 @@ snapshots: '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 - '@better-auth/prisma-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))': + '@better-auth/prisma-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.5.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.10.0(@types/node@26.5.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))': dependencies: '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 optionalDependencies: - '@prisma/client': 7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3) - prisma: 7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + '@prisma/client': 7.10.0(prisma@7.10.0(@types/node@26.5.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3) + prisma: 7.10.0(@types/node@26.5.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) '@better-auth/telemetry@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: @@ -5312,7 +5312,7 @@ snapshots: '@jest/console@30.4.1': dependencies: '@jest/types': 30.4.1 - '@types/node': 26.2.0 + '@types/node': 26.5.1 chalk: 4.1.2 jest-message-util: 30.4.1 jest-util: 30.4.1 @@ -5326,7 +5326,7 @@ snapshots: '@jest/test-result': 30.4.1 '@jest/transform': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 26.2.0 + '@types/node': 26.5.1 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 4.4.0 @@ -5334,7 +5334,7 @@ snapshots: fast-json-stable-stringify: 2.1.0 graceful-fs: 4.2.11 jest-changed-files: 30.4.1 - jest-config: 30.4.2(@types/node@26.2.0) + jest-config: 30.4.2(@types/node@26.5.1) jest-haste-map: 30.4.1 jest-message-util: 30.4.1 jest-regex-util: 30.4.0 @@ -5360,7 +5360,7 @@ snapshots: dependencies: '@jest/fake-timers': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 26.2.0 + '@types/node': 26.5.1 jest-mock: 30.4.1 '@jest/expect-utils@30.4.1': @@ -5378,7 +5378,7 @@ snapshots: dependencies: '@jest/types': 30.4.1 '@sinonjs/fake-timers': 15.4.0 - '@types/node': 26.2.0 + '@types/node': 26.5.1 jest-message-util: 30.4.1 jest-mock: 30.4.1 jest-util: 30.4.1 @@ -5396,7 +5396,7 @@ snapshots: '@jest/pattern@30.4.0': dependencies: - '@types/node': 26.2.0 + '@types/node': 26.5.1 jest-regex-util: 30.4.0 '@jest/reporters@30.4.1': @@ -5407,7 +5407,7 @@ snapshots: '@jest/transform': 30.4.1 '@jest/types': 30.4.1 '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 26.2.0 + '@types/node': 26.5.1 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit-x: 0.2.2 @@ -5483,7 +5483,7 @@ snapshots: '@jest/schemas': 30.4.1 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 26.2.0 + '@types/node': 26.5.1 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -5582,11 +5582,11 @@ snapshots: '@prisma/client-runtime-utils@7.10.0': {} - '@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3)': + '@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.5.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3)': dependencies: '@prisma/client-runtime-utils': 7.10.0 optionalDependencies: - prisma: 7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + prisma: 7.10.0(@types/node@26.5.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) typescript: 6.0.3 '@prisma/config@7.10.0': @@ -6335,25 +6335,25 @@ snapshots: '@types/node@14.18.63': {} - '@types/node@26.2.0': + '@types/node@26.5.1': dependencies: - undici-types: 8.3.0 + undici-types: 8.9.0 '@types/nodemailer@7.0.10': dependencies: - '@types/node': 26.2.0 + '@types/node': 26.5.1 '@types/pako@2.0.4': {} '@types/pg@8.23.1': dependencies: - '@types/node': 26.2.0 + '@types/node': 26.5.1 pg-protocol: 1.16.0 pg-types: 2.2.0 '@types/qrcode@1.5.6': dependencies: - '@types/node': 26.2.0 + '@types/node': 26.5.1 '@types/raf@3.4.3': optional: true @@ -6771,14 +6771,14 @@ snapshots: baseline-browser-mapping@2.11.21: {} - better-auth@1.7.3(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(mysql2@3.24.3(@types/node@26.2.0))(next@16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.20.0)(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + better-auth@1.7.3(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.5.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(mysql2@3.24.3(@types/node@26.5.1))(next@16.3.3(@babel/core@7.29.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.20.0)(prisma@7.10.0(@types/node@26.5.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/drizzle-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) '@better-auth/kysely-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.5) '@better-auth/memory-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) '@better-auth/mongo-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3)) + '@better-auth/prisma-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.5.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.10.0(@types/node@26.5.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3)) '@better-auth/telemetry': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -6791,11 +6791,11 @@ snapshots: nanostores: 1.5.3 zod: 4.5.4 optionalDependencies: - '@prisma/client': 7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3) - mysql2: 3.24.3(@types/node@26.2.0) - next: 16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@prisma/client': 7.10.0(prisma@7.10.0(@types/node@26.5.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3) + mysql2: 3.24.3(@types/node@26.5.1) + next: 16.3.3(@babel/core@7.29.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) pg: 8.20.0 - prisma: 7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + prisma: 7.10.0(@types/node@26.5.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: @@ -7645,7 +7645,7 @@ snapshots: '@jest/expect': 30.4.1 '@jest/test-result': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 26.2.0 + '@types/node': 26.5.1 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.2 @@ -7665,7 +7665,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.4.2(@types/node@26.2.0): + jest-cli@30.4.2(@types/node@26.5.1): dependencies: '@jest/core': 30.4.2 '@jest/test-result': 30.4.1 @@ -7673,7 +7673,7 @@ snapshots: chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.4.2(@types/node@26.2.0) + jest-config: 30.4.2(@types/node@26.5.1) jest-util: 30.4.1 jest-validate: 30.4.1 yargs: 17.7.3 @@ -7684,7 +7684,7 @@ snapshots: - supports-color - ts-node - jest-config@30.4.2(@types/node@26.2.0): + jest-config@30.4.2(@types/node@26.5.1): dependencies: '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 @@ -7710,7 +7710,7 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 26.2.0 + '@types/node': 26.5.1 transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -7739,7 +7739,7 @@ snapshots: '@jest/environment': 30.4.1 '@jest/fake-timers': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 26.2.0 + '@types/node': 26.5.1 jest-mock: 30.4.1 jest-util: 30.4.1 jest-validate: 30.4.1 @@ -7747,7 +7747,7 @@ snapshots: jest-haste-map@30.4.1: dependencies: '@jest/types': 30.4.1 - '@types/node': 26.2.0 + '@types/node': 26.5.1 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -7787,7 +7787,7 @@ snapshots: jest-mock@30.4.1: dependencies: '@jest/types': 30.4.1 - '@types/node': 26.2.0 + '@types/node': 26.5.1 jest-util: 30.4.1 jest-pnp-resolver@1.2.3(jest-resolve@30.4.1): @@ -7821,7 +7821,7 @@ snapshots: '@jest/test-result': 30.4.1 '@jest/transform': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 26.2.0 + '@types/node': 26.5.1 chalk: 4.1.2 emittery: 0.13.1 exit-x: 0.2.2 @@ -7850,7 +7850,7 @@ snapshots: '@jest/test-result': 30.4.1 '@jest/transform': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 26.2.0 + '@types/node': 26.5.1 chalk: 4.1.2 cjs-module-lexer: 2.2.0 collect-v8-coverage: 1.0.3 @@ -7897,7 +7897,7 @@ snapshots: jest-util@30.4.1: dependencies: '@jest/types': 30.4.1 - '@types/node': 26.2.0 + '@types/node': 26.5.1 chalk: 4.1.2 ci-info: 4.4.0 graceful-fs: 4.2.11 @@ -7916,7 +7916,7 @@ snapshots: dependencies: '@jest/test-result': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 26.2.0 + '@types/node': 26.5.1 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -7925,18 +7925,18 @@ snapshots: jest-worker@30.4.1: dependencies: - '@types/node': 26.2.0 + '@types/node': 26.5.1 '@ungap/structured-clone': 1.3.3 jest-util: 30.4.1 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.4.2(@types/node@26.2.0): + jest@30.4.2(@types/node@26.5.1): dependencies: '@jest/core': 30.4.2 '@jest/types': 30.4.1 import-local: 3.2.0 - jest-cli: 30.4.2(@types/node@26.2.0) + jest-cli: 30.4.2(@types/node@26.5.1) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -8204,9 +8204,9 @@ snapshots: ms@2.1.3: {} - mysql2@3.24.3(@types/node@26.2.0): + mysql2@3.24.3(@types/node@26.5.1): dependencies: - '@types/node': 26.2.0 + '@types/node': 26.5.1 aws-ssl-profiles: 1.1.2 generate-function: 2.3.1 iconv-lite: 0.7.3 @@ -8229,7 +8229,7 @@ snapshots: natural-compare@1.4.0: {} - next@16.3.3(@babel/core@7.29.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.3(@babel/core@7.29.0)(@types/node@26.5.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@next/env': 16.3.3 '@swc/helpers': 0.5.23 @@ -8248,7 +8248,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.3.3 '@next/swc-win32-arm64-msvc': 16.3.3 '@next/swc-win32-x64-msvc': 16.3.3 - sharp: 0.35.4(@types/node@26.2.0) + sharp: 0.35.4(@types/node@26.5.1) transitivePeerDependencies: - '@babel/core' - '@types/node' @@ -8454,15 +8454,15 @@ snapshots: '@jest/schemas': 30.4.1 ansi-styles: 5.2.0 react-is-18: react-is@18.3.1 - react-is-19: react-is@19.2.8 + react-is-19: react-is@19.3.0 - prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3): + prisma@7.10.0(@types/node@26.5.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3): dependencies: '@prisma/config': 7.10.0 '@prisma/dev': 0.24.17(typescript@6.0.3) '@prisma/engines': 7.10.0 '@prisma/studio-core': 0.33.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - mysql2: 3.24.3(@types/node@26.2.0) + mysql2: 3.24.3(@types/node@26.5.1) postgres: 3.4.7 optionalDependencies: typescript: 6.0.3 @@ -8593,7 +8593,7 @@ snapshots: react-is@18.3.1: {} - react-is@19.2.8: {} + react-is@19.3.0: {} react-redux@9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1): dependencies: @@ -8628,7 +8628,7 @@ snapshots: readdirp@5.1.1: {} - recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1): + recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.3.0)(react@19.2.8)(redux@5.0.1): dependencies: '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8) clsx: 2.1.1 @@ -8638,7 +8638,7 @@ snapshots: immer: 11.1.16 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - react-is: 19.2.8 + react-is: 19.3.0 react-redux: 9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) reselect: 5.2.0 tiny-invariant: 1.3.3 @@ -8734,7 +8734,7 @@ snapshots: setimmediate@1.0.5: {} - sharp@0.35.4(@types/node@26.2.0): + sharp@0.35.4(@types/node@26.5.1): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 @@ -8765,7 +8765,7 @@ snapshots: '@img/sharp-win32-arm64': 0.35.4 '@img/sharp-win32-ia32': 0.35.4 '@img/sharp-win32-x64': 0.35.4 - '@types/node': 26.2.0 + '@types/node': 26.5.1 optional: true shebang-command@2.0.0: @@ -8960,7 +8960,7 @@ snapshots: typescript@6.0.3: {} - undici-types@8.3.0: {} + undici-types@8.9.0: {} undici@8.10.0: {} From e422040e427ce9a4ed4bc3e6a25e690576388f9e Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Tue, 15 Sep 2026 08:38:41 +0200 Subject: [PATCH 006/139] =?UTF-8?q?fix(anmeldungen):=20volle=20Preiskatego?= =?UTF-8?q?rie=20auf=20die=20Warteliste,=20keine=20=C3=9Cberbuchung=20beim?= =?UTF-8?q?=20Bearbeiten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine ausgebuchte Preiskategorie ließ die Anmeldung mit "Die Preisoption … ist ausgebucht." scheitern — auch bei Kursen mit Warteliste. Sie gilt jetzt wie ein voller Kurs: mit Warteliste kommt die ganze Anmeldung darauf, ohne wird sie weiterhin abgelehnt. "Automatisch" bei der Erfassung durch das Team entscheidet genauso. findFullPriceTier liefert dafür die volle Kategorie, statt zu werfen; assertPriceTierCapacity baut darauf auf. updateMyRegistration prüfte die Kurskapazität nur bei Kursen ohne Warteliste. Mit Warteliste konnte eine bestätigte Anmeldung Teilnehmer hinzufügen, blieb bestätigt und überbuchte den Kurs. Die Prüfung gilt jetzt unabhängig von der Warteliste, allerdings nur für bestätigte Anmeldungen und nur für das, was die Änderung dazu belegt: in einem bewusst überbuchten Kurs lässt sich weiter die Telefonnummer ändern oder ein Teilnehmer abmelden. Anmeldungen auf der Warteliste belegen keine Plätze und werden nicht mehr geprüft — wer wegen einer vollen Kategorie dort steht, konnte sonst gar nichts mehr ändern. Die Bearbeitungsseite hält es genauso. --- src/app/registrations/[id]/edit/page.tsx | 8 + .../helpers/__tests__/course-capacity.test.ts | 31 ++++ src/server/api/helpers/course-capacity.ts | 46 ++++-- src/server/api/routers/registrations.ts | 140 ++++++++++++------ 4 files changed, 167 insertions(+), 58 deletions(-) diff --git a/src/app/registrations/[id]/edit/page.tsx b/src/app/registrations/[id]/edit/page.tsx index fbe539d4..cf102513 100644 --- a/src/app/registrations/[id]/edit/page.tsx +++ b/src/app/registrations/[id]/edit/page.tsx @@ -279,9 +279,16 @@ export default function EditRegistrationPage() { const activeParticipants = participants.filter((p) => !p.isDeleted); + // Plätze belegt nur eine bestätigte Anmeldung. Auf der Warteliste darf sie + // wachsen und jede Kategorie wählen — die Plätze prüft erst das Nachrücken. + // Der Server hält es genauso. + const holdsSeats = + registration?.registrationStatus === RegistrationStatus.CONFIRMED; + const canAddParticipant = () => { if (!availability) return false; if (participantsLocked) return false; + if (!holdsSeats) return true; const currentActive = activeParticipants.length; const originalCount = registration?.participants.length ?? 0; const netNew = currentActive - originalCount; @@ -294,6 +301,7 @@ export default function EditRegistrationPage() { }; const isPriceOptionAvailable = (priceOptionId: string) => { + if (!holdsSeats) return true; if ( !availability?.capacityByPriceOption || !registration?.course?.priceOptions diff --git a/src/server/api/helpers/__tests__/course-capacity.test.ts b/src/server/api/helpers/__tests__/course-capacity.test.ts index 8a100e5f..8618a855 100644 --- a/src/server/api/helpers/__tests__/course-capacity.test.ts +++ b/src/server/api/helpers/__tests__/course-capacity.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@jest/globals"; import { assertPriceTierCapacity, computeCourseCapacity, + findFullPriceTier, } from "../course-capacity"; describe("computeCourseCapacity", () => { @@ -151,4 +152,34 @@ describe("assertPriceTierCapacity", () => { ); expect(seen).toHaveLength(0); }); + + describe("findFullPriceTier", () => { + // Die Anmeldung entscheidet damit zwischen Warteliste und Ablehnung, + // statt über den Fehler von assertPriceTierCapacity zu stolpern. + it("returns the full tier instead of throwing", async () => { + const { db } = dbWith(6); + await expect( + findFullPriceTier(db, "course", priceOptions, { wasserburg: 1 }), + ).resolves.toMatchObject({ id: "wasserburg" }); + }); + + it("returns null when every tier has room", async () => { + const { db } = dbWith(5); + await expect( + findFullPriceTier(db, "course", priceOptions, { wasserburg: 1 }), + ).resolves.toBeNull(); + }); + + it("excludes the registration being edited from the count", async () => { + const { db, seen } = dbWith(0); + await findFullPriceTier( + db, + "course", + priceOptions, + { kinder: 2 }, + "registration-1", + ); + expect(JSON.stringify(seen[0])).toContain("registration-1"); + }); + }); }); diff --git a/src/server/api/helpers/course-capacity.ts b/src/server/api/helpers/course-capacity.ts index 14cf0c9b..dc9349d2 100644 --- a/src/server/api/helpers/course-capacity.ts +++ b/src/server/api/helpers/course-capacity.ts @@ -67,12 +67,19 @@ export async function countConfirmedParticipants( }); } +type TierPriceOption = { + id: string; + label: string; + maxParticipants: number | null; +}; + +export function priceTierFullMessage(option: { label: string }): string { + return `Die Preisoption "${option.label}" ist ausgebucht.`; +} + /** - * Throws BAD_REQUEST when adding `additionsByLabel` participants would - * overbook any limited price tier (counting CONFIRMED registrations only). - */ -/** - * Prüft je Preiskategorie, ob die neuen Teilnehmer noch hineinpassen. + * Die erste Preiskategorie, in die die neuen Teilnehmer nicht mehr passen — + * `null`, wenn alle passen. Gezählt werden nur bestätigte Anmeldungen. * * Zählt über `priceOptionId`, nicht über das Label: ein Kurs darf zwei * Kategorien mit demselben Namen führen, und über das Label wurde die eine @@ -82,17 +89,13 @@ export async function countConfirmedParticipants( * Label im Kurs eindeutig ist; bei Duplikaten sind sie nicht zuzuordnen und * bleiben außen vor (die Kurs-Gesamtkapazität greift weiterhin). */ -export async function assertPriceTierCapacity( +export async function findFullPriceTier( db: Db | Tx, courseId: string, - priceOptions: Array<{ - id: string; - label: string; - maxParticipants: number | null; - }>, + priceOptions: TierPriceOption[], additionsByOptionId: Record, excludeRegistrationId?: string, -): Promise { +): Promise { for (const [optionId, addition] of Object.entries(additionsByOptionId)) { const priceOption = priceOptions.find((p) => p.id === optionId); if (priceOption?.maxParticipants == null) continue; @@ -119,12 +122,23 @@ export async function assertPriceTierCapacity( }); if (currentCount + addition > priceOption.maxParticipants) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Die Preisoption "${priceOption.label}" ist ausgebucht.`, - }); + return priceOption; } } + return null; +} + +/** Wie {@link findFullPriceTier}, lehnt eine volle Kategorie aber ab. */ +export async function assertPriceTierCapacity( + ...args: Parameters +): Promise { + const fullOption = await findFullPriceTier(...args); + if (fullOption) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: priceTierFullMessage(fullOption), + }); + } } const SERIALIZATION_FAILURE = "P2034"; diff --git a/src/server/api/routers/registrations.ts b/src/server/api/routers/registrations.ts index c36a955f..6a49fd04 100644 --- a/src/server/api/routers/registrations.ts +++ b/src/server/api/routers/registrations.ts @@ -58,8 +58,11 @@ import { assertPriceTierCapacity, computeCourseCapacity, countConfirmedParticipants, + findFullPriceTier, + priceTierFullMessage, runSerializable, } from "../helpers/course-capacity"; +import { resolveParticipantPriceOption } from "@/lib/course-price-options"; import { logAudit } from "../helpers/audit"; import { createNotification, @@ -433,20 +436,30 @@ export const registrationsRouter = createTRPCRouter({ status = RegistrationStatus.WAITLIST; } - // Per-price-tier limits are enforced here too (they previously were - // only checked on registration updates, not on creation). + // Auch die Preiskategorien haben ihre Grenzen. Eine ausgebuchte gilt + // wie ein voller Kurs: mit Warteliste kommt die Anmeldung darauf, + // ohne wird sie abgelehnt — sie scheiterte sonst trotz Warteliste. if (status === RegistrationStatus.CONFIRMED) { const additionsByOptionId: Record = {}; for (const participant of participantsWithPriceOptions) { additionsByOptionId[participant.priceOptionId] = (additionsByOptionId[participant.priceOptionId] ?? 0) + 1; } - await assertPriceTierCapacity( + const fullOption = await findFullPriceTier( tx, input.courseId, course.priceOptions, additionsByOptionId, ); + if (fullOption) { + if (!course.allowWaitingList) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: priceTierFullMessage(fullOption), + }); + } + status = RegistrationStatus.WAITLIST; + } } const created = await tx.courseRegistration.create({ @@ -695,9 +708,23 @@ export const registrationsRouter = createTRPCRouter({ const newParticipants = participantsWithPriceOptions.length; const fitsInCapacity = confirmedCount + newParticipants <= capacity; + const additionsByOptionId: Record = {}; + for (const participant of participantsWithPriceOptions) { + additionsByOptionId[participant.priceOptionId] = + (additionsByOptionId[participant.priceOptionId] ?? 0) + 1; + } + const fullOption = await findFullPriceTier( + tx, + input.courseId, + course.priceOptions, + additionsByOptionId, + ); + + // "Automatisch" behandelt eine ausgebuchte Preiskategorie wie einen + // vollen Kurs, genau wie die öffentliche Anmeldung. const status: RegistrationStatus = requestedStatus ?? - (fitsInCapacity || !course.allowWaitingList + ((fitsInCapacity && !fullOption) || !course.allowWaitingList ? RegistrationStatus.CONFIRMED : RegistrationStatus.WAITLIST); @@ -716,18 +743,15 @@ export const registrationsRouter = createTRPCRouter({ // The overbooking acknowledgement covers per-price-option caps too: // staff who knowingly exceed the course capacity should not be // stopped by a tier limit right afterwards. - if (status === RegistrationStatus.CONFIRMED && !allowOverbooking) { - const additionsByOptionId: Record = {}; - for (const participant of participantsWithPriceOptions) { - additionsByOptionId[participant.priceOptionId] = - (additionsByOptionId[participant.priceOptionId] ?? 0) + 1; - } - await assertPriceTierCapacity( - tx, - input.courseId, - course.priceOptions, - additionsByOptionId, - ); + if ( + status === RegistrationStatus.CONFIRMED && + fullOption && + !allowOverbooking + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: priceTierFullMessage(fullOption), + }); } const created = await tx.courseRegistration.create({ @@ -1580,26 +1604,49 @@ export const registrationsRouter = createTRPCRouter({ ), }; + // Plätze belegt nur eine bestätigte Anmeldung — und die muss beim + // Bearbeiten in den Kurs passen, ob er eine Warteliste hat oder nicht. + // Mit Warteliste wurde das bisher übersprungen: eine bestätigte + // Anmeldung konnte Teilnehmer hinzufügen und den Kurs überbuchen. Eine + // Anmeldung auf der Warteliste belegt nichts; ihre Plätze prüft erst das + // Nachrücken. + const holdsSeats = + registration.registrationStatus === RegistrationStatus.CONFIRMED; + + // Geprüft wird nur, was die Änderung dazu belegt: in einem bewusst + // überbuchten Kurs soll sich trotzdem die Telefonnummer ändern oder ein + // Teilnehmer abmelden lassen. + const bookedByOptionId: Record = {}; + for (const participant of registration.participants) { + const optionId = resolveParticipantPriceOption( + participant, + course.priceOptions, + )?.id; + if (optionId) { + bookedByOptionId[optionId] = (bookedByOptionId[optionId] ?? 0) + 1; + } + } + // Capacity checks and the delete-and-rewrite of participants run in one // SERIALIZABLE transaction: no overbooking through concurrent edits, and // no half-rewritten participant list if anything fails midway. const updatedRegistration = await runSerializable(ctx.db, async (tx) => { - const currentParticipantsExcludingThis = - await countConfirmedParticipants(tx, course.id, id); - - const newTotalParticipants = - currentParticipantsExcludingThis + - participantsWithPriceOptions.length; - const maxParticipants = computeCourseCapacity(course); + if ( + holdsSeats && + participantsWithPriceOptions.length > registration.participants.length + ) { + const currentParticipantsExcludingThis = + await countConfirmedParticipants(tx, course.id, id); + const maxParticipants = computeCourseCapacity(course); - if (newTotalParticipants > maxParticipants) { - if (!course.allowWaitingList) { + if ( + currentParticipantsExcludingThis + + participantsWithPriceOptions.length > + maxParticipants + ) { const availableSpots = maxParticipants - currentParticipantsExcludingThis; - if ( - availableSpots > 0 && - availableSpots < participantsWithPriceOptions.length - ) { + if (availableSpots > 0) { throw new TRPCError({ code: "BAD_REQUEST", message: `Nur noch ${availableSpots} ${availableSpots === 1 ? "Platz" : "Plätze"} verfügbar, aber Sie versuchen ${participantsWithPriceOptions.length} ${participantsWithPriceOptions.length === 1 ? "Teilnehmer" : "Teilnehmer"} anzumelden. Bitte reduzieren Sie die Anzahl der Teilnehmer.`, @@ -1607,25 +1654,34 @@ export const registrationsRouter = createTRPCRouter({ } throw new TRPCError({ code: "BAD_REQUEST", - message: "Cannot add more participants - course is full", + message: + "Der Kurs ist ausgebucht – es können keine Teilnehmer hinzugefügt werden.", }); } } - const priceOptionCounts: Record = {}; - for (const participant of participantsWithPriceOptions) { - if (participant.priceOptionId) { - priceOptionCounts[participant.priceOptionId] = - (priceOptionCounts[participant.priceOptionId] ?? 0) + 1; + if (holdsSeats) { + const grownOptionCounts: Record = {}; + const priceOptionCounts: Record = {}; + for (const participant of participantsWithPriceOptions) { + if (participant.priceOptionId) { + priceOptionCounts[participant.priceOptionId] = + (priceOptionCounts[participant.priceOptionId] ?? 0) + 1; + } } + for (const [optionId, count] of Object.entries(priceOptionCounts)) { + if (count > (bookedByOptionId[optionId] ?? 0)) { + grownOptionCounts[optionId] = count; + } + } + await assertPriceTierCapacity( + tx, + course.id, + course.priceOptions, + grownOptionCounts, + id, + ); } - await assertPriceTierCapacity( - tx, - course.id, - course.priceOptions, - priceOptionCounts, - id, - ); const existingParticipantIds = participantsWithPriceOptions .filter((p) => p.id) From a426bf3ce61e5119f71fbfbd9a63c557b7801ca5 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Tue, 15 Sep 2026 08:38:41 +0200 Subject: [PATCH 007/139] =?UTF-8?q?fix(anmeldung):=20Warteliste=20ank?= =?UTF-8?q?=C3=BCndigen,=20wenn=20die=20Pl=C3=A4tze=20nicht=20reichen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Wartelisten-Hinweis erschien nur bei einem komplett vollen Kurs. Bei 2 freien Plätzen und 4 Teilnehmern gab es keinen Hinweis, und der Toast meldete "Ihre Anmeldung war erfolgreich.", obwohl der Server die ganze Anmeldung auf die Warteliste gesetzt hatte — nur die Mail stimmte. Schritt 3 warnt jetzt, sobald die Teilnehmer nicht in die freien Plätze des Kurses oder einer Preiskategorie passen: mit Warteliste, dass die Anmeldung nicht aufgeteilt wird und alle gemeinsam nachrücken, ohne Warteliste mit der Bitte, die Anzahl zu reduzieren. Die Anzahlung ist dann als "nach Platzbestätigung" ausgewiesen. Der Toast richtet sich nach dem Status, den der Server vergeben hat. Die Team-Erfassung zählt eine volle Kategorie ebenfalls als fehlende Plätze und fragt dann nach der Überbuchung. --- .../events/course-registration-form.tsx | 42 +++++++-- .../seat-shortage-notice.tsx | 73 +++++++++++++++ .../step-3-summary.tsx | 19 ++-- .../events/course-registration-form/types.ts | 8 +- .../events/course-registration-page.tsx | 2 + .../courses/[id]/participants/new/page.tsx | 1 + .../registration-seat-shortage.test.ts | 93 +++++++++++++++++++ src/lib/registration-seat-shortage.ts | 65 +++++++++++++ 8 files changed, 282 insertions(+), 21 deletions(-) create mode 100644 src/app/_components/events/course-registration-form/seat-shortage-notice.tsx create mode 100644 src/lib/__tests__/registration-seat-shortage.test.ts create mode 100644 src/lib/registration-seat-shortage.ts diff --git a/src/app/_components/events/course-registration-form.tsx b/src/app/_components/events/course-registration-form.tsx index f299e9d8..cdb43c70 100644 --- a/src/app/_components/events/course-registration-form.tsx +++ b/src/app/_components/events/course-registration-form.tsx @@ -23,6 +23,7 @@ import { Step2Participants } from "./course-registration-form/step-2-participant import { Step3Summary } from "./course-registration-form/step-3-summary"; import { validateStep as validateStepUtil } from "./course-registration-form/utils"; import { registrationErrorMessage } from "@/lib/registration-error-message"; +import { registrationSeatShortage } from "@/lib/registration-seat-shortage"; import { ScrollableModal, ScrollableModalCard, @@ -38,6 +39,7 @@ export default function CourseRegistrationForm({ currentUser, staffMode = false, availableSlots, + capacityByPriceOption, }: CourseRegistrationFormProps) { const toast = useToast(); const registrationMutation = api.registrations.create.useMutation(); @@ -309,13 +311,21 @@ export default function CourseRegistrationForm({ const canProceed = validateStep(currentStep); + // Why the entered participants won't all get a seat: too few free seats in + // the course or in one of the chosen price options. + const seatShortage = registrationSeatShortage({ + participantPriceOptionIds: registrationData.participants.map( + (p) => p.priceOptionId, + ), + availableSlots, + priceOptions: course.priceOptions, + capacityByPriceOption, + }); + // Seats short for what has been entered — the point at which the staff - // mutation requires an explicit overbooking consent. Falls back to the - // course-is-full flag when the free-seat count was not passed in. - const staffSeatsShort = - availableSlots != null && Number.isFinite(availableSlots) - ? registrationData.participants.length > availableSlots - : isWaitlist; + // mutation requires an explicit overbooking consent. The course-is-full + // flag covers a free-seat count that was not passed in. + const staffSeatsShort = isWaitlist || seatShortage !== null; // Mirrors the server: "AUTO" only becomes a waiting-list entry when the // course actually offers one, otherwise it confirms. @@ -326,6 +336,11 @@ export default function CourseRegistrationForm({ ? "WAITLIST" : "CONFIRMED"; + // The whole registration goes onto the waiting list — it is never split. + const expectsWaitlist = staffMode + ? staffResolvedStatus === "WAITLIST" + : !!course.allowWaitingList && staffSeatsShort; + // Same rule the staff mutation enforces server-side: confirming beyond the // capacity needs the acknowledgement. const blockedByFull = @@ -402,11 +417,17 @@ export default function CourseRegistrationForm({ }; const handlers = { - onSuccess: () => { + // The status the server assigned, not the form's guess: seats may have + // been taken since the page loaded, and it used to report success + // although the whole registration had landed on the waiting list. + onSuccess: (registration: { registrationStatus: string }) => { + const waitlisted = registration.registrationStatus === "WAITLIST"; toast.success( staffMode - ? "Die Anmeldung wurde erfasst." - : isWaitlist + ? waitlisted + ? "Die Anmeldung wurde auf der Warteliste erfasst." + : "Die Anmeldung wurde erfasst." + : waitlisted ? "Sie wurden auf die Warteliste gesetzt." : "Ihre Anmeldung war erfolgreich.", ); @@ -535,7 +556,8 @@ export default function CourseRegistrationForm({ registrationData.registrantEmail.trim().toLowerCase() === currentUser.email.toLowerCase() } - isWaitlist={isWaitlist} + isWaitlist={expectsWaitlist} + seatShortage={seatShortage} staff={ staffMode ? { diff --git a/src/app/_components/events/course-registration-form/seat-shortage-notice.tsx b/src/app/_components/events/course-registration-form/seat-shortage-notice.tsx new file mode 100644 index 00000000..befa8a51 --- /dev/null +++ b/src/app/_components/events/course-registration-form/seat-shortage-notice.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { priceOptionDisplayLabel } from "@/lib/course-price-options"; +import type { SeatShortage } from "@/lib/registration-seat-shortage"; +import type { CourseWithRelations } from "./types"; + +function seats(count: number): string { + return count === 1 ? "ist nur noch 1 Platz" : `sind nur noch ${count} Plätze`; +} + +/** + * Hinweis im letzten Schritt, wenn die Plätze für die eingetragenen + * Teilnehmer nicht reichen: mit Warteliste landet die **ganze** Anmeldung + * dort, ohne wird sie abgelehnt. Aufgeteilt wird eine Anmeldung nicht. + */ +export function SeatShortageNotice({ + course, + shortage, + participantCount, +}: { + course: CourseWithRelations; + shortage: SeatShortage; + participantCount: number; +}) { + const option = + shortage.kind === "priceOption" + ? course.priceOptions.find((po) => po.id === shortage.priceOptionId) + : undefined; + const optionLabel = option + ? `„${priceOptionDisplayLabel(option, course.priceOptions)}“` + : ""; + + const cause = + shortage.kind === "course" + ? shortage.free === 0 + ? "Der Kurs ist bereits ausgebucht." + : `Im Kurs ${seats(shortage.free)} frei, Sie melden ${shortage.requested} Teilnehmer an.` + : shortage.free === 0 + ? `Die Preiskategorie ${optionLabel} ist bereits ausgebucht.` + : `In der Preiskategorie ${optionLabel} ${seats(shortage.free)} frei, Sie melden ${shortage.requested} Teilnehmer darin an.`; + + if (!course.allowWaitingList) { + return ( +
+

+ Nicht genug freie Plätze: {cause}{" "} + {shortage.kind === "course" + ? "Bitte reduzieren Sie die Anzahl der Teilnehmer." + : "Bitte wählen Sie eine andere Preiskategorie oder reduzieren Sie die Anzahl der Teilnehmer."} +

+
+ ); + } + + const partlyFree = shortage.free > 0; + + return ( +
+

+ Hinweis: {cause}{" "} + {participantCount > 1 + ? `Ihre Anmeldung wird nicht aufgeteilt: alle ${participantCount} Teilnehmer kommen auf die Warteliste und werden gemeinsam bestätigt, sobald genug Plätze frei sind.` + : "Sie werden auf die Warteliste gesetzt und bei einem freigewordenen Platz benachrichtigt."} + {partlyFree && participantCount > 1 + ? ` Sollen die freien Plätze sofort genutzt werden, melden Sie hier nur so viele Teilnehmer an und die übrigen in einer zweiten Anmeldung.` + : ""} +

+
+ ); +} diff --git a/src/app/_components/events/course-registration-form/step-3-summary.tsx b/src/app/_components/events/course-registration-form/step-3-summary.tsx index 8cfc2b8c..b7a6ba5f 100644 --- a/src/app/_components/events/course-registration-form/step-3-summary.tsx +++ b/src/app/_components/events/course-registration-form/step-3-summary.tsx @@ -10,6 +10,8 @@ import type { } from "./types"; import { StaffOptions } from "./staff-options"; import { DownPaymentSummary } from "./down-payment-summary"; +import { SeatShortageNotice } from "./seat-shortage-notice"; +import type { SeatShortage } from "@/lib/registration-seat-shortage"; import { calculateTotalPrice, calculateOriginalPrice, @@ -36,6 +38,8 @@ interface Step3SummaryProps { /** Signed in with the registrant's e-mail, so "Meine Anmeldungen" lists it. */ listedInMyRegistrations: boolean; isWaitlist: boolean; + /** Too few free seats for the participants entered, if so. */ + seatShortage: SeatShortage | null; /** Set when the course team records the registration itself. */ staff?: { options: StaffRegistrationOptions; @@ -57,6 +61,7 @@ export function Step3Summary({ setDownPaymentAcknowledged, listedInMyRegistrations, isWaitlist, + seatShortage, staff, }: Step3SummaryProps) { const downPaymentAmount = calculateDownPayment(registrationData, course); @@ -396,14 +401,12 @@ export function Step3Summary({ )} - {isWaitlist && !staff && ( -
-

- Hinweis: Der Kurs ist bereits ausgebucht. Sie - werden auf die Warteliste gesetzt und bei einem freigewordenen Platz - benachrichtigt. -

-
+ {!staff && seatShortage && ( + )} ); diff --git a/src/app/_components/events/course-registration-form/types.ts b/src/app/_components/events/course-registration-form/types.ts index 282b6998..72ffa82e 100644 --- a/src/app/_components/events/course-registration-form/types.ts +++ b/src/app/_components/events/course-registration-form/types.ts @@ -58,11 +58,13 @@ export interface CourseRegistrationFormProps { */ staffMode?: boolean; /** - * Free seats left in the course, used in staff mode to ask for an - * overbooking consent as soon as the entered participants no longer fit — - * not only when the course is already completely full. + * Free seats left in the course. Step 3 warns as soon as the entered + * participants no longer fit — not only when the course is already + * completely full — and staff mode asks for an overbooking consent then. */ availableSlots?: number; + /** Free seats per price option (by id), as `getAvailableSlots` reports them. */ + capacityByPriceOption?: Record | null; } export type Step = 1 | 2 | 3; diff --git a/src/app/_components/events/course-registration-page.tsx b/src/app/_components/events/course-registration-page.tsx index 6b8a9bfa..539fdcb3 100644 --- a/src/app/_components/events/course-registration-page.tsx +++ b/src/app/_components/events/course-registration-page.tsx @@ -233,6 +233,8 @@ export default function CourseRegistrationPage({ onClose={onCloseForm} onSuccess={onSuccessForm} isWaitlist={isWaitlist} + availableSlots={spots.availableSlots} + capacityByPriceOption={spots.capacityByPriceOption} currentUser={userProfile ?? null} /> )} diff --git a/src/app/dashboard/courses/[id]/participants/new/page.tsx b/src/app/dashboard/courses/[id]/participants/new/page.tsx index ba608f4d..ee489c1d 100644 --- a/src/app/dashboard/courses/[id]/participants/new/page.tsx +++ b/src/app/dashboard/courses/[id]/participants/new/page.tsx @@ -187,6 +187,7 @@ export default function NewCourseRegistrationPage() { currentUser={null} isWaitlist={spots?.isFull ?? false} availableSlots={spots?.availableSlots} + capacityByPriceOption={spots?.capacityByPriceOption} onClose={() => router.push(participantsUrl)} onSuccess={() => { void utils.courses.getRegistrations.invalidate({ courseId }); diff --git a/src/lib/__tests__/registration-seat-shortage.test.ts b/src/lib/__tests__/registration-seat-shortage.test.ts new file mode 100644 index 00000000..5b76f0b2 --- /dev/null +++ b/src/lib/__tests__/registration-seat-shortage.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "@jest/globals"; +import { registrationSeatShortage } from "../registration-seat-shortage"; + +const priceOptions = [ + { id: "erwachsene", maxParticipants: 10 }, + { id: "kinder", maxParticipants: null }, +]; + +describe("registrationSeatShortage", () => { + it("reports nothing while no participant is entered", () => { + expect( + registrationSeatShortage({ + participantPriceOptionIds: [], + availableSlots: 0, + priceOptions, + capacityByPriceOption: null, + }), + ).toBeNull(); + }); + + it("reports nothing when everyone fits", () => { + expect( + registrationSeatShortage({ + participantPriceOptionIds: ["kinder", "kinder"], + availableSlots: 2, + priceOptions, + capacityByPriceOption: { erwachsene: 5, kinder: 2 }, + }), + ).toBeNull(); + }); + + it("reports a course with fewer free seats than participants, not only a full one", () => { + expect( + registrationSeatShortage({ + participantPriceOptionIds: ["kinder", "kinder", "kinder", "kinder"], + availableSlots: 2, + priceOptions, + capacityByPriceOption: null, + }), + ).toEqual({ kind: "course", free: 2, requested: 4 }); + }); + + it("treats an unlimited course as having room", () => { + expect( + registrationSeatShortage({ + participantPriceOptionIds: ["kinder", "kinder"], + availableSlots: Infinity, + priceOptions, + capacityByPriceOption: null, + }), + ).toBeNull(); + }); + + it("reports a full price option even though the course still has seats", () => { + expect( + registrationSeatShortage({ + participantPriceOptionIds: ["erwachsene", "erwachsene", "kinder"], + availableSlots: 8, + priceOptions, + capacityByPriceOption: { erwachsene: 1, kinder: 8 }, + }), + ).toEqual({ + kind: "priceOption", + priceOptionId: "erwachsene", + free: 1, + requested: 2, + }); + }); + + it("ignores the shared pool of options without their own limit", () => { + // Der Pool gilt für alle unbegrenzten Kategorien zusammen und ist über + // `availableSlots` schon abgedeckt. + expect( + registrationSeatShortage({ + participantPriceOptionIds: ["kinder", "kinder"], + availableSlots: undefined, + priceOptions, + capacityByPriceOption: { erwachsene: 5, kinder: 1 }, + }), + ).toBeNull(); + }); + + it("names the course before a price option", () => { + expect( + registrationSeatShortage({ + participantPriceOptionIds: ["erwachsene", "erwachsene"], + availableSlots: 1, + priceOptions, + capacityByPriceOption: { erwachsene: 0 }, + }), + ).toMatchObject({ kind: "course" }); + }); +}); diff --git a/src/lib/registration-seat-shortage.ts b/src/lib/registration-seat-shortage.ts new file mode 100644 index 00000000..c7adb418 --- /dev/null +++ b/src/lib/registration-seat-shortage.ts @@ -0,0 +1,65 @@ +/** + * Warum die eingetragenen Teilnehmer voraussichtlich keinen Platz bekommen. + * + * Der Wartelisten-Hinweis im Formular erschien nur bei einem ganz vollen Kurs: + * wer vier Teilnehmer für zwei freie Plätze eintrug, erfuhr erst aus der Mail, + * dass die ganze Anmeldung auf der Warteliste steht. Die freien Plätze stammen + * vom Laden der Seite — entscheidend bleibt der Server, dies ist nur der + * Hinweis vorab. + */ +export type SeatShortage = + | { kind: "course"; free: number; requested: number } + | { + kind: "priceOption"; + priceOptionId: string; + free: number; + /** Teilnehmer in dieser Kategorie, nicht in der ganzen Anmeldung. */ + requested: number; + }; + +export function registrationSeatShortage({ + participantPriceOptionIds, + availableSlots, + priceOptions, + capacityByPriceOption, +}: { + /** Preiskategorie je eingetragenem Teilnehmer. */ + participantPriceOptionIds: ReadonlyArray; + /** Freie Plätze im Kurs; `Infinity` oder fehlend heißt unbegrenzt. */ + availableSlots: number | null | undefined; + priceOptions: ReadonlyArray<{ id: string; maxParticipants: number | null }>; + /** Restplätze je Kategorie, wie `courses.getAvailableSlots` sie liefert. */ + capacityByPriceOption: Readonly> | null | undefined; +}): SeatShortage | null { + const requested = participantPriceOptionIds.length; + if (requested === 0) return null; + + if ( + availableSlots != null && + Number.isFinite(availableSlots) && + requested > availableSlots + ) { + return { kind: "course", free: availableSlots, requested }; + } + + // Nur Kategorien mit eigenem Limit, wie auf dem Server: unbegrenzte teilen + // sich die Plätze des Kurses, und die sind oben schon geprüft. + for (const option of priceOptions) { + if (option.maxParticipants == null) continue; + const free = capacityByPriceOption?.[option.id]; + if (free == null) continue; + const inOption = participantPriceOptionIds.filter( + (id) => id === option.id, + ).length; + if (inOption > free) { + return { + kind: "priceOption", + priceOptionId: option.id, + free, + requested: inOption, + }; + } + } + + return null; +} From c6c65a2b994627c5e3eac191a25966452b8e1463 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Tue, 15 Sep 2026 09:24:44 +0200 Subject: [PATCH 008/139] =?UTF-8?q?feat(anmeldungen):=20Anmeldung=20auf=20?= =?UTF-8?q?freie=20Pl=C3=A4tze=20und=20Warteliste=20aufteilen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reichten die freien Plätze nicht für alle Teilnehmer, kam die ganze Anmeldung auf die Warteliste – auch wenn ein Teil der Gruppe noch Platz gehabt hätte. create und createByStaff nehmen jetzt confirmedParticipantIndexes entgegen: die Gewählten werden eine bestätigte Anmeldung, die übrigen eine eigene auf der Warteliste. Beide tragen dieselbe registrationGroupId, weil Kapazität, Rechnungen und Anzahlung an der Anmeldung hängen, nicht am Teilnehmer. Aufgeteilt wird nur, wenn der Kurs eine Warteliste führt und nicht ohnehin alle Platz haben; beim Team nur ohne ausdrücklichen Status. Die Auswahl wird in der Transaktion noch einmal gegen Kurs und Preiskategorien geprüft. Passt sie inzwischen nicht mehr, schlägt das Absenden fehl, statt still anders aufzuteilen. Der Geschwisterkindrabatt wird über alle nicht stornierten Teile berechnet und jedem Teil der Anteil seiner Teilnehmer zugerechnet – getrennte Geschwister verlieren ihn nicht, auch nicht beim Bearbeiten oder nachträglichen Gewähren. Genehmigen, Ablehnen und "zum vollen Preis bestätigen" gelten für alle Teile, mit einer Mail über die Summe. confirmAtFullPrice setzt den Status nicht mehr auf bestätigt; eine wartende Anmeldung hätte den Kurs damit überbucht. Die Anzahlung gilt je Teil und ist erst mit der Platzbestätigung fällig. Eine "bereits eingegangene" Anzahlung verbucht das Team beim bestätigten Teil. Statt zweier widersprüchlicher Mails geht eine gemeinsame hinaus, mit Preis, Anzahlung und Link je Teil; das Kursteam bekommt eine Benachrichtigung "teilweise Warteliste". getById liefert die übrigen Teile als groupParts, bei Zugriff über einen Zugangslink mit eigenem Token. Migration 20260915120000_registration_groups: nur eine neue Spalte samt Index. --- .../migration.sql | 5 + prisma/schema.prisma | 10 + src/lib/__tests__/registration-split.test.ts | 212 +++++ src/lib/registration-split.ts | 219 +++++ src/server/api/helpers/course-capacity.ts | 54 ++ src/server/api/helpers/registration-group.ts | 98 ++ src/server/api/routers/registrations.ts | 854 ++++++++++++------ src/server/email/index.ts | 1 + src/server/email/registration-split-email.ts | 39 + .../templates/course-registration-split.tsx | 368 ++++++++ 10 files changed, 1583 insertions(+), 277 deletions(-) create mode 100644 prisma/migrations/20260915120000_registration_groups/migration.sql create mode 100644 src/lib/__tests__/registration-split.test.ts create mode 100644 src/lib/registration-split.ts create mode 100644 src/server/api/helpers/registration-group.ts create mode 100644 src/server/email/registration-split-email.ts create mode 100644 src/server/email/templates/course-registration-split.tsx diff --git a/prisma/migrations/20260915120000_registration_groups/migration.sql b/prisma/migrations/20260915120000_registration_groups/migration.sql new file mode 100644 index 00000000..6b49a2d2 --- /dev/null +++ b/prisma/migrations/20260915120000_registration_groups/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "CourseRegistration" ADD COLUMN "registrationGroupId" TEXT; + +-- CreateIndex +CREATE INDEX "CourseRegistration_registrationGroupId_idx" ON "CourseRegistration"("registrationGroupId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2e7651cc..e450a0d6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -839,6 +839,15 @@ model CourseRegistration { invoiceId String? @unique invoiceDate DateTime? + /** + * Teile einer aufgeteilten Anmeldung tragen dieselbe Gruppe: reichten die + * freien Plätze nicht für alle, wurden die Gewählten bestätigt und die + * übrigen auf die Warteliste gesetzt — als eigene Anmeldungen, weil + * Kapazität, Rechnungen und Anzahlung an der Anmeldung hängen. Der + * Geschwisterkindrabatt wird über die Gruppe berechnet. null: nicht geteilt. + */ + registrationGroupId String? + // Relations participants Participant[] invoices Invoice[] @@ -853,6 +862,7 @@ model CourseRegistration { @@index([siblingDiscountStatus]) @@index([courseId, registrationStatus]) @@index([registrantId]) + @@index([registrationGroupId]) } /// One e-mail an organizer sent to the registrants of a course, archived so diff --git a/src/lib/__tests__/registration-split.test.ts b/src/lib/__tests__/registration-split.test.ts new file mode 100644 index 00000000..c5b72afe --- /dev/null +++ b/src/lib/__tests__/registration-split.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "@jest/globals"; +import { + defaultSeatSelection, + normalizeSeatSelection, + partPricing, + planRegistrationParts, + seatSelectionProblem, + siblingDiscountWithinGroup, + splitsSiblingGroup, +} from "../registration-split"; + +const priceOptions = [ + { id: "einzel", maxParticipants: 6 }, + { id: "kinder", maxParticipants: null }, +]; + +describe("defaultSeatSelection", () => { + it("fills the free seats in list order", () => { + expect( + defaultSeatSelection(["kinder", "kinder", "kinder", "kinder"], { + availableSlots: 2, + priceOptions, + capacityByPriceOption: null, + }), + ).toEqual([0, 1]); + }); + + it("skips a participant whose price option is full and keeps filling", () => { + expect( + defaultSeatSelection(["einzel", "einzel", "kinder"], { + availableSlots: 2, + priceOptions, + capacityByPriceOption: { einzel: 1 }, + }), + ).toEqual([0, 2]); + }); + + it("selects nobody when no seat is free", () => { + expect( + defaultSeatSelection(["kinder", "kinder"], { + availableSlots: 0, + priceOptions, + capacityByPriceOption: null, + }), + ).toEqual([]); + }); +}); + +describe("seatSelectionProblem", () => { + const ids = ["einzel", "einzel", "kinder"]; + const availability = { + availableSlots: 2, + priceOptions, + capacityByPriceOption: { einzel: 1 }, + }; + + it("accepts a selection that fits course and price options", () => { + expect(seatSelectionProblem(ids, [0, 2], availability)).toBeNull(); + }); + + it("rejects an empty selection", () => { + expect(seatSelectionProblem(ids, [], availability)).toEqual({ + kind: "empty", + }); + }); + + it("rejects selecting everyone, which is no split", () => { + expect(seatSelectionProblem(ids, [0, 1, 2], availability)).toEqual({ + kind: "all", + }); + }); + + it("names a price option the selection overfills", () => { + expect(seatSelectionProblem(ids, [0, 1], availability)).toMatchObject({ + kind: "priceOption", + priceOptionId: "einzel", + free: 1, + }); + }); +}); + +describe("normalizeSeatSelection", () => { + it("sorts a valid selection", () => { + expect(normalizeSeatSelection([2, 0], 4)).toEqual([0, 2]); + }); + + it.each([ + ["empty", [], 3], + ["everyone", [0, 1, 2], 3], + ["duplicates", [0, 0], 3], + ["out of range", [3], 3], + ["negative", [-1], 3], + ["fractions", [0.5], 3], + ])("rejects %s", (_, indexes, count) => { + expect(normalizeSeatSelection(indexes, count)).toBeNull(); + }); +}); + +describe("splitsSiblingGroup", () => { + const participants = [ + { siblingGroupId: "a" }, + { siblingGroupId: "a" }, + { siblingGroupId: null }, + ]; + + it("detects siblings on both sides", () => { + expect(splitsSiblingGroup(participants, [0, 2])).toBe(true); + }); + + it("is fine when siblings stay together", () => { + expect(splitsSiblingGroup(participants, [0, 1])).toBe(false); + }); +}); + +describe("sibling discount across parts", () => { + const older = { + birthDate: new Date("2010-01-01"), + siblingGroupId: "a", + price: 100, + }; + const younger = { + birthDate: new Date("2014-01-01"), + siblingGroupId: "a", + price: 100, + }; + const other = { birthDate: new Date("2000-01-01"), price: 50 }; + + it("keeps the discount of a sibling waiting on the list", () => { + // Der Ältere zahlt voll, der Jüngere bekommt 20 % — auch getrennt. + expect(siblingDiscountWithinGroup([younger], [older])).toBe(20); + expect(siblingDiscountWithinGroup([older], [younger])).toBe(0); + }); + + it("prices each part from its own participants", () => { + const participants = [older, other, younger]; + expect( + partPricing(participants, [0, 1], { withSiblingDiscount: true }), + ).toEqual({ + originalTotalPrice: 150, + siblingDiscountAmount: 0, + totalPrice: 150, + }); + expect( + partPricing(participants, [2], { withSiblingDiscount: true }), + ).toEqual({ + originalTotalPrice: 100, + siblingDiscountAmount: 20, + totalPrice: 80, + }); + }); + + it("drops the discount when it was not applied for", () => { + expect( + partPricing([older, younger], [1], { withSiblingDiscount: false }), + ).toMatchObject({ siblingDiscountAmount: 0, totalPrice: 100 }); + }); +}); + +describe("planRegistrationParts", () => { + const participants = [ + { + name: "Anna", + birthDate: new Date("2010-01-01"), + siblingGroupId: "a", + price: 100, + }, + { + name: "Ben", + birthDate: new Date("2014-01-01"), + siblingGroupId: "a", + price: 100, + }, + { name: "Clara", birthDate: new Date("2000-01-01"), price: 50 }, + ]; + const priceOf = (participant: { price: number }) => participant.price; + + it("keeps an unsplit registration in one part", () => { + const plan = planRegistrationParts(participants, priceOf, { + status: "WAITLIST", + confirmedIndexes: null, + withSiblingDiscount: true, + }); + expect(plan.waitlist).toBeNull(); + expect(plan.primary).toMatchObject({ + status: "WAITLIST", + originalTotalPrice: 250, + siblingDiscountAmount: 20, + totalPrice: 230, + }); + expect(plan.primary.participants).toHaveLength(3); + }); + + it("confirms the chosen participants and waitlists the rest", () => { + const plan = planRegistrationParts(participants, priceOf, { + status: "WAITLIST", + confirmedIndexes: [1, 2], + withSiblingDiscount: true, + }); + expect(plan.primary.status).toBe("CONFIRMED"); + expect(plan.primary.participants.map((p) => p.name)).toEqual([ + "Ben", + "Clara", + ]); + expect(plan.primary).toMatchObject({ + siblingDiscountAmount: 20, + totalPrice: 130, + }); + expect(plan.waitlist?.status).toBe("WAITLIST"); + expect(plan.waitlist?.participants.map((p) => p.name)).toEqual(["Anna"]); + expect(plan.waitlist?.totalPrice).toBe(100); + }); +}); diff --git a/src/lib/registration-split.ts b/src/lib/registration-split.ts new file mode 100644 index 00000000..0f3fa6e3 --- /dev/null +++ b/src/lib/registration-split.ts @@ -0,0 +1,219 @@ +import { + computeSiblingDiscounts, + roundMoney, + type SiblingDiscountParticipant, +} from "./sibling-discount"; +import { + registrationSeatShortage, + type SeatShortage, +} from "./registration-seat-shortage"; + +/** + * Aufteilen einer Anmeldung. + * + * Reichen die freien Plätze nicht für alle Teilnehmer, wählen die Anmeldenden, + * wer sie bekommt: die Gewählten werden eine bestätigte Anmeldung, die übrigen + * eine auf der Warteliste. Zwei Anmeldungen mit gemeinsamer + * `registrationGroupId` statt eines Status je Teilnehmer, weil Kapazität, + * Rechnungen und Anzahlung an der Anmeldung hängen. + */ + +/** Meldung, wenn die gewählten Teilnehmer beim Absenden nicht mehr passen. */ +export const SEAT_SELECTION_OUTDATED_MESSAGE = + "Für Ihre Auswahl sind inzwischen nicht mehr genug Plätze frei. Bitte passen Sie die Auswahl an."; + +/** Freie Plätze, wie `courses.getAvailableSlots` sie meldet. */ +export type SeatAvailability = { + /** Freie Plätze im Kurs; `Infinity` oder fehlend heißt unbegrenzt. */ + availableSlots: number | null | undefined; + priceOptions: ReadonlyArray<{ id: string; maxParticipants: number | null }>; + capacityByPriceOption: Readonly> | null | undefined; +}; + +/** + * Die vorbelegte Auswahl: Teilnehmer in Listenreihenfolge, solange Kurs und + * Preiskategorie Platz haben. Wer in einer vollen Kategorie steht, wird + * übersprungen — nachfolgende in anderen Kategorien können noch passen. + */ +export function defaultSeatSelection( + participantPriceOptionIds: ReadonlyArray, + availability: SeatAvailability, +): number[] { + const selected: number[] = []; + for (let index = 0; index < participantPriceOptionIds.length; index++) { + const candidate = [...selected, index]; + const shortage = registrationSeatShortage({ + participantPriceOptionIds: candidate.map( + (i) => participantPriceOptionIds[i], + ), + ...availability, + }); + if (shortage === null) selected.push(index); + } + return selected; +} + +export type SeatSelectionProblem = + | { kind: "empty" } + /** Alle gewählt — dann ist es keine Aufteilung, und alle passen nicht. */ + | { kind: "all" } + | SeatShortage; + +/** Warum eine Auswahl nicht in die freien Plätze passt — `null`, wenn sie passt. */ +export function seatSelectionProblem( + participantPriceOptionIds: ReadonlyArray, + selectedIndexes: readonly number[], + availability: SeatAvailability, +): SeatSelectionProblem | null { + if (selectedIndexes.length === 0) return { kind: "empty" }; + if (selectedIndexes.length >= participantPriceOptionIds.length) { + return { kind: "all" }; + } + return registrationSeatShortage({ + participantPriceOptionIds: selectedIndexes.map( + (i) => participantPriceOptionIds[i], + ), + ...availability, + }); +} + +/** + * Prüft die übermittelte Auswahl: ganze Zahlen im Bereich, keine doppelt, und + * ein echter Teil — mindestens einer bestätigt, mindestens einer wartet. + * Liefert die Auswahl sortiert oder `null`, wenn sie so nicht taugt. + */ +export function normalizeSeatSelection( + indexes: readonly number[], + participantCount: number, +): number[] | null { + const unique = [...new Set(indexes)].sort((a, b) => a - b); + if (unique.length !== indexes.length) return null; + if (unique.length === 0 || unique.length >= participantCount) return null; + if ( + unique.some((i) => !Number.isInteger(i) || i < 0 || i >= participantCount) + ) { + return null; + } + return unique; +} + +/** Ob die Auswahl Geschwister auf verschiedene Teile verteilt. */ +export function splitsSiblingGroup( + participants: ReadonlyArray<{ siblingGroupId?: string | null }>, + selectedIndexes: readonly number[], +): boolean { + const selected = new Set(selectedIndexes); + const sides = new Map>(); + participants.forEach((participant, index) => { + if (!participant.siblingGroupId) return; + const side = sides.get(participant.siblingGroupId) ?? new Set(); + side.add(selected.has(index)); + sides.set(participant.siblingGroupId, side); + }); + return [...sides.values()].some((side) => side.size > 1); +} + +/** + * Geschwisterkindrabatt eines Teils. Berechnet wird über alle Teile der + * Gruppe — getrennte Geschwister verlieren ihn nicht — und jedem Teil der + * Anteil seiner eigenen Teilnehmer zugerechnet. + */ +export function siblingDiscountWithinGroup( + own: readonly SiblingDiscountParticipant[], + others: readonly SiblingDiscountParticipant[], +): number { + const { discountPerParticipant } = computeSiblingDiscounts([ + ...own, + ...others, + ]); + return roundMoney( + discountPerParticipant + .slice(0, own.length) + .reduce((sum, discount) => sum + discount, 0), + ); +} + +export type PartPricing = { + /** Summe der Kategoriepreise der Teilnehmer dieses Teils. */ + originalTotalPrice: number; + siblingDiscountAmount: number; + totalPrice: number; +}; + +type PartStatus = "CONFIRMED" | "WAITLIST" | "CANCELLED"; + +export type PlannedPart

= PartPricing & { + status: PartStatus; + participants: P[]; +}; + +/** + * Die Anmeldungen, die aus einem Absenden entstehen: eine mit `status` — oder, + * mit Auswahl, die Gewählten bestätigt und die übrigen auf der Warteliste. + */ +export function planRegistrationParts< + P extends { birthDate: Date; siblingGroupId?: string | null }, +>( + participants: readonly P[], + priceOf: (participant: P) => number, + { + status, + confirmedIndexes, + withSiblingDiscount, + }: { + status: PartStatus; + confirmedIndexes: readonly number[] | null; + withSiblingDiscount: boolean; + }, +): { primary: PlannedPart

; waitlist: PlannedPart

| null } { + const priced = participants.map((participant) => ({ + birthDate: participant.birthDate, + siblingGroupId: participant.siblingGroupId, + price: priceOf(participant), + })); + const part = (indexes: number[], partStatus: PartStatus): PlannedPart

=> ({ + status: partStatus, + participants: indexes.map((index) => participants[index]!), + ...partPricing(priced, indexes, { withSiblingDiscount }), + }); + + const all = participants.map((_, index) => index); + if (!confirmedIndexes) { + return { primary: part(all, status), waitlist: null }; + } + const chosen = new Set(confirmedIndexes); + return { + primary: part( + all.filter((index) => chosen.has(index)), + "CONFIRMED", + ), + waitlist: part( + all.filter((index) => !chosen.has(index)), + "WAITLIST", + ), + }; +} + +/** Preise eines Teils: seine Teilnehmer, Rabatt über die ganze Gruppe. */ +export function partPricing( + participants: readonly SiblingDiscountParticipant[], + ownIndexes: readonly number[], + { withSiblingDiscount }: { withSiblingDiscount: boolean }, +): PartPricing { + const ownSet = new Set(ownIndexes); + const own = participants.filter((_, index) => ownSet.has(index)); + const others = participants.filter((_, index) => !ownSet.has(index)); + + const originalTotalPrice = roundMoney( + own.reduce((sum, participant) => sum + participant.price, 0), + ); + const siblingDiscountAmount = withSiblingDiscount + ? siblingDiscountWithinGroup(own, others) + : 0; + + return { + originalTotalPrice, + siblingDiscountAmount, + totalPrice: roundMoney(originalTotalPrice - siblingDiscountAmount), + }; +} diff --git a/src/server/api/helpers/course-capacity.ts b/src/server/api/helpers/course-capacity.ts index dc9349d2..6e7f1d4b 100644 --- a/src/server/api/helpers/course-capacity.ts +++ b/src/server/api/helpers/course-capacity.ts @@ -1,6 +1,7 @@ import { Prisma, RegistrationStatus } from "~/generated/prisma/client"; import { TRPCError } from "@trpc/server"; import type { db as database } from "@/server/db"; +import { SEAT_SELECTION_OUTDATED_MESSAGE } from "@/lib/registration-split"; type Db = typeof database; type Tx = Prisma.TransactionClient; @@ -141,6 +142,59 @@ export async function assertPriceTierCapacity( } } +/** + * Prüft beim Aufteilen, ob die für die freien Plätze gewählten Teilnehmer + * noch in Kurs und Preiskategorien passen. Die Plätze stammen vom Laden der + * Seite und können inzwischen vergeben sein. + */ +export async function assertSeatSelectionFits( + db: Db | Tx, + { + course, + participants, + selection, + confirmedCount, + }: { + course: { + id: string; + maxParticipants: number | null; + priceOptions: TierPriceOption[]; + }; + participants: ReadonlyArray<{ priceOptionId: string }>; + selection: readonly number[]; + /** Bereits bestätigte Teilnehmer des Kurses. */ + confirmedCount: number; + }, +): Promise { + const outdated = () => + new TRPCError({ + code: "BAD_REQUEST", + message: SEAT_SELECTION_OUTDATED_MESSAGE, + }); + + if (confirmedCount + selection.length > computeCourseCapacity(course)) { + throw outdated(); + } + + const additionsByOptionId: Record = {}; + for (const index of selection) { + const optionId = participants[index]?.priceOptionId; + if (optionId) { + additionsByOptionId[optionId] = (additionsByOptionId[optionId] ?? 0) + 1; + } + } + if ( + await findFullPriceTier( + db, + course.id, + course.priceOptions, + additionsByOptionId, + ) + ) { + throw outdated(); + } +} + const SERIALIZATION_FAILURE = "P2034"; const MAX_RETRIES = 3; diff --git a/src/server/api/helpers/registration-group.ts b/src/server/api/helpers/registration-group.ts new file mode 100644 index 00000000..01899774 --- /dev/null +++ b/src/server/api/helpers/registration-group.ts @@ -0,0 +1,98 @@ +import { + RegistrationStatus, + type SiblingDiscountStatus, +} from "~/generated/prisma/client"; +import type { db as database } from "@/server/db"; +import { roundMoney } from "@/lib/sibling-discount"; + +type Db = typeof database; + +type GroupMember = { id: string; registrationGroupId: string | null }; + +/** + * Teilnehmer der übrigen, nicht stornierten Teile einer aufgeteilten + * Anmeldung. Der Geschwisterkindrabatt wird über sie mitberechnet, damit ein + * Teil ihn nicht verliert, nur weil das ältere Geschwister im anderen steht. + */ +export async function otherPartParticipants( + db: Db, + registration: GroupMember, +): Promise< + Array<{ + birthDate: Date; + siblingGroupId: string | null; + priceOptionId: string | null; + }> +> { + if (!registration.registrationGroupId) return []; + return db.participant.findMany({ + where: { + registration: { + registrationGroupId: registration.registrationGroupId, + id: { not: registration.id }, + registrationStatus: { not: RegistrationStatus.CANCELLED }, + }, + }, + select: { birthDate: true, siblingGroupId: true, priceOptionId: true }, + }); +} + +/** + * Die Teile, über deren Rabatt gemeinsam entschieden wird: diese Anmeldung + * und — ist sie aufgeteilt — die übrigen nicht stornierten Teile mit + * demselben Rabattstatus. Berechnet wurde der Rabatt über alle Teile, also + * wird er auch für alle genehmigt oder abgelehnt. + */ +export async function siblingDiscountParts( + db: Db, + registration: GroupMember, + status: SiblingDiscountStatus, +) { + return db.courseRegistration.findMany({ + where: registration.registrationGroupId + ? { + OR: [ + { id: registration.id }, + { + registrationGroupId: registration.registrationGroupId, + siblingDiscountStatus: status, + registrationStatus: { not: RegistrationStatus.CANCELLED }, + }, + ], + } + : { id: registration.id }, + select: { + id: true, + registrationStatus: true, + totalPrice: true, + originalTotalPrice: true, + siblingDiscountAmount: true, + _count: { select: { participants: true } }, + }, + orderBy: { createdAt: "asc" }, + }); +} + +/** Summen über die Teile — für die eine Mail zur Rabattentscheidung. */ +export function sumDiscountParts( + parts: ReadonlyArray<{ + totalPrice: number; + originalTotalPrice: number | null; + siblingDiscountAmount: number | null; + _count: { participants: number }; + }>, +) { + const sum = (value: (part: (typeof parts)[number]) => number) => + roundMoney(parts.reduce((total, part) => total + value(part), 0)); + return { + originalTotalPrice: sum( + (part) => part.originalTotalPrice ?? part.totalPrice, + ), + siblingDiscountAmount: sum((part) => part.siblingDiscountAmount ?? 0), + totalPrice: sum((part) => part.totalPrice), + participantCount: parts.reduce( + (total, part) => total + part._count.participants, + 0, + ), + }; +} diff --git a/src/server/api/routers/registrations.ts b/src/server/api/routers/registrations.ts index 6a49fd04..862a4263 100644 --- a/src/server/api/routers/registrations.ts +++ b/src/server/api/routers/registrations.ts @@ -53,16 +53,29 @@ import { permissionProcedure, permissionProcedureAny, } from "../middleware/permissions"; -import { computeSiblingDiscounts, roundMoney } from "@/lib/sibling-discount"; +import { roundMoney } from "@/lib/sibling-discount"; +import { + otherPartParticipants, + siblingDiscountParts, + sumDiscountParts, +} from "../helpers/registration-group"; import { assertPriceTierCapacity, + assertSeatSelectionFits, computeCourseCapacity, countConfirmedParticipants, findFullPriceTier, priceTierFullMessage, runSerializable, } from "../helpers/course-capacity"; +import { randomUUID } from "node:crypto"; import { resolveParticipantPriceOption } from "@/lib/course-price-options"; +import { + normalizeSeatSelection, + planRegistrationParts, + siblingDiscountWithinGroup, +} from "@/lib/registration-split"; +import { createRegistrationAccessToken } from "@/server/utils/registration-access-token"; import { logAudit } from "../helpers/audit"; import { createNotification, @@ -182,6 +195,133 @@ async function sendRegistrationCreatedEmail(args: { } } +type CreatedRegistrationPart = Prisma.CourseRegistrationGetPayload<{ + include: { + participants: true; + course: { select: { title: true; startDate: true; endDate: true } }; + }; +}>; + +/** Die Anmeldung(en) eines Absendens: aufgeteilt bestätigt + wartend. */ +type CreatedParts = + { + primary: { registration: T }; + waitlist: { registration: T } | null; + }; + +/** Preis der gewählten Kategorie eines Teilnehmers. */ +function priceOptionPrice( + course: { priceOptions: Array<{ id: string; price: number }> }, + participant: { priceOptionId?: string | null }, +): number { + return ( + course.priceOptions.find( + (option) => option.id === participant.priceOptionId, + )?.price ?? 0 + ); +} + +/** + * Die übermittelte Auswahl fürs Aufteilen, geprüft; `null` ohne Auswahl. Ob + * tatsächlich aufgeteilt wird, entscheidet sich erst an den freien Plätzen. + */ +function parseSeatSelection( + indexes: number[] | undefined, + participantCount: number, + course: { allowWaitingList: boolean | null }, +): number[] | null { + if (!indexes) return null; + const selection = normalizeSeatSelection(indexes, participantCount); + if (!selection) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Die Auswahl für die freien Plätze ist ungültig.", + }); + } + if (!course.allowWaitingList) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Dieser Kurs hat keine Warteliste – die Anmeldung lässt sich nicht aufteilen.", + }); + } + return selection; +} + +/** + * Mail zu einem Absenden. Aufgeteilt geht eine Mail für beide Teile hinaus — + * zwei hätten nebeneinander „bestätigt“ und „Warteliste“ gemeldet. + */ +async function sendRegistrationPartsEmail( + { primary, waitlist }: CreatedParts, + course: Parameters[1], +): Promise { + if (!waitlist) { + const { registration } = primary; + await sendRegistrationCreatedEmail({ + registration, + registrationStatus: registration.registrationStatus, + siblingDiscountStatus: registration.siblingDiscountStatus, + originalTotalPrice: + registration.originalTotalPrice ?? registration.totalPrice, + siblingDiscountAmount: registration.siblingDiscountAmount ?? 0, + totalPrice: registration.totalPrice, + downPayment: downPaymentMailInfo(registration, course), + }); + return; + } + + const emailService = await getEmailService(); + if (!emailService.isEmailConfigured()) return; + + const mailPart = (registration: CreatedRegistrationPart) => ({ + registrationId: registration.id, + participantNames: registration.participants.map( + (participant) => `${participant.firstName} ${participant.lastName}`, + ), + totalPrice: registration.totalPrice, + manageUrl: registrationAccessUrl(registration), + downPayment: downPaymentMailInfo(registration, course), + }); + + try { + await emailService.sendCourseRegistrationSplitEmail({ + email: primary.registration.registrantEmail, + registrantFirstName: primary.registration.registrantFirstName, + registrantLastName: primary.registration.registrantLastName, + courseTitle: primary.registration.course.title, + startDate: primary.registration.course.startDate, + endDate: primary.registration.course.endDate, + confirmed: mailPart(primary.registration), + waitlist: mailPart(waitlist.registration), + discountPending: [primary, waitlist].some( + ({ registration }) => + registration.siblingDiscountStatus === SiblingDiscountStatus.PENDING, + ), + }); + } catch (error) { + log.error("Failed to send split registration email:", error); + } +} + +/** + * Antwort auf ein Absenden: die bestätigte (oder einzige) Anmeldung, dazu der + * wartende Teil einer Aufteilung. + */ +function withWaitlistPart( + parts: CreatedParts, +) { + return { + ...parts.primary.registration, + waitlistPart: parts.waitlist + ? { + id: parts.waitlist.registration.id, + participantCount: parts.waitlist.registration.participants.length, + } + : null, + }; +} + /** In-app notification for the course team (creator + organizers). */ async function notifyCourseTeamOfNewRegistration( db: typeof database, @@ -193,6 +333,8 @@ async function notifyCourseTeamOfNewRegistration( actorId: string | null; registration: CreatedRegistration; registrationStatus: RegistrationStatus; + /** The waiting-list part when the registration was split. */ + waitlistRegistration?: CreatedRegistration; /** Marks entries the team recorded manually instead of public sign-ups. */ byStaff?: boolean; }, @@ -213,13 +355,18 @@ async function notifyCourseTeamOfNewRegistration( args.registrationStatus === RegistrationStatus.WAITLIST; const participantCount = args.registration.participants.length; const prefix = args.byStaff ? "Nachgetragene Anmeldung" : "Neue Anmeldung"; + const registrant = `${args.registration.registrantFirstName} ${args.registration.registrantLastName}`; for (const userId of recipients) { await createNotification(db, userId, { type: "registration.new", - title: isWaitlisted - ? `${prefix} (Warteliste): ${args.courseTitle}` - : `${prefix}: ${args.courseTitle}`, - body: `${args.registration.registrantFirstName} ${args.registration.registrantLastName} — ${participantCount} ${participantCount === 1 ? "Teilnehmer" : "Teilnehmer"}`, + title: args.waitlistRegistration + ? `${prefix} (teilweise Warteliste): ${args.courseTitle}` + : isWaitlisted + ? `${prefix} (Warteliste): ${args.courseTitle}` + : `${prefix}: ${args.courseTitle}`, + body: args.waitlistRegistration + ? `${registrant} — ${participantCount} bestätigt, ${args.waitlistRegistration.participants.length} auf der Warteliste` + : `${registrant} — ${participantCount} ${participantCount === 1 ? "Teilnehmer" : "Teilnehmer"}`, url: `/dashboard/courses/${args.courseId}/participants/${args.registration.id}`, }); } @@ -293,6 +440,13 @@ export const registrationsRouter = createTRPCRouter({ siblingGroupId: z.string().optional(), // Groups siblings together for discount }), ), + /** + * Reichen die Plätze nicht für alle: die Teilnehmer (Index in + * `participants`), die die freien Plätze bekommen. Die übrigen kommen + * als eigene, verknüpfte Anmeldung auf die Warteliste. Ohne Angabe + * wartet die ganze Anmeldung. + */ + confirmedParticipantIndexes: z.array(z.number().int()).optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -300,6 +454,7 @@ export const registrationsRouter = createTRPCRouter({ participants: participantsInput, paymentMethod: inputPaymentMethod, downPaymentAcknowledged, + confirmedParticipantIndexes, ...registrationData } = input; @@ -356,11 +511,12 @@ export const registrationsRouter = createTRPCRouter({ }); } - const { participants: participantsWithPriceOptions, originalTotalPrice } = + const { participants: participantsWithPriceOptions } = prepareParticipantsForCourse(participantsInput, course); // Die Anzahlung wird bei der Anmeldung festgehalten. Betrag, Bankdaten - // und Erstattungshinweis muss die Anmeldung ausdrücklich bestätigen. + // und Erstattungshinweis muss die Anmeldung ausdrücklich bestätigen — + // bei einer Aufteilung für beide Teile zugleich. const downPaymentAmount = registrationDownPayment( course, participantsWithPriceOptions, @@ -372,30 +528,11 @@ export const registrationsRouter = createTRPCRouter({ }); } - let totalPrice = originalTotalPrice; - let siblingDiscountAmount = 0; - let siblingDiscountStatus: SiblingDiscountStatus = - SiblingDiscountStatus.NONE; - - if (input.siblingDiscountApplied && course.allowSiblingDiscount) { - // Age eligibility is evaluated at course start, so the discount does - // not change between registration and invoicing. - const { totalDiscount } = computeSiblingDiscounts( - participantsWithPriceOptions.map((p) => ({ - birthDate: p.birthDate, - siblingGroupId: p.siblingGroupId, - price: - course.priceOptions.find((po) => po.id === p.priceOptionId) - ?.price ?? 0, - })), - ); - siblingDiscountAmount = totalDiscount; - - if (siblingDiscountAmount > 0) { - totalPrice = roundMoney(originalTotalPrice - siblingDiscountAmount); - siblingDiscountStatus = SiblingDiscountStatus.PENDING; - } - } + const seatSelection = parseSeatSelection( + confirmedParticipantIndexes, + participantsWithPriceOptions.length, + course, + ); const resolvedPaymentMethod = resolveCoursePaymentMethod( course, @@ -403,89 +540,120 @@ export const registrationsRouter = createTRPCRouter({ ); // Capacity check and insert run in one SERIALIZABLE transaction so two - // concurrent registrations cannot both take the last seat. - const { registration, registrationStatus } = await runSerializable( - ctx.db, - async (tx) => { - const currentParticipantsCount = await countConfirmedParticipants( - tx, - input.courseId, - ); + // concurrent registrations cannot both take the last seat. The sibling + // discount is priced per part there, over all participants. + const parts = await runSerializable(ctx.db, async (tx) => { + const currentParticipantsCount = await countConfirmedParticipants( + tx, + input.courseId, + ); - const newParticipants = participantsWithPriceOptions.length; - const totalAfterRegistration = - currentParticipantsCount + newParticipants; - const maxParticipants = computeCourseCapacity(course); - const availableSpots = maxParticipants - currentParticipantsCount; + const newParticipants = participantsWithPriceOptions.length; + const totalAfterRegistration = + currentParticipantsCount + newParticipants; + const maxParticipants = computeCourseCapacity(course); + const availableSpots = maxParticipants - currentParticipantsCount; - let status: RegistrationStatus = RegistrationStatus.CONFIRMED; + let status: RegistrationStatus = RegistrationStatus.CONFIRMED; - if (totalAfterRegistration > maxParticipants) { - if (!course.allowWaitingList) { - if (availableSpots > 0 && availableSpots < newParticipants) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Nur noch ${availableSpots} ${availableSpots === 1 ? "Platz" : "Plätze"} verfügbar, aber Sie versuchen ${newParticipants} ${newParticipants === 1 ? "Teilnehmer" : "Teilnehmer"} anzumelden. Bitte reduzieren Sie die Anzahl der Teilnehmer oder kontaktieren Sie uns.`, - }); - } + if (totalAfterRegistration > maxParticipants) { + if (!course.allowWaitingList) { + if (availableSpots > 0 && availableSpots < newParticipants) { throw new TRPCError({ code: "BAD_REQUEST", - message: "Course is full and waiting list is not available", + message: `Nur noch ${availableSpots} ${availableSpots === 1 ? "Platz" : "Plätze"} verfügbar, aber Sie versuchen ${newParticipants} ${newParticipants === 1 ? "Teilnehmer" : "Teilnehmer"} anzumelden. Bitte reduzieren Sie die Anzahl der Teilnehmer oder kontaktieren Sie uns.`, }); } - status = RegistrationStatus.WAITLIST; + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Course is full and waiting list is not available", + }); } + status = RegistrationStatus.WAITLIST; + } - // Auch die Preiskategorien haben ihre Grenzen. Eine ausgebuchte gilt - // wie ein voller Kurs: mit Warteliste kommt die Anmeldung darauf, - // ohne wird sie abgelehnt — sie scheiterte sonst trotz Warteliste. - if (status === RegistrationStatus.CONFIRMED) { - const additionsByOptionId: Record = {}; - for (const participant of participantsWithPriceOptions) { - additionsByOptionId[participant.priceOptionId] = - (additionsByOptionId[participant.priceOptionId] ?? 0) + 1; - } - const fullOption = await findFullPriceTier( - tx, - input.courseId, - course.priceOptions, - additionsByOptionId, - ); - if (fullOption) { - if (!course.allowWaitingList) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: priceTierFullMessage(fullOption), - }); - } - status = RegistrationStatus.WAITLIST; + // Auch die Preiskategorien haben ihre Grenzen. Eine ausgebuchte gilt + // wie ein voller Kurs: mit Warteliste kommt die Anmeldung darauf, + // ohne wird sie abgelehnt — sie scheiterte sonst trotz Warteliste. + if (status === RegistrationStatus.CONFIRMED) { + const additionsByOptionId: Record = {}; + for (const participant of participantsWithPriceOptions) { + additionsByOptionId[participant.priceOptionId] = + (additionsByOptionId[participant.priceOptionId] ?? 0) + 1; + } + const fullOption = await findFullPriceTier( + tx, + input.courseId, + course.priceOptions, + additionsByOptionId, + ); + if (fullOption) { + if (!course.allowWaitingList) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: priceTierFullMessage(fullOption), + }); } + status = RegistrationStatus.WAITLIST; } + } - const created = await tx.courseRegistration.create({ + // Aufgeteilt wird nur, wenn die Anmeldenden es gewählt haben und + // nicht ohnehin alle Platz haben. Passt ihre Auswahl inzwischen + // nicht mehr, entscheiden sie neu: still anders aufzuteilen hieße, + // an ihrer Stelle zu bestimmen, wer mitfährt. + const confirmedIndexes = + status === RegistrationStatus.WAITLIST ? seatSelection : null; + if (confirmedIndexes) { + await assertSeatSelectionFits(tx, { + course, + participants: participantsWithPriceOptions, + selection: confirmedIndexes, + confirmedCount: currentParticipantsCount, + }); + } + + const plan = planRegistrationParts( + participantsWithPriceOptions, + (participant) => priceOptionPrice(course, participant), + { + status, + confirmedIndexes, + withSiblingDiscount: + input.siblingDiscountApplied && course.allowSiblingDiscount, + }, + ); + const registrationGroupId = plan.waitlist ? randomUUID() : null; + + const createPart = async (part: typeof plan.primary) => { + const partDownPayment = registrationDownPayment( + course, + part.participants, + ); + const discounted = part.siblingDiscountAmount > 0; + const registration = await tx.courseRegistration.create({ data: { ...registrationData, ...(resolvedPaymentMethod != null ? { paymentMethod: resolvedPaymentMethod } : {}), - totalPrice, // Use server-calculated price (after discount if applied) - downPaymentAmount, - downPaymentStatus: downPaymentAmount + totalPrice: part.totalPrice, + downPaymentAmount: partDownPayment, + downPaymentStatus: partDownPayment ? DownPaymentStatus.OPEN : null, - originalTotalPrice: - input.siblingDiscountApplied && siblingDiscountAmount > 0 - ? originalTotalPrice - : null, - siblingDiscountAmount: - input.siblingDiscountApplied && siblingDiscountAmount > 0 - ? siblingDiscountAmount - : null, + originalTotalPrice: discounted ? part.originalTotalPrice : null, + siblingDiscountAmount: discounted + ? part.siblingDiscountAmount + : null, siblingDiscountApplied: input.siblingDiscountApplied ?? false, - siblingDiscountStatus, - registrationStatus: status, + siblingDiscountStatus: discounted + ? SiblingDiscountStatus.PENDING + : SiblingDiscountStatus.NONE, + registrationStatus: part.status, + registrationGroupId, participants: { - create: participantsWithPriceOptions.map((participant) => ({ + create: part.participants.map((participant) => ({ ...participant, // Führend für Kapazität und Belegung; `priceOption` bleibt // als Anzeige-Snapshot daneben stehen. @@ -507,31 +675,28 @@ export const registrationsRouter = createTRPCRouter({ }, }, }); + return { registration, part }; + }; - return { registration: created, registrationStatus: status }; - }, - ); - - await sendRegistrationCreatedEmail({ - registration, - registrationStatus, - siblingDiscountStatus, - originalTotalPrice, - siblingDiscountAmount, - totalPrice, - downPayment: downPaymentMailInfo(registration, course), + return { + primary: await createPart(plan.primary), + waitlist: plan.waitlist ? await createPart(plan.waitlist) : null, + }; }); + await sendRegistrationPartsEmail(parts, course); + await notifyCourseTeamOfNewRegistration(ctx.db, { courseId: input.courseId, courseTitle: course.title, courseCreatedById: course.createdById, actorId: ctx.session?.user.id ?? null, - registration, - registrationStatus, + registration: parts.primary.registration, + registrationStatus: parts.primary.part.status, + waitlistRegistration: parts.waitlist?.registration, }); - return registration; + return withWaitlistPart(parts); }), /** @@ -592,6 +757,12 @@ export const registrationsRouter = createTRPCRouter({ sendConfirmationEmail: z.boolean().default(true), /** Anzahlung lag der Anmeldung schon bei (z. B. Papierformular mit Überweisung). */ downPaymentAlreadyPaid: z.boolean().default(false), + /** + * Wie bei `create`: die Teilnehmer, die die freien Plätze bekommen, + * während die übrigen als verknüpfte Anmeldung warten. Nur ohne + * ausdrücklichen Status. + */ + confirmedParticipantIndexes: z.array(z.number().int()).optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -602,6 +773,7 @@ export const registrationsRouter = createTRPCRouter({ allowOverbooking, sendConfirmationEmail, downPaymentAlreadyPaid, + confirmedParticipantIndexes, ...registrationData } = input; @@ -654,115 +826,127 @@ export const registrationsRouter = createTRPCRouter({ // Die Altersgrenzen einer Kategorie gelten für Anmeldende; das Kursteam // darf sie im Einzelfall übergehen — es kennt die Ausnahme, die es // gerade einträgt. - const { participants: participantsWithPriceOptions, originalTotalPrice } = + const { participants: participantsWithPriceOptions } = prepareParticipantsForCourse(participantsInput, course, { allowAgeMismatch: true, }); - // Das Team bestätigt die Hinweise im Namen der Anmeldung (wie die - // Teilnahmebedingungen) und kann eine schon eingegangene Anzahlung - // gleich mit verbuchen. - const downPaymentAmount = registrationDownPayment( + // Aufgeteilt wird wie bei der öffentlichen Anmeldung — aber nur, wenn + // das Team keinen Status ausdrücklich vorgibt. + const seatSelection = requestedStatus + ? null + : parseSeatSelection( + confirmedParticipantIndexes, + participantsWithPriceOptions.length, + course, + ); + + const resolvedPaymentMethod = resolveCoursePaymentMethod( course, - participantsWithPriceOptions, + inputPaymentMethod, ); - const downPaymentBooked = - Boolean(downPaymentAmount) && downPaymentAlreadyPaid; - - let totalPrice = originalTotalPrice; - let siblingDiscountAmount = 0; - let siblingDiscountStatus: SiblingDiscountStatus = - SiblingDiscountStatus.NONE; - if (input.siblingDiscountApplied && course.allowSiblingDiscount) { - const { totalDiscount } = computeSiblingDiscounts( - participantsWithPriceOptions.map((p) => ({ - birthDate: p.birthDate, - siblingGroupId: p.siblingGroupId, - price: - course.priceOptions.find((po) => po.id === p.priceOptionId) - ?.price ?? 0, - })), + const parts = await runSerializable(ctx.db, async (tx) => { + const confirmedCount = await countConfirmedParticipants( + tx, + input.courseId, ); - siblingDiscountAmount = totalDiscount; + const capacity = computeCourseCapacity(course); + const newParticipants = participantsWithPriceOptions.length; + const fitsInCapacity = confirmedCount + newParticipants <= capacity; - if (siblingDiscountAmount > 0) { - totalPrice = roundMoney(originalTotalPrice - siblingDiscountAmount); - siblingDiscountStatus = SiblingDiscountStatus.PENDING; + const additionsByOptionId: Record = {}; + for (const participant of participantsWithPriceOptions) { + additionsByOptionId[participant.priceOptionId] = + (additionsByOptionId[participant.priceOptionId] ?? 0) + 1; } - } - - const resolvedPaymentMethod = resolveCoursePaymentMethod( - course, - inputPaymentMethod, - ); + const fullOption = await findFullPriceTier( + tx, + input.courseId, + course.priceOptions, + additionsByOptionId, + ); - const { registration, registrationStatus } = await runSerializable( - ctx.db, - async (tx) => { - const confirmedCount = await countConfirmedParticipants( - tx, - input.courseId, - ); - const capacity = computeCourseCapacity(course); - const newParticipants = participantsWithPriceOptions.length; - const fitsInCapacity = confirmedCount + newParticipants <= capacity; + // "Automatisch" behandelt eine ausgebuchte Preiskategorie wie einen + // vollen Kurs, genau wie die öffentliche Anmeldung. + const status: RegistrationStatus = + requestedStatus ?? + ((fitsInCapacity && !fullOption) || !course.allowWaitingList + ? RegistrationStatus.CONFIRMED + : RegistrationStatus.WAITLIST); - const additionsByOptionId: Record = {}; - for (const participant of participantsWithPriceOptions) { - additionsByOptionId[participant.priceOptionId] = - (additionsByOptionId[participant.priceOptionId] ?? 0) + 1; - } - const fullOption = await findFullPriceTier( - tx, - input.courseId, - course.priceOptions, - additionsByOptionId, - ); + if ( + status === RegistrationStatus.CONFIRMED && + !fitsInCapacity && + !allowOverbooking + ) { + const availableSpots = Math.max(0, capacity - confirmedCount); + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Der Kurs hat nur noch ${availableSpots} ${availableSpots === 1 ? "freien Platz" : "freie Plätze"}, die Anmeldung umfasst ${newParticipants} ${newParticipants === 1 ? "Teilnehmer" : "Teilnehmer"}. Auf die Warteliste setzen oder Überbuchung ausdrücklich zulassen.`, + }); + } - // "Automatisch" behandelt eine ausgebuchte Preiskategorie wie einen - // vollen Kurs, genau wie die öffentliche Anmeldung. - const status: RegistrationStatus = - requestedStatus ?? - ((fitsInCapacity && !fullOption) || !course.allowWaitingList - ? RegistrationStatus.CONFIRMED - : RegistrationStatus.WAITLIST); + // The overbooking acknowledgement covers per-price-option caps too: + // staff who knowingly exceed the course capacity should not be + // stopped by a tier limit right afterwards. + if ( + status === RegistrationStatus.CONFIRMED && + fullOption && + !allowOverbooking + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: priceTierFullMessage(fullOption), + }); + } - if ( - status === RegistrationStatus.CONFIRMED && - !fitsInCapacity && - !allowOverbooking - ) { - const availableSpots = Math.max(0, capacity - confirmedCount); - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Der Kurs hat nur noch ${availableSpots} ${availableSpots === 1 ? "freien Platz" : "freie Plätze"}, die Anmeldung umfasst ${newParticipants} ${newParticipants === 1 ? "Teilnehmer" : "Teilnehmer"}. Auf die Warteliste setzen oder Überbuchung ausdrücklich zulassen.`, - }); - } + const confirmedIndexes = + status === RegistrationStatus.WAITLIST ? seatSelection : null; + if (confirmedIndexes) { + await assertSeatSelectionFits(tx, { + course, + participants: participantsWithPriceOptions, + selection: confirmedIndexes, + confirmedCount, + }); + } - // The overbooking acknowledgement covers per-price-option caps too: - // staff who knowingly exceed the course capacity should not be - // stopped by a tier limit right afterwards. - if ( - status === RegistrationStatus.CONFIRMED && - fullOption && - !allowOverbooking - ) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: priceTierFullMessage(fullOption), - }); - } + const plan = planRegistrationParts( + participantsWithPriceOptions, + (participant) => priceOptionPrice(course, participant), + { + status, + confirmedIndexes, + withSiblingDiscount: + input.siblingDiscountApplied && course.allowSiblingDiscount, + }, + ); + const registrationGroupId = plan.waitlist ? randomUUID() : null; - const created = await tx.courseRegistration.create({ + const createPart = async (part: typeof plan.primary) => { + const partDownPayment = registrationDownPayment( + course, + part.participants, + ); + // Das Team bestätigt die Hinweise im Namen der Anmeldung (wie die + // Teilnahmebedingungen) und kann eine schon eingegangene Anzahlung + // gleich mit verbuchen — aufgeteilt beim bestätigten Teil, dem + // einzigen, bei dem sie schon fällig ist. + const downPaymentBooked = + Boolean(partDownPayment) && + downPaymentAlreadyPaid && + (!plan.waitlist || part.status === RegistrationStatus.CONFIRMED); + const discounted = part.siblingDiscountAmount > 0; + const registration = await tx.courseRegistration.create({ data: { ...registrationData, ...(resolvedPaymentMethod != null ? { paymentMethod: resolvedPaymentMethod } : {}), - totalPrice, - downPaymentAmount, - downPaymentStatus: downPaymentAmount + totalPrice: part.totalPrice, + downPaymentAmount: partDownPayment, + downPaymentStatus: partDownPayment ? downPaymentBooked ? DownPaymentStatus.PAID : DownPaymentStatus.OPEN @@ -771,15 +955,18 @@ export const registrationsRouter = createTRPCRouter({ downPaymentPaidAt: new Date(), downPaymentPaidById: ctx.session.user.id, }), - originalTotalPrice: - siblingDiscountAmount > 0 ? originalTotalPrice : null, - siblingDiscountAmount: - siblingDiscountAmount > 0 ? siblingDiscountAmount : null, + originalTotalPrice: discounted ? part.originalTotalPrice : null, + siblingDiscountAmount: discounted + ? part.siblingDiscountAmount + : null, siblingDiscountApplied: input.siblingDiscountApplied ?? false, - siblingDiscountStatus, - registrationStatus: status, + siblingDiscountStatus: discounted + ? SiblingDiscountStatus.PENDING + : SiblingDiscountStatus.NONE, + registrationStatus: part.status, + registrationGroupId, participants: { - create: participantsWithPriceOptions.map((participant) => ({ + create: part.participants.map((participant) => ({ ...participant, priceOptionId: participant.priceOptionId, customFields: @@ -799,21 +986,19 @@ export const registrationsRouter = createTRPCRouter({ }, }, }); + return { registration, part }; + }; - return { registration: created, registrationStatus: status }; - }, - ); + return { + primary: await createPart(plan.primary), + waitlist: plan.waitlist ? await createPart(plan.waitlist) : null, + }; + }); + + const { registration } = parts.primary; if (sendConfirmationEmail) { - await sendRegistrationCreatedEmail({ - registration, - registrationStatus, - siblingDiscountStatus, - originalTotalPrice, - siblingDiscountAmount, - totalPrice, - downPayment: downPaymentMailInfo(registration, course), - }); + await sendRegistrationPartsEmail(parts, course); } await notifyCourseTeamOfNewRegistration(ctx.db, { @@ -822,7 +1007,8 @@ export const registrationsRouter = createTRPCRouter({ courseCreatedById: course.createdById, actorId: ctx.session.user.id, registration, - registrationStatus, + registrationStatus: registration.registrationStatus, + waitlistRegistration: parts.waitlist?.registration, byStaff: true, }); @@ -836,8 +1022,13 @@ export const registrationsRouter = createTRPCRouter({ courseId: input.courseId, registrantEmail: registration.registrantEmail, participantCount: registration.participants.length, - registrationStatus, - totalPrice, + registrationStatus: registration.registrationStatus, + totalPrice: registration.totalPrice, + ...(parts.waitlist && { + waitlistRegistrationId: parts.waitlist.registration.id, + waitlistParticipantCount: + parts.waitlist.registration.participants.length, + }), allowOverbooking, sendConfirmationEmail, afterDeadline: isRegistrationDeadlinePassed( @@ -846,7 +1037,7 @@ export const registrationsRouter = createTRPCRouter({ }, }); - return registration; + return withWaitlistPart(parts); }), /** @@ -1034,12 +1225,41 @@ export const registrationsRouter = createTRPCRouter({ }); } + // Die übrigen Teile einer aufgeteilten Anmeldung. Sie gehören derselben + // Anmeldung an, also sieht sie, wer diesen Teil sehen darf; wer über + // einen Zugangslink kommt, bekommt die Links zu den anderen Teilen mit. + const groupParts = registration.registrationGroupId + ? await ctx.db.courseRegistration.findMany({ + where: { + registrationGroupId: registration.registrationGroupId, + id: { not: registration.id }, + }, + select: { + id: true, + registrationStatus: true, + registrantEmail: true, + participants: { select: { firstName: true, lastName: true } }, + }, + orderBy: { createdAt: "asc" }, + }) + : []; + const { // eslint-disable-next-line @typescript-eslint/no-unused-vars course: { createdById, collaborators, ...course }, ...rest } = registration; - return { ...rest, course }; + return { + ...rest, + course, + groupParts: groupParts.map(({ registrantEmail, ...part }) => ({ + ...part, + accessToken: + isOwner && input.accessToken + ? createRegistrationAccessToken(part.id, registrantEmail) + : null, + })), + }; }), canManageRegistration: protectedProcedure @@ -1538,16 +1758,38 @@ export const registrationsRouter = createTRPCRouter({ let siblingDiscountStatus = registration.siblingDiscountStatus; if (input.siblingDiscountApplied && course.allowSiblingDiscount) { - const { totalDiscount } = computeSiblingDiscounts( - participantsWithPriceOptions.map((p) => ({ - birthDate: p.birthDate, - siblingGroupId: p.siblingGroupId, - price: - course.priceOptions.find((po) => po.id === p.priceOptionId) - ?.price ?? 0, - })), + // Bei einer aufgeteilten Anmeldung zählen die Geschwister in den + // anderen, nicht stornierten Teilen mit — sonst verlöre ein Teil beim + // Bearbeiten seinen Rabatt, nur weil das ältere Geschwister wartet. + const otherPartParticipants = registration.registrationGroupId + ? await ctx.db.participant.findMany({ + where: { + registration: { + registrationGroupId: registration.registrationGroupId, + id: { not: id }, + registrationStatus: { not: RegistrationStatus.CANCELLED }, + }, + }, + select: { + birthDate: true, + siblingGroupId: true, + priceOptionId: true, + }, + }) + : []; + const priced = (p: { + birthDate: Date; + siblingGroupId?: string | null; + priceOptionId?: string | null; + }) => ({ + birthDate: p.birthDate, + siblingGroupId: p.siblingGroupId, + price: priceOptionPrice(course, p), + }); + siblingDiscountAmount = siblingDiscountWithinGroup( + participantsWithPriceOptions.map(priced), + otherPartParticipants.map(priced), ); - siblingDiscountAmount = totalDiscount; if (siblingDiscountAmount > 0) { totalPrice = roundMoney(originalTotalPrice - siblingDiscountAmount); @@ -2331,11 +2573,21 @@ export const registrationsRouter = createTRPCRouter({ // updateStatus, which re-checks capacity) and a cancelled one stays // cancelled. The previous behavior force-set CONFIRMED here, which // could overbook a full course. - const updated = await ctx.db.courseRegistration.update({ - where: { id: input.registrationId }, + // Aufgeteilt gilt die Entscheidung für alle Teile, über die der Rabatt + // berechnet wurde — mit einer Mail über die Summe. + const parts = await siblingDiscountParts( + ctx.db, + registration, + SiblingDiscountStatus.PENDING, + ); + await ctx.db.courseRegistration.updateMany({ + where: { id: { in: parts.map((part) => part.id) } }, data: { siblingDiscountStatus: SiblingDiscountStatus.APPROVED, }, + }); + const updated = await ctx.db.courseRegistration.findUniqueOrThrow({ + where: { id: input.registrationId }, include: { participants: true, course: { @@ -2347,12 +2599,12 @@ export const registrationsRouter = createTRPCRouter({ }, }, }); + const totals = sumDiscountParts(parts); const emailService = await getEmailService(); if ( emailService.isEmailConfigured() && - updated.originalTotalPrice && - updated.siblingDiscountAmount + totals.siblingDiscountAmount > 0 ) { try { await emailService.sendSiblingDiscountApprovedEmail( @@ -2362,10 +2614,10 @@ export const registrationsRouter = createTRPCRouter({ updated.course.title, updated.course.startDate, updated.course.endDate, - updated.originalTotalPrice, - updated.siblingDiscountAmount, - updated.totalPrice, - updated.participants.length, + totals.originalTotalPrice, + totals.siblingDiscountAmount, + totals.totalPrice, + totals.participantCount, updated.id, registrationAccessUrl(updated), ); @@ -2407,16 +2659,29 @@ export const registrationsRouter = createTRPCRouter({ }); } - const originalPrice = - registration.originalTotalPrice ?? registration.totalPrice; - const updated = await ctx.db.courseRegistration.update({ + // Wie beim Genehmigen: aufgeteilt gilt die Ablehnung für alle Teile, + // jeder zurück auf seinen eigenen vollen Preis. + const parts = await siblingDiscountParts( + ctx.db, + registration, + SiblingDiscountStatus.PENDING, + ); + await ctx.db.$transaction( + parts.map((part) => + ctx.db.courseRegistration.update({ + where: { id: part.id }, + data: { + siblingDiscountStatus: SiblingDiscountStatus.REJECTED, + totalPrice: part.originalTotalPrice ?? part.totalPrice, + siblingDiscountAmount: null, + originalTotalPrice: null, + }, + }), + ), + ); + const totals = sumDiscountParts(parts); + const updated = await ctx.db.courseRegistration.findUniqueOrThrow({ where: { id: input.registrationId }, - data: { - siblingDiscountStatus: SiblingDiscountStatus.REJECTED, - totalPrice: originalPrice, - siblingDiscountAmount: null, - originalTotalPrice: null, - }, include: { participants: true, course: { @@ -2439,8 +2704,8 @@ export const registrationsRouter = createTRPCRouter({ updated.course.title, updated.course.startDate, updated.course.endDate, - originalPrice, - updated.participants.length, + totals.originalTotalPrice, + totals.participantCount, updated.id, registrationAccessUrl(updated), ); @@ -2542,15 +2807,20 @@ export const registrationsRouter = createTRPCRouter({ }); } - const { totalDiscount } = computeSiblingDiscounts( - registration.participants.map((participant) => ({ - birthDate: participant.birthDate, - siblingGroupId: participant.siblingGroupId, - price: - registration.course.priceOptions.find( - (option) => option.id === participant.priceOptionId, - )?.price ?? 0, - })), + // Aufgeteilt zählen die Geschwister in den übrigen Teilen mit; dieser + // Teil bekommt den Anteil seiner eigenen Teilnehmer. + const priced = (participant: { + birthDate: Date; + siblingGroupId: string | null; + priceOptionId: string | null; + }) => ({ + birthDate: participant.birthDate, + siblingGroupId: participant.siblingGroupId, + price: priceOptionPrice(registration.course, participant), + }); + const totalDiscount = siblingDiscountWithinGroup( + registration.participants.map(priced), + (await otherPartParticipants(ctx.db, registration)).map(priced), ); if (totalDiscount <= 0) { @@ -2825,15 +3095,26 @@ export const registrationsRouter = createTRPCRouter({ }); } - const updated = await ctx.db.courseRegistration.update({ - where: { id: input.registrationId }, + // Aufgeteilt gilt die Zusage zum vollen Preis für alle abgelehnten + // Teile. Der Status bleibt, wie er ist: früher wurde hier „bestätigt“ + // erzwungen, womit eine wartende Anmeldung den Kurs überbucht hätte. + const parts = await siblingDiscountParts( + ctx.db, + registration, + SiblingDiscountStatus.REJECTED, + ); + const partIds = parts.map((part) => part.id); + await ctx.db.courseRegistration.updateMany({ + where: { id: { in: partIds } }, data: { siblingDiscountStatus: SiblingDiscountStatus.NONE, siblingDiscountApplied: false, siblingDiscountAmount: null, originalTotalPrice: null, - registrationStatus: RegistrationStatus.CONFIRMED, }, + }); + const updatedParts = await ctx.db.courseRegistration.findMany({ + where: { id: { in: partIds } }, include: { participants: true, course: { @@ -2844,29 +3125,48 @@ export const registrationsRouter = createTRPCRouter({ }, }, }, + orderBy: { createdAt: "asc" }, }); + // Eine Bestätigung bekommt nur, was tatsächlich einen Platz hat. const emailService = await getEmailService(); if (emailService.isEmailConfigured()) { - try { - await emailService.sendCourseRegistrationConfirmedEmail( - updated.registrantEmail, - updated.registrantFirstName, - updated.registrantLastName, - updated.course.title, - updated.course.startDate, - updated.course.endDate, - updated.totalPrice, - updated.participants.length, - updated.id, - registrationAccessUrl(updated), - downPaymentMailInfo(updated, registration.course), - ); - } catch (error) { - log.error("Failed to send confirmation email:", error); + for (const part of updatedParts) { + if (part.registrationStatus !== RegistrationStatus.CONFIRMED) { + continue; + } + try { + await emailService.sendCourseRegistrationConfirmedEmail( + part.registrantEmail, + part.registrantFirstName, + part.registrantLastName, + part.course.title, + part.course.startDate, + part.course.endDate, + part.totalPrice, + part.participants.length, + part.id, + registrationAccessUrl(part), + downPaymentMailInfo(part, registration.course), + ); + } catch (error) { + log.error("Failed to send confirmation email:", error); + } } } - return updated; + return ctx.db.courseRegistration.findUniqueOrThrow({ + where: { id: input.registrationId }, + include: { + participants: true, + course: { + select: { + title: true, + startDate: true, + endDate: true, + }, + }, + }, + }); }), }); diff --git a/src/server/email/index.ts b/src/server/email/index.ts index e509b301..676c5b83 100644 --- a/src/server/email/index.ts +++ b/src/server/email/index.ts @@ -480,6 +480,7 @@ function buildCourseMailFrom(senderName: string): string | undefined { } export { sendEmail } from "./send-email"; +export { sendCourseRegistrationSplitEmail } from "./registration-split-email"; export { transporter, verifyEmailConnection, diff --git a/src/server/email/registration-split-email.ts b/src/server/email/registration-split-email.ts new file mode 100644 index 00000000..cd596424 --- /dev/null +++ b/src/server/email/registration-split-email.ts @@ -0,0 +1,39 @@ +import { render } from "@react-email/components"; +import { sendEmail } from "./send-email"; +import { + CourseRegistrationSplit, + type SplitPartMailInfo, +} from "./templates/course-registration-split"; +import { downPaymentQrAttachment } from "./down-payment"; + +/** + * Eine Mail für beide Teile einer aufgeteilten Anmeldung. Der GiroCode gehört + * zur Anzahlung des bestätigten Teils — der wartende zahlt erst nach der + * Platzbestätigung. + */ +export async function sendCourseRegistrationSplitEmail(params: { + email: string; + registrantFirstName: string; + registrantLastName: string; + courseTitle: string; + startDate: Date; + endDate: Date; + confirmed: SplitPartMailInfo; + waitlist: SplitPartMailInfo; + discountPending: boolean; +}) { + const { email, ...props } = params; + const qrCode = props.confirmed.downPayment + ? await downPaymentQrAttachment(props.confirmed.downPayment) + : null; + const html = await render( + CourseRegistrationSplit({ ...props, downPaymentHasQr: qrCode !== null }), + ); + + return sendEmail({ + to: email, + subject: `Anmeldung teilweise bestätigt: ${props.courseTitle} - Posaunenwerk Rheinland`, + html, + ...(qrCode && { attachments: [qrCode] }), + }); +} diff --git a/src/server/email/templates/course-registration-split.tsx b/src/server/email/templates/course-registration-split.tsx new file mode 100644 index 00000000..8e6d6fdb --- /dev/null +++ b/src/server/email/templates/course-registration-split.tsx @@ -0,0 +1,368 @@ +import { + Html, + Head, + Body, + Container, + Section, + Text, + Hr, + Button, +} from "@react-email/components"; +import { DownPaymentSection } from "./down-payment-section"; +import type { DownPaymentMailInfo } from "../down-payment"; + +export interface SplitPartMailInfo { + registrationId: string; + participantNames: string[]; + totalPrice: number; + /** Magic link to this part of the registration. */ + manageUrl?: string; + downPayment?: DownPaymentMailInfo | null; +} + +interface CourseRegistrationSplitProps { + registrantFirstName: string; + registrantLastName: string; + courseTitle: string; + startDate: Date; + endDate: Date; + confirmed: SplitPartMailInfo; + waitlist: SplitPartMailInfo; + /** GiroCode der Anzahlung des bestätigten Teils als Inline-Anhang. */ + downPaymentHasQr: boolean; + /** Geschwisterkindrabatt beantragt und noch nicht geprüft. */ + discountPending: boolean; +} + +/** + * Eine Mail für beide Teile einer aufgeteilten Anmeldung: wer bestätigt ist, + * wer wartet, und für jeden Teil Preis, Anzahlung und Link. Zwei getrennte + * Mails hätten nebeneinander „bestätigt“ und „ausgebucht“ gemeldet. + */ +export function CourseRegistrationSplit({ + registrantFirstName, + registrantLastName, + courseTitle, + startDate, + endDate, + confirmed, + waitlist, + downPaymentHasQr, + discountPending, +}: CourseRegistrationSplitProps) { + const formatDate = (date: Date) => { + return new Intl.DateTimeFormat("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }).format(date); + }; + + const formatPrice = (price: number) => { + return new Intl.NumberFormat("de-DE", { + style: "currency", + currency: "EUR", + }).format(price); + }; + + const priceSuffix = discountPending ? " (vorbehaltlich Rabattprüfung)" : ""; + + return ( + + + + +

+ Posaunenwerk Rheinland + + Posaunenwerk der Evangelischen Kirche im Rheinland + +
+ +
+ Anmeldung teilweise bestätigt + + + Hallo {registrantFirstName} {registrantLastName}, + + + + vielen Dank für deine Anmeldung! Für den folgenden Kurs waren + nicht mehr genug Plätze für alle frei. Wie von dir gewählt, haben + wir deine Anmeldung aufgeteilt: + + +
+ {courseTitle} + + Start: {formatDate(startDate)} + + + Ende: {formatDate(endDate)} + +
+ +
+ ✅ Bestätigt + + {confirmed.participantNames.join(", ")} + + + Gesamtpreis{priceSuffix}:{" "} + {formatPrice(confirmed.totalPrice)} + +
+ + {confirmed.downPayment && ( + + )} + +
+ ⏳ Auf der Warteliste + + {waitlist.participantNames.join(", ")} + + + Gesamtpreis{priceSuffix}:{" "} + {formatPrice(waitlist.totalPrice)} + + + Sollten Plätze frei werden, benachrichtigen wir dich per E-Mail. + +
+ + {waitlist.downPayment && ( + + )} + + {discountPending && ( +
+ + Dein Antrag auf Geschwisterkindrabatt wird derzeit geprüft. Er + gilt für beide Teile deiner Anmeldung; du erhältst eine + separate E-Mail, sobald er genehmigt oder abgelehnt wurde. + +
+ )} + +
+ + + Du erhältst in Kürze weitere Informationen zum Kurs per E-Mail. + Bei Fragen kannst du dich gerne an uns wenden. + + + {confirmed.manageUrl && waitlist.manageUrl && ( + <> +
+ +
+
+ +
+ + Über diese Links kannst du beide Teile deiner Anmeldung ändern + oder stornieren — ganz ohne Benutzerkonto. Bitte gib sie nicht + weiter. + + + )} + + + Deine Anmelde-IDs: {confirmed.registrationId}{" "} + (bestätigt), {waitlist.registrationId}{" "} + (Warteliste) + +
+ +
+ + Posaunenwerk der Evangelischen Kirche im Rheinland + +
+ + + + ); +} + +const main = { + backgroundColor: "#f5f5f5", + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', +}; + +const container = { + backgroundColor: "#ffffff", + margin: "0 auto", + padding: "0", + marginBottom: "64px", + maxWidth: "600px", + borderRadius: "8px", + boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)", +}; + +const header = { + backgroundColor: "#faa619", + padding: "32px 24px", + textAlign: "center" as const, + borderRadius: "8px 8px 0 0", +}; + +const logoText = { + color: "#ffffff", + fontSize: "28px", + fontWeight: "bold", + margin: "0 0 8px 0", + letterSpacing: "0.5px", +}; + +const tagline = { + color: "#ffffff", + fontSize: "12px", + fontWeight: "normal", + margin: "0", + opacity: 0.95, + letterSpacing: "0.3px", +}; + +const content = { + padding: "32px 24px", +}; + +const heading = { + fontSize: "24px", + fontWeight: "bold", + color: "#58595b", + marginBottom: "24px", +}; + +const paragraph = { + fontSize: "16px", + lineHeight: "26px", + color: "#58595b", + marginBottom: "16px", +}; + +const smallParagraph = { + fontSize: "14px", + lineHeight: "22px", + color: "#58595b", + marginBottom: "16px", +}; + +const courseInfo = { + backgroundColor: "#f9fafb", + padding: "20px", + borderRadius: "8px", + margin: "24px 0", + border: "1px solid #e5e7eb", +}; + +const confirmedBox = { + backgroundColor: "#f0fdf4", + padding: "20px", + borderRadius: "8px", + margin: "24px 0 16px", + border: "1px solid #bbf7d0", +}; + +const waitlistBox = { + backgroundColor: "#fff7ed", + padding: "20px", + borderRadius: "8px", + margin: "24px 0 16px", + border: "1px solid #fed7aa", +}; + +const partTitle = { + fontSize: "18px", + fontWeight: "bold", + color: "#58595b", + marginBottom: "8px", +}; + +const courseTitleStyle = { + fontSize: "20px", + fontWeight: "bold", + color: "#58595b", + marginBottom: "16px", +}; + +const courseDetail = { + fontSize: "16px", + lineHeight: "24px", + color: "#58595b", + marginBottom: "8px", +}; + +const pendingWarning = { + backgroundColor: "#fef3c7", + padding: "16px", + borderRadius: "8px", + margin: "24px 0", + border: "1px solid #fcd34d", +}; + +const pendingWarningText = { + fontSize: "14px", + lineHeight: "22px", + color: "#92400e", + margin: "0", +}; + +const buttonContainer = { + textAlign: "center" as const, + margin: "16px 0", +}; + +const button = { + backgroundColor: "#faa619", + borderRadius: "8px", + color: "#ffffff", + fontSize: "16px", + fontWeight: "bold", + textDecoration: "none", + textAlign: "center" as const, + display: "inline-block", + padding: "14px 32px", + boxShadow: "0 2px 4px rgba(250, 166, 25, 0.3)", +}; + +const secondaryButton = { + ...button, + backgroundColor: "#ffffff", + color: "#faa619", + border: "2px solid #faa619", + boxShadow: "none", +}; + +const hr = { + borderColor: "#e5e7eb", + margin: "32px 0", +}; + +const footerSection = { + padding: "24px", + backgroundColor: "#f9fafb", + textAlign: "center" as const, + borderRadius: "0 0 8px 8px", +}; + +const footerText = { + fontSize: "12px", + color: "#9ca3af", + margin: "0", +}; From 8bc0a992fcbdfdf80b4df18ac7e53b69e8394070 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Tue, 15 Sep 2026 09:24:44 +0200 Subject: [PATCH 009/139] =?UTF-8?q?feat(anmeldung):=20im=20Formular=20w?= =?UTF-8?q?=C3=A4hlen,=20wer=20die=20freien=20Pl=C3=A4tze=20bekommt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reichen die Plätze nicht für alle, bietet Schritt 3 die Wahl: ganze Anmeldung auf die Warteliste (wie bisher) oder die freien Plätze jetzt nutzen. Dann wählen die Anmeldenden, wer sie bekommt – vorbelegt in Listenreihenfolge, ohne Teilnehmer, deren Kategorie voll ist. Teilnehmer, die nicht mehr passen würden, sind nicht wählbar; eine leere Auswahl sperrt das Absenden. Werden Geschwister getrennt, sagt der Hinweis, dass der Rabatt erhalten bleibt. Die Wahl steht vor Preis und Anzahlung, weil beide ihr folgen: der Anzahlungsblock gilt aufgeteilt den bestätigten Teilnehmern und nennt die spätere Anzahlung der wartenden. Die Team-Erfassung bekommt dafür den Status "Aufteilen". Nach Änderungen an der Teilnehmerliste beginnt die Auswahl neu. Waren die Plätze beim Absenden schon vergeben, lädt das Formular sie neu. Der Toast nennt, wie viele bestätigt sind und wie viele warten. --- .../events/course-registration-form.tsx | 147 +++++++++++-- .../down-payment-summary.tsx | 15 ++ .../seat-shortage-notice.tsx | 50 ++--- .../seat-split-choice.tsx | 201 ++++++++++++++++++ .../staff-options.tsx | 16 +- .../step-3-summary.tsx | 116 ++++++++-- .../events/course-registration-form/types.ts | 8 +- 7 files changed, 488 insertions(+), 65 deletions(-) create mode 100644 src/app/_components/events/course-registration-form/seat-split-choice.tsx diff --git a/src/app/_components/events/course-registration-form.tsx b/src/app/_components/events/course-registration-form.tsx index cdb43c70..e8d1c091 100644 --- a/src/app/_components/events/course-registration-form.tsx +++ b/src/app/_components/events/course-registration-form.tsx @@ -24,6 +24,12 @@ import { Step3Summary } from "./course-registration-form/step-3-summary"; import { validateStep as validateStepUtil } from "./course-registration-form/utils"; import { registrationErrorMessage } from "@/lib/registration-error-message"; import { registrationSeatShortage } from "@/lib/registration-seat-shortage"; +import { + defaultSeatSelection, + SEAT_SELECTION_OUTDATED_MESSAGE, + seatSelectionProblem, +} from "@/lib/registration-split"; +import { useRouter } from "next/navigation"; import { ScrollableModal, ScrollableModalCard, @@ -74,6 +80,14 @@ export default function CourseRegistrationForm({ const [downPaymentAcknowledged, setDownPaymentAcknowledged] = useState(false); const [submitError, setSubmitError] = useState(""); const [showDiscardConfirm, setShowDiscardConfirm] = useState(false); + const router = useRouter(); + const utils = api.useUtils(); + /** Who gets the free seats, for the participant list it was chosen on. */ + const [seatSplit, setSeatSplit] = useState<{ + participants: RegistrationData["participants"]; + splitting: boolean; + indexes: number[]; + } | null>(null); const [staffOptions, setStaffOptions] = useState({ registrationStatus: "AUTO", sendConfirmationEmail: true, @@ -311,15 +325,20 @@ export default function CourseRegistrationForm({ const canProceed = validateStep(currentStep); - // Why the entered participants won't all get a seat: too few free seats in - // the course or in one of the chosen price options. - const seatShortage = registrationSeatShortage({ - participantPriceOptionIds: registrationData.participants.map( - (p) => p.priceOptionId, - ), + const participantPriceOptionIds = registrationData.participants.map( + (p) => p.priceOptionId, + ); + const seatAvailability = { availableSlots, priceOptions: course.priceOptions, capacityByPriceOption, + }; + + // Why the entered participants won't all get a seat: too few free seats in + // the course or in one of the chosen price options. + const seatShortage = registrationSeatShortage({ + participantPriceOptionIds, + ...seatAvailability, }); // Seats short for what has been entered — the point at which the staff @@ -327,19 +346,64 @@ export default function CourseRegistrationForm({ // flag covers a free-seat count that was not passed in. const staffSeatsShort = isWaitlist || seatShortage !== null; + // Splitting needs a waiting list for the rest and at least one participant + // who fits right now. + const defaultSelection = defaultSeatSelection( + participantPriceOptionIds, + seatAvailability, + ); + const canSplit = + !!course.allowWaitingList && + seatShortage !== null && + registrationData.participants.length > 1 && + defaultSelection.length > 0; + + // A selection belongs to the participant list it was made for — changing + // the list in step 2 starts over from the default. + const currentSplit = + seatSplit?.participants === registrationData.participants + ? seatSplit + : null; + const staffStatus = + staffOptions.registrationStatus === "SPLIT" && !canSplit + ? "AUTO" + : staffOptions.registrationStatus; + const splitting = + canSplit && + (staffMode ? staffStatus === "SPLIT" : (currentSplit?.splitting ?? false)); + const selectedIndexes = currentSplit?.indexes ?? defaultSelection; + const seatSelectionIssue = splitting + ? seatSelectionProblem( + participantPriceOptionIds, + selectedIndexes, + seatAvailability, + ) + : null; + const updateSeatSplit = (change: { + splitting?: boolean; + indexes?: number[]; + }) => + setSeatSplit({ + participants: registrationData.participants, + splitting: change.splitting ?? currentSplit?.splitting ?? false, + indexes: change.indexes ?? selectedIndexes, + }); + // Mirrors the server: "AUTO" only becomes a waiting-list entry when the // course actually offers one, otherwise it confirms. const staffResolvedStatus = - staffOptions.registrationStatus !== "AUTO" - ? staffOptions.registrationStatus + staffStatus !== "AUTO" + ? staffStatus : staffSeatsShort && course.allowWaitingList ? "WAITLIST" : "CONFIRMED"; - // The whole registration goes onto the waiting list — it is never split. - const expectsWaitlist = staffMode - ? staffResolvedStatus === "WAITLIST" - : !!course.allowWaitingList && staffSeatsShort; + // Unless split, the whole registration goes onto the waiting list. + const expectsWaitlist = splitting + ? false + : staffMode + ? staffResolvedStatus === "WAITLIST" + : !!course.allowWaitingList && staffSeatsShort; // Same rule the staff mutation enforces server-side: confirming beyond the // capacity needs the acknowledgement. @@ -420,16 +484,27 @@ export default function CourseRegistrationForm({ // The status the server assigned, not the form's guess: seats may have // been taken since the page loaded, and it used to report success // although the whole registration had landed on the waiting list. - onSuccess: (registration: { registrationStatus: string }) => { + onSuccess: (registration: { + registrationStatus: string; + participants: unknown[]; + waitlistPart: { participantCount: number } | null; + }) => { const waitlisted = registration.registrationStatus === "WAITLIST"; + const split = registration.waitlistPart + ? `${registration.participants.length} Teilnehmer bestätigt, ${registration.waitlistPart.participantCount} auf der Warteliste` + : null; toast.success( staffMode - ? waitlisted - ? "Die Anmeldung wurde auf der Warteliste erfasst." - : "Die Anmeldung wurde erfasst." - : waitlisted - ? "Sie wurden auf die Warteliste gesetzt." - : "Ihre Anmeldung war erfolgreich.", + ? split + ? `Die Anmeldung wurde aufgeteilt erfasst: ${split}.` + : waitlisted + ? "Die Anmeldung wurde auf der Warteliste erfasst." + : "Die Anmeldung wurde erfasst." + : split + ? `Ihre Anmeldung wurde aufgeteilt: ${split}.` + : waitlisted + ? "Sie wurden auf die Warteliste gesetzt." + : "Ihre Anmeldung war erfolgreich.", ); onSuccess(); }, @@ -443,16 +518,28 @@ export default function CourseRegistrationForm({ setSubmitError(message); toast.error(message); console.error("Registration error:", error); + // Seats behind the chosen split were taken meanwhile: reload the free + // seats so the selection can be adjusted right here. + if (error.message === SEAT_SELECTION_OUTDATED_MESSAGE) { + router.refresh(); + void utils.courses.getAvailableSlots.invalidate({ id: course.id }); + } }, }; + const splitPayload = splitting + ? { confirmedParticipantIndexes: selectedIndexes } + : {}; + if (staffMode) { staffRegistrationMutation.mutate( { ...payload, - ...(staffOptions.registrationStatus !== "AUTO" && { - registrationStatus: staffOptions.registrationStatus, - }), + ...splitPayload, + ...(staffStatus !== "AUTO" && + staffStatus !== "SPLIT" && { + registrationStatus: staffStatus, + }), allowOverbooking: staffOptions.allowOverbooking, sendConfirmationEmail: staffOptions.sendConfirmationEmail, downPaymentAlreadyPaid: staffOptions.downPaymentAlreadyPaid, @@ -558,6 +645,15 @@ export default function CourseRegistrationForm({ } isWaitlist={expectsWaitlist} seatShortage={seatShortage} + seatSplit={{ + availability: seatAvailability, + canSplit, + splitting, + setSplitting: (next) => updateSeatSplit({ splitting: next }), + selectedIndexes, + setSelectedIndexes: (indexes) => updateSeatSplit({ indexes }), + problem: seatSelectionIssue, + }} staff={ staffMode ? { @@ -619,7 +715,12 @@ export default function CourseRegistrationForm({ + + + Falls der Button nicht funktioniert, kopiere diese Adresse in deinen + Browser: + + {href} + + ); +} + +export interface WaitlistPromotionOfferProps { + registrantFirstName: string; + registrantLastName: string; + courseTitle: string; + startDate: Date; + endDate: Date; + participantNames: string[]; + /** Wie viele der Teilnehmer die freien Plätze nutzen könnten. */ + seats: number; + expiresAt: Date; + /** Magic link to the registration, where the choice is made. */ + manageUrl?: string; + /** Mit dem Nachrücken wird für die Gewählten eine Anzahlung fällig. */ + hasDownPayment: boolean; +} + +/** + * Angebot an die erste wartende Anmeldung, wenn die frei gewordenen Plätze + * nicht für alle ihre Teilnehmer reichen. + */ +export function WaitlistPromotionOffer({ + registrantFirstName, + registrantLastName, + courseTitle, + startDate, + endDate, + participantNames, + seats, + expiresAt, + manageUrl, + hasDownPayment, +}: WaitlistPromotionOfferProps) { + return ( + + + Hallo {registrantFirstName} {registrantLastName}, + + + für deine Anmeldung auf der Warteliste sind Plätze frei geworden: + + +
+ {courseTitle} + + Start: {formatDate(startDate)} + + + Ende: {formatDate(endDate)} + +
+ +
+ + {seatsText(seats)} frei – für {participantNames.length} Teilnehmer + + {participantNames.join(", ")} + + Antwort bis: {formatDateTime(expiresAt)} Uhr + +
+ + + Du kannst wählen, wer nachrückt: Die Gewählten sind dann bestätigt, die + übrigen bleiben auf der Warteliste. + {hasDownPayment + ? " Für die Nachrückenden wird dann die Anzahlung fällig." + : ""} + + + Möchtet ihr lieber gemeinsam warten, lehne das Angebot ab. Die Plätze + gehen dann an die Nächsten auf der Warteliste, und deine Anmeldung + behält ihren Platz. Das gilt ebenso, wenn die Frist verstreicht. + + +
+ + +
+ ); +} + +export interface WaitlistPromotionOfferExpiredProps { + registrantFirstName: string; + registrantLastName: string; + courseTitle: string; + participantNames: string[]; + manageUrl?: string; +} + +/** Das Angebot ist ohne Antwort abgelaufen; die Anmeldung wartet weiter. */ +export function WaitlistPromotionOfferExpired({ + registrantFirstName, + registrantLastName, + courseTitle, + participantNames, + manageUrl, +}: WaitlistPromotionOfferExpiredProps) { + return ( + + + Hallo {registrantFirstName} {registrantLastName}, + + + dein Angebot, für den Kurs {courseTitle} von der + Warteliste nachzurücken, ist abgelaufen. Die freien Plätze gehen an die + Nächsten auf der Warteliste. + + + Deine Anmeldung ({participantNames.join(", ")}) bleibt auf der + Warteliste und behält ihren Platz. Werden weitere Plätze frei, melden + wir uns wieder. + + +
+ + +
+ ); +} + +export interface WaitlistPromotionOfferExpiringTeamProps { + courseTitle: string; + registrantName: string; + participantNames: string[]; + seats: number; + expiresAt: Date; + dashboardUrl: string; +} + +/** Hinweis ans Kursteam, dass ein Angebot bald ohne Antwort abläuft. */ +export function WaitlistPromotionOfferExpiringTeam({ + courseTitle, + registrantName, + participantNames, + seats, + expiresAt, + dashboardUrl, +}: WaitlistPromotionOfferExpiringTeamProps) { + return ( + + Hallo, + + für den Kurs {courseTitle} läuft ein Nachrück-Angebot + ab, auf das noch niemand geantwortet hat: + + +
+ Anmeldung von {registrantName} + {participantNames.join(", ")} + + Angeboten: {seatsText(seats)} + + + Läuft ab: {formatDateTime(expiresAt)} Uhr + +
+ + + Bis dahin hält die Warteliste an. Antwortet niemand, gehen die Plätze + danach automatisch an die Nächsten. Ihr könnt nachfragen oder die + Anmeldung im Dashboard selbst bestätigen. + + +
+ +
+
+ ); +} + +const main = { + backgroundColor: "#f5f5f5", + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', +}; + +const container = { + backgroundColor: "#ffffff", + margin: "0 auto", + padding: "0", + marginBottom: "64px", + maxWidth: "600px", + borderRadius: "8px", + boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)", +}; + +const header = { + backgroundColor: "#faa619", + padding: "32px 24px", + textAlign: "center" as const, + borderRadius: "8px 8px 0 0", +}; + +const logoText = { + color: "#ffffff", + fontSize: "28px", + fontWeight: "bold", + margin: "0 0 8px 0", + letterSpacing: "0.5px", +}; + +const tagline = { + color: "#ffffff", + fontSize: "12px", + fontWeight: "normal", + margin: "0", + opacity: 0.95, + letterSpacing: "0.3px", +}; + +const content = { + padding: "32px 24px", +}; + +const headingStyle = { + fontSize: "24px", + fontWeight: "bold", + color: "#58595b", + marginBottom: "24px", +}; + +const paragraph = { + fontSize: "16px", + lineHeight: "26px", + color: "#58595b", + marginBottom: "16px", +}; + +const smallParagraph = { + fontSize: "14px", + lineHeight: "22px", + color: "#58595b", + marginBottom: "8px", +}; + +const linkText = { + fontSize: "12px", + lineHeight: "20px", + color: "#faa619", + wordBreak: "break-all" as const, + marginBottom: "16px", +}; + +const courseInfo = { + backgroundColor: "#f9fafb", + padding: "20px", + borderRadius: "8px", + margin: "24px 0", + border: "1px solid #e5e7eb", +}; + +const offerBox = { + backgroundColor: "#fff7ed", + padding: "20px", + borderRadius: "8px", + margin: "24px 0", + border: "1px solid #fed7aa", +}; + +const offerTitle = { + fontSize: "18px", + fontWeight: "bold", + color: "#58595b", + marginBottom: "8px", +}; + +const courseTitleStyle = { + fontSize: "20px", + fontWeight: "bold", + color: "#58595b", + marginBottom: "16px", +}; + +const courseDetail = { + fontSize: "16px", + lineHeight: "24px", + color: "#58595b", + marginBottom: "8px", +}; + +const buttonContainer = { + textAlign: "center" as const, + margin: "32px 0", +}; + +const button = { + backgroundColor: "#faa619", + borderRadius: "8px", + color: "#ffffff", + fontSize: "16px", + fontWeight: "bold", + textDecoration: "none", + textAlign: "center" as const, + display: "inline-block", + padding: "14px 32px", + boxShadow: "0 2px 4px rgba(250, 166, 25, 0.3)", +}; + +const hr = { + borderColor: "#e5e7eb", + margin: "32px 0", +}; + +const footerSection = { + padding: "24px", + backgroundColor: "#f9fafb", + textAlign: "center" as const, + borderRadius: "0 0 8px 8px", +}; + +const footerText = { + fontSize: "12px", + color: "#9ca3af", + margin: "0", +}; diff --git a/src/server/email/waitlist-offer-emails.ts b/src/server/email/waitlist-offer-emails.ts new file mode 100644 index 00000000..7899474e --- /dev/null +++ b/src/server/email/waitlist-offer-emails.ts @@ -0,0 +1,51 @@ +import { render } from "@react-email/components"; +import { sendEmail } from "./send-email"; +import { + WaitlistPromotionOffer, + WaitlistPromotionOfferExpired, + WaitlistPromotionOfferExpiringTeam, + type WaitlistPromotionOfferExpiredProps, + type WaitlistPromotionOfferExpiringTeamProps, + type WaitlistPromotionOfferProps, +} from "./templates/waitlist-promotion-offer"; + +/** Angebot an die Anmeldenden, einen Teil ihrer Teilnehmer nachrücken zu lassen. */ +export async function sendWaitlistPromotionOfferEmail({ + email, + ...props +}: WaitlistPromotionOfferProps & { email: string }) { + const html = await render(WaitlistPromotionOffer(props)); + return sendEmail({ + to: email, + subject: `Plätze frei geworden: ${props.courseTitle} - Posaunenwerk Rheinland`, + html, + }); +} + +/** Das Angebot ist ohne Antwort abgelaufen. */ +export async function sendWaitlistPromotionOfferExpiredEmail({ + email, + ...props +}: WaitlistPromotionOfferExpiredProps & { email: string }) { + const html = await render(WaitlistPromotionOfferExpired(props)); + return sendEmail({ + to: email, + subject: `Platzangebot abgelaufen: ${props.courseTitle} - Posaunenwerk Rheinland`, + html, + }); +} + +/** Erinnerung ans Kursteam, eine Mail je Empfänger. */ +export async function sendWaitlistPromotionOfferExpiringTeamEmail({ + recipients, + ...props +}: WaitlistPromotionOfferExpiringTeamProps & { recipients: string[] }) { + const html = await render(WaitlistPromotionOfferExpiringTeam(props)); + for (const to of recipients) { + await sendEmail({ + to, + subject: `Nachrück-Angebot läuft bald ab: ${props.courseTitle} - Posaunenwerk Rheinland`, + html, + }); + } +} From 342e6650a3080a991414bfa15695a1b848dedbb6 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Tue, 15 Sep 2026 09:53:35 +0200 Subject: [PATCH 012/139] =?UTF-8?q?feat(cron):=20abgelaufene=20Nachr=C3=BC?= =?UTF-8?q?ck-Angebote=20schlie=C3=9Fen,=20Kursteam=20erinnern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/cron/waitlist-offers stößt für jeden Kurs mit abgelaufenem Angebot das Nachrücken an – das schließt das Angebot, gibt die Plätze an die Nächsten und verschickt die Mails – und erinnert das Kursteam einmal, sobald ein Angebot in höchstens zwei Tagen abläuft. Die Route läuft auch ohne SMTP: das Weitergeben der Plätze darf nicht am Mailversand hängen; offene Erinnerungen gehen dann beim nächsten Lauf. Auf mittwald braucht es dafür einen mStudio-Cronjob mit scripts/trigger-waitlist-offers.mjs (Vorschlag: stündlich), eingetragen in der Deployment-Checkliste. Das Dockerfile kopiert das Skript ins Image – und jetzt auch trigger-newsletter-cleanup.mjs, das laut Checkliste dort liegen sollte, aber fehlte. --- Dockerfile | 4 +- docs/DEPLOYMENT-CHECKLIST.md | 12 +- scripts/trigger-waitlist-offers.mjs | 38 ++++ src/app/api/cron/waitlist-offers/route.ts | 54 ++++++ src/server/jobs/waitlist-promotion-offers.ts | 188 +++++++++++++++++++ 5 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 scripts/trigger-waitlist-offers.mjs create mode 100644 src/app/api/cron/waitlist-offers/route.ts create mode 100644 src/server/jobs/waitlist-promotion-offers.ts diff --git a/Dockerfile b/Dockerfile index e99a4640..b8dec904 100644 --- a/Dockerfile +++ b/Dockerfile @@ -103,8 +103,10 @@ COPY --from=builder --chown=nextjs:nodejs /app/src/lib/bezirke.ts ./src/lib/ COPY --from=builder --chown=nextjs:nodejs /app/src/lib/slug.ts ./src/lib/ COPY --from=builder --chown=nextjs:nodejs /app/src/lib/phone-number.ts ./src/lib/ -# Trigger script for the mStudio cron job (mittwald), see the script header +# Trigger scripts for the mStudio cron jobs (mittwald), see the script headers COPY --chown=nextjs:nodejs scripts/trigger-registration-closed.mjs ./scripts/ +COPY --chown=nextjs:nodejs scripts/trigger-newsletter-cleanup.mjs ./scripts/ +COPY --chown=nextjs:nodejs scripts/trigger-waitlist-offers.mjs ./scripts/ # Switch to non-root user USER nextjs diff --git a/docs/DEPLOYMENT-CHECKLIST.md b/docs/DEPLOYMENT-CHECKLIST.md index cef01f44..01476bd8 100644 --- a/docs/DEPLOYMENT-CHECKLIST.md +++ b/docs/DEPLOYMENT-CHECKLIST.md @@ -145,15 +145,21 @@ Reihenfolge beim ersten Deploy auf eine leere Datenbank: Die geplanten Jobs laufen auf mittwald **nicht** im Stack, sondern als mStudio-Cronjobs. Sie müssen dort einmalig angelegt sein — ohne sie werden -keine Anmeldeschluss-Mails verschickt und unbestätigte Newsletter-Anmeldungen -nie gelöscht (was die Datenschutzerklärung mit 30 Tagen zusagt). +keine Anmeldeschluss-Mails verschickt, unbestätigte Newsletter-Anmeldungen +nie gelöscht (was die Datenschutzerklärung mit 30 Tagen zusagt) und +abgelaufene Nachrück-Angebote nie geschlossen: die Warteliste bliebe dann +beim ersten unbeantworteten Angebot stehen. - [ ] `registration-closed` — ruft `POST /api/cron/registration-closed` auf (Vorschlag: alle 6 Stunden), Skript `scripts/trigger-registration-closed.mjs` - [ ] `newsletter-cleanup` — ruft `POST /api/cron/newsletter-cleanup` auf (Vorschlag: täglich), Skript `scripts/trigger-newsletter-cleanup.mjs` +- [ ] `waitlist-offers` — ruft `POST /api/cron/waitlist-offers` auf + (Vorschlag: stündlich), Skript `scripts/trigger-waitlist-offers.mjs`; + schließt abgelaufene Nachrück-Angebote und erinnert das Kursteam zwei + Tage vor Ablauf -Beide brauchen `Authorization: Bearer $CRON_SECRET` — ohne den Header +Alle brauchen `Authorization: Bearer $CRON_SECRET` — ohne den Header antworten die Routen mit 401. Einmal von Hand testen: ```bash diff --git a/scripts/trigger-waitlist-offers.mjs b/scripts/trigger-waitlist-offers.mjs new file mode 100644 index 00000000..33a53c10 --- /dev/null +++ b/scripts/trigger-waitlist-offers.mjs @@ -0,0 +1,38 @@ +// ============================================================================= +// Trigger for the waitlist-offers cron route (from inside the app container) +// ============================================================================= +// +// Used by the mStudio cron job targeting the "app" stack service: +// command: node /app/scripts/trigger-waitlist-offers.mjs +// +// Calls the app's own /api/cron/waitlist-offers route on localhost using +// CRON_SECRET from the container environment. Exits non-zero on any failure +// so mStudio's execution history and failure alerting reflect real outcomes. +// +// ============================================================================= + +const secret = process.env.CRON_SECRET; +if (!secret) { + console.error("CRON_SECRET is not set"); + process.exit(1); +} + +const port = process.env.PORT ?? "3000"; +const url = `http://localhost:${port}/api/cron/waitlist-offers`; + +try { + // Stay below the route's maxDuration of 300s. + const response = await fetch(url, { + method: "POST", + headers: { authorization: `Bearer ${secret}` }, + signal: AbortSignal.timeout(290_000), + }); + const body = await response.text(); + console.log(`HTTP ${response.status}: ${body}`); + if (!response.ok) { + process.exit(1); + } +} catch (error) { + console.error("Trigger failed:", error); + process.exit(1); +} diff --git a/src/app/api/cron/waitlist-offers/route.ts b/src/app/api/cron/waitlist-offers/route.ts new file mode 100644 index 00000000..ab52957e --- /dev/null +++ b/src/app/api/cron/waitlist-offers/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { processWaitlistPromotionOffers } from "@/server/jobs/waitlist-promotion-offers"; + +import { createLogger } from "@/server/utils/logger"; + +const log = createLogger("Cron"); + +export const dynamic = "force-dynamic"; +export const maxDuration = 300; + +function authorizeCron(request: NextRequest): boolean { + const secret = process.env.CRON_SECRET; + if (!secret) { + return false; + } + const authHeader = request.headers.get("authorization"); + return authHeader === `Bearer ${secret}`; +} + +/** + * POST /api/cron/waitlist-offers + * + * Schließt abgelaufene Nachrück-Angebote — die Plätze gehen an die Nächsten + * auf der Warteliste — und erinnert das Kursteam an Angebote, die in den + * nächsten zwei Tagen ablaufen. Läuft auch ohne SMTP: das Weitergeben der + * Plätze darf nicht am Mailversand hängen. + * + * Auf mittwald über einen mStudio-Cronjob mit + * scripts/trigger-waitlist-offers.mjs (Vorschlag: stündlich). + * Erfordert Authorization: Bearer . + */ +export async function POST(request: NextRequest) { + if (!authorizeCron(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const result = await processWaitlistPromotionOffers(); + return NextResponse.json(result); + } catch (error) { + log.error("Waitlist offer run failed:", error); + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Internal error", + }, + { status: 500 }, + ); + } +} + +/** Allow GET for simple cron services that only support GET. */ +export async function GET(request: NextRequest) { + return POST(request); +} diff --git a/src/server/jobs/waitlist-promotion-offers.ts b/src/server/jobs/waitlist-promotion-offers.ts new file mode 100644 index 00000000..931aa7a0 --- /dev/null +++ b/src/server/jobs/waitlist-promotion-offers.ts @@ -0,0 +1,188 @@ +import "server-only"; + +import { db } from "@/server/db"; +import { getBaseUrl } from "@/server/utils/get-base-url"; +import { + CourseCollaboratorRole, + RegistrationStatus, +} from "~/generated/prisma/client"; +import { loadSeatAvailability } from "@/server/api/helpers/course-capacity"; +import { + promoteFromWaitlist, + sendPromotionEmails, +} from "@/server/api/helpers/waitlist-promotion"; +import { resolveParticipantPriceOption } from "@/lib/course-price-options"; +import { + PROMOTION_OFFER_REMINDER_HOURS, + promotionOfferReminderDue, + usableSeats, +} from "@/lib/waitlist-offer"; + +import { createLogger } from "@/server/utils/logger"; + +const log = createLogger("Waitlist Offers"); + +export type WaitlistOfferRunResult = { + /** Kurse, in denen ein abgelaufenes Angebot das Nachrücken angestoßen hat. */ + courses: number; + expired: number; + promoted: number; + offered: number; + reminded: number; + errors: Array<{ id: string; error: string }>; +}; + +/** + * Stündlicher Lauf für Nachrück-Angebote: + * + * 1. Abgelaufene Angebote: Nachrücken für den Kurs anstoßen — das schließt + * das Angebot, gibt die Plätze an die Nächsten weiter und verschickt die + * Mails. + * 2. Angebote mit höchstens noch zwei Tagen: das Kursteam einmal erinnern. + */ +export async function processWaitlistPromotionOffers( + now = new Date(), +): Promise { + const result: WaitlistOfferRunResult = { + courses: 0, + expired: 0, + promoted: 0, + offered: 0, + reminded: 0, + errors: [], + }; + + const expiredCourses = await db.courseRegistration.findMany({ + where: { + registrationStatus: RegistrationStatus.WAITLIST, + promotionOfferExpiresAt: { lte: now }, + course: { startDate: { gt: now } }, + }, + select: { courseId: true }, + distinct: ["courseId"], + }); + + for (const { courseId } of expiredCourses) { + try { + const run = await promoteFromWaitlist(db, courseId); + await sendPromotionEmails(run); + result.courses += 1; + result.expired += run.expired.length; + result.promoted += run.promoted.length; + result.offered += run.offered.length; + } catch (error) { + log.error(`Promotion run for course ${courseId} failed:`, error); + result.errors.push({ + id: courseId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + const expiring = await db.courseRegistration.findMany({ + where: { + registrationStatus: RegistrationStatus.WAITLIST, + promotionOfferReminderSentAt: null, + promotionOfferExpiresAt: { + gt: now, + lte: new Date( + now.getTime() + PROMOTION_OFFER_REMINDER_HOURS * 60 * 60 * 1000, + ), + }, + }, + include: { + participants: { + select: { + firstName: true, + lastName: true, + priceOptionId: true, + priceOption: true, + }, + }, + course: { + select: { + id: true, + title: true, + maxParticipants: true, + priceOptions: { + select: { id: true, label: true, maxParticipants: true }, + }, + createdBy: { select: { email: true } }, + collaborators: { + where: { role: CourseCollaboratorRole.ORGANIZER }, + select: { user: { select: { email: true } } }, + }, + }, + }, + }, + }); + + if (expiring.length === 0) return result; + + const emailService = await import("@/server/email"); + // Ohne Mailversand bleibt die Erinnerung offen und geht beim nächsten Lauf. + if (!emailService.isEmailConfigured()) return result; + + for (const registration of expiring) { + const expiresAt = registration.promotionOfferExpiresAt; + if ( + !expiresAt || + !promotionOfferReminderDue( + { + expiresAt, + reminderSentAt: registration.promotionOfferReminderSentAt, + }, + now, + ) + ) { + continue; + } + + const { course } = registration; + const recipients = [ + ...new Set( + [ + course.createdBy?.email, + ...course.collaborators.map((c) => c.user.email), + ] + .filter((email): email is string => !!email) + .map((email) => email.trim().toLowerCase()), + ), + ]; + + try { + if (recipients.length > 0) { + const availability = await loadSeatAvailability(db, course); + await emailService.sendWaitlistPromotionOfferExpiringTeamEmail({ + recipients, + courseTitle: course.title, + registrantName: `${registration.registrantFirstName} ${registration.registrantLastName}`, + participantNames: registration.participants.map( + (p) => `${p.firstName} ${p.lastName}`, + ), + seats: usableSeats( + registration.participants.map( + (p) => resolveParticipantPriceOption(p, course.priceOptions)?.id, + ), + availability, + ), + expiresAt, + dashboardUrl: `${getBaseUrl()}/dashboard/courses/${course.id}/participants/${registration.id}`, + }); + } + await db.courseRegistration.update({ + where: { id: registration.id }, + data: { promotionOfferReminderSentAt: now }, + }); + result.reminded += 1; + } catch (error) { + log.error(`Offer reminder for ${registration.id} failed:`, error); + result.errors.push({ + id: registration.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return result; +} From e816726708b5ec61afb168237d0e6f94eaea476d Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Tue, 15 Sep 2026 09:53:35 +0200 Subject: [PATCH 013/139] =?UTF-8?q?feat(anmeldung):=20Nachr=C3=BCck-Angebo?= =?UTF-8?q?t=20auf=20der=20Anmeldungsseite=20beantworten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Läuft ein Angebot, zeigt die Detailseite der Anmeldung die Frist und dieselbe Auswahl wie beim Aufteilen während der Anmeldung: vorbelegt, wer in die freien Plätze passt, alle übrigen nicht wählbar. Dazu "Nachrücken lassen" und "Angebot ablehnen" mit Rückfrage. Sind inzwischen genug Plätze für alle frei, rücken alle nach. Waren die Plätze beim Absenden schon vergeben, lädt die Seite sie neu. Der Hinweis zu den fehlenden Plätzen spricht dort von der wartenden Anmeldung ("nicht genug für alle 3 Teilnehmer") statt vom Anmelden. "Meine Anmeldungen" kennzeichnet ein laufendes Angebot mit "Nachrücken möglich", die Detailseite im Dashboard mit der Frist. --- .../seat-shortage-notice.tsx | 25 ++- .../seat-split-choice.tsx | 22 +- .../events/promotion-offer-card.tsx | 190 ++++++++++++++++++ .../participants/[registrationId]/page.tsx | 16 ++ src/app/registrations/[id]/page.tsx | 17 ++ src/app/registrations/page.tsx | 9 + 6 files changed, 270 insertions(+), 9 deletions(-) create mode 100644 src/app/_components/events/promotion-offer-card.tsx diff --git a/src/app/_components/events/course-registration-form/seat-shortage-notice.tsx b/src/app/_components/events/course-registration-form/seat-shortage-notice.tsx index 3ab63570..922826f6 100644 --- a/src/app/_components/events/course-registration-form/seat-shortage-notice.tsx +++ b/src/app/_components/events/course-registration-form/seat-shortage-notice.tsx @@ -8,15 +8,34 @@ function seats(count: number): string { return count === 1 ? "ist nur noch 1 Platz" : `sind nur noch ${count} Plätze`; } -/** Ein Satz dazu, woran es fehlt: am Kurs oder an einer Preiskategorie. */ +/** Was die Hinweise vom Kurs brauchen — im Formular wie an einer Anmeldung. */ +export type ShortageCourse = { + priceOptions: ReadonlyArray<{ + id: string; + label: string; + description: string | null; + }>; +}; + +/** + * Ein Satz dazu, woran es fehlt: am Kurs oder an einer Preiskategorie. + * `waiting`: für eine Anmeldung, die schon auf der Warteliste steht — nicht + * für eine, die gerade ausgefüllt wird. + */ export function seatShortageCause( - course: CourseWithRelations, + course: ShortageCourse, shortage: SeatShortage, + { waiting = false }: { waiting?: boolean } = {}, ): string { + const tail = (inOption: boolean) => + waiting + ? ` – nicht genug für alle ${shortage.requested} Teilnehmer${inOption ? " dieser Kategorie" : ""}.` + : `, Sie melden ${shortage.requested} Teilnehmer${inOption ? " darin" : ""} an.`; + if (shortage.kind === "course") { return shortage.free === 0 ? "Der Kurs ist bereits ausgebucht." - : `Im Kurs ${seats(shortage.free)} frei, Sie melden ${shortage.requested} Teilnehmer an.`; + : `Im Kurs ${seats(shortage.free)} frei${tail(false)}`; } const option = course.priceOptions.find( diff --git a/src/app/_components/events/course-registration-form/seat-split-choice.tsx b/src/app/_components/events/course-registration-form/seat-split-choice.tsx index ec5bde98..6bad6173 100644 --- a/src/app/_components/events/course-registration-form/seat-split-choice.tsx +++ b/src/app/_components/events/course-registration-form/seat-split-choice.tsx @@ -11,12 +11,19 @@ import { type SeatAvailability, type SeatSelectionProblem, } from "@/lib/registration-split"; -import { seatShortageCause } from "./seat-shortage-notice"; -import type { CourseWithRelations, ParticipantDraft } from "./types"; +import { seatShortageCause, type ShortageCourse } from "./seat-shortage-notice"; + +/** Was die Auswahl von einem Teilnehmer braucht — im Formular wie gespeichert. */ +export type SplitChoiceParticipant = { + firstName: string; + lastName: string; + priceOptionId?: string | null; + siblingGroupId?: string | null; +}; interface SeatSplitChoiceProps { - course: CourseWithRelations; - participants: ParticipantDraft[]; + course: ShortageCourse; + participants: SplitChoiceParticipant[]; shortage: SeatShortage; availability: SeatAvailability; /** @@ -24,6 +31,8 @@ interface SeatSplitChoiceProps { * Aufteilen. Das Kursteam wählt das über den Status der Anmeldung. */ showModeChoice: boolean; + /** Die Anmeldung steht schon auf der Warteliste (Nachrück-Angebot). */ + waiting?: boolean; splitting: boolean; onSplittingChange: (splitting: boolean) => void; selectedIndexes: number[]; @@ -42,6 +51,7 @@ export function SeatSplitChoice({ shortage, availability, showModeChoice, + waiting = false, splitting, onSplittingChange, selectedIndexes, @@ -74,7 +84,7 @@ export function SeatSplitChoice({ ? "Bitte wählen Sie mindestens einen Teilnehmer für die freien Plätze." : problem.kind === "all" ? "Die Plätze reichen nicht für alle – mindestens ein Teilnehmer kommt auf die Warteliste." - : seatShortageCause(course, problem); + : seatShortageCause(course, problem, { waiting }); const radioCard = "dark:bg-dark-background flex cursor-pointer items-start gap-3 rounded-lg border border-orange-200 bg-white p-3 dark:border-orange-800"; @@ -83,7 +93,7 @@ export function SeatSplitChoice({

Nicht genug freie Plätze:{" "} - {seatShortageCause(course, shortage)} + {seatShortageCause(course, shortage, { waiting })}

{showModeChoice && ( diff --git a/src/app/_components/events/promotion-offer-card.tsx b/src/app/_components/events/promotion-offer-card.tsx new file mode 100644 index 00000000..5da1483a --- /dev/null +++ b/src/app/_components/events/promotion-offer-card.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useState } from "react"; +import { api } from "@/trpc/react"; +import { useToast } from "@/app/_components/ui/toast"; +import { registrationSeatShortage } from "@/lib/registration-seat-shortage"; +import { + defaultSeatSelection, + SEAT_SELECTION_OUTDATED_MESSAGE, + seatSelectionProblem, + type SeatAvailability, +} from "@/lib/registration-split"; +import { + SeatSplitChoice, + type SplitChoiceParticipant, +} from "./course-registration-form/seat-split-choice"; +import type { ShortageCourse } from "./course-registration-form/seat-shortage-notice"; + +const formatDateTime = (date: Date) => + new Date(date).toLocaleString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + +interface PromotionOfferCardProps { + registrationId: string; + accessToken?: string; + course: ShortageCourse; + participants: Array; + offer: { expiresAt: Date; availability: SeatAvailability }; + /** After an answer, or when the free seats changed meanwhile. */ + onChanged: () => void; +} + +/** + * Nachrück-Angebot auf der Detailseite: Es sind Plätze frei, aber nicht für + * alle. Die Anmeldenden wählen, wer nachrückt — oder lehnen ab, dann gehen + * die Plätze an die Nächsten und die Anmeldung behält ihren Platz. + */ +export function PromotionOfferCard({ + registrationId, + accessToken, + course, + participants, + offer, + onChanged, +}: PromotionOfferCardProps) { + const toast = useToast(); + const priceOptionIds = participants.map((p) => p.priceOptionId); + const [selectedIndexes, setSelectedIndexes] = useState(() => + defaultSeatSelection(priceOptionIds, offer.availability), + ); + const [confirmDecline, setConfirmDecline] = useState(false); + + const shortage = registrationSeatShortage({ + participantPriceOptionIds: priceOptionIds, + ...offer.availability, + }); + // Inzwischen genug Plätze für alle: dann rücken alle nach. + const indexes = shortage ? selectedIndexes : participants.map((_, i) => i); + const problem = shortage + ? seatSelectionProblem(priceOptionIds, indexes, offer.availability) + : null; + + const handleError = (error: { message: string }) => { + toast.error(error.message); + if (error.message === SEAT_SELECTION_OUTDATED_MESSAGE) onChanged(); + }; + + const accept = api.registrations.acceptPromotionOffer.useMutation({ + onSuccess: (result) => { + toast.success( + result.waitlistRegistrationId + ? `${indexes.length} Teilnehmer sind nachgerückt, die übrigen bleiben auf der Warteliste.` + : "Alle Teilnehmer sind nachgerückt – die Anmeldung ist bestätigt.", + ); + onChanged(); + }, + onError: handleError, + }); + + const decline = api.registrations.declinePromotionOffer.useMutation({ + onSuccess: () => { + toast.success( + "Angebot abgelehnt. Die Anmeldung bleibt auf der Warteliste.", + ); + onChanged(); + }, + onError: handleError, + }); + + const pending = accept.isPending || decline.isPending; + + return ( +
+

+ Plätze frei geworden +

+

+ Bis {formatDateTime(offer.expiresAt)} Uhr kannst du + wählen, wer nachrückt. Die Gewählten sind dann bestätigt, die übrigen + bleiben auf der Warteliste. Lehnst du ab oder verstreicht die Frist, + gehen die Plätze an die Nächsten – deine Anmeldung behält ihren Platz. +

+ +
+ {shortage ? ( + undefined} + selectedIndexes={selectedIndexes} + onSelectedIndexesChange={setSelectedIndexes} + problem={problem} + /> + ) : ( +

+ Inzwischen sind genug Plätze für alle Teilnehmer frei. +

+ )} +
+ + {confirmDecline ? ( +
+

+ Angebot wirklich ablehnen? Die freien Plätze gehen dann an die + Nächsten auf der Warteliste. +

+
+ + +
+
+ ) : ( +
+ + +
+ )} +
+ ); +} diff --git a/src/app/dashboard/courses/[id]/participants/[registrationId]/page.tsx b/src/app/dashboard/courses/[id]/participants/[registrationId]/page.tsx index 8b69ca1e..0f1fb3b4 100644 --- a/src/app/dashboard/courses/[id]/participants/[registrationId]/page.tsx +++ b/src/app/dashboard/courses/[id]/participants/[registrationId]/page.tsx @@ -498,6 +498,22 @@ export default function RegistrationDetailPage() { {part.participants.length}) ))} + {registration.promotionOffer && ( + + Nachrück-Angebot bis{" "} + {new Date( + registration.promotionOffer.expiresAt, + ).toLocaleString("de-DE", { + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + })} + + )} {canEdit && (
+ {registration.promotionOffer && ( + { + void utils.registrations.getById.invalidate({ + id: registrationId, + }); + void utils.registrations.getMyRegistrations.invalidate(); + }} + /> + )} + {registration.groupParts.length > 0 && (

diff --git a/src/app/registrations/page.tsx b/src/app/registrations/page.tsx index 4cd377d6..f01bbee1 100644 --- a/src/app/registrations/page.tsx +++ b/src/app/registrations/page.tsx @@ -351,6 +351,15 @@ export default function MyRegistrationsPage() { Aufgeteilt )} + {registration.registrationStatus === + RegistrationStatus.WAITLIST && + registration.promotionOfferExpiresAt && + new Date(registration.promotionOfferExpiresAt) > + new Date() && ( + + Nachrücken möglich + + )}

From b65d68553ed442599c8a7f7d6664e6d167d06335 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Tue, 15 Sep 2026 18:11:53 +0200 Subject: [PATCH 014/139] =?UTF-8?q?fix(anmeldungen):=20beim=20Best=C3=A4ti?= =?UTF-8?q?gen=20von=20der=20Warteliste=20auch=20Preiskategorien=20pr?= =?UTF-8?q?=C3=BCfen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updateStatus prüfte beim Bestätigen einer wartenden Anmeldung nur die Kurskapazität. Das Kursteam konnte so eine Anmeldung in eine volle Preiskategorie bestätigen – etwa die Wasserburg-Einzelzimmer, während der Kurs insgesamt noch Plätze hatte. Die Kategorien werden jetzt mit findFullPriceTier geprüft, nach id wie in allen übrigen Prüfungen; Teilnehmer aus der Zeit vor der id-Migration über ein eindeutiges Label. Das Nachrücken zählt seit den vorigen Commits ebenso. Closes #315 --- src/server/api/routers/registrations.ts | 30 ++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/server/api/routers/registrations.ts b/src/server/api/routers/registrations.ts index fb127bf1..bd31768a 100644 --- a/src/server/api/routers/registrations.ts +++ b/src/server/api/routers/registrations.ts @@ -2087,7 +2087,7 @@ export const registrationsRouter = createTRPCRouter({ select: { maxParticipants: true, priceOptions: { - select: { label: true, maxParticipants: true }, + select: { id: true, label: true, maxParticipants: true }, }, }, }); @@ -2104,6 +2104,34 @@ export const registrationsRouter = createTRPCRouter({ "Der Kurs ist bereits voll — die Anmeldung kann nicht bestätigt werden.", }); } + + // Auch die Preiskategorien: eine volle Kategorie hat keinen Platz, + // selbst wenn der Kurs noch welche hat. Gezählt wird nach id wie in + // allen übrigen Prüfungen — Altbestand über ein eindeutiges Label. + const additionsByOptionId: Record = {}; + for (const participant of registration.participants) { + const optionId = resolveParticipantPriceOption( + participant, + course.priceOptions, + )?.id; + if (optionId) { + additionsByOptionId[optionId] = + (additionsByOptionId[optionId] ?? 0) + 1; + } + } + const fullOption = await findFullPriceTier( + tx, + registration.courseId, + course.priceOptions, + additionsByOptionId, + registration.id, + ); + if (fullOption) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `${priceTierFullMessage(fullOption)} Die Anmeldung kann nicht bestätigt werden.`, + }); + } } return tx.courseRegistration.update({ From 377647107e7b1af14ec56bfba3732d34c07e1ab2 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Tue, 15 Sep 2026 23:55:34 +0200 Subject: [PATCH 015/139] =?UTF-8?q?feat(design):=20Programmheft-Grundlagen?= =?UTF-8?q?=20=E2=80=93=20Archivo,=20Tokens=20und=20Hilfsklassen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archivo mit Breitenachse als Variable für die öffentliche Website (das Dashboard bleibt bei Inter). Neue Farb-Tokens (Tinte, Papier, Messing-Tinte, Haarlinie, Nachtdruck), schmale Schnitte, Satzbreite, Füllzeile, Fokus- und Markierungsfarben sowie reduzierte Bewegung. --- src/app/layout.tsx | 20 +++++- src/styles/globals.css | 155 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 2 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dd02cc46..04588ed2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata, Viewport } from "next"; -import { Inter } from "next/font/google"; +import { Archivo, Inter } from "next/font/google"; import "../styles/globals.css"; import { AppChrome } from "./_components/general/app-chrome"; import { TRPCReactProvider } from "@/trpc/react"; @@ -22,6 +22,18 @@ import { const inter = Inter({ subsets: ["latin"] }); +/** + * Programmheft-Schrift der öffentlichen Website. Die Breitenachse trägt beide + * Stimmen einer Familie: schmal für Titel, Daten und Programmköpfe, normal für + * Text. Nur als Variable geladen — das Dashboard bleibt bei Inter. + */ +const archivo = Archivo({ + subsets: ["latin", "latin-ext"], + axes: ["wdth"], + variable: "--font-archivo", + display: "swap", +}); + export const metadata: Metadata = { // Makes every relative URL in child metadata (canonicals, og:image) absolute. metadataBase: new URL(siteUrl()), @@ -91,7 +103,11 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + Text danach"), + ).toBe("Ablauf\n\nText danach"); + }); + + it("verschluckt kein Klammerpaar im Linkziel", () => { + expect( + markdownToPlainText("[Klick](https://de.wikipedia.org/wiki/Zug_(Musik))"), + ).toBe("Klick"); + }); + + it("löst HTML-Entitäten auf", () => { + expect(markdownToPlainText("Bläser & Chor")).toBe("Bläser & Chor"); + }); + + it("behält das Gendersternchen und einzelne Unterstriche", () => { + expect(markdownToPlainText("Für Jungbläser*innen")).toBe( + "Für Jungbläser*innen", + ); + expect(markdownToPlainText("Datei kurs_2026 beachten")).toBe( + "Datei kurs_2026 beachten", + ); + }); + + it("behält Absätze und Umbrüche des Altbestands unverändert", () => { + const altbestand = + "Leitung: RPW Matthias Schirg und Team\nZielgruppe: Posaunenchorbläser\n\nAnmeldung bis zum 1. Mai."; + + expect(markdownToPlainText(altbestand)).toBe(altbestand); + }); + + it("faltet für einzeilige Felder alle Umbrüche zusammen", () => { + expect(markdownToSingleLine("## Ablauf\n\nProbe\nKonzert\n\n- Noten")).toBe( + "Ablauf Probe Konzert Noten", + ); + }); + + it("verträgt leere Eingaben", () => { + expect(markdownToPlainText("")).toBe(""); + expect(markdownToSingleLine(" ")).toBe(""); + }); +}); + +describe("Klartext und Darstellung sagen dasselbe", () => { + const proben = [ + "Vorweihnachtliches Konzert für Blechbläser und Orgel", + "Lehrgang für Jungbläser*innen aller Altersstufen", + "Leitung: RPW Matthias Schirg und Team\nZielgruppe: Posaunenchorbläser", + "Erste Zeile\nZweite Zeile\n\nNeuer Absatz", + "### Ablauf\n\nBitte **pünktlich** sein.\n\n- Anreise\n- Probe\n\n[Anmeldung](https://posaunenwerk.de/anmeldung)", + "Ein Zitat:\n\n> Musik verbindet.\n\nDanach *kursiv*.", + ]; + + it.each(proben)("Probe %#", (markdown) => { + expect(markdownToPlainText(markdown)).toBe( + htmlZuText(descriptionToHtml(markdown)), + ); + }); +}); diff --git a/src/lib/description-html.ts b/src/lib/description-html.ts new file mode 100644 index 00000000..87224d1d --- /dev/null +++ b/src/lib/description-html.ts @@ -0,0 +1,29 @@ +import { marked } from "marked"; + +/** + * Markdown-Optionen der Beschreibungen. + * + * `breaks: true` ist der Grund, weshalb der Umstieg auf Markdown ohne + * Datenwanderung auskommt: Die vorhandenen Klartext-Beschreibungen sind + * gültiges Markdown, und mit dieser Einstellung bleibt jeder einzelne + * Zeilenumbruch ein Umbruch statt zu einem Leerzeichen zu werden. Leerzeilen + * trennen wie bisher Absätze. + * + * Die Optionen stehen bewusst am Aufruf und nicht in einem globalen + * `marked.use()`: `marked` ist ein Singleton, und der Beitragseditor stellt + * dieselbe Bibliothek im selben Bündel ein. Was hier gilt, soll nicht davon + * abhängen, welches Modul zuerst geladen wurde. + */ +const OPTIONS = { gfm: true, breaks: true, async: false } as const; + +/** + * Beschreibung (Markdown) als HTML. + * + * Nicht direkt in die Seite geben: Das Ergebnis enthält alles, was im Text + * stand, auch rohes HTML. Für die Anzeige `renderDescriptionHtml` aus + * `@/lib/sanitize` benutzen — das nimmt diesen Schritt und filtert danach. + */ +export function descriptionToHtml(markdown: string): string { + if (!markdown.trim()) return ""; + return String(marked.parse(markdown, OPTIONS)); +} diff --git a/src/lib/description.ts b/src/lib/description.ts new file mode 100644 index 00000000..9fe4cc66 --- /dev/null +++ b/src/lib/description.ts @@ -0,0 +1,18 @@ +/** + * Höchstlänge der gespeicherten Beschreibung von Terminen und Kursen. + * + * Gespeichert wird Markdown, die Auszeichnung zählt also mit: Ein Link kostet + * rund 30 Zeichen mehr als sein Text, eine Überschrift vier, fett und kursiv + * je zwei bis vier. Gemessen am Bestand ist das reichlich — die längste + * Beschreibung in der Datenbank hat 1068 Zeichen, gut ein Zehntel davon. + * + * Termine hatten bisher 5000, Kurse 10.000 Zeichen. Zwei Grenzen für denselben + * Text in derselben Schreibfläche sind nicht zu erklären, deshalb gilt jetzt + * für beide dieselbe. + * + * Eigene Datei, weil die Formulare diese Zahl brauchen, aber nicht die + * Markdown-Bibliothek: `description-html.ts` zieht `marked` mit, und `marked` + * erklärt sich nicht als nebenwirkungsfrei — der Packer könnte es also nicht + * wieder herauslösen. + */ +export const MAX_DESCRIPTION_LENGTH = 10000; diff --git a/src/lib/markdown-to-plain-text.ts b/src/lib/markdown-to-plain-text.ts index cb9e4dcc..a34b69a0 100644 --- a/src/lib/markdown-to-plain-text.ts +++ b/src/lib/markdown-to-plain-text.ts @@ -1,29 +1,123 @@ /** - * Converts markdown to plain text. + * HTML-Entitäten, die in gespeichertem Markdown vorkommen können. * - * Strips markdown syntax directly instead of rendering to HTML first — that - * way no HTML is ever produced and the result needs no sanitization before it - * goes into an XML feed or a `` attribute. + * Der Editor schreibt sie nicht selbst, aber importierte und von Hand + * eingefügte Texte tragen sie; in der Anzeige werden sie zu Zeichen. Ohne + * diese Auflösung stünde „Bläser & Chor" im Kalender statt „Bläser & Chor". + */ +const ENTITIES: Record = { + "&": "&", + "<": "<", + ">": ">", + """: '"', + "'": "'", + "'": "'", + " ": " ", +}; + +/** + * Ablage für maskierte Satzzeichen im privaten Unicode-Bereich (U+E000 ff.). + * Alle maskierbaren Zeichen sind ASCII, landen also zwischen U+E020 und + * U+E07F und kollidieren mit nichts, was in einer Beschreibung stehen kann. + */ +const MASKIERT_AB = 0xe000; + +/** + * Wandelt Markdown in Klartext um. + * + * Für alles, was keine Auszeichnung darstellen kann: iCal-Feed, Seiten- und + * Social-Media-Metadaten, Suchtreffer, E-Mail-Entwürfe, Vorlagen. Die + * Syntaxzeichen werden direkt aus dem Quelltext entfernt, statt erst HTML zu + * erzeugen und es wieder abzuräumen — so entsteht nie HTML, das jemand + * filtern müsste, und der Helfer bleibt ohne Markdown-Bibliothek im + * Browser-Bündel. + * + * Absätze und Zeilenumbrüche bleiben erhalten, weil die Beschreibungen mit + * `breaks: true` dargestellt werden: Was dort ein Umbruch ist, ist hier eine + * neue Zeile. Wer eine einzeilige Fassung braucht (Metadaten), faltet die + * Zeilen anschließend selbst zusammen — siehe `plainTextExcerpt`. + * + * Die Reihenfolge der Ersetzungen ist bewusst: Bilder vor Links (sonst bleibt + * vom `![alt](url)` ein einzelnes „!" stehen), Codeblöcke vor Inline-Code, + * Hervorhebungen vor den Maskierungen (`\*` soll ein Sternchen bleiben). */ export function markdownToPlainText(markdown: string): string { if (!markdown) return ""; - let text = markdown.replace(/^#{1,6}\s+/gm, ""); - text = text.replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1"); - text = text.replace(/!\[([^\]]*)\]\([^\)]+\)/g, "$1"); - text = text.replace(/\*\*([^*]+)\*\*/g, "$1"); - text = text.replace(/\*([^*]+)\*/g, "$1"); - text = text.replace(/~~([^~]+)~~/g, "$1"); - text = text.replace(/`([^`]+)`/g, "$1"); + let text = markdown.replace(/\r\n?/g, "\n"); + + // Codeblöcke ganz weg — ihr Inhalt ist kein Fließtext. text = text.replace(/```[\s\S]*?```/g, ""); - text = text.replace(/^[\s]*[-*+]\s+/gm, ""); - text = text.replace(/^\d+\.\s+/gm, ""); - text = text.replace(/^>\s+/gm, ""); - text = text.replace(/^---+/gm, ""); - text = text.replace(/[<>]/g, ""); - text = text.replace(/\n\s*\n\s*\n/g, "\n\n"); - text = text.replace(/[ \t]+/g, " "); + // Maskierte Satzzeichen zuerst aus dem Weg räumen: Turndown schreibt jedes + // Zeichen, das Markdown deuten würde, mit Rückstrich — aus der Zeile „1. + // Tag: Anreise" wird beim Speichern „1\. Tag: Anreise". Blieben sie stehen, + // hielten die Regeln weiter unten „\*Hinweis\*" für eine Hervorhebung und + // ließen die Rückstriche übrig. Deshalb wandern sie so lange in den + // privaten Unicode-Bereich, dass keine Regel sie mehr sieht. + text = text.replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, (_treffer, zeichen) => + String.fromCharCode(MASKIERT_AB + (zeichen as string).charCodeAt(0)), + ); + + // Skript- und Stilblöcke samt Inhalt: Der steht nie im Fließtext. Ohne + // diese Zeile bliebe von `` das „alert(1)" im + // Kalendereintrag stehen — gemessen. Gefährlich ist das nicht (Klartext + // wird nirgends ausgeführt), nur falsch. + text = text.replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, ""); + + // Sonst nur echte Tags entfernen, nicht jedes spitze Klammerpaar: „Kinder + // <10 Jahre" ist Text und wird in der Anzeige auch als Text dargestellt. + // Die frühere Fassung löschte pauschal `<` und `>` und machte aus + // `Wort` das Wort „uWort/u". + text = text.replace(/<\/?[a-zA-Z][^<>]*>/g, ""); + + // Das Ziel darf ein Klammerpaar enthalten (`javascript:alert(1)`, aber auch + // Wikipedia-Adressen mit Klammern) — eine Ebene reicht dafür. Vorher blieb + // von `[Klick](javascript:alert(1))` ein „Klick)" übrig. + const ZIEL = /\((?:[^()]|\([^()]*\))*\)/.source; + text = text.replace(new RegExp(`!\\[([^\\]]*)\\]${ZIEL}`, "g"), "$1"); + text = text.replace(new RegExp(`\\[([^\\]]+)\\]${ZIEL}`, "g"), "$1"); + + // Zeilenanfänge: Überschrift, Zitat, Aufzählung, Trennlinie. Die Nummern + // nummerierter Listen bleiben stehen — ohne sie verlöre „1. Anreise, 2. + // Probe" seine Ordnung. + text = text.replace(/^ {0,3}#{1,6}\s+/gm, ""); + text = text.replace(/^ {0,3}>\s?/gm, ""); + text = text.replace(/^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/gm, ""); + text = text.replace(/^ {0,3}[-*+][ \t]+/gm, ""); + text = text.replace(/^ {0,3}(\d{1,9})[.)][ \t]+/gm, "$1. "); + + text = text.replace(/(\*\*\*|___)([^\n]+?)\1/g, "$2"); + text = text.replace(/(\*\*|__)([^\n]+?)\1/g, "$2"); + // Sternchen zeichnen auch innerhalb eines Wortes aus (so stellt Markdown + // „Bläser*innen und Jungbläser*innen" kursiv), Unterstriche nicht — deshalb + // fordert die zweite Regel eine Wortgrenze. Ohne sie verlöre „Kosten + // 50_60_70" seine Unterstriche, obwohl die Anzeige sie behält. + text = text.replace(/\*([^*\n]+)\*/g, "$1"); + text = text.replace(/(^|[^\w\\])_([^_\n]+)_(?![\w])/gm, "$1$2"); + text = text.replace(/~~([^~\n]+)~~/g, "$1"); + text = text.replace(/`([^`\n]+)`/g, "$1"); + + // Maskierte Zeichen zurückholen (siehe oben). + text = text.replace(/[-]/g, (zeichen) => + String.fromCharCode(zeichen.charCodeAt(0) - MASKIERT_AB), + ); + + for (const [entity, char] of Object.entries(ENTITIES)) { + text = text.split(entity).join(char); + } + + text = text.replace(/[ \t]+$/gm, ""); + text = text.replace(/[ \t]{2,}/g, " "); + text = text.replace(/\n{3,}/g, "\n\n"); return text.trim(); } + +/** + * Einzeilige Fassung für Felder, die keine Umbrüche vertragen (Metadaten, + * Kalender-Kurzfassungen, Suchtreffer). + */ +export function markdownToSingleLine(markdown: string): string { + return markdownToPlainText(markdown).replace(/\s+/g, " ").trim(); +} diff --git a/src/lib/sanitize.ts b/src/lib/sanitize.ts index a795f871..8c90da0e 100644 --- a/src/lib/sanitize.ts +++ b/src/lib/sanitize.ts @@ -1,4 +1,5 @@ import DOMPurify from "isomorphic-dompurify"; +import { descriptionToHtml } from "@/lib/description-html"; // Force safe rel on links: user-authored content may set target="_blank", // and without noopener the target page gets a handle on our window. @@ -89,3 +90,24 @@ export function sanitizeHtml(dirty: string): string { ADD_URI_SAFE_ATTR: ["data-copyright", "data-creator"], }); } + +/** + * Beschreibung eines Termins oder Kurses als anzeigefertiges HTML. + * + * Eine Stelle für beide Schritte, damit keine Ansicht das Filtern vergisst: + * Markdown zu HTML (`descriptionToHtml`), danach derselbe Filter wie bei den + * Beiträgen. Gespeichert wird wie dort Markdown; bereinigt wird beim Anzeigen, + * nicht beim Speichern — der Bestand und die Einfuhr liefen sonst am Filter + * vorbei, und DOMPurify auf Markdown angewandt zerstört ihn (aus „> Zitat" + * würde „> Zitat", aus „a & b" „a & b"). + * + * `null` für leere Beschreibungen, damit die Ansichten den Abschnitt ohne + * eigene Prüfung weglassen können. + */ +export function renderDescriptionHtml( + markdown: string | null | undefined, +): string | null { + if (!markdown?.trim()) return null; + const html = sanitizeHtml(descriptionToHtml(markdown)); + return html.trim() ? html : null; +} diff --git a/src/lib/seo.ts b/src/lib/seo.ts index 4eadbd72..61e3d837 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import { getBaseUrl } from "@/server/utils/get-base-url"; -import { markdownToPlainText } from "@/lib/markdown-to-plain-text"; +import { markdownToSingleLine } from "@/lib/markdown-to-plain-text"; export const SITE_NAME = "Posaunenwerk Rheinland"; export const SITE_DESCRIPTION = @@ -17,8 +17,9 @@ export function siteUrl(path = "/"): string { /** * Plain-text summary for `` / `og:description`. * - * Post and course bodies are markdown, but the TipTap editor can leave inline - * HTML behind, so tags are stripped first. Truncation lands on a word boundary + * Beiträge und Beschreibungen sind Markdown; `markdownToSingleLine` räumt die + * Auszeichnung ab, löst Maskierungen auf und faltet die Umbrüche zusammen — + * ein Meta-Feld hat nur eine Zeile. Truncation lands on a word boundary * because search engines cut mid-word descriptions with an ellipsis anyway. */ export function plainTextExcerpt( @@ -27,9 +28,7 @@ export function plainTextExcerpt( ): string | undefined { if (!source) return undefined; - const text = markdownToPlainText(source.replace(/<[^>]*>/g, " ")) - .replace(/\s+/g, " ") - .trim(); + const text = markdownToSingleLine(source); if (!text) return undefined; if (text.length <= maxLength) return text; diff --git a/src/server/api/routers/courses.ts b/src/server/api/routers/courses.ts index 31990716..5f0c254d 100644 --- a/src/server/api/routers/courses.ts +++ b/src/server/api/routers/courses.ts @@ -26,6 +26,7 @@ import { notifySubmittedForReview, } from "../helpers/review-notifications"; import { PERMISSIONS } from "@/lib/permissions"; +import { MAX_DESCRIPTION_LENGTH } from "@/lib/description"; import { getCourseCapacitySummary } from "@/lib/course-available-slots"; import { validatePriceOptionDistinctness } from "@/lib/course-price-options"; import { @@ -771,7 +772,8 @@ export const coursesRouter = createTRPCRouter({ /** Empty means "derive it from the title"; see createCourseSlug. */ slug: z.string().max(MAX_SLUG_LENGTH).optional(), motto: z.string().max(500).optional(), - description: z.string().min(1).max(10000), + // Markdown, siehe MAX_DESCRIPTION_LENGTH. + description: z.string().min(1).max(MAX_DESCRIPTION_LENGTH), imageId: z.string().optional(), startDate: z.date(), endDate: z.date(), @@ -1094,7 +1096,7 @@ export const coursesRouter = createTRPCRouter({ /** Only sent when the author deliberately renamed it; empty = leave as is. */ slug: z.string().max(MAX_SLUG_LENGTH).optional(), motto: z.string().max(500).optional(), - description: z.string().max(10000).optional(), + description: z.string().max(MAX_DESCRIPTION_LENGTH).optional(), imageId: z.string().optional().nullable(), startDate: z.date().optional(), endDate: z.date().optional(), diff --git a/src/server/api/routers/events.ts b/src/server/api/routers/events.ts index 9d80f8d0..81ca0be1 100644 --- a/src/server/api/routers/events.ts +++ b/src/server/api/routers/events.ts @@ -22,6 +22,7 @@ import { notifySubmittedForReview, } from "../helpers/review-notifications"; import { PERMISSIONS } from "@/lib/permissions"; +import { MAX_DESCRIPTION_LENGTH } from "@/lib/description"; import { permissionProcedure } from "../middleware/permissions"; import { createEventSlug, updateEventSlug } from "../helpers/content-slug"; import { isUuid, MAX_SLUG_LENGTH } from "@/lib/slug"; @@ -436,7 +437,9 @@ export const eventsRouter = createTRPCRouter({ /** Empty means "derive it from the title"; see createEventSlug. */ slug: z.string().max(MAX_SLUG_LENGTH).optional(), motto: z.string().max(500).optional(), - description: z.string().max(5000).optional(), + // Markdown, siehe MAX_DESCRIPTION_LENGTH: dieselbe Grenze wie bei + // Kursen, weil es derselbe Text in derselben Schreibfläche ist. + description: z.string().max(MAX_DESCRIPTION_LENGTH).optional(), coverImageId: z.string().optional(), downloadIds: z.array(z.string()).optional(), eventDate: z.date(), @@ -568,7 +571,7 @@ export const eventsRouter = createTRPCRouter({ /** Only sent when the author deliberately renamed it; empty = leave as is. */ slug: z.string().max(MAX_SLUG_LENGTH).optional(), motto: z.string().max(500).optional(), - description: z.string().max(5000).optional(), + description: z.string().max(MAX_DESCRIPTION_LENGTH).optional(), coverImageId: z.string().optional().nullable(), downloadIds: z.array(z.string()).optional(), eventDate: z.date().optional(), diff --git a/src/server/api/routers/search.ts b/src/server/api/routers/search.ts index 3225faa4..533a86f4 100644 --- a/src/server/api/routers/search.ts +++ b/src/server/api/routers/search.ts @@ -3,6 +3,7 @@ import { createTRPCRouter, rateLimitedPublicProcedure } from "../trpc"; import { ContentStatus } from "~/generated/prisma/client"; import { GAMES } from "@/app/spiele/_lib/games"; import { coursePath, eventPath, postPath } from "@/lib/slug"; +import { markdownToSingleLine } from "@/lib/markdown-to-plain-text"; export type SearchResultType = | "post" @@ -882,7 +883,9 @@ export const searchRouter = createTRPCRouter({ id: event.id, type: "event", title: event.title, - description: event.description, + // Treffer sind einzeilige Anrisse: Markdown-Zeichen aus der + // Beschreibung hätten hier nichts dargestellt, nur gestört. + description: markdownToSingleLine(event.description ?? "") || null, url: eventPath(event), imageUrl: event.coverImage?.url ?? null, date: event.eventDate, @@ -908,7 +911,7 @@ export const searchRouter = createTRPCRouter({ id: course.id, type: "course", title: course.title, - description: course.description, + description: markdownToSingleLine(course.description) || null, url: coursePath(course), imageUrl: null, date: course.startDate, diff --git a/src/server/api/routers/utils.ts b/src/server/api/routers/utils.ts index b608f724..95b3f195 100644 --- a/src/server/api/routers/utils.ts +++ b/src/server/api/routers/utils.ts @@ -21,11 +21,24 @@ import { searchAddresses } from "@/server/utils/address-search"; import { clientKeyFromHeaders, rateLimit } from "@/server/utils/rate-limit"; import { createUnsubscribeToken } from "@/server/utils/unsubscribe-token"; import { eventPath, postPath } from "@/lib/slug"; +import { markdownToSingleLine } from "@/lib/markdown-to-plain-text"; import { createLogger } from "@/server/utils/logger"; const log = createLogger("Utils"); +/** + * Anriss einer Termin-Beschreibung für den Newsletter-Entwurf. + * + * Klartext, obwohl der Entwurf selbst Markdown ist: Der Anriss wird auf 200 + * Zeichen gekürzt, und ein Schnitt mitten in `[Text](URL)` oder hinter einem + * einzelnen `**` hätte die Auszeichnung des restlichen Entwurfs verschoben. + */ +function kurzerAnriss(markdown: string, maxLength = 200): string { + const text = markdownToSingleLine(markdown); + return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text; +} + marked.use({ gfm: true, breaks: true, @@ -694,9 +707,7 @@ export const newsletterRouter = createTRPCRouter({ locationText ? `**Ort:** ${locationText}\n\n` : "" }${ event.description - ? `${event.description.substring(0, 200)}${ - event.description.length > 200 ? "..." : "" - }\n\n` + ? `${kurzerAnriss(event.description)}\n\n` : "" }[Mehr erfahren →](${eventUrl})\n\n`, ); diff --git a/src/styles/beschreibung.css b/src/styles/beschreibung.css new file mode 100644 index 00000000..0ba4143d --- /dev/null +++ b/src/styles/beschreibung.css @@ -0,0 +1,223 @@ +/* + * Beschreibung eines Termins oder Kurses. + * + * Die schlanke Schwester von article-content.css: dieselbe Satzhaltung, aber + * ohne Bildvokabular, Ausbruchsbreiten und Codeblöcke — eine Beschreibung + * kennt Absätze, Überschriften, Listen, Links und Hervorhebungen. Sie steht in + * vier Ansichten (öffentliche Termin- und Kursseite, dieselben Seiten im + * Dashboard) und in der Schreibfläche; was hier steht, sieht die Redakteurin + * beim Schreiben wie die Leserin auf der Seite. + * + * Vorher trugen diese Container `prose dark:prose-invert`. Das + * Typography-Plugin ist nicht geladen — nachgemessen hatten Absätze deshalb + * gar keinen Abstand (`margin` 0px, alle Absätze klebten aneinander) und + * Listen keine Aufzählungszeichen, weil Tailwinds Preflight beides + * zurücksetzt. + * + * Kein `!important` wie im Artikelkörper: Dort steckten Tailwind-Klassen im + * gespeicherten HTML, die überstimmt werden mussten. Beschreibungen sind + * Markdown und tragen keine Klassen. + * + * Wichtig für den Umfluss des Titelbildes: Diese Datei setzt am Container + * weder `overflow`, `display: flow-root` noch eine eigene Spalte. Ein solcher + * Block würde nicht mehr um das nebenstehende Bild fließen. + */ +.beschreibung { + color: var(--color-ink); + line-height: 1.7; +} + +.dark .beschreibung { + color: var(--color-night-text); +} + +/* Der erste Block sitzt an der Oberkante — den Abstand nach oben führt der + Abschnitt, nicht der Text. */ +.beschreibung > :first-child { + margin-top: 0; +} + +.beschreibung > :last-child { + margin-bottom: 0; +} + +.beschreibung p { + margin-bottom: 1.25rem; +} + +/* Köpfe sprechen schmal wie die Programmköpfe, nur zwei Stufen kleiner: Der + Abschnittskopf „Beschreibung" darüber ist eine h2 in 1,75rem. */ +.beschreibung h2, +.beschreibung h3, +.beschreibung h4 { + font-family: var(--font-programm); + font-weight: 700; + color: var(--color-ink); + line-height: 1.2; + margin-top: 2rem; + margin-bottom: 0.625rem; +} + +.dark .beschreibung h2, +.dark .beschreibung h3, +.dark .beschreibung h4 { + color: var(--color-night-text); +} + +.beschreibung h2, +.beschreibung h3 { + font-variation-settings: "wdth" 68; +} + +.beschreibung h2 { + font-size: 1.5rem; +} + +.beschreibung h3 { + font-size: 1.25rem; +} + +/* Die dritte Stufe steht schon tief in der Gliederung und bleibt halbschmal, + damit sie sich von der Fließschrift eher durch Fettung unterscheidet. */ +.beschreibung h4 { + font-size: 1.125rem; + font-variation-settings: "wdth" 84; +} + +.beschreibung strong, +.beschreibung b { + font-weight: 700; + color: inherit; +} + +.beschreibung em, +.beschreibung i { + font-style: italic; +} + +/* Tailwinds Preflight nimmt Listen ihre Zeichen und ihren Einzug — beides + steht hier wieder. */ +.beschreibung ul, +.beschreibung ol { + margin: 1.25rem 0; + padding-left: 1.5rem; +} + +.beschreibung ul { + list-style-type: disc; +} + +.beschreibung ol { + list-style-type: decimal; +} + +.beschreibung li { + margin: 0.375rem 0; +} + +.beschreibung li > ul, +.beschreibung li > ol { + margin: 0.375rem 0; +} + +.beschreibung li p { + margin-bottom: 0; +} + +/* + * Links wie `link-ink` im übrigen Heft: Messing-Tinte auf Papier (5,26:1), + * Druckorange im Nachtdruck (9,16:1), immer unterstrichen — an der Farbe + * allein wäre ein Link für farbfehlsichtige Leser nicht zu erkennen. + */ +.beschreibung a { + color: var(--color-primary-ink); + font-weight: 600; + text-decoration-line: underline; + text-decoration-thickness: 1px; + text-underline-offset: 4px; +} + +.beschreibung a:hover { + text-decoration-thickness: 2px; +} + +.dark .beschreibung a { + color: var(--color-primary); +} + +/* Zitat: Orange darf als Linie stehen, nicht als Schrift. */ +.beschreibung blockquote { + border-left: 4px solid var(--color-primary); + padding-left: 1.25rem; + margin: 1.5rem 0; + font-style: italic; + color: var(--color-dark); +} + +.dark .beschreibung blockquote { + color: var(--color-night-muted); +} + +.beschreibung hr { + border: none; + border-top: 1px solid var(--color-rule); + margin: 2rem 0; +} + +.dark .beschreibung hr { + border-top-color: var(--color-night-rule); +} + +/* + * Bilder und Tabellen erzeugt die Schreibfläche der Beschreibung nicht. Sie + * können aber aus der Einfuhr alter Daten stammen — dann sollen sie nicht aus + * der Spalte laufen, sondern den Linien des Hefts folgen. + */ +.beschreibung img { + max-width: 100%; + height: auto; + margin: 1.25rem 0; +} + +.beschreibung table { + width: 100%; + border-collapse: collapse; + margin: 1.5rem 0; +} + +.beschreibung th, +.beschreibung td { + padding: 0.5rem; + border: 1px solid var(--color-rule); + text-align: left; +} + +.dark .beschreibung th, +.dark .beschreibung td { + border-color: var(--color-night-rule); +} + +.beschreibung th { + border-bottom: 2px solid var(--color-ink); + font-weight: 600; +} + +.dark .beschreibung th { + border-bottom-color: var(--color-night-text); +} + +@media (max-width: 48rem) { + .beschreibung h2 { + font-size: 1.375rem; + } + + .beschreibung h3 { + font-size: 1.1875rem; + } + + .beschreibung h2, + .beschreibung h3, + .beschreibung h4 { + margin-top: 1.5rem; + } +} From 2da88e97d277621aa0a989f4d5989d73f4ffced7 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Thu, 17 Sep 2026 20:59:45 +0200 Subject: [PATCH 108/139] chore(beschreibungen): Skript gegen harte Zeilenumbrueche im Bestand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mit Markdown bleibt jeder Zeilenumbruch ein Umbruch. Von den 16 Einzelumbruechen im Bestand sind 9 gewollt ("Leitung: …" / "Zielgruppe: …"), 7 stammen aus dem Textfeld und stehen mitten im Satz. Das Skript fuegt nur dort zusammen, wo der Satz weiterlaeuft: naechste Zeile klein geschrieben oder vorige endet auf ein Funktionswort. Probelauf ist der Standard, erst --apply schreibt. Betrifft lokal zwei Kursbeschreibungen. --- prisma/backfill-description-linebreaks.ts | 179 ++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 prisma/backfill-description-linebreaks.ts diff --git a/prisma/backfill-description-linebreaks.ts b/prisma/backfill-description-linebreaks.ts new file mode 100644 index 00000000..ce2277e4 --- /dev/null +++ b/prisma/backfill-description-linebreaks.ts @@ -0,0 +1,179 @@ +/** + * Harte Zeilenumbrüche aus Beschreibungen von Terminen und Kursen entfernen. + * + * Hintergrund: Beschreibungen werden seit #311 als Markdown angezeigt, und + * jeder einzelne Zeilenumbruch bleibt dabei ein Umbruch (`breaks: true`) — + * anders als vorher, wo alle Umbrüche verschwanden und der Text als ein Block + * ohne Absätze stand. Das ist für gewollte Umbrüche richtig („Leitung: …“ / + * „Zielgruppe: …“, gemessen 9 von 16 Umbrüchen im Bestand). Die übrigen 7 + * stammen aus der Eingabe: Die Redaktion hat im Textfeld von Hand umbrochen, + * mitten im Satz. Die stehen jetzt sichtbar im Satz und gehören aus den Daten + * heraus, nicht in eine Sonderregel der Anzeige. + * + * Die Regel fügt zwei Zeilen nur zusammen, wenn der Satz erkennbar weiterläuft: + * Die nächste Zeile beginnt klein, oder die vorige endet auf ein Funktionswort + * („und“, „den“, „mit“, „ihren“ …). Endet eine Zeile mit Satzzeichen, beginnt + * die nächste eine Aufzählung oder steht sie für sich („… und Team“ / + * „Zielgruppe: …“), bleibt der Umbruch. + * + * Standard ist ein Probelauf. Erst `--apply` schreibt, und dann einzeln in + * einer Transaktion, damit ein Fehler nichts halb Geschriebenes hinterlässt. + * + * pnpm exec tsx prisma/backfill-description-linebreaks.ts + * pnpm exec tsx prisma/backfill-description-linebreaks.ts --apply + */ +import "dotenv/config"; +import { db } from "@/server/db"; + +/** + * Wörter, nach denen ein Satz weiterläuft, auch wenn das nächste Wort groß + * beginnt — im Deutschen sind das die Artikel, Präpositionen, Konjunktionen + * und Pronomen vor einem Substantiv („nach den \n Sommerferien“). + */ +const FUNKTIONSWOERTER = new Set([ + "aber", + "als", + "am", + "an", + "auf", + "aus", + "bei", + "beim", + "bis", + "das", + "dem", + "den", + "der", + "des", + "die", + "durch", + "ein", + "eine", + "einem", + "einen", + "einer", + "eines", + "für", + "gegen", + "ihr", + "ihre", + "ihrem", + "ihren", + "ihrer", + "im", + "in", + "mit", + "nach", + "oder", + "ohne", + "pro", + "seine", + "seinem", + "seinen", + "seiner", + "sowie", + "über", + "um", + "und", + "unser", + "unsere", + "unserem", + "unseren", + "unserer", + "unter", + "vom", + "von", + "vor", + "während", + "zu", + "zum", + "zur", +]); + +const SATZENDE = /[.!?:;)\]"»“]\s*$/; +const LISTENANFANG = /^\s*([-*+>#]|\d+[.)])\s/; + +/** Läuft der Satz von `vorige` in `naechste` weiter? */ +function laeuftWeiter(vorige: string, naechste: string): boolean { + if (!vorige.trim() || !naechste.trim()) return false; + if (SATZENDE.test(vorige)) return false; + if (LISTENANFANG.test(naechste) || LISTENANFANG.test(vorige)) return false; + // Zwei Leerzeichen am Zeilenende sind in Markdown ein ausdrücklicher Umbruch. + if (/ {2}$/.test(vorige)) return false; + if (/^[a-zäöüß]/.test(naechste.trimStart())) return true; + const letztesWort = vorige.trim().split(/\s+/).pop() ?? ""; + return FUNKTIONSWOERTER.has(letztesWort.toLowerCase()); +} + +export function entwirreHarteUmbrueche(text: string): string { + const zeilen = text.split("\n"); + const raus: string[] = []; + for (const zeile of zeilen) { + const vorige = raus[raus.length - 1]; + if (vorige !== undefined && laeuftWeiter(vorige, zeile)) { + raus[raus.length - 1] = `${vorige.trimEnd()} ${zeile.trimStart()}`; + } else { + raus.push(zeile); + } + } + return raus.join("\n"); +} + +async function main() { + const schreiben = process.argv.includes("--apply"); + + const events = await db.event.findMany({ + where: { description: { contains: "\n" } }, + select: { id: true, title: true, description: true }, + }); + const courses = await db.course.findMany({ + where: { description: { contains: "\n" } }, + select: { id: true, title: true, description: true }, + }); + + let geaendert = 0; + + for (const [art, eintraege] of [ + ["Termin", events], + ["Kurs", courses], + ] as const) { + for (const eintrag of eintraege) { + const alt = eintrag.description ?? ""; + const neu = entwirreHarteUmbrueche(alt); + if (neu === alt) continue; + geaendert++; + console.log(`\n=== ${art}: ${eintrag.title} (${eintrag.id})`); + console.log(`--- vorher\n${alt}`); + console.log(`+++ nachher\n${neu}`); + if (!schreiben) continue; + await db.$transaction(async (tx) => { + if (art === "Termin") { + await tx.event.update({ + where: { id: eintrag.id }, + data: { description: neu }, + }); + } else { + await tx.course.update({ + where: { id: eintrag.id }, + data: { description: neu }, + }); + } + }); + } + } + + console.log( + `\n${geaendert} von ${events.length + courses.length} Beschreibungen mit Umbrüchen betroffen — ${ + schreiben + ? "geschrieben" + : "Probelauf, nichts geschrieben (--apply schreibt)" + }`, + ); +} + +main() + .catch((fehler) => { + console.error(fehler); + process.exit(1); + }) + .finally(() => void db.$disconnect()); From 77de15321a1e2d197cd2aac03afe7e38cb3d30c6 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Thu, 17 Sep 2026 23:27:23 +0200 Subject: [PATCH 109/139] feat(downloads): Bilder als Downloads, Vorschau am Termin PNG, JPG und WebP sind im Ordner downloads erlaubt, neuer FileType IMAGE (additive Migration). Am Termin stehen Bild-Downloads als Vorschaubild mit Download-Link vor den uebrigen Dateien; Materialien, Verwaltung, Auswahl im Editor und Beitragsseiten zeigen "Bild" bzw. das echte Format statt PDF. Erlaubte Typen, Groessen und Beschriftungen kommen jetzt aus einer Datei statt aus fuenf. Dabei zwei Fehler mitgenommen: WebP und WAV sind beide RIFF und wurden nur an den ersten vier Bytes erkannt, seit der Ordner beides annimmt haette eine WAV-Datei als WebP gegolten; und bei unbekannter Endung blieb im Upload-Fenster der Typ der vorigen Datei stehen. Export und Import tragen den neuen Typ ohne Aenderung, per Rundlauf belegt. Closes #309 --- .../migration.sql | 3 + prisma/schema.prisma | 1 + .../editor/download-picker-modal.tsx | 73 ++++---- .../_components/events/event-detail-view.tsx | 37 +++- .../general/download-image-preview.tsx | 126 ++++++++++++++ .../_components/posts/post-detail-view.tsx | 16 +- src/app/api/upload/route.ts | 36 ++-- src/app/dashboard/downloads/page.tsx | 64 ++++--- src/app/dashboard/posts/[id]/page.tsx | 13 +- .../materialien/_components/download-row.tsx | 5 +- src/lib/__tests__/download-file-types.test.ts | 155 +++++++++++++++++ src/lib/download-file-types.ts | 163 ++++++++++++++++++ 12 files changed, 572 insertions(+), 120 deletions(-) create mode 100644 prisma/migrations/20260917140000_download_file_type_image/migration.sql create mode 100644 src/app/_components/general/download-image-preview.tsx create mode 100644 src/lib/__tests__/download-file-types.test.ts create mode 100644 src/lib/download-file-types.ts diff --git a/prisma/migrations/20260917140000_download_file_type_image/migration.sql b/prisma/migrations/20260917140000_download_file_type_image/migration.sql new file mode 100644 index 00000000..e9fb4f2b --- /dev/null +++ b/prisma/migrations/20260917140000_download_file_type_image/migration.sql @@ -0,0 +1,3 @@ +-- AlterEnum +-- Bilddateien (JPG, PNG, WebP) als Downloads, vor allem Flyer an Terminen (#309). +ALTER TYPE "FileType" ADD VALUE 'IMAGE'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2e7651cc..ba0a210f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -453,6 +453,7 @@ enum FileType { XLSX ZIP MP3 + IMAGE // JPG, PNG oder WebP – z. B. Flyer an Terminen } enum ContentStatus { diff --git a/src/app/_components/editor/download-picker-modal.tsx b/src/app/_components/editor/download-picker-modal.tsx index b59e0960..aaf29412 100644 --- a/src/app/_components/editor/download-picker-modal.tsx +++ b/src/app/_components/editor/download-picker-modal.tsx @@ -13,6 +13,16 @@ import { ScrollableModalBody, ScrollableModalFooter, } from "@/app/_components/ui/scrollable-modal"; +import { + DOWNLOAD_FILE_TYPE_ICONS, + DOWNLOAD_FILE_TYPE_LABELS, + DOWNLOAD_UPLOAD_ACCEPT, + DOWNLOAD_UPLOAD_FORMATS_LABEL, + DOWNLOAD_UPLOAD_MAX_BYTES, + DOWNLOAD_UPLOAD_MAX_LABEL, + downloadFileTypeForExtension, + downloadFormatCode, +} from "@/lib/download-file-types"; const categoryLabels: Record = { BLECHBLATT: "Rheinisches Blechblatt", @@ -22,22 +32,6 @@ const categoryLabels: Record = { SONSTIGES: "Sonstiges", }; -const fileTypeLabels: Record = { - PDF: "PDF", - DOCX: "Word", - XLSX: "Excel", - ZIP: "ZIP", - MP3: "Audio", -}; - -const fileTypeIcons: Record = { - PDF: "📄", - DOCX: "📝", - XLSX: "📊", - ZIP: "📦", - MP3: "🎵", -}; - /** Register-Reihe wie im Medien-Picker: Unterstreichung in Tinte statt Orange. */ function tabClass(active: boolean) { return cn( @@ -134,8 +128,10 @@ export default function DownloadPickerModal({ const processFile = useCallback( async (file: File) => { - if (file.size > 50 * 1024 * 1024) { - setUploadError("Die Datei ist zu groß. Maximal 50MB erlaubt."); + if (file.size > DOWNLOAD_UPLOAD_MAX_BYTES) { + setUploadError( + `Die Datei ist zu groß. Maximal ${DOWNLOAD_UPLOAD_MAX_LABEL} erlaubt.`, + ); return; } @@ -167,13 +163,9 @@ export default function DownloadPickerModal({ setUploadedFileUrl(data.url); setUploadedFileSize(data.size); - const ext = data.extension.toLowerCase(); - if (ext === "pdf") setNewFileType("PDF"); - else if (["doc", "docx"].includes(ext)) setNewFileType("DOCX"); - else if (["xls", "xlsx"].includes(ext)) setNewFileType("XLSX"); - else if (ext === "zip") setNewFileType("ZIP"); - else if (["mp3", "wav", "ogg"].includes(ext)) setNewFileType("MP3"); - else setNewFileType("PDF"); + setNewFileType( + downloadFileTypeForExtension(data.extension) ?? FileType.PDF, + ); if (!newTitle) { setNewTitle(file.name.replace(/\.[^/.]+$/, "")); @@ -243,10 +235,13 @@ export default function DownloadPickerModal({ const handleInsert = () => { if (selectedDownload) { + // Der Editor schreibt den Typ sichtbar in den Linktext („… (PDF)“): + // Dort gehört das Format hin, nicht der Enum-Wert — sonst stünde bei + // einem Flyer „(IMAGE)“ im Beitrag. onSelect( selectedDownload.title, selectedDownload.fileUrl, - selectedDownload.fileType, + downloadFormatCode(selectedDownload), selectedDownload.id, ); onClose(); @@ -363,7 +358,7 @@ export default function DownloadPickerModal({ )} > - {fileTypeIcons[download.fileType]} + {DOWNLOAD_FILE_TYPE_ICONS[download.fileType]}

@@ -375,7 +370,9 @@ export default function DownloadPickerModal({

)}
- {fileTypeLabels[download.fileType]} + + {DOWNLOAD_FILE_TYPE_LABELS[download.fileType]} + • {categoryLabels[download.category]} {download.fileSize && ( @@ -404,7 +401,7 @@ export default function DownloadPickerModal({ {uploadedFileUrl ? (
- {fileTypeIcons[newFileType]} + {DOWNLOAD_FILE_TYPE_ICONS[newFileType]}

@@ -412,7 +409,7 @@ export default function DownloadPickerModal({

{formatFileSize(uploadedFileSize)} •{" "} - {fileTypeLabels[newFileType]} + {DOWNLOAD_FILE_TYPE_LABELS[newFileType]}

diff --git a/src/app/_components/events/event-detail-view.tsx b/src/app/_components/events/event-detail-view.tsx index 95110da6..53c049a1 100644 --- a/src/app/_components/events/event-detail-view.tsx +++ b/src/app/_components/events/event-detail-view.tsx @@ -31,6 +31,11 @@ import { } from "lucide-react"; import LocationNavigationLink from "@/app/_components/general/location-navigation-link"; +import { DownloadImagePreview } from "@/app/_components/general/download-image-preview"; +import { + downloadFormatCode, + isPreviewableImageDownload, +} from "@/lib/download-file-types"; type EventWithRelations = RouterOutputs["events"]["getById"]; @@ -441,16 +446,30 @@ export default function EventDetailView({ event }: EventDetailViewProps) { Downloads + {/* Bilder (Flyer) zuerst und mit Vorschau: Sie sind das, was + man am Termin sucht, und ein Dateiname sagt über einen + Flyer nichts. Die übrigen Dateien folgen als Wegzeilen. */} - {event.downloads.map((ed) => ( - - ))} + {event.downloads + .filter((ed) => isPreviewableImageDownload(ed.download)) + .map((ed) => ( + + ))} + {event.downloads + .filter((ed) => !isPreviewableImageDownload(ed.download)) + .map((ed) => ( + + ))}
)} diff --git a/src/app/_components/general/download-image-preview.tsx b/src/app/_components/general/download-image-preview.tsx new file mode 100644 index 00000000..dd4a6963 --- /dev/null +++ b/src/app/_components/general/download-image-preview.tsx @@ -0,0 +1,126 @@ +"use client"; + +import Image from "next/image"; +import { useState } from "react"; +import { Download, ImageOff } from "lucide-react"; +import type { FileType } from "~/generated/prisma/enums"; +import { formatFileSize } from "@/app/_components/media/media-shared"; +import { cn } from "@/lib/utils"; +import { + downloadAttachmentUrl, + downloadFormatCode, +} from "@/lib/download-file-types"; + +export interface DownloadImagePreviewProps { + download: { + title: string; + description?: string | null; + fileUrl: string; + fileType: FileType; + fileSize?: number | null; + }; +} + +/** + * Bild-Download (vor allem Flyer an Terminen) als Zeile einer Download-Liste: + * Vorschaubild links, daneben Titel, Dateimeta und ein echter Download-Link. + * + * Steht als `
  • ` in derselben `WayList` wie die übrigen `WayRow`s — gleiche + * Haarlinie, gleiche Titelgröße —, damit Flyer und PDFs eine Liste bleiben. + * Die Zeile füllt sich bewusst nicht orange wie eine Wegzeile: Sie trägt zwei + * Ziele (ansehen, herunterladen), eine volle Klickfläche wäre mehrdeutig. + * + * Nur für Downloads, die `isPreviewableImageDownload` durchlassen. + */ +export function DownloadImagePreview({ download }: DownloadImagePreviewProps) { + const format = downloadFormatCode(download); + const size = download.fileSize ? formatFileSize(download.fileSize) : ""; + + return ( +
  • + +
    +

    + {download.title} +

    +

    + {format} + {size ? {size} : null} +

    + {download.description ? ( +

    + {download.description} +

    + ) : null} + {/* Ohne `target`: Die Antwort kommt als Anhang, ein neuer Tab bliebe + leer zurück. */} + + + Herunterladen + + : {download.title} ({size ? `${format}, ${size}` : format}) + + +
    +
  • + ); +} + +/** Hochformat nahe DIN A: Flyer füllen den Rahmen, Querformate stehen darin. */ +const FRAME = + "border-rule dark:border-night-rule bg-paper dark:bg-night-raised relative aspect-[5/7] w-24 shrink-0 border sm:w-32"; + +/** + * Vorschaubild im festen Rahmen, ganz gezeigt (`object-contain`) — ein + * beschnittener Flyer verliert genau das, was man sehen will. + * + * Lightbox (#310): Bis sie da ist, öffnet das Bild sich in voller Größe in + * einem neuen Tab. Zum Einhängen ersetzt sie nur den Link unten; Rahmen, + * Größe und Fallback bleiben. + * + * Schlägt das Laden fehl (etwa ein noch nicht freigegebener Download, den + * Besucher nicht sehen dürfen), steht ein ruhiger Platzhalter statt eines + * kaputten Bildsymbols; der Download-Link daneben bleibt. + */ +function PreviewThumbnail({ src, title }: { src: string; title: string }) { + const [failed, setFailed] = useState(false); + + if (failed) { + return ( + + + + ); + } + + return ( + + setFailed(true)} + /> + + {title} in voller Größe ansehen (öffnet in neuem Tab) + + + ); +} diff --git a/src/app/_components/posts/post-detail-view.tsx b/src/app/_components/posts/post-detail-view.tsx index df9731d0..e03d1fa2 100644 --- a/src/app/_components/posts/post-detail-view.tsx +++ b/src/app/_components/posts/post-detail-view.tsx @@ -15,7 +15,7 @@ import { NewsColumns } from "@/app/_components/programmheft/news"; import ImageLightbox from "./image-lightbox"; import MediaCredit from "@/app/_components/general/media-credit"; import PublicShareButton from "@/app/_components/general/public-share-button"; -import type { FileType } from "~/generated/prisma/enums"; +import { DOWNLOAD_FILE_TYPE_LABELS } from "@/lib/download-file-types"; import { useSession } from "@/lib/auth"; import { api } from "@/trpc/react"; import { usePermissions } from "@/lib/use-permissions"; @@ -32,14 +32,6 @@ const DATE = new Intl.DateTimeFormat("de-DE", { year: "numeric", }); -const fileTypeLabels: Record = { - PDF: "PDF", - DOCX: "Word", - XLSX: "Excel", - ZIP: "ZIP", - MP3: "Audio", -}; - function formatFileSize(bytes: number | null): string { if (!bytes) return ""; if (bytes < 1024) return `${bytes} B`; @@ -332,15 +324,15 @@ export default function PostDetailView({ key={download.id} href={download.fileUrl} kind="download" - fileType={fileTypeLabels[download.fileType]} + fileType={DOWNLOAD_FILE_TYPE_LABELS[download.fileType]} title={download.title} description={ download.description || download.fileSize ? [ download.description, download.fileSize - ? `${fileTypeLabels[download.fileType]} · ${formatFileSize(download.fileSize)}` - : fileTypeLabels[download.fileType], + ? `${DOWNLOAD_FILE_TYPE_LABELS[download.fileType]} · ${formatFileSize(download.fileSize)}` + : DOWNLOAD_FILE_TYPE_LABELS[download.fileType], ] .filter(Boolean) .join(" — ") diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 0dd21d71..15cf167b 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -7,6 +7,10 @@ import { MEDIA_UPLOAD_MAX_BYTES, MEDIA_UPLOAD_MIME_TYPES, } from "@/lib/media-upload"; +import { + DOWNLOAD_UPLOAD_MAX_BYTES, + DOWNLOAD_UPLOAD_MIME_TYPES, +} from "@/lib/download-file-types"; import { createLogger } from "@/server/utils/logger"; @@ -26,19 +30,7 @@ function isAllowedFolder(folder: string): folder is UploadFolder { const validTypesByFolder: Record = { profiles: ["image/jpeg", "image/jpg", "image/png", "image/webp"], - downloads: [ - "application/pdf", - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/vnd.ms-excel", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "application/zip", - "application/x-zip-compressed", - "audio/mpeg", - "audio/mp3", - "audio/wav", - "audio/ogg", - ], + downloads: [...DOWNLOAD_UPLOAD_MIME_TYPES], media: [...MEDIA_UPLOAD_MIME_TYPES], // Attachments for course mails. No audio: these travel inside the message, // where a 30 MB recording would just bounce off the recipients' mailboxes. @@ -57,14 +49,20 @@ const validTypesByFolder: Record = { const maxSizeByFolder: Record = { profiles: 5 * 1024 * 1024, - downloads: 50 * 1024 * 1024, + downloads: DOWNLOAD_UPLOAD_MAX_BYTES, media: MEDIA_UPLOAD_MAX_BYTES, // Per file; the send mutation additionally caps the combined size, since // mail servers reject the whole message once it grows past ~25 MB. "course-mail": 10 * 1024 * 1024, }; -const magicBytes: Record = { +// `null` steht für ein beliebiges Byte. WebP und WAV sind beide RIFF-Container +// und unterscheiden sich erst ab Byte 8 („WEBP“ bzw. „WAVE“, davor die +// Länge). Mit nur „RIFF“ als Signatur käme jede WAV-Datei als image/webp +// durch den Ordner `downloads`, der seit #309 beides annimmt. +const RIFF_ANY_SIZE = [0x52, 0x49, 0x46, 0x46, null, null, null, null]; + +const magicBytes: Record = { "image/jpeg": [[0xff, 0xd8, 0xff]], "image/jpg": [[0xff, 0xd8, 0xff]], "image/png": [[0x89, 0x50, 0x4e, 0x47]], @@ -72,7 +70,7 @@ const magicBytes: Record = { [0x47, 0x49, 0x46, 0x38, 0x37, 0x61], [0x47, 0x49, 0x46, 0x38, 0x39, 0x61], ], - "image/webp": [[0x52, 0x49, 0x46, 0x46]], + "image/webp": [[...RIFF_ANY_SIZE, 0x57, 0x45, 0x42, 0x50]], "application/pdf": [[0x25, 0x50, 0x44, 0x46]], // Legacy Office formats are OLE compound files "application/msword": [[0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]], @@ -106,7 +104,7 @@ const magicBytes: Record = { [0xff, 0xf2], [0x49, 0x44, 0x33], ], - "audio/wav": [[0x52, 0x49, 0x46, 0x46]], + "audio/wav": [[...RIFF_ANY_SIZE, 0x57, 0x41, 0x56, 0x45]], "audio/ogg": [[0x4f, 0x67, 0x67, 0x53]], }; @@ -115,7 +113,9 @@ function validateMagicBytes(buffer: Buffer, claimedType: string): boolean { // Fail closed: a type we can't verify is a type we don't accept if (!signatures) return false; return signatures.some((sig) => - sig.every((byte, i) => buffer.length > i && buffer[i] === byte), + sig.every( + (byte, i) => buffer.length > i && (byte === null || buffer[i] === byte), + ), ); } diff --git a/src/app/dashboard/downloads/page.tsx b/src/app/dashboard/downloads/page.tsx index 2670a0eb..69f688fd 100644 --- a/src/app/dashboard/downloads/page.tsx +++ b/src/app/dashboard/downloads/page.tsx @@ -28,6 +28,15 @@ import { ScrollableModalFooter, } from "@/app/_components/ui/scrollable-modal"; import { Tag, type TagTone } from "@/app/_components/programmheft/tag"; +import { + DOWNLOAD_FILE_TYPE_ICONS, + DOWNLOAD_FILE_TYPE_LABELS, + DOWNLOAD_UPLOAD_ACCEPT, + DOWNLOAD_UPLOAD_FORMATS_LABEL, + DOWNLOAD_UPLOAD_MAX_BYTES, + DOWNLOAD_UPLOAD_MAX_LABEL, + downloadFileTypeForExtension, +} from "@/lib/download-file-types"; // Dashboard access is now controlled by permissions @@ -72,22 +81,6 @@ const categoryLabels: Record = { SONSTIGES: "Sonstiges", }; -const fileTypeLabels: Record = { - PDF: "PDF", - DOCX: "Word", - XLSX: "Excel", - ZIP: "ZIP", - MP3: "Audio", -}; - -const fileTypeIcons: Record = { - PDF: "📄", - DOCX: "📝", - XLSX: "📊", - ZIP: "📦", - MP3: "🎵", -}; - function formatFileSize(bytes: number | null): string { if (!bytes) return ""; if (bytes < 1024) return `${bytes} B`; @@ -266,8 +259,10 @@ export default function DashboardDownloadsPage() { const file = e instanceof File ? e : e.target.files?.[0]; if (!file) return; - if (file.size > 50 * 1024 * 1024) { - setUploadError("Die Datei ist zu groß. Maximal 50MB erlaubt."); + if (file.size > DOWNLOAD_UPLOAD_MAX_BYTES) { + setUploadError( + `Die Datei ist zu groß. Maximal ${DOWNLOAD_UPLOAD_MAX_LABEL} erlaubt.`, + ); return; } @@ -298,12 +293,11 @@ export default function DashboardDownloadsPage() { setUploadedFileUrl(data.url); setUploadedFileSize(data.size); - const ext = data.extension.toLowerCase(); - if (ext === "pdf") setNewFileType("PDF"); - else if (["doc", "docx"].includes(ext)) setNewFileType("DOCX"); - else if (["xls", "xlsx"].includes(ext)) setNewFileType("XLSX"); - else if (ext === "zip") setNewFileType("ZIP"); - else if (["mp3", "wav", "ogg"].includes(ext)) setNewFileType("MP3"); + // Dieselbe Endungstabelle wie im Download-Picker. Vorher blieb bei einer + // Endung ohne eigenen Zweig der Typ der vorigen Datei stehen. + setNewFileType( + downloadFileTypeForExtension(data.extension) ?? FileType.PDF, + ); if (!newTitle) { const baseName = file.name.replace(/\.[^/.]+$/, ""); @@ -378,14 +372,14 @@ export default function DashboardDownloadsPage() { return (
    - {fileTypeIcons[download.fileType]} + {DOWNLOAD_FILE_TYPE_ICONS[download.fileType]}

    {download.title}

    - {fileTypeLabels[download.fileType]} + {DOWNLOAD_FILE_TYPE_LABELS[download.fileType]} {download.fileSize && ` • ${formatFileSize(download.fileSize)}`}

    @@ -399,11 +393,14 @@ export default function DashboardDownloadsPage() { header: "Kategorie", meta: { filterVariant: "set" }, }), - column.accessor((download) => fileTypeLabels[download.fileType], { - id: "fileType", - header: "Typ", - meta: { filterVariant: "set" }, - }), + column.accessor( + (download) => DOWNLOAD_FILE_TYPE_LABELS[download.fileType], + { + id: "fileType", + header: "Typ", + meta: { filterVariant: "set" }, + }, + ), column.accessor((download) => download.fileSize ?? 0, { id: "fileSize", header: "Größe", @@ -568,7 +565,7 @@ export default function DashboardDownloadsPage() { ref={fileInputRef} type="file" onChange={handleFileUpload} - accept=".pdf,.doc,.docx,.xls,.xlsx,.zip,.mp3,.wav,.ogg" + accept={DOWNLOAD_UPLOAD_ACCEPT} className="hidden" /> {isUploading ? ( @@ -602,7 +599,8 @@ export default function DashboardDownloadsPage() { oder klicken zum Auswählen

    - PDF, Word, Excel, ZIP, Audio (max. 50MB) + {DOWNLOAD_UPLOAD_FORMATS_LABEL} (max.{" "} + {DOWNLOAD_UPLOAD_MAX_LABEL})

    )} diff --git a/src/app/dashboard/posts/[id]/page.tsx b/src/app/dashboard/posts/[id]/page.tsx index 9270139b..693d2657 100644 --- a/src/app/dashboard/posts/[id]/page.tsx +++ b/src/app/dashboard/posts/[id]/page.tsx @@ -12,6 +12,7 @@ import { ContentStatus, PostCategory } from "~/generated/prisma/enums"; import "@/styles/article-content.css"; import { useToast } from "@/app/_components/ui/toast"; import { sanitizeHtml } from "@/lib/sanitize"; +import { DOWNLOAD_FILE_TYPE_ICONS } from "@/lib/download-file-types"; import { Edit, Trash2, @@ -383,17 +384,7 @@ export default function PostDetailPage() { >
    - {download.fileType === "PDF" - ? "📄" - : download.fileType === "DOCX" - ? "📝" - : download.fileType === "XLSX" - ? "📊" - : download.fileType === "ZIP" - ? "📦" - : download.fileType === "MP3" - ? "🎵" - : "📁"} + {DOWNLOAD_FILE_TYPE_ICONS[download.fileType]}

    diff --git a/src/app/materialien/_components/download-row.tsx b/src/app/materialien/_components/download-row.tsx index 9f1ca70c..53413026 100644 --- a/src/app/materialien/_components/download-row.tsx +++ b/src/app/materialien/_components/download-row.tsx @@ -2,6 +2,7 @@ import { Download } from "lucide-react"; import { SmartLink } from "@/app/_components/programmheft/link"; import { Tag } from "@/app/_components/programmheft/tag"; import type { RouterOutputs } from "@/trpc/react"; +import { downloadFormatCode } from "@/lib/download-file-types"; export type DownloadItem = RouterOutputs["materials"]["getDownloads"]["downloads"][number]; @@ -17,7 +18,9 @@ function formatFileSize(bytes: number): string { * erweitert um Dateimeta (Typ, Größe, Datum), Auszug und Tags. */ export function DownloadRow({ download }: { download: DownloadItem }) { - const fileType = download.fileType.toUpperCase(); + // Formatkürzel statt Enum-Wert: Ein Flyer steht als „PNG“ da, nicht als + // „IMAGE“ — und so kündigt ihn auch der Screenreader-Hinweis an. + const fileType = downloadFormatCode(download); const date = download.createdAt.toLocaleDateString("de-DE"); return ( diff --git a/src/lib/__tests__/download-file-types.test.ts b/src/lib/__tests__/download-file-types.test.ts new file mode 100644 index 00000000..e0c35bce --- /dev/null +++ b/src/lib/__tests__/download-file-types.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "@jest/globals"; +import { FileType } from "~/generated/prisma/enums"; +import { + DOWNLOAD_FILE_TYPE_ICONS, + DOWNLOAD_FILE_TYPE_LABELS, + DOWNLOAD_UPLOAD_ACCEPT, + DOWNLOAD_UPLOAD_MIME_TYPES, + downloadAttachmentUrl, + downloadFileTypeForExtension, + downloadFormatCode, + isPreviewableImageDownload, +} from "../download-file-types"; + +describe("Download-Dateitypen", () => { + it("beschriftet und bebildert jeden FileType, auch künftige", () => { + for (const type of Object.values(FileType)) { + expect(DOWNLOAD_FILE_TYPE_LABELS[type]).toBeTruthy(); + expect(DOWNLOAD_FILE_TYPE_ICONS[type]).toBeTruthy(); + } + expect(DOWNLOAD_FILE_TYPE_LABELS.IMAGE).toBe("Bild"); + }); + + it("nimmt JPG, PNG und WebP an, aber kein GIF und kein SVG", () => { + const types: readonly string[] = DOWNLOAD_UPLOAD_MIME_TYPES; + expect(types).toEqual( + expect.arrayContaining(["image/jpeg", "image/png", "image/webp"]), + ); + expect(types).not.toContain("image/gif"); + expect(types).not.toContain("image/svg+xml"); + expect(DOWNLOAD_UPLOAD_ACCEPT.split(",")).toEqual( + expect.arrayContaining([".jpg", ".jpeg", ".png", ".webp", ".pdf"]), + ); + }); +}); + +describe("downloadFileTypeForExtension", () => { + it.each([ + ["pdf", FileType.PDF], + ["docx", FileType.DOCX], + ["doc", FileType.DOCX], + ["xls", FileType.XLSX], + ["zip", FileType.ZIP], + ["wav", FileType.MP3], + ["jpg", FileType.IMAGE], + ["JPEG", FileType.IMAGE], + [".png", FileType.IMAGE], + ["webp", FileType.IMAGE], + ])("%s → %s", (extension, expected) => { + expect(downloadFileTypeForExtension(extension)).toBe(expected); + }); + + it("gibt für fremde Endungen null statt still PDF zurück", () => { + expect(downloadFileTypeForExtension("gif")).toBeNull(); + expect(downloadFileTypeForExtension("svg")).toBeNull(); + expect(downloadFileTypeForExtension("")).toBeNull(); + // Kein Treffer über den Objekt-Prototyp. + expect(downloadFileTypeForExtension("constructor")).toBeNull(); + }); +}); + +describe("isPreviewableImageDownload", () => { + it("zeigt Bilder aus dem eigenen Upload-Ordner als Vorschau", () => { + expect( + isPreviewableImageDownload({ + fileType: FileType.IMAGE, + fileUrl: "/api/uploads/downloads/flyer-abc-1.png", + }), + ).toBe(true); + expect( + isPreviewableImageDownload({ + fileType: FileType.IMAGE, + fileUrl: "/api/uploads/downloads/flyer-abc-1.JPG", + }), + ).toBe(true); + }); + + it("lässt fremde Hosts draußen, die next/image nicht laden darf", () => { + expect( + isPreviewableImageDownload({ + fileType: FileType.IMAGE, + fileUrl: "https://example.org/flyer.png", + }), + ).toBe(false); + }); + + it("verlässt sich nicht allein auf den Typ", () => { + expect( + isPreviewableImageDownload({ + fileType: FileType.IMAGE, + fileUrl: "/api/uploads/downloads/versehentlich.pdf", + }), + ).toBe(false); + expect( + isPreviewableImageDownload({ + fileType: FileType.PDF, + fileUrl: "/api/uploads/downloads/flyer.png", + }), + ).toBe(false); + }); +}); + +describe("downloadFormatCode", () => { + it("nennt bei Bildern das Format statt des Sammeltyps", () => { + expect( + downloadFormatCode({ + fileType: FileType.IMAGE, + fileUrl: "/api/uploads/downloads/a.jpeg?x=1", + }), + ).toBe("JPG"); + expect( + downloadFormatCode({ + fileType: FileType.IMAGE, + fileUrl: "/api/uploads/downloads/a.webp", + }), + ).toBe("WebP"); + expect( + downloadFormatCode({ + fileType: FileType.IMAGE, + fileUrl: "https://example.org/bild", + }), + ).toBe("Bild"); + }); + + it("lässt die übrigen Kürzel wie bisher", () => { + expect( + downloadFormatCode({ fileType: FileType.DOCX, fileUrl: "/x.docx" }), + ).toBe("DOCX"); + expect( + downloadFormatCode({ fileType: FileType.PDF, fileUrl: "/x.pdf" }), + ).toBe("PDF"); + }); +}); + +describe("downloadAttachmentUrl", () => { + it("hängt Download-Schalter und lesbaren Namen an eigene Uploads", () => { + const url = downloadAttachmentUrl({ + fileUrl: "/api/uploads/downloads/flyer-abc-1.png", + title: "Flyer Landesposaunentag & Kirchentag", + }); + const [path, query] = url.split("?"); + expect(path).toBe("/api/uploads/downloads/flyer-abc-1.png"); + const params = new URLSearchParams(query); + expect(params.get("download")).toBe("1"); + expect(params.get("name")).toBe("Flyer Landesposaunentag & Kirchentag"); + }); + + it("lässt fremde URLs unverändert", () => { + expect( + downloadAttachmentUrl({ + fileUrl: "https://example.org/flyer.png", + title: "Flyer", + }), + ).toBe("https://example.org/flyer.png"); + }); +}); diff --git a/src/lib/download-file-types.ts b/src/lib/download-file-types.ts new file mode 100644 index 00000000..f9905a66 --- /dev/null +++ b/src/lib/download-file-types.ts @@ -0,0 +1,163 @@ +import { FileType } from "~/generated/prisma/enums"; + +/** + * Was der Upload-Ordner `downloads` annimmt und wie ein Download-Dateityp + * heißt — einmal notiert, von Upload-Route, Verwaltung, Download-Picker und + * den öffentlichen Listen gelesen. + * + * Vorher standen Beschriftungen und Symbole als eigene Tabellen in fünf + * Dateien, die Endungserkennung doppelt. Ein neuer Typ (Bilder, #309) hätte an + * jeder Stelle einzeln nachgezogen werden müssen; die Stelle, die man + * vergisst, zeigt dann „IMAGE“ oder ein leeres Feld. + */ + +/** Bilder, die als Download taugen — vor allem Flyer an Terminen. Kein GIF. */ +export const DOWNLOAD_IMAGE_MIME_TYPES = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", +] as const; + +export const DOWNLOAD_UPLOAD_MIME_TYPES = [ + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/zip", + "application/x-zip-compressed", + "audio/mpeg", + "audio/mp3", + "audio/wav", + "audio/ogg", + ...DOWNLOAD_IMAGE_MIME_TYPES, +] as const; + +/** Wert für `accept` am Datei-Feld. */ +export const DOWNLOAD_UPLOAD_ACCEPT = + ".pdf,.doc,.docx,.xls,.xlsx,.zip,.mp3,.wav,.ogg,.jpg,.jpeg,.png,.webp"; + +export const DOWNLOAD_UPLOAD_MAX_BYTES = 50 * 1024 * 1024; + +export const DOWNLOAD_UPLOAD_MAX_LABEL = "50 MB"; + +/** Für Hinweise am Upload-Feld. */ +export const DOWNLOAD_UPLOAD_FORMATS_LABEL = + "PDF, Word, Excel, ZIP, Audio oder Bild (JPG, PNG, WebP)"; + +export const DOWNLOAD_FILE_TYPE_LABELS: Record = { + PDF: "PDF", + DOCX: "Word", + XLSX: "Excel", + ZIP: "ZIP", + MP3: "Audio", + IMAGE: "Bild", +}; + +export const DOWNLOAD_FILE_TYPE_ICONS: Record = { + PDF: "📄", + DOCX: "📝", + XLSX: "📊", + ZIP: "📦", + MP3: "🎵", + IMAGE: "🖼️", +}; + +const FILE_TYPE_BY_EXTENSION: Record = { + pdf: FileType.PDF, + doc: FileType.DOCX, + docx: FileType.DOCX, + xls: FileType.XLSX, + xlsx: FileType.XLSX, + zip: FileType.ZIP, + mp3: FileType.MP3, + wav: FileType.MP3, + ogg: FileType.MP3, + jpg: FileType.IMAGE, + jpeg: FileType.IMAGE, + png: FileType.IMAGE, + webp: FileType.IMAGE, +}; + +/** + * Dateityp zur Endung, die die Upload-Route zurückgibt. `null` für Endungen, + * die kein Download sein dürfen — der Aufrufer soll dann nicht still „PDF“ + * annehmen. + */ +export function downloadFileTypeForExtension( + extension: string, +): FileType | null { + const key = extension.trim().replace(/^\./, "").toLowerCase(); + return Object.hasOwn(FILE_TYPE_BY_EXTENSION, key) + ? FILE_TYPE_BY_EXTENSION[key]! + : null; +} + +/** Endung des letzten Pfadstücks, ohne Query und Fragment, kleingeschrieben. */ +function urlExtension(url: string): string { + const path = url.split(/[?#]/)[0] ?? ""; + const lastSegment = path.split("/").pop() ?? ""; + return lastSegment.includes(".") + ? (lastSegment.split(".").pop() ?? "").toLowerCase() + : ""; +} + +const IMAGE_FORMAT_CODES: Record = { + jpg: "JPG", + jpeg: "JPG", + png: "PNG", + webp: "WebP", +}; + +interface DownloadFileRef { + fileType: FileType; + fileUrl: string; +} + +/** + * Bild-Download, der sich als Vorschau zeigen lässt: Typ `IMAGE` und eine + * Datei aus dem eigenen Upload-Ordner mit Bildendung. + * + * Fremde URLs bleiben bewusst draußen — `next/image` kennt keine + * `remotePatterns`, ein fremder Host würde die ganze Seite abstürzen lassen + * statt nur das Bild. Die Endung schützt vor Einträgen, deren Typ von Hand + * falsch gesetzt wurde: Ein PDF als `` bliebe ein leerer Rahmen. + */ +export function isPreviewableImageDownload(download: DownloadFileRef): boolean { + return ( + download.fileType === FileType.IMAGE && + download.fileUrl.startsWith("/api/uploads/") && + Object.hasOwn(IMAGE_FORMAT_CODES, urlExtension(download.fileUrl)) + ); +} + +/** + * Kurzes Format-Etikett wie „PDF“ oder „PNG“ — für Dateimeta und den + * Screenreader-Hinweis am Link. Bei Bildern sagt die Endung mehr als der + * Sammeltyp; ohne erkennbare Endung steht „Bild“. + */ +export function downloadFormatCode(download: DownloadFileRef): string { + if (download.fileType === FileType.IMAGE) { + const extension = urlExtension(download.fileUrl); + return Object.hasOwn(IMAGE_FORMAT_CODES, extension) + ? IMAGE_FORMAT_CODES[extension]! + : "Bild"; + } + return download.fileType; +} + +/** + * Link, der die Datei herunterlädt statt sie anzuzeigen. Die Upload-Route + * liefert Bilder sonst inline aus; `?download=1` setzt + * `Content-Disposition: attachment`, `name` den lesbaren Dateinamen (die + * Route hängt die echte Endung an). Fremde URLs bleiben unverändert. + */ +export function downloadAttachmentUrl(download: { + fileUrl: string; + title: string; +}): string { + if (!download.fileUrl.startsWith("/api/uploads/")) return download.fileUrl; + const params = new URLSearchParams({ download: "1", name: download.title }); + return `${download.fileUrl}?${params.toString()}`; +} From c95a53047dcc6c04817b91225eae68c7150ad072 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Thu, 17 Sep 2026 23:35:08 +0200 Subject: [PATCH 110/139] feat(downloads): Vorschau eines Bild-Downloads oeffnet die Lightbox Der Uebergang zwischen #309 und #310: Das Vorschaubild oeffnete das Bild in einem neuen Tab, weil die Lightbox beim Bau noch nicht da war. Jetzt wie jedes andere Bild der Seite in der Lightbox. Ohne Lupe im Eckfeld -- der Rahmen ist 96px schmal, und daneben steht ohnehin "Herunterladen". --- .../general/download-image-preview.tsx | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/app/_components/general/download-image-preview.tsx b/src/app/_components/general/download-image-preview.tsx index dd4a6963..8cbdcc49 100644 --- a/src/app/_components/general/download-image-preview.tsx +++ b/src/app/_components/general/download-image-preview.tsx @@ -4,6 +4,7 @@ import Image from "next/image"; import { useState } from "react"; import { Download, ImageOff } from "lucide-react"; import type { FileType } from "~/generated/prisma/enums"; +import ZoomableImage from "@/app/_components/general/zoomable-image"; import { formatFileSize } from "@/app/_components/media/media-shared"; import { cn } from "@/lib/utils"; import { @@ -78,9 +79,9 @@ const FRAME = * Vorschaubild im festen Rahmen, ganz gezeigt (`object-contain`) — ein * beschnittener Flyer verliert genau das, was man sehen will. * - * Lightbox (#310): Bis sie da ist, öffnet das Bild sich in voller Größe in - * einem neuen Tab. Zum Einhängen ersetzt sie nur den Link unten; Rahmen, - * Größe und Fallback bleiben. + * Ein Klick öffnet es in der Lightbox — wie jedes andere Bild der Seite. + * Keine Lupe im Eckfeld: Der Rahmen ist 96px schmal, und rechts daneben steht + * ohnehin „Herunterladen“, das Bild ist also erkennbar zweifach nutzbar. * * Schlägt das Laden fehl (etwa ein noch nicht freigegebener Download, den * Besucher nicht sehen dürfen), steht ein ruhiger Platzhalter statt eines @@ -101,13 +102,13 @@ function PreviewThumbnail({ src, title }: { src: string; title: string }) { } return ( - setFailed(true)} /> - - {title} in voller Größe ansehen (öffnet in neuem Tab) - - + ); } From ad31d739af79365067e7d4aad2517d7f79467d97 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Thu, 17 Sep 2026 23:42:35 +0200 Subject: [PATCH 111/139] fix(startseite): Kommende Termine in zwei Gruppen Kurse mit offener Anmeldung standen ohne Erklaerung vor den Terminen. Auf dem Telefon las sich die Liste damit als 29. Okt - 02. Okt - 08. Okt, und weil die Anmeldezeile mit Frist und Schaltflaeche 309px hoch ist, sah man beim ersten Blick nur sie: Das spaeteste Datum wirkte wie der naechste Termin. Jetzt tragen beide Teile eine eigene Marke, "Anmeldung laeuft" und "Naechste Termine". Offen zeigt nur noch eine Zeile statt zwei, damit von den naechsten Terminen auf dem Telefon etwas uebrig bleibt. Gibt es nur eine der beiden Gruppen, entfallen die Marken. Marken und Wegliste sind knapp gesetzt: Mit dem ueblichen Abstand rutschte "Alle Termine" auf 1440x900 19px unter die Falz, jetzt endet es bei 894px. --- src/app/_components/homepage/programm.tsx | 93 ++++++++++++++++++----- 1 file changed, 73 insertions(+), 20 deletions(-) diff --git a/src/app/_components/homepage/programm.tsx b/src/app/_components/homepage/programm.tsx index 27d3e606..b71ca224 100644 --- a/src/app/_components/homepage/programm.tsx +++ b/src/app/_components/homepage/programm.tsx @@ -19,6 +19,13 @@ import { */ const MAX_ROWS = 3; +/** + * Zeilen für die Anmeldungen. Eine, weil eine Anmeldezeile mit Frist und + * Schaltfläche auf 390px 309px hoch ist: Zwei davon füllen den Bildschirm des + * Telefons, und von den eigentlichen nächsten Terminen bliebe nichts übrig. + */ +const MAX_OFFEN = 1; + interface ProgrammProps { events: ProgrammeEvent[]; courses: ProgrammeCourse[]; @@ -28,10 +35,40 @@ interface ProgrammProps { } /** - * „Kommende Termine“ als Programm. Kurse mit offener Anmeldung stehen vorn - * (früheste Frist zuerst), markiert durch ihr oranges Datumsfeld und eine - * Anmelde-Schaltfläche; gibt es andere Termine, bleibt mindestens einer davon - * im ersten Bildschirm. + * Marke über einer Gruppe: leiser als die Abschnittsüberschrift, im Ton der + * Monatszeile im Datumsfeld. Als Überschrift und nicht als Absatz, damit die + * Gruppen auch für Vorlesegeräte zwei Gruppen sind. + */ +function Gruppenmarke({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +

    + {children} +

    + ); +} + +/** + * „Kommende Termine“ als Programm, in zwei Gruppen: erst Kurse mit offener + * Anmeldung (früheste Frist zuerst), dann die nächsten Termine nach Datum. + * + * Die Gruppen tragen eigene Marken, weil die Liste sonst als eine einzige + * Reihe nach Datum gelesen wird und dann falsch aussieht: Auf dem Telefon + * stand hier „29. Okt – 02. Okt – 08. Okt“, und weil die erste Zeile mit + * Anmelde-Schaltfläche 309px hoch ist, sah man beim ersten Blick nur sie. Das + * späteste Datum wirkte so wie der nächste Termin. + * + * Nur eine Gruppe vorhanden (nichts offen, oder nur Offenes) heißt: keine + * Marken — dann erklärt die Abschnittsüberschrift die Liste bereits. */ export default function Programm({ events, @@ -39,7 +76,7 @@ export default function Programm({ isLoading, now, }: ProgrammProps) { - const entries = useMemo(() => { + const { offene, andere } = useMemo(() => { const openCourses = courses .filter((course) => isRegistrationOpen(course, now)) .sort( @@ -49,22 +86,23 @@ export default function Programm({ ); const openIds = new Set(openCourses.map((course) => course.id)); - const others = [ + const rest = [ ...events.map(eventEntry), ...courses .filter((course) => !openIds.has(course.id)) .map((course) => courseEntry(course, now)), ].sort((a, b) => a.start.getTime() - b.start.getTime()); - const openSlots = others.length > 0 ? MAX_ROWS - 1 : MAX_ROWS; - return [ - ...openCourses - .slice(0, openSlots) - .map((course) => courseEntry(course, now)), - ...others, - ].slice(0, MAX_ROWS); + const offeneZeilen = rest.length > 0 ? MAX_OFFEN : MAX_ROWS; + const offene = openCourses + .slice(0, offeneZeilen) + .map((course) => courseEntry(course, now)); + + return { offene, andere: rest.slice(0, MAX_ROWS - offene.length) }; }, [events, courses, now]); + const zweiGruppen = offene.length > 0 && andere.length > 0; + return (
    - + {isLoading || !zweiGruppen ? ( + + ) : ( + <> + Anmeldung läuft + + + Nächste Termine + + + )} - + Date: Thu, 17 Sep 2026 23:46:15 +0200 Subject: [PATCH 112/139] fix(suche): Schreiblinie statt Fokuskasten im Suchfeld Beim Oeffnen springt der Cursor ins Suchfeld, und der gemeinsame Fokusrahmen zeichnete sofort einen 3px-Kasten mit Abstand -- im 2px-Rahmen des Fensters ein Kasten im Kasten. Jetzt wird die Linie unter der Zeile zur Tinte, wie eine Schreiblinie im Heft; die Zeile traegt immer 2px, also springt nichts. Der Ring bleibt ueberall sonst: Zuruecksetzen, "Alle Ergebnisse" und die Treffer zeigen ihn unveraendert, nachgemessen beim Durchtabben. --- src/app/_components/general/search-modal.tsx | 4 ++-- src/styles/globals.css | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/app/_components/general/search-modal.tsx b/src/app/_components/general/search-modal.tsx index 6ccefb7b..bc8b12bd 100644 --- a/src/app/_components/general/search-modal.tsx +++ b/src/app/_components/general/search-modal.tsx @@ -178,7 +178,7 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
    -
    +
    setQuery(e.target.value)} placeholder="Suche nach Terminen, Beiträgen, Downloads…" aria-label="Suchbegriff" - className="text-ink dark:text-night-text placeholder:text-dark dark:placeholder:text-night-muted w-full bg-transparent py-4 text-lg outline-none" + className="suchfeld text-ink dark:text-night-text placeholder:text-dark dark:placeholder:text-night-muted w-full bg-transparent py-4 text-lg outline-none" /> {query && (
    + {/* Anmeldung */} + + {/* District & Category */}
    diff --git a/src/app/_components/homepage/programm.tsx b/src/app/_components/homepage/programm.tsx index b71ca224..a6f855d1 100644 --- a/src/app/_components/homepage/programm.tsx +++ b/src/app/_components/homepage/programm.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo } from "react"; +import Link from "next/link"; import { Heading } from "@/app/_components/programmheft/section-head"; import { ProgrammeList } from "@/app/_components/programmheft/programme"; import { WayList, WayRow } from "@/app/_components/programmheft/way-list"; @@ -67,6 +68,10 @@ function Gruppenmarke({ * Anmelde-Schaltfläche 309px hoch ist, sah man beim ersten Blick nur sie. Das * späteste Datum wirkte so wie der nächste Termin. * + * Offen steht nur eine Zeile: Mehr wird zu voll, und die übrigen offenen + * Anmeldungen führt eine Zeile darunter auf die Terminseite, gefiltert auf + * offene Anmeldungen. + * * Nur eine Gruppe vorhanden (nichts offen, oder nur Offenes) heißt: keine * Marken — dann erklärt die Abschnittsüberschrift die Liste bereits. */ @@ -76,7 +81,7 @@ export default function Programm({ isLoading, now, }: ProgrammProps) { - const { offene, andere } = useMemo(() => { + const { offene, andere, weitereOffene } = useMemo(() => { const openCourses = courses .filter((course) => isRegistrationOpen(course, now)) .sort( @@ -98,7 +103,11 @@ export default function Programm({ .slice(0, offeneZeilen) .map((course) => courseEntry(course, now)); - return { offene, andere: rest.slice(0, MAX_ROWS - offene.length) }; + return { + offene, + andere: rest.slice(0, MAX_ROWS - offene.length), + weitereOffene: openCourses.length - offene.length, + }; }, [events, courses, now]); const zweiGruppen = offene.length > 0 && andere.length > 0; @@ -123,6 +132,15 @@ export default function Programm({ <> Anmeldung läuft + {weitereOffene > 0 ? ( +

    + + {weitereOffene === 1 + ? "Ein weiterer Lehrgang nimmt Anmeldungen an" + : `${weitereOffene} weitere Lehrgänge nehmen Anmeldungen an`} + +

    + ) : null} Nächste Termine {[0, 1, 2].map((i) => (
    0 ? "lg:border-l" : "" }`} > @@ -142,16 +142,18 @@ export function NewsColumns({ } return ( - // Jede Spalte bekommt denselben linken Innenabstand, das Raster wird dafür - // um genau diesen Betrag nach links gezogen. Vorher hingen die Abstände am - // laufenden Index — die mittleren Spalten waren dadurch schmaler, ihre - // Bildfelder bei festem Seitenverhältnis niedriger und die Überschriften - // standen nicht mehr auf einer Linie. -
      + // Jede Spalte bekommt denselben Innenabstand auf beiden Seiten, das Raster + // wird um die Hälfte des Zwischenraums nach außen gezogen. Zwei Gründe: + // Hingen die Abstände am laufenden Index, wären die mittleren Spalten + // schmaler, ihre Bildfelder niedriger und die Überschriften stünden nicht + // mehr auf einer Linie. Und läge der Abstand nur links, säße die + // Haarlinie bündig an der vorigen Spalte statt zwischen beiden — gemessen + // 0px zur linken und 41px zur rechten Spalte. +
        {posts.map((post, i) => (
      • From a77179a51c28c46bd1176af47e2739d9a62e4e38 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Thu, 17 Sep 2026 23:56:28 +0200 Subject: [PATCH 114/139] fix(anmeldungen): Keine Rabattmarke an stornierten Anmeldungen Eine stornierte Anmeldung trug weiter "Rabatt pruefen" -- da ist nichts mehr zu pruefen, und die Marke verspricht eine Entscheidung, die nicht mehr kommt. Auf der Detailseite entfallen aus demselben Grund die Rabatt-Hinweise. Ausserdem hiess die Marke aus Sicht der Anmeldenden falsch: Sie pruefen nichts, es wird geprueft. Jetzt "Rabatt wird geprueft"; im Dashboard bleibt "Rabatt pruefen", dort ist es eine Aufgabe. --- src/app/registrations/[id]/page.tsx | 6 ++++++ src/app/registrations/page.tsx | 14 +++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/app/registrations/[id]/page.tsx b/src/app/registrations/[id]/page.tsx index 07555e1c..af6a79dc 100644 --- a/src/app/registrations/[id]/page.tsx +++ b/src/app/registrations/[id]/page.tsx @@ -264,6 +264,9 @@ export default function ViewRegistrationPage() { } const statusTag = STATUS_TAG[registration.registrationStatus]; + /** Storniert heißt: Die Rabattfrage ist erledigt, egal wie sie stand. */ + const storniert = + registration.registrationStatus === RegistrationStatus.CANCELLED; const priceRows: { label: ReactNode; value: ReactNode }[] = []; const hasDiscountBreakdown = @@ -581,6 +584,7 @@ export default function ViewRegistrationPage() {

        {hasDiscountBreakdown && + !storniert && registration.siblingDiscountStatus === "PENDING" && (

        @@ -590,12 +594,14 @@ export default function ViewRegistrationPage() { )} {hasDiscountBreakdown && + !storniert && registration.siblingDiscountStatus === "APPROVED" && (

        ✓ Ihr Rabattantrag wurde genehmigt.

        )} {hasDiscountBreakdown && + !storniert && registration.siblingDiscountStatus === "REJECTED" && (

        diff --git a/src/app/registrations/page.tsx b/src/app/registrations/page.tsx index 5985f921..fae543d6 100644 --- a/src/app/registrations/page.tsx +++ b/src/app/registrations/page.tsx @@ -63,10 +63,15 @@ const STATUS_TAG: Record = CANCELLED: { label: "Storniert", tone: "cancelled" }, }; +/** + * Aus Sicht der Anmeldenden, nicht des Teams: Hier ist nichts zu prüfen, + * sondern etwas wird geprüft. „Rabatt prüfen“ steht weiterhin im Dashboard, + * wo es tatsächlich eine Aufgabe ist. + */ const DISCOUNT_TAG: Partial< Record > = { - PENDING: { label: "Rabatt prüfen", tone: "orange" }, + PENDING: { label: "Rabatt wird geprüft", tone: "orange" }, APPROVED: { label: "Rabatt genehmigt", tone: "inverse" }, REJECTED: { label: "Rabatt abgelehnt", tone: "ink" }, }; @@ -308,7 +313,14 @@ export default function MyRegistrationsPage() { {data.registrations.map((registration) => { const editInfo = getEditDeadlineInfo(registration); const statusTag = STATUS_TAG[registration.registrationStatus]; + // Bei einer stornierten Anmeldung gibt es keinen Rabatt mehr: + // Ein „wird geprüft“ daneben verspricht eine Entscheidung, die + // nicht mehr kommt. + const cancelled = + registration.registrationStatus === + RegistrationStatus.CANCELLED; const discountTag = + !cancelled && registration.siblingDiscountStatus && registration.siblingDiscountStatus !== SiblingDiscountStatus.NONE From fc70a65eb9e41b16a0e174bb46e5ab7e3ab641c6 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Fri, 18 Sep 2026 00:00:21 +0200 Subject: [PATCH 115/139] fix(import): Anfragen bis 100 MB statt 10 MB Importe von ZIPs ueber 10 MB scheiterten, ohne dass die Route je etwas davon sah: Alle Anfragen laufen durch src/proxy.ts, und Next begrenzt sie dort standardmaessig auf 10 MB (experimental.proxyClientMaxBodySize). Dieselbe Grenze traf Datei-Uploads, obwohl die Upload-Route 50 MB erlaubt. Jetzt 100 MB. Gemessen: Eine 15-MB-Anfrage an /api/import/events kommt in der Route an und scheitert erst an ihrem Inhalt, vorher brach Next sie mit "Request body exceeded 10MB" ab. Beim Ausrollen muss die Grenze des vorgelagerten Webservers dazu passen. --- next.config.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/next.config.js b/next.config.js index 90374465..98d95091 100644 --- a/next.config.js +++ b/next.config.js @@ -19,6 +19,17 @@ const config = { }, experimental: { + /* + * Alle Anfragen laufen durch `src/proxy.ts`, und Next begrenzt sie dort + * standardmäßig auf 10 MB. Genau daran scheiterten Importe: Ein ZIP mit + * Medien ist schnell größer, und die Upload-Route erlaubt ohnehin 50 MB + * je Datei. Der Server liest den Rumpf komplett in den Speicher, deshalb + * 100 MB und nicht mehr. + * + * Achtung beim Ausrollen: Ein vorgelagerter Webserver (mittwald) hat eine + * eigene Grenze, die dazu passen muss. + */ + proxyClientMaxBodySize: "100mb", webpackMemoryOptimizations: true, optimizePackageImports: [ "recharts", From fbc2402acc3098fcee20014e35866f2639894112 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Fri, 18 Sep 2026 00:06:18 +0200 Subject: [PATCH 116/139] fix(startseite): Hinweis auf offene Anmeldungen oeffnet die Liste Der Verweis zeigt eine gefilterte Auswahl. Wer den Kalender als Vorliebe gespeichert hat, landete dort -- die wenigen Treffer standen dann ueber Monate verstreut, und der Zweck des Links war dahin. Der Link gibt jetzt view=list mit. Gemessen mit einem Konto, dessen Vorliebe auf Kalender steht: /termine oeffnet den Kalender, der Link die Liste. --- src/app/_components/homepage/programm.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/_components/homepage/programm.tsx b/src/app/_components/homepage/programm.tsx index a6f855d1..2038d61c 100644 --- a/src/app/_components/homepage/programm.tsx +++ b/src/app/_components/homepage/programm.tsx @@ -134,7 +134,13 @@ export default function Programm({ {weitereOffene > 0 ? (

        - + {/* `view=list`: Der Verweis zeigt eine gefilterte Auswahl — + im Kalender einer gespeicherten Vorliebe wären die Treffer + über Monate verstreut und der Zweck des Links dahin. */} + {weitereOffene === 1 ? "Ein weiterer Lehrgang nimmt Anmeldungen an" : `${weitereOffene} weitere Lehrgänge nehmen Anmeldungen an`} From d928ea2f72bd2f0edcbb0fd22d954fc1c0064aa5 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Fri, 18 Sep 2026 00:09:34 +0200 Subject: [PATCH 117/139] fix(termine): Sammelbegriff Angebote, Kategoriefilter entfernt Unter den Kursen stehen nicht nur Lehrgaenge, sondern auch Workshops, Freizeiten, Veranstaltungen und ein Komponistenportrait (im Bestand 9/3/2/1/1). "Lehrgang" war als Sammelbegriff also falsch: Hinweis auf der Startseite und der neue Filter sprechen jetzt von Angeboten. Der Kategoriefilter ist raus. Er mischte Termin-Kategorien und Kursarten in einer Liste, deren Inhalt sich mit dem Typ-Filter aenderte -- das stiftete mehr Verwirrung als Nutzen. Bezirk steht dadurch auf voller Breite und hat jetzt eine gekoppelte Beschriftung. --- src/app/_components/events/events-client.tsx | 114 +++++-------------- src/app/_components/homepage/programm.tsx | 6 +- 2 files changed, 31 insertions(+), 89 deletions(-) diff --git a/src/app/_components/events/events-client.tsx b/src/app/_components/events/events-client.tsx index a39957b3..a623cf5f 100644 --- a/src/app/_components/events/events-client.tsx +++ b/src/app/_components/events/events-client.tsx @@ -24,7 +24,6 @@ import { } from "@/app/_components/programmheft/programme-data"; import { ProgrammeList } from "@/app/_components/programmheft/programme"; import { isRegistrationOpen } from "@/app/_components/programmheft/programme-data"; -import { COURSE_TYPE_MAP, EVENT_CATEGORY_MAP } from "@/lib/termine-labels"; import { useStickyTop } from "@/lib/use-sticky-top"; import { useTitelVorbei } from "@/lib/use-titel-vorbei"; import CalendarView from "./calendar/calendar-view"; @@ -199,9 +198,6 @@ export default function EventsClient({ const [selectedDistrict, setSelectedDistrict] = useState( params.get("district") || "all", ); - const [selectedCategory, setSelectedCategory] = useState( - params.get("category") || "all", - ); /** * `?anmeldung=offen` — die Startseite zeigt nur einen Lehrgang mit offener * Anmeldung und verweist für die übrigen hierher. @@ -242,7 +238,9 @@ export default function EventsClient({ if (filterType === "courses" && item.type !== "course") return false; // Termine nehmen keine Anmeldungen entgegen; der Filter lässt also - // nur Lehrgänge übrig, deren Anmeldung gerade läuft. + // nur Angebote übrig, deren Anmeldung gerade läuft. „Angebote“, weil + // darunter neben Lehrgängen auch Workshops, Freizeiten und + // Komponistenporträts stehen. if ( nurOffeneAnmeldung && (item.type !== "course" || !isRegistrationOpen(item, now)) @@ -262,23 +260,10 @@ export default function EventsClient({ } } - if (selectedCategory !== "all") { - if (item.type === "event") { - const enumValue = EVENT_CATEGORY_MAP[selectedCategory]; - if (enumValue && item.category !== enumValue) return false; - } else { - const courseTypeEnum = COURSE_TYPE_MAP[selectedCategory]; - - if (courseTypeEnum && item.courseType !== courseTypeEnum) { - return false; - } - } - } - return true; }); }, - [filterType, selectedDistrict, selectedCategory, nurOffeneAnmeldung, now], + [filterType, selectedDistrict, nurOffeneAnmeldung, now], ); const futureItems = useMemo(() => { @@ -402,26 +387,12 @@ export default function EventsClient({ .map((b) => `Bezirk ${b.number} (${b.name})`), ]; - const eventCategories = ["Konzert", "Gottesdienst", "Probe", "Andere"]; - const courseCategories = [ - "Lehrgang", - "Freizeit", - "Workshop", - "Komponistenportrait", - "Veranstaltung", - "Andere", - ]; - const hasActiveFilters = - filterType !== "all" || - selectedDistrict !== "all" || - selectedCategory !== "all" || - nurOffeneAnmeldung; + filterType !== "all" || selectedDistrict !== "all" || nurOffeneAnmeldung; const resetFilters = () => { setFilterType("all"); setSelectedDistrict("all"); - setSelectedCategory("all"); setNurOffeneAnmeldung(false); }; @@ -582,62 +553,31 @@ export default function EventsClient({ className="border-ink checked:bg-ink dark:border-night-text dark:checked:bg-night-text bg-paper dark:bg-night h-5 w-5 shrink-0 cursor-pointer appearance-none border-2" /> - Nur Lehrgänge mit offener Anmeldung + Nur Angebote mit offener Anmeldung - {/* District & Category */} -

        -
        - - -
        - -
        - - -
        + {/* Bezirk */} +
        + +
        {/* Darstellung */} diff --git a/src/app/_components/homepage/programm.tsx b/src/app/_components/homepage/programm.tsx index 2038d61c..ac7ecafd 100644 --- a/src/app/_components/homepage/programm.tsx +++ b/src/app/_components/homepage/programm.tsx @@ -141,9 +141,11 @@ export default function Programm({ href="/termine?anmeldung=offen&view=list" className="link-ink" > + {/* „Angebote“ statt „Lehrgänge“: Unter den Kursen stecken + auch Workshops, Freizeiten und ein Komponistenporträt. */} {weitereOffene === 1 - ? "Ein weiterer Lehrgang nimmt Anmeldungen an" - : `${weitereOffene} weitere Lehrgänge nehmen Anmeldungen an`} + ? "Ein weiteres Angebot nimmt Anmeldungen an" + : `${weitereOffene} weitere Angebote nehmen Anmeldungen an`}

        ) : null} From 90329f4419d3547ebb943139f2fcd729dfb54d25 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Fri, 18 Sep 2026 00:13:13 +0200 Subject: [PATCH 118/139] fix(termine): Filterbereich neu geordnet Seit die Kategorie weg ist, standen vier Bloecke untereinander. Jetzt eine Zeile mit Typ und Bezirk nebeneinander, darunter durch eine Haarlinie abgesetzt die Schalter: links der Filter fuer offene Anmeldungen, rechts die Monatsgruppierung, die keine Auswahl trifft, sondern nur gliedert. "Filter zuruecksetzen" steht am rechten Ende derselben Zeile. Die beiden Kontrollkaestchen hatten zwei verschiedene Stile (einmal selbst gezeichnet, einmal nativ mit rounded-none) und teilen sich jetzt einen. Die Typ-Umschalter tragen aria-pressed und sind 44px hoch; das Auswahlfeld daneben war 4px niedriger als ihr Rahmen und misst jetzt gleich 48px -- in Chromium und WebKit nachgemessen, oben 500, unten 548. --- src/app/_components/events/events-client.tsx | 181 +++++++++++-------- 1 file changed, 102 insertions(+), 79 deletions(-) diff --git a/src/app/_components/events/events-client.tsx b/src/app/_components/events/events-client.tsx index a623cf5f..9cd11cbf 100644 --- a/src/app/_components/events/events-client.tsx +++ b/src/app/_components/events/events-client.tsx @@ -68,6 +68,10 @@ const TYPE_OPTIONS: { value: FilterType; label: string }[] = [ const TOGGLE_BUTTON = "flex h-11 w-11 items-center justify-center transition-colors"; const TOGGLE_ACTIVE = "bg-ink text-paper dark:bg-night-text dark:text-night"; +/** Kontrollkästchen im Programmheft: eckig, Haarlinie, gefüllt statt Haken. */ +const CHECKBOX = + "border-ink checked:bg-ink dark:border-night-text dark:checked:bg-night-text bg-paper dark:bg-night h-5 w-5 shrink-0 cursor-pointer appearance-none border-2"; + const TOGGLE_INACTIVE = "text-ink hover:bg-ink hover:text-paper dark:text-night-text dark:hover:bg-night-text dark:hover:text-night"; @@ -396,8 +400,13 @@ export default function EventsClient({ setNurOffeneAnmeldung(false); }; + /* + * `min-h-12`: Die Umschalter daneben sind 44px hoch und stehen in einem + * Rahmen von 2px, ihr Kasten misst also 48px. Ein Feld mit 44px stünde + * sichtbar niedriger daneben. + */ const selectFieldClass = - "rounded-none! border-ink! dark:border-night-text! text-ink! dark:text-night-text! bg-paper! dark:bg-night! w-full border-2! px-3 py-2 text-sm"; + "rounded-none! border-ink! dark:border-night-text! text-ink! dark:text-night-text! bg-paper! dark:bg-night! min-h-12 w-full border-2! px-3 py-2 text-sm"; return ( - {/* Type Filter */} -
        - -
        - {TYPE_OPTIONS.map((option, index) => ( - - ))} + /* + * Zwei Zeilen statt vier gestapelter Blöcke: Seit die Kategorie + * weg ist, stehen Typ und Bezirk nebeneinander. Darunter die + * Schalter — links der Filter, rechts die Darstellung, die + * keine Auswahl trifft, sondern nur die Liste gliedert und + * deshalb durch die Haarlinie abgesetzt ist. + */ +
        +
        + {/* Typ */} +
        + + Typ + +
        + {TYPE_OPTIONS.map((option, index) => ( + + ))} +
        -
        - - {/* Anmeldung */} - - {/* Bezirk */} -
        - - + {/* Bezirk */} +
        + + +
        - {/* Darstellung */} -
        -
        )}
        From d527f23108296e7c07eb1ca9970d98492ccf6331 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Fri, 18 Sep 2026 00:32:48 +0200 Subject: [PATCH 119/139] fix(export): Preisoptionen und die uebrigen Felder wandern mit Der Kursexport schrieb die Preiskategorien gar nicht, ebenso wenig Anmeldefelder, oeffentliches Kursteam und das Kursbild als Datei; beim Termin fehlten Preiskategorien und verknuepfte Dateien. Der Import schrieb von den vorhandenen Feldern nur einen Bruchteil zurueck -- Motto, Kursnummer, Anmelde- und Zahlungseinstellungen, Anzahlung, Voraussetzungen gingen verloren. Die Preise waren damit doppelt verloren. Termine: Der Import las ensembleType, der Export schreibt aber performingEnsembleType -- die Ensemble-Art kam nie an. Der alte Name wird weiter gelesen, damit aeltere ZIPs gleich bleiben. Bewusst nicht uebernommen: Pruefvermerke und Benachrichtigungszeitpunkte werden zurueckgesetzt, publishedAt folgt dem Status. Das Kursteam MIT Zugang bleibt draussen -- das sind Berechtigungen auf Konten dieses Bestands, ueber ein ZIP liessen sich damit still Zugriffe einrichten. Kursnummer nur, wenn sie im Ziel frei ist: Sie traegt den Rechnungsnummernkreis. Ohne Nummer entfaellt die Anzahlung mit Meldung. Slug wird uebernommen, wenn frei, sonst neu aus Titel und Jahr. Gemessen im Rundlauf: Kurs 38 Felder, 6 gewollte Abweichungen; Preise, Anmeldefelder und Kursteam ohne Abweichung; Termin 27 Felder, 5 gewollte. Gesamtexport rein additiv, alte ZIPs importieren weiterhin. --- src/app/api/export/[type]/route.ts | 39 +- src/app/api/import/[type]/route.ts | 152 +++++-- src/server/api/helpers/content-slug.ts | 57 +++ .../utils/__tests__/course-import.test.ts | 404 ++++++++++++++++++ .../utils/__tests__/event-import.test.ts | 205 +++++++++ src/server/utils/course-import.ts | 324 ++++++++++++++ src/server/utils/event-import.ts | 148 +++++++ src/server/utils/import-references.ts | 55 ++- src/server/utils/import-values.ts | 94 ++++ 9 files changed, 1434 insertions(+), 44 deletions(-) create mode 100644 src/server/utils/__tests__/course-import.test.ts create mode 100644 src/server/utils/__tests__/event-import.test.ts create mode 100644 src/server/utils/course-import.ts create mode 100644 src/server/utils/event-import.ts create mode 100644 src/server/utils/import-values.ts diff --git a/src/app/api/export/[type]/route.ts b/src/app/api/export/[type]/route.ts index e060d3df..e532391c 100644 --- a/src/app/api/export/[type]/route.ts +++ b/src/app/api/export/[type]/route.ts @@ -153,6 +153,16 @@ export async function GET( image: true, }, }, + priceOptions: true, + // Die Datei selbst hat einen eigenen Export; hier reisen nur die + // Merkmale mit, an denen der Import sie wiederfindet. + downloads: { + include: { + download: { + select: { id: true, title: true, fileUrl: true }, + }, + }, + }, }, orderBy: { eventDate: "desc" }, }); @@ -163,6 +173,11 @@ export async function GET( jsonData = { events: events.map((event) => ({ ...event, + downloads: event.downloads.map((link) => ({ + downloadId: link.downloadId, + title: link.download.title, + fileUrl: link.download.fileUrl, + })), coverImageUrl: event.coverImage?.url, locationName: event.location?.name, bezirkName: event.bezirk?.name, @@ -391,11 +406,24 @@ export async function GET( // Bewusst ohne Anmeldungen, Teilnehmende und Rechnungen: Das sind // personenbezogene Daten, und ein Kurs soll sich weitergeben lassen, // ohne sie mitzunehmen. + // Ebenfalls draußen bleibt das Kursteam mit Zugang (`collaborators`): + // Das sind Berechtigungen auf Konten dieses Bestands, kein Inhalt — + // über ein ZIP vergeben ließen sich damit stillschweigend Zugriffe + // einrichten. Öffentlich genannte Teammitglieder ohne Zugang + // (`guestTeamMembers`) stehen dagegen auf der Kursseite und wandern mit. const courses = await db.course.findMany({ where: idFilter, include: { + // Das Kursbild reist als Media-Zeile mit, genau wie das Titelbild + // eines Termins oder Beitrags — kein Sonderweg nötig. + image: true, location: true, bezirk: true, + // Preiskategorien, Anmeldefelder und das öffentlich genannte + // Kursteam gehören zum Kurs und fehlten bisher ganz. + priceOptions: { orderBy: { createdAt: "asc" } }, + customFields: { orderBy: { sortOrder: "asc" } }, + guestTeamMembers: { orderBy: { sortOrder: "asc" } }, createdBy: { select: { id: true, @@ -420,6 +448,7 @@ export async function GET( jsonData = { courses: courses.map((course) => ({ ...course, + imageUrl: course.image?.url, locationName: course.location?.name, bezirkName: course.bezirk?.name, createdByEmail: course.createdBy?.email, @@ -429,19 +458,13 @@ export async function GET( count: courses.length, }; - const zipBuffer = await createExportZip(jsonData, [], "courses.json"); + mediaFiles = courses; filename = buildExportFilename( type, date, selectedIds ? courses : null, ); - - return new NextResponse(zipBuffer as unknown as BodyInit, { - headers: { - "Content-Type": "application/zip", - "Content-Disposition": `attachment; filename="${filename}"`, - }, - }); + break; } default: diff --git a/src/app/api/import/[type]/route.ts b/src/app/api/import/[type]/route.ts index c27e56ff..e6a2ad1f 100644 --- a/src/app/api/import/[type]/route.ts +++ b/src/app/api/import/[type]/route.ts @@ -18,16 +18,30 @@ import { createReferenceResolver, type UnresolvedReference, } from "@/server/utils/import-references"; +import { + readCourseContent, + readCourseCustomFields, + readCourseDownPayment, + readCourseGuestTeamMembers, + readCoursePriceOptions, +} from "@/server/utils/course-import"; +import { + readEventContent, + readEventDownloadRefs, + readEventPriceOptions, +} from "@/server/utils/event-import"; +import { readText } from "@/server/utils/import-values"; +import { + importCourseSlug, + importEventSlug, +} from "@/server/api/helpers/content-slug"; +import { normalizeCourseNumber } from "@/lib/invoice-document"; import { formatPhoneNumberOrNull } from "@/lib/phone-number"; import { ContentStatus, PostCategory, - EventCategory, - EventEnsembleType, DownloadCategory, FileType, - CourseType, - TargetAudience, HistoryCategory, } from "~/generated/prisma/client"; import { Prisma } from "~/generated/prisma/client"; @@ -271,7 +285,8 @@ export async function POST( ? mediaIdMap[coverImageId] || coverImageId : null; - const title = (eventData.title as string) || "unbenannt"; + const content = readEventContent(eventData); + const title = content.title; // An id from another database means nothing here, so every // reference is resolved against this one first. @@ -285,15 +300,32 @@ export async function POST( ); } + // Ein Set, weil zwei Verweise über Pfad und Titel auf dieselbe Datei + // führen können — zweimal verknüpft verletzte das der Eindeutigkeit + // von Termin und Datei. + const downloadIds = new Set(); + for (const ref of readEventDownloadRefs(eventData.downloads)) { + const downloadId = await references.downloadId( + ref.downloadId, + ref.fileUrl, + ref.title, + title, + ); + if (downloadId) downloadIds.add(downloadId); + } + + const priceOptions = readEventPriceOptions(eventData.priceOptions); + results.push( await db.event.create({ data: { - title: eventData.title as string, - motto: (eventData.motto as string) || null, - description: (eventData.description as string) || null, - eventDate: new Date(eventData.eventDate as string), - cancelled: (eventData.cancelled as boolean) || false, - category: eventData.category as string as EventCategory, + ...content, + slug: await importEventSlug( + db, + title, + content.eventDate, + eventData.slug, + ), bezirkId: await references.bezirkId( eventData.bezirkId, eventData.bezirk, @@ -311,13 +343,18 @@ export async function POST( eventData.auswahlChor, title, ), - performingEnsembleType: (eventData.ensembleType as string) - ? (eventData.ensembleType as string as EventEnsembleType) - : null, coverImageId: newCoverImageId, - status: - (eventData.status as string as ContentStatus) || - ContentStatus.DRAFT, + createdById: session.user.id, + ...(priceOptions.length > 0 && { + priceOptions: { create: priceOptions }, + }), + ...(downloadIds.size > 0 && { + downloads: { + create: [...downloadIds].map((downloadId) => ({ + downloadId, + })), + }, + }), }, }), ); @@ -661,7 +698,8 @@ export async function POST( const results = []; for (const courseData of courses) { - const title = (courseData.title as string) || "ohne Titel"; + const content = readCourseContent(courseData); + const title = content.title; // An id from another database means nothing here. let locationId = await references.knownLocationId( @@ -674,33 +712,77 @@ export async function POST( ); } + const imageId = readText(courseData.imageId); + const newImageId = imageId ? mediaIdMap[imageId] || imageId : null; + + // Die Kursnummer ist global eindeutig und bildet den Nummernkreis + // der Rechnungen (RE--). Ist sie im Zielbestand + // schon vergeben, kommt der Kurs ohne Nummer an: Zwei Kurse teilten + // sich sonst eine Rechnungsfolge. Eine Ersatznummer wird bewusst + // nicht erfunden — die Nummer stammt aus der Buchhaltung, nicht aus + // diesem Vorgang. + const exportedNumber = normalizeCourseNumber( + readText(courseData.courseNumber), + ); + const courseNumberTaken = + exportedNumber !== null && + (await db.course.count({ + where: { courseNumber: exportedNumber }, + })) > 0; + const courseNumber = courseNumberTaken ? null : exportedNumber; + if (courseNumberTaken) { + references.note(title, "Kursnummer", exportedNumber); + } + + const downPayment = readCourseDownPayment({ + raw: courseData, + courseNumber, + isFree: content.isFree, + isExternal: Boolean(content.externalRegistrationUrl), + allowSiblingDiscount: content.allowSiblingDiscount, + priceOptions: readCoursePriceOptions(courseData.priceOptions), + }); + if (downPayment.droppedReason) { + references.note(title, "Anzahlung", downPayment.droppedReason); + } + + const customFields = readCourseCustomFields(courseData.customFields); + const guestTeamMembers = readCourseGuestTeamMembers( + courseData.guestTeamMembers, + ); + results.push( await db.course.create({ data: { - title: courseData.title as string, - description: (courseData.description as string) || "", - courseType: courseData.courseType as string as CourseType, - targetAudience: - (courseData.targetAudience as string as TargetAudience) || - null, - startDate: new Date(courseData.startDate as string), - endDate: courseData.endDate - ? new Date(courseData.endDate as string) - : new Date(courseData.startDate as string), - registrationDeadline: courseData.registrationDeadline - ? new Date(courseData.registrationDeadline as string) - : null, - maxParticipants: (courseData.maxParticipants as number) || null, + ...content, + slug: await importCourseSlug( + db, + title, + content.startDate, + courseData.slug, + ), + courseNumber, + downPaymentMode: downPayment.downPaymentMode, + downPaymentAmount: downPayment.downPaymentAmount, + downPaymentRefundPolicy: downPayment.downPaymentRefundPolicy, + downPaymentRefundText: downPayment.downPaymentRefundText, + imageId: newImageId, bezirkId: await references.bezirkId( courseData.bezirkId, courseData.bezirk, title, ), locationId, - status: - (courseData.status as string as ContentStatus) || - ContentStatus.DRAFT, createdById: session.user.id, + ...(downPayment.priceOptions.length > 0 && { + priceOptions: { create: downPayment.priceOptions }, + }), + ...(customFields.length > 0 && { + customFields: { create: customFields }, + }), + ...(guestTeamMembers.length > 0 && { + guestTeamMembers: { create: guestTeamMembers }, + }), }, }), ); diff --git a/src/server/api/helpers/content-slug.ts b/src/server/api/helpers/content-slug.ts index 2b691b68..d374aee1 100644 --- a/src/server/api/helpers/content-slug.ts +++ b/src/server/api/helpers/content-slug.ts @@ -164,3 +164,60 @@ export async function updateCourseSlug( })) > 0, ); } + +/** + * Slug für einen Eintrag aus einem Import. + * + * Anders als im Formular darf ein besetzter Slug hier nicht scheitern: Ein + * Import läuft über viele Einträge auf einmal, und ein einziger vergebener + * Slug würde den ganzen Vorgang abbrechen. Der ausgegebene Slug wird deshalb + * übernommen, solange er frei und gültig ist — dann führt eine geteilte + * Adresse nach dem Wiedereinspielen wieder auf denselben Eintrag. Ist er + * vergeben, entsteht ein neuer aus Titel und Jahr, wie beim Anlegen. + */ +async function importedSlug( + requested: unknown, + base: string, + fallback: string, + isTaken: IsTaken, +): Promise { + const candidate = typeof requested === "string" ? requested.trim() : ""; + if (candidate && !slugProblem(candidate) && !(await isTaken(candidate))) { + return candidate; + } + return uniqueSlug(base, isTaken, fallback); +} + +export async function importEventSlug( + db: Db, + title: string, + eventDate: Date, + requested: unknown, +): Promise { + const isTaken: IsTaken = async (candidate) => + (await db.event.count({ where: { slug: candidate } })) > 0; + + return importedSlug( + requested, + datedSlugBase(title, eventDate), + "termin", + isTaken, + ); +} + +export async function importCourseSlug( + db: Db, + title: string, + startDate: Date, + requested: unknown, +): Promise { + const isTaken: IsTaken = async (candidate) => + (await db.course.count({ where: { slug: candidate } })) > 0; + + return importedSlug( + requested, + datedSlugBase(title, startDate), + "kurs", + isTaken, + ); +} diff --git a/src/server/utils/__tests__/course-import.test.ts b/src/server/utils/__tests__/course-import.test.ts new file mode 100644 index 00000000..c0368f03 --- /dev/null +++ b/src/server/utils/__tests__/course-import.test.ts @@ -0,0 +1,404 @@ +import { describe, expect, it } from "@jest/globals"; +import { + readCourseContent, + readCourseCustomFields, + readCourseDownPayment, + readCourseGuestTeamMembers, + readCoursePriceOptions, +} from "../course-import"; + +/** + * Prüft die Abbildung Export → Import für Kurse: Was das ZIP schreibt, muss + * auf der anderen Seite wieder ankommen — und was nicht zum Inhalt gehört, + * darf nicht mitwandern. + * + * Die Beispielnutzlast ist nach dem echten `courses.json` gebaut: Der Export + * schreibt die Kursfelder unverändert aus der Datenbank, Datumsangaben also + * als ISO-Zeichenkette. + */ +const exportedCourse = ( + overrides: Record = {}, +): Record => ({ + id: "aaaaaaaa-0000-4000-8000-000000000001", + slug: "blaeserfreizeit-2026", + title: "Bläserfreizeit", + motto: "Gemeinsam klingen", + description: "Eine Woche mit **Noten**.", + courseNumber: "99001", + imageId: "media-1", + startDate: "2026-07-01T09:00:00.000Z", + endDate: "2026-07-05T16:00:00.000Z", + locationId: "location-1", + courseType: "FREIZEIT", + targetAudience: "JUGEND", + bezirkId: "bezirk-1", + registrationOpen: true, + registrationOpensAt: "2026-02-01T08:00:00.000Z", + registrationDeadline: "2026-06-01T23:59:00.000Z", + externalProviderName: null, + externalRegistrationUrl: null, + registrationClosedNotifiedAt: "2026-06-02T06:00:00.000Z", + maxParticipants: 40, + allowWaitingList: true, + isFree: false, + priceInfo: "Geschwisterkinder günstiger.", + allowSiblingDiscount: true, + paymentCashAllowed: true, + paymentInvoiceAllowed: false, + invoicingEnabled: true, + downPaymentMode: "TICKET", + downPaymentAmount: null, + downPaymentRefundPolicy: "CUSTOM", + downPaymentRefundText: "Erstattung bis vier Wochen vorher.", + prerequisites: "Zwei Jahre Spielerfahrung.", + whatToBring: "Instrument und Notenständer.", + status: "DRAFT", + reviewNotes: "Prüfvermerk", + reviewDate: "2026-05-20T10:00:00.000Z", + createdById: "user-1", + reviewerId: "user-2", + publishedAt: "2026-05-21T10:00:00.000Z", + createdAt: "2026-05-01T10:00:00.000Z", + updatedAt: "2026-05-02T10:00:00.000Z", + ...overrides, +}); + +describe("readCourseContent", () => { + it("übernimmt die Inhaltsfelder des Exports", () => { + const content = readCourseContent(exportedCourse()); + + expect(content).toMatchObject({ + title: "Bläserfreizeit", + motto: "Gemeinsam klingen", + description: "Eine Woche mit **Noten**.", + courseType: "FREIZEIT", + targetAudience: "JUGEND", + registrationOpen: true, + maxParticipants: 40, + allowWaitingList: true, + isFree: false, + priceInfo: "Geschwisterkinder günstiger.", + allowSiblingDiscount: true, + paymentCashAllowed: true, + paymentInvoiceAllowed: false, + invoicingEnabled: true, + prerequisites: "Zwei Jahre Spielerfahrung.", + whatToBring: "Instrument und Notenständer.", + status: "DRAFT", + }); + expect(content.startDate.toISOString()).toBe("2026-07-01T09:00:00.000Z"); + expect(content.endDate.toISOString()).toBe("2026-07-05T16:00:00.000Z"); + expect(content.registrationOpensAt?.toISOString()).toBe( + "2026-02-01T08:00:00.000Z", + ); + expect(content.registrationDeadline?.toISOString()).toBe( + "2026-06-01T23:59:00.000Z", + ); + }); + + it("lässt Prüfvermerke und Benachrichtigungszeitpunkte draußen", () => { + const content = readCourseContent(exportedCourse()) as Record< + string, + unknown + >; + + for (const field of [ + "id", + "createdById", + "reviewerId", + "reviewNotes", + "reviewDate", + "registrationClosedNotifiedAt", + "createdAt", + "updatedAt", + ]) { + expect(content[field]).toBeUndefined(); + } + }); + + it("setzt den Veröffentlichungszeitpunkt nur bei freigegebenen Kursen", () => { + expect(readCourseContent(exportedCourse()).publishedAt).toBeNull(); + + const approved = readCourseContent( + exportedCourse({ status: "APPROVED" }), + ).publishedAt; + expect(approved?.toISOString()).toBe("2026-05-21T10:00:00.000Z"); + }); + + it("füllt einen freigegebenen Kurs ohne Datum mit der Gegenwart", () => { + const before = Date.now(); + const publishedAt = readCourseContent( + exportedCourse({ status: "APPROVED", publishedAt: null }), + ).publishedAt; + + expect(publishedAt).not.toBeNull(); + expect(publishedAt!.getTime()).toBeGreaterThanOrEqual(before); + }); + + it("liest ein ZIP aus dem alten Stand mit Standardwerten", () => { + // Genau die Felder, die der bisherige Import geschrieben hat. + const content = readCourseContent({ + title: "Alter Kurs", + description: "Text", + courseType: "WORKSHOP", + startDate: "2026-03-01T09:00:00.000Z", + status: "DRAFT", + }); + + expect(content).toMatchObject({ + title: "Alter Kurs", + motto: null, + targetAudience: null, + registrationOpen: false, + allowWaitingList: false, + isFree: false, + allowSiblingDiscount: false, + paymentCashAllowed: true, + paymentInvoiceAllowed: true, + invoicingEnabled: false, + publishedAt: null, + }); + // Ohne Enddatum gilt der Starttag — wie schon vorher. + expect(content.endDate.toISOString()).toBe("2026-03-01T09:00:00.000Z"); + }); + + it("fällt bei unbekannten Aufzählungswerten auf den Standard zurück", () => { + const content = readCourseContent( + exportedCourse({ courseType: "GIBTESNICHT", status: "ERFUNDEN" }), + ); + + expect(content.courseType).toBe("OTHER"); + expect(content.status).toBe("DRAFT"); + }); +}); + +describe("readCoursePriceOptions", () => { + it("übernimmt Preis, Begrenzung, Altersgrenzen und Anzahlung", () => { + expect( + readCoursePriceOptions([ + { + id: "po-1", + courseId: "aaaa", + label: "Jugendliche", + price: 185.5, + description: "Bis einschließlich 17 Jahre", + maxParticipants: 12, + minAge: null, + maxAge: 17, + downPaymentAmount: 40, + createdAt: "2026-05-01T10:00:00.000Z", + }, + { + id: "po-2", + courseId: "aaaa", + label: "Erwachsene", + price: 245, + description: null, + maxParticipants: null, + minAge: 18, + maxAge: null, + downPaymentAmount: 60, + }, + ]), + ).toEqual([ + { + label: "Jugendliche", + price: 185.5, + description: "Bis einschließlich 17 Jahre", + maxParticipants: 12, + minAge: null, + maxAge: 17, + downPaymentAmount: 40, + }, + { + label: "Erwachsene", + price: 245, + description: null, + maxParticipants: null, + minAge: 18, + maxAge: null, + downPaymentAmount: 60, + }, + ]); + }); + + it("nimmt einen kostenlosen Platz mit, überspringt aber Kategorien ohne Preis oder Bezeichnung", () => { + expect( + readCoursePriceOptions([ + { label: "Frei", price: 0 }, + { label: "Ohne Preis" }, + { price: 20 }, + "unsinn", + null, + ]).map((option) => option.label), + ).toEqual(["Frei"]); + }); + + it("liefert für fehlende Kategorien eine leere Liste", () => { + expect(readCoursePriceOptions(undefined)).toEqual([]); + expect(readCoursePriceOptions({})).toEqual([]); + }); +}); + +describe("readCourseCustomFields", () => { + it("übernimmt Anmeldefelder samt Auswahlliste", () => { + expect( + readCourseCustomFields([ + { + id: "cf-1", + courseId: "aaaa", + fieldName: "Stimme", + fieldType: "SELECT", + options: ["Trompete", "Posaune"], + isRequired: true, + helpText: "Was spielst du?", + sortOrder: 0, + }, + ]), + ).toEqual([ + { + fieldName: "Stimme", + fieldType: "SELECT", + options: ["Trompete", "Posaune"], + isRequired: true, + helpText: "Was spielst du?", + sortOrder: 0, + }, + ]); + }); + + it("überspringt Felder mit unbekannter Art und zählt die Reihenfolge sonst durch", () => { + const fields = readCourseCustomFields([ + { fieldName: "Kaputt", fieldType: "REGENBOGEN" }, + { fieldName: "Allergien", fieldType: "TEXTAREA" }, + ]); + + expect(fields).toHaveLength(1); + expect(fields[0]).toMatchObject({ + fieldName: "Allergien", + fieldType: "TEXTAREA", + options: undefined, + isRequired: false, + sortOrder: 1, + }); + }); +}); + +describe("readCourseGuestTeamMembers", () => { + it("übernimmt das öffentlich genannte Kursteam", () => { + expect( + readCourseGuestTeamMembers([ + { id: "g1", displayName: "Gastdozent", bio: "Leitet das Blech." }, + { displayName: " " }, + ]), + ).toEqual([ + { displayName: "Gastdozent", bio: "Leitet das Blech.", sortOrder: 0 }, + ]); + }); +}); + +describe("readCourseDownPayment", () => { + const priceOptions = [ + { + label: "Jugendliche", + price: 185.5, + description: null, + maxParticipants: 12, + minAge: null, + maxAge: 17, + downPaymentAmount: 40, + }, + { + label: "Erwachsene", + price: 245, + description: null, + maxParticipants: null, + minAge: 18, + maxAge: null, + downPaymentAmount: 60, + }, + ]; + + it("übernimmt die Anzahlung, wenn die Kursnummer mitkam", () => { + const result = readCourseDownPayment({ + raw: exportedCourse(), + courseNumber: "99001", + isFree: false, + isExternal: false, + allowSiblingDiscount: true, + priceOptions, + }); + + expect(result.droppedReason).toBeNull(); + expect(result.downPaymentMode).toBe("TICKET"); + expect(result.downPaymentRefundPolicy).toBe("CUSTOM"); + expect(result.downPaymentRefundText).toBe( + "Erstattung bis vier Wochen vorher.", + ); + expect(result.priceOptions.map((o) => o.downPaymentAmount)).toEqual([ + 40, 60, + ]); + }); + + it("lässt die Anzahlung weg, wenn die Kursnummer im Zielbestand vergeben war", () => { + const result = readCourseDownPayment({ + raw: exportedCourse(), + courseNumber: null, + isFree: false, + isExternal: false, + allowSiblingDiscount: true, + priceOptions, + }); + + expect(result.downPaymentMode).toBe("NONE"); + expect(result.downPaymentAmount).toBeNull(); + expect(result.downPaymentRefundText).toBeNull(); + expect(result.droppedReason).toContain("Kursnummer"); + // Die Preise bleiben, nur die Beträge der Anzahlung verschwinden. + expect(result.priceOptions.map((o) => o.price)).toEqual([185.5, 245]); + expect(result.priceOptions.map((o) => o.downPaymentAmount)).toEqual([ + null, + null, + ]); + }); + + it("räumt Kategoriebeträge weg, wenn die Anzahlung am Kurs hängt", () => { + const result = readCourseDownPayment({ + raw: exportedCourse({ + downPaymentMode: "COURSE", + downPaymentAmount: 50, + downPaymentRefundPolicy: "REFUNDABLE", + downPaymentRefundText: "wird ignoriert", + }), + courseNumber: "99001", + isFree: false, + isExternal: false, + allowSiblingDiscount: false, + priceOptions, + }); + + expect(result.droppedReason).toBeNull(); + expect(result.downPaymentMode).toBe("COURSE"); + expect(result.downPaymentAmount).toBe(50); + // Nur bei CUSTOM steht ein eigener Hinweis. + expect(result.downPaymentRefundText).toBeNull(); + expect(result.priceOptions.map((o) => o.downPaymentAmount)).toEqual([ + null, + null, + ]); + }); + + it("bleibt bei einem Kurs ohne Anzahlung still", () => { + const result = readCourseDownPayment({ + raw: { downPaymentMode: "NONE" }, + courseNumber: null, + isFree: true, + isExternal: false, + allowSiblingDiscount: false, + priceOptions: [], + }); + + expect(result.downPaymentMode).toBe("NONE"); + expect(result.droppedReason).toBeNull(); + }); +}); diff --git a/src/server/utils/__tests__/event-import.test.ts b/src/server/utils/__tests__/event-import.test.ts new file mode 100644 index 00000000..bdf07eae --- /dev/null +++ b/src/server/utils/__tests__/event-import.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "@jest/globals"; +import { + readEventContent, + readEventDownloadRefs, + readEventPriceOptions, +} from "../event-import"; + +/** + * Abbildung Export → Import für Termine. Die Beispielnutzlast ist nach dem + * echten `events.json` gebaut. + */ +const exportedEvent = ( + overrides: Record = {}, +): Record => ({ + id: "bbbbbbbb-0000-4000-8000-000000000001", + slug: "adventskonzert-2026", + title: "Adventskonzert", + motto: "Macht hoch die Tür", + description: "Ein Abend mit Blechbläsern.", + coverImageId: "media-2", + eventDate: "2026-11-14T19:00:00.000Z", + duration: 90, + cancelled: false, + locationId: "location-2", + category: "KONZERT", + bezirkId: "bezirk-1", + districtName: "Bezirksname aus dem Freitext", + performingEnsembleType: "ENSEMBLE", + ensembleId: "ensemble-1", + auswahlChorId: null, + performingEnsembleName: "Auftretendes Ensemble", + leitung: "Musterperson", + openToParticipants: true, + participationInfo: "Mitspielen ab 14 Jahren.", + isFree: false, + priceInfo: "Kollekte erbeten.", + status: "DRAFT", + reviewNotes: "Prüfvermerk", + reviewDate: "2026-09-01T10:00:00.000Z", + createdById: "user-1", + reviewerId: "user-2", + publishedAt: "2026-09-02T10:00:00.000Z", + createdAt: "2026-08-01T10:00:00.000Z", + updatedAt: "2026-08-02T10:00:00.000Z", + ...overrides, +}); + +describe("readEventContent", () => { + it("übernimmt die Inhaltsfelder des Exports", () => { + const content = readEventContent(exportedEvent()); + + expect(content).toMatchObject({ + title: "Adventskonzert", + motto: "Macht hoch die Tür", + description: "Ein Abend mit Blechbläsern.", + duration: 90, + cancelled: false, + category: "KONZERT", + districtName: "Bezirksname aus dem Freitext", + performingEnsembleName: "Auftretendes Ensemble", + leitung: "Musterperson", + openToParticipants: true, + participationInfo: "Mitspielen ab 14 Jahren.", + isFree: false, + priceInfo: "Kollekte erbeten.", + status: "DRAFT", + }); + expect(content.eventDate.toISOString()).toBe("2026-11-14T19:00:00.000Z"); + }); + + it("liest die Ensemble-Art unter dem Namen, den der Export schreibt", () => { + // Der Import las früher `ensembleType`; der Export schrieb schon immer + // `performingEnsembleType`, weshalb die Art nie ankam. + expect(readEventContent(exportedEvent()).performingEnsembleType).toBe( + "ENSEMBLE", + ); + }); + + it("liest den alten Namen weiter, damit von Hand gebaute Dateien gehen", () => { + const content = readEventContent( + exportedEvent({ + performingEnsembleType: undefined, + ensembleType: "AUSWAHLCHOR", + }), + ); + + expect(content.performingEnsembleType).toBe("AUSWAHLCHOR"); + }); + + it("lässt Prüfvermerke und Herkunftsfelder draußen", () => { + const content = readEventContent(exportedEvent()) as Record< + string, + unknown + >; + + for (const field of [ + "id", + "createdById", + "reviewerId", + "reviewNotes", + "reviewDate", + "createdAt", + "updatedAt", + ]) { + expect(content[field]).toBeUndefined(); + } + }); + + it("setzt den Veröffentlichungszeitpunkt nur bei freigegebenen Terminen", () => { + expect(readEventContent(exportedEvent()).publishedAt).toBeNull(); + expect( + readEventContent( + exportedEvent({ status: "APPROVED" }), + ).publishedAt?.toISOString(), + ).toBe("2026-09-02T10:00:00.000Z"); + }); + + it("liest ein ZIP aus dem alten Stand mit Standardwerten", () => { + const content = readEventContent({ + title: "Alter Termin", + eventDate: "2026-04-01T18:00:00.000Z", + category: "GOTTESDIENST", + status: "DRAFT", + }); + + expect(content).toMatchObject({ + title: "Alter Termin", + motto: null, + description: null, + duration: null, + cancelled: false, + districtName: null, + performingEnsembleType: null, + performingEnsembleName: null, + leitung: null, + openToParticipants: false, + // Termine sind im Zweifel kostenlos — so steht es auch im Schema. + isFree: true, + publishedAt: null, + }); + }); + + it("fällt bei unbekannter Art auf „ANDERE“ zurück", () => { + expect( + readEventContent(exportedEvent({ category: "PARTY" })).category, + ).toBe("ANDERE"); + }); +}); + +describe("readEventPriceOptions", () => { + it("übernimmt die Eintrittspreise", () => { + expect( + readEventPriceOptions([ + { + id: "epo-1", + eventId: "bbbb", + label: "Abendkasse", + price: 12, + description: "An der Abendkasse", + }, + { id: "epo-2", eventId: "bbbb", label: "Ermäßigt", price: 8 }, + ]), + ).toEqual([ + { label: "Abendkasse", price: 12, description: "An der Abendkasse" }, + { label: "Ermäßigt", price: 8, description: null }, + ]); + }); + + it("liefert für ein ZIP ohne Preisblock eine leere Liste", () => { + expect(readEventPriceOptions(undefined)).toEqual([]); + }); +}); + +describe("readEventDownloadRefs", () => { + it("liest die flache Form, die der Export schreibt", () => { + expect( + readEventDownloadRefs([ + { + downloadId: "download-1", + title: "Satzung", + fileUrl: "/downloads/satzung.pdf", + }, + ]), + ).toEqual([ + { + downloadId: "download-1", + title: "Satzung", + fileUrl: "/downloads/satzung.pdf", + }, + ]); + }); + + it("versteht auch die verschachtelte Form einer selbst gebauten Datei", () => { + expect( + readEventDownloadRefs([ + { download: { id: "download-2", title: "Noten", fileUrl: null } }, + ]), + ).toEqual([{ downloadId: "download-2", title: "Noten", fileUrl: null }]); + }); + + it("überspringt Verweise ohne jedes Merkmal", () => { + expect(readEventDownloadRefs([{}, null, "unsinn"])).toEqual([]); + expect(readEventDownloadRefs(undefined)).toEqual([]); + }); +}); diff --git a/src/server/utils/course-import.ts b/src/server/utils/course-import.ts new file mode 100644 index 00000000..88fd52dc --- /dev/null +++ b/src/server/utils/course-import.ts @@ -0,0 +1,324 @@ +import type { Prisma } from "~/generated/prisma/client"; +import { + ContentStatus, + CourseType, + CustomFieldType, + DownPaymentMode, + DownPaymentRefundPolicy, + TargetAudience, +} from "~/generated/prisma/enums"; +import { + normalizeDownPaymentConfig, + priceOptionDownPaymentAmount, + validateDownPaymentSettings, +} from "@/lib/course-down-payment"; +import { + readBoolean, + readDate, + readEnum, + readInteger, + readNumber, + readPublishedAt, + readText, +} from "./import-values"; + +/** + * Reine Leseregeln für einen Kurs aus einem Export-ZIP. + * + * Getrennt von der Route, weil hier die inhaltlichen Entscheidungen stecken: + * was zum Kurs gehört und mitwandert, und was am Zielsystem neu entsteht. + * Alles, was die Datenbank braucht (Bezirk, Standort, Bild, Slug, Kursnummer), + * setzt die Route davor und reicht es hier hinein. + * + * Bewusst draußen bleiben Anmeldungen, Teilnehmende, Rechnungen, Wartelisten + * und Nachrück-Angebote: Ein Kurs soll sich weitergeben lassen, ohne + * personenbezogene Daten mitzunehmen. + */ + +export type CoursePriceOptionImport = { + label: string; + price: number; + description: string | null; + maxParticipants: number | null; + minAge: number | null; + maxAge: number | null; + downPaymentAmount: number | null; +}; + +export type CourseCustomFieldImport = { + fieldName: string; + fieldType: CustomFieldType; + options: Prisma.InputJsonValue | undefined; + isRequired: boolean; + helpText: string | null; + sortOrder: number; +}; + +export type CourseGuestTeamMemberImport = { + displayName: string; + bio: string | null; + sortOrder: number; +}; + +/** + * Preiskategorien eines Kurses. + * + * Eine Kategorie ohne Bezeichnung oder ohne lesbaren Preis wird übersprungen: + * Sie ließe sich im Formular gar nicht anlegen, und im Kurs stünde danach eine + * Zeile, die niemand zuordnen kann. Der Anzahlungsbetrag wandert mit, wird aber + * erst in {@link readCourseDownPayment} gegen den Modus des Kurses geprüft. + */ +export function readCoursePriceOptions( + raw: unknown, +): CoursePriceOptionImport[] { + if (!Array.isArray(raw)) return []; + + return raw.flatMap((entry) => { + if (typeof entry !== "object" || entry === null) return []; + const option = entry as Record; + + const label = readText(option.label); + const price = readNumber(option.price); + if (!label || price === null) return []; + + return [ + { + label, + price, + description: readText(option.description), + maxParticipants: readInteger(option.maxParticipants), + minAge: readInteger(option.minAge), + maxAge: readInteger(option.maxAge), + downPaymentAmount: readNumber(option.downPaymentAmount), + }, + ]; + }); +} + +/** + * Zusätzliche Anmeldefelder. + * + * Ein Feld mit unbekannter Art wird ausgelassen statt auf TEXT zurückgesetzt: + * Die Art bestimmt, was das Anmeldeformular zeigt und was es annimmt — ein + * geratenes Feld führte zu Antworten, die zur Auswertung nicht passen. + */ +export function readCourseCustomFields( + raw: unknown, +): CourseCustomFieldImport[] { + if (!Array.isArray(raw)) return []; + + return raw.flatMap((entry, index) => { + if (typeof entry !== "object" || entry === null) return []; + const field = entry as Record; + + const fieldName = readText(field.fieldName); + const fieldType = readEnum(field.fieldType, CustomFieldType, null); + if (!fieldName || !fieldType) return []; + + // Auswahllisten stehen als JSON im Feld; alles andere hat keine Optionen. + const options = + Array.isArray(field.options) || + (typeof field.options === "object" && field.options !== null) + ? (field.options as Prisma.InputJsonValue) + : undefined; + + return [ + { + fieldName, + fieldType, + options, + isRequired: readBoolean(field.isRequired, false), + helpText: readText(field.helpText), + sortOrder: readInteger(field.sortOrder) ?? index, + }, + ]; + }); +} + +/** + * Das öffentlich genannte Kursteam ohne Zugang zum Dashboard — Teil der + * Kursseite wie die Leitung eines Termins, deshalb Inhalt und kein Datensatz + * über eine anmeldende Person. + */ +export function readCourseGuestTeamMembers( + raw: unknown, +): CourseGuestTeamMemberImport[] { + if (!Array.isArray(raw)) return []; + + return raw.flatMap((entry, index) => { + if (typeof entry !== "object" || entry === null) return []; + const member = entry as Record; + + const displayName = readText(member.displayName); + if (!displayName) return []; + + return [ + { + displayName, + bio: readText(member.bio), + sortOrder: readInteger(member.sortOrder) ?? index, + }, + ]; + }); +} + +export type CourseContentImport = { + title: string; + motto: string | null; + description: string; + courseType: CourseType; + targetAudience: TargetAudience | null; + startDate: Date; + endDate: Date; + registrationOpen: boolean; + registrationOpensAt: Date | null; + registrationDeadline: Date | null; + externalProviderName: string | null; + externalRegistrationUrl: string | null; + maxParticipants: number | null; + allowWaitingList: boolean; + isFree: boolean; + priceInfo: string | null; + allowSiblingDiscount: boolean; + paymentCashAllowed: boolean; + paymentInvoiceAllowed: boolean; + invoicingEnabled: boolean; + prerequisites: string | null; + whatToBring: string | null; + status: ContentStatus; + publishedAt: Date | null; +}; + +/** + * Die Kursfelder, die ohne Datenbank aus dem Export zu lesen sind. + * + * Nicht dabei und bewusst nicht übernommen: + * - `registrationClosedNotifiedAt` — ein Vermerk, wann die Übersichtsmail nach + * Anmeldeschluss verschickt wurde. Mitkopiert hielte er die Mail im + * Zielsystem für erledigt, obwohl sie dort nie lief. + * - `reviewNotes`, `reviewDate`, `reviewerId` — Prüfvermerke einer Freigabe, + * die in diesem Bestand nicht stattgefunden hat. + * - `createdAt`, `updatedAt` — der Kurs entsteht hier gerade neu. + * - `id`, `createdById` — gehören dem jeweiligen Bestand, nicht dem Inhalt. + */ +export function readCourseContent( + raw: Record, +): CourseContentImport { + // Ohne Startdatum ließe sich der Kurs nirgends einordnen; das Enddatum darf + // fehlen und ist dann der Starttag, wie schon vor dieser Änderung. + const startDate = readDate(raw.startDate) ?? new Date(); + const status = readEnum(raw.status, ContentStatus, ContentStatus.DRAFT); + + return { + title: readText(raw.title) ?? "ohne Titel", + motto: readText(raw.motto), + description: typeof raw.description === "string" ? raw.description : "", + courseType: readEnum(raw.courseType, CourseType, CourseType.OTHER), + targetAudience: readEnum(raw.targetAudience, TargetAudience, null), + startDate, + endDate: readDate(raw.endDate) ?? startDate, + registrationOpen: readBoolean(raw.registrationOpen, false), + registrationOpensAt: readDate(raw.registrationOpensAt), + registrationDeadline: readDate(raw.registrationDeadline), + externalProviderName: readText(raw.externalProviderName), + externalRegistrationUrl: readText(raw.externalRegistrationUrl), + maxParticipants: readInteger(raw.maxParticipants), + allowWaitingList: readBoolean(raw.allowWaitingList, false), + isFree: readBoolean(raw.isFree, false), + priceInfo: readText(raw.priceInfo), + allowSiblingDiscount: readBoolean(raw.allowSiblingDiscount, false), + paymentCashAllowed: readBoolean(raw.paymentCashAllowed, true), + paymentInvoiceAllowed: readBoolean(raw.paymentInvoiceAllowed, true), + invoicingEnabled: readBoolean(raw.invoicingEnabled, false), + prerequisites: readText(raw.prerequisites), + whatToBring: readText(raw.whatToBring), + status, + publishedAt: readPublishedAt( + raw.publishedAt, + status === ContentStatus.APPROVED, + ), + }; +} + +export type CourseDownPaymentImport = { + downPaymentMode: DownPaymentMode; + downPaymentAmount: number | null; + downPaymentRefundPolicy: DownPaymentRefundPolicy; + downPaymentRefundText: string | null; + /** Die Kategorien mit dem zum Modus passenden Anzahlungsbetrag. */ + priceOptions: CoursePriceOptionImport[]; + /** Warum die Anzahlung nicht übernommen wurde — `null`, wenn sie mitkam. */ + droppedReason: string | null; +}; + +/** + * Anzahlung eines importierten Kurses. + * + * Die Anzahlung hängt an der Kursnummer: Sie steht im Verwendungszweck der + * Überweisung. Kursnummern sind aber global eindeutig, und im Zielbestand kann + * dieselbe Nummer schon vergeben sein — dann kommt der Kurs ohne Nummer an. + * Statt eine Anzahlung anzulegen, deren Verwendungszweck ins Leere zeigt, wird + * sie in diesem Fall weggelassen und die Preiskategorien behalten nur ihren + * Preis. Geprüft wird mit denselben Hausregeln wie im Kursformular, damit aus + * einem Import keine Einstellung entsteht, die das Formular später ablehnt. + */ +export function readCourseDownPayment(args: { + raw: Record; + courseNumber: string | null; + isFree: boolean; + isExternal: boolean; + allowSiblingDiscount: boolean; + priceOptions: CoursePriceOptionImport[]; +}): CourseDownPaymentImport { + const { raw, courseNumber, isFree, isExternal, allowSiblingDiscount } = args; + + const config = normalizeDownPaymentConfig({ + downPaymentMode: readEnum( + raw.downPaymentMode, + DownPaymentMode, + DownPaymentMode.NONE, + ), + downPaymentAmount: readNumber(raw.downPaymentAmount), + downPaymentRefundPolicy: readEnum( + raw.downPaymentRefundPolicy, + DownPaymentRefundPolicy, + DownPaymentRefundPolicy.NON_REFUNDABLE, + ), + downPaymentRefundText: readText(raw.downPaymentRefundText), + }); + + const withAmounts = args.priceOptions.map((option) => ({ + ...option, + downPaymentAmount: priceOptionDownPaymentAmount( + config.downPaymentMode, + option.downPaymentAmount, + ), + })); + + const problem = validateDownPaymentSettings({ + ...config, + isFree, + isExternal, + courseNumber, + allowSiblingDiscount, + priceOptions: withAmounts, + }); + + if (problem) { + return { + ...normalizeDownPaymentConfig({ + downPaymentMode: DownPaymentMode.NONE, + downPaymentAmount: null, + downPaymentRefundPolicy: DownPaymentRefundPolicy.NON_REFUNDABLE, + downPaymentRefundText: null, + }), + priceOptions: args.priceOptions.map((option) => ({ + ...option, + downPaymentAmount: null, + })), + droppedReason: problem, + }; + } + + return { ...config, priceOptions: withAmounts, droppedReason: null }; +} diff --git a/src/server/utils/event-import.ts b/src/server/utils/event-import.ts new file mode 100644 index 00000000..9a0c1cbf --- /dev/null +++ b/src/server/utils/event-import.ts @@ -0,0 +1,148 @@ +import { + ContentStatus, + EventCategory, + EventEnsembleType, +} from "~/generated/prisma/enums"; +import { + readBoolean, + readDate, + readEnum, + readInteger, + readNumber, + readPublishedAt, + readText, +} from "./import-values"; + +/** + * Reine Leseregeln für einen Termin aus einem Export-ZIP — das Gegenstück zu + * `course-import.ts`. Was die Datenbank braucht (Bezirk, Standort, Chor, + * Auswahlchor, Bild, Slug, verknüpfte Dateien), setzt die Route davor. + */ + +export type EventPriceOptionImport = { + label: string; + price: number; + description: string | null; +}; + +/** Wie bei Kursen: ohne Bezeichnung und Preis ist eine Kategorie nichts wert. */ +export function readEventPriceOptions(raw: unknown): EventPriceOptionImport[] { + if (!Array.isArray(raw)) return []; + + return raw.flatMap((entry) => { + if (typeof entry !== "object" || entry === null) return []; + const option = entry as Record; + + const label = readText(option.label); + const price = readNumber(option.price); + if (!label || price === null) return []; + + return [{ label, price, description: readText(option.description) }]; + }); +} + +/** + * Verweis auf eine Datei, die am Termin hängt. + * + * Dateien sind eigene Einträge mit eigenem Export (`downloads`) und werden hier + * nicht mitgeliefert — ein Termin-ZIP soll nicht dieselbe Datei ein zweites Mal + * in den Bestand legen. Mit ausgegeben werden nur die Merkmale, an denen sich + * die vorhandene Datei im Zielbestand wiederfinden lässt. + */ +export type EventDownloadRef = { + downloadId: string | null; + title: string | null; + fileUrl: string | null; +}; + +export function readEventDownloadRefs(raw: unknown): EventDownloadRef[] { + if (!Array.isArray(raw)) return []; + + return raw.flatMap((entry) => { + if (typeof entry !== "object" || entry === null) return []; + const link = entry as Record; + + // Ältere Ausgaben kannten den Block nicht; neuere schreiben die flache + // Form. Die verschachtelte Form (`download: { … }`) kann aus einer von Hand + // zusammengestellten Datei kommen. + const nested = ( + typeof link.download === "object" && link.download !== null + ? link.download + : {} + ) as Record; + + const downloadId = readText(link.downloadId) ?? readText(nested.id); + const title = readText(link.title) ?? readText(nested.title); + const fileUrl = readText(link.fileUrl) ?? readText(nested.fileUrl); + if (!downloadId && !title && !fileUrl) return []; + + return [{ downloadId, title, fileUrl }]; + }); +} + +export type EventContentImport = { + title: string; + motto: string | null; + description: string | null; + eventDate: Date; + duration: number | null; + cancelled: boolean; + category: EventCategory; + districtName: string | null; + performingEnsembleType: EventEnsembleType | null; + performingEnsembleName: string | null; + leitung: string | null; + openToParticipants: boolean; + participationInfo: string | null; + isFree: boolean; + priceInfo: string | null; + status: ContentStatus; + publishedAt: Date | null; +}; + +/** + * Die Terminfelder, die ohne Datenbank aus dem Export zu lesen sind. + * + * Nicht übernommen werden — wie beim Kurs — `reviewNotes`, `reviewDate` und + * `reviewerId` (Prüfvermerke einer Freigabe, die hier nicht stattgefunden hat) + * sowie `id`, `createdById`, `createdAt` und `updatedAt`. + */ +export function readEventContent( + raw: Record, +): EventContentImport { + const status = readEnum(raw.status, ContentStatus, ContentStatus.DRAFT); + + return { + title: readText(raw.title) ?? "unbenannt", + motto: readText(raw.motto), + description: readText(raw.description), + eventDate: readDate(raw.eventDate) ?? new Date(), + duration: readInteger(raw.duration), + cancelled: readBoolean(raw.cancelled, false), + category: readEnum(raw.category, EventCategory, EventCategory.ANDERE), + districtName: readText(raw.districtName), + /** + * Der Export schreibt die Ensemble-Art seit jeher als + * `performingEnsembleType`; der Import las `ensembleType` und bekam deshalb + * nie einen Wert — die Art ging bei jedem Rundlauf verloren. Der alte Name + * wird weiter gelesen, damit von Hand geschriebene Dateien, die sich am + * bisherigen Importer orientiert haben, nicht plötzlich ausfallen. + */ + performingEnsembleType: readEnum( + raw.performingEnsembleType ?? raw.ensembleType, + EventEnsembleType, + null, + ), + performingEnsembleName: readText(raw.performingEnsembleName), + leitung: readText(raw.leitung), + openToParticipants: readBoolean(raw.openToParticipants, false), + participationInfo: readText(raw.participationInfo), + isFree: readBoolean(raw.isFree, true), + priceInfo: readText(raw.priceInfo), + status, + publishedAt: readPublishedAt( + raw.publishedAt, + status === ContentStatus.APPROVED, + ), + }; +} diff --git a/src/server/utils/import-references.ts b/src/server/utils/import-references.ts index e47f427e..0373caa2 100644 --- a/src/server/utils/import-references.ts +++ b/src/server/utils/import-references.ts @@ -15,7 +15,7 @@ import type { PrismaClient } from "~/generated/prisma/client"; */ type Db = Pick< PrismaClient, - "bezirk" | "ensemble" | "auswahlChor" | "location" | "user" + "bezirk" | "ensemble" | "auswahlChor" | "location" | "user" | "download" >; export type UnresolvedReference = { @@ -186,6 +186,50 @@ export function createReferenceResolver(db: Db) { }); }, + /** + * Dateien am Termin. Sie haben einen eigenen Export und werden hier nicht + * mitgeliefert, also wird die vorhandene Datei gesucht: über die id, sonst + * über den Ablageort (derselbe Pfad meint dieselbe Datei), sonst über den + * Titel. Findet sich nichts, bleibt die Verknüpfung weg und wird gemeldet — + * eine Datei aus dem Nichts anzulegen hieße, einen Eintrag ohne Datei + * dahinter zu erzeugen. + */ + async downloadId( + rawId: unknown, + fileUrl: unknown, + title: unknown, + subject: string, + ): Promise { + const id = text(rawId); + const url = text(fileUrl); + const name = text(title); + if (!id && !url && !name) return null; + + return memo(`download:${id ?? url ?? name}`, async () => { + if (id) { + const byId = await db.download.findUnique({ + where: { id }, + select: { id: true }, + }); + if (byId) return byId.id; + } + + const match = await db.download.findFirst({ + where: { + OR: [ + ...(url ? [{ fileUrl: url }] : []), + ...(name ? [{ title: name }] : []), + ], + }, + select: { id: true }, + }); + if (match) return match.id; + + record(subject, "downloadId", name ?? url ?? id ?? ""); + return null; + }); + }, + /** True when the target database already knows this Standort id. */ async knownLocationId(rawId: unknown): Promise { const id = text(rawId); @@ -200,6 +244,15 @@ export function createReferenceResolver(db: Db) { }); }, + /** + * Etwas, das der Zielbestand nicht übernehmen konnte und das in denselben + * Bericht gehört wie ein nicht auflösbarer Verweis — etwa eine Anzahlung, + * deren Kursnummer hier schon vergeben war. + */ + note(subject: string, field: string, value: string) { + record(subject, field, value); + }, + unresolved, }; } diff --git a/src/server/utils/import-values.ts b/src/server/utils/import-values.ts new file mode 100644 index 00000000..43c4876a --- /dev/null +++ b/src/server/utils/import-values.ts @@ -0,0 +1,94 @@ +/** + * Lesehilfen für Import-Nutzlasten. + * + * Ein ZIP ist ungeprüfte Eingabe: Es kann aus einer älteren Ausgabe stammen, + * von Hand geschrieben oder aus einer Tabelle zusammengesetzt sein. Jeder Wert + * wird deshalb auf seine Form geprüft, statt ihn blind an Prisma zu reichen — + * ein einziger falscher Typ ließe sonst den ganzen Import scheitern. + * + * Alles hier ist rein und ohne Datenbank, damit die Leseregeln einzeln + * prüfbar bleiben. + */ + +export function readText(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export function readBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +/** + * JSON-Zahlen, aber auch Zahlen in Anführungszeichen — Tabellenausgaben + * schreiben Preise gern als Zeichenkette. NaN und Unendlich fallen heraus: + * damit ließe sich später kein Betrag mehr ausrechnen. + */ +export function readNumber(value: unknown): number | null { + if (typeof value === "number") return Number.isFinite(value) ? value : null; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +/** Ganze Zahlen (Teilnehmerzahl, Altersgrenze, Reihenfolge, Dauer). */ +export function readInteger(value: unknown): number | null { + const parsed = readNumber(value); + return parsed === null ? null : Math.trunc(parsed); +} + +/** Datumsangaben stehen im Export als ISO-Zeichenkette. */ +export function readDate(value: unknown): Date | null { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value; + } + if (typeof value === "string" || typeof value === "number") { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + return null; +} + +/** + * Nur Werte, die das Schema kennt. Ein unbekannter Wert — aus einer älteren + * Ausgabe oder einem fremden System — fällt auf den Standard zurück, statt den + * Import an einem Prisma-Fehler abbrechen zu lassen. + */ +export function readEnum>( + value: unknown, + values: T, + fallback: T[keyof T], +): T[keyof T]; +export function readEnum>( + value: unknown, + values: T, + fallback: null, +): T[keyof T] | null; +export function readEnum>( + value: unknown, + values: T, + fallback: T[keyof T] | null, +): T[keyof T] | null { + const text = readText(value); + if (text && (Object.values(values) as string[]).includes(text)) { + return text as T[keyof T]; + } + return fallback; +} + +/** + * Der Veröffentlichungszeitpunkt hängt am Status: Die Router setzen ihn beim + * Freigeben und löschen ihn beim Zurückziehen. Ein Import, der ihn ungeprüft + * übernähme, könnte einen Entwurf mit Veröffentlichungsdatum erzeugen; einer, + * der ihn wegließe, einen freigegebenen Eintrag ohne — im Dashboard stünde + * dann nichts in der Spalte „Veröffentlicht“. Also gilt dieselbe Regel wie + * beim Freigeben, mit dem ausgegebenen Zeitpunkt, wo er mitkam. + */ +export function readPublishedAt( + raw: unknown, + isApproved: boolean, +): Date | null { + if (!isApproved) return null; + return readDate(raw) ?? new Date(); +} From 8132734d4cc963283954fa3801837efa2025407d Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Fri, 18 Sep 2026 00:46:57 +0200 Subject: [PATCH 120/139] fix(import): Beitraege behalten Slug, Verfasserin und Bildausschnitt Der Beitrags-Import schrieb nur Titel, Auszug, Inhalt, Rubrik, Bezirk, Anheftung, Status und Titelbild zurueck. Verloren gingen der Slug (jeder Beitrag bekam eine neue Adresse), das Veroeffentlichungsdatum, der frei geschriebene Name der verfassenden Person und der Bildausschnitt -- das Titelbild landete danach anders im Rahmen. Wie bei Kurs und Termin: Slug wird uebernommen, solange frei, sonst neu aus dem Titel; publishedAt folgt dem Status; Pruefvermerke bleiben draussen. Der Export gibt jetzt die Adresse der verfassenden Person mit, damit der Import dieselbe Person im Zielbestand wiederfindet -- sonst traegt der Beitrag weiter ihren Namen als Text. Gemessen im Rundlauf: Slug, Auszug, Rubrik, Anheftung, Status, Name, Bildausschnitt und Veroeffentlichungsdatum kommen zurueck, der Pruefvermerk nicht. Zweiter Import desselben ZIP: neuer Slug statt Abbruch. --- src/app/api/export/[type]/route.ts | 10 ++ src/app/api/import/[type]/route.ts | 25 +++-- src/server/api/helpers/content-slug.ts | 11 ++ .../utils/__tests__/post-import.test.ts | 106 ++++++++++++++++++ src/server/utils/post-import.ts | 74 ++++++++++++ 5 files changed, 216 insertions(+), 10 deletions(-) create mode 100644 src/server/utils/__tests__/post-import.test.ts create mode 100644 src/server/utils/post-import.ts diff --git a/src/app/api/export/[type]/route.ts b/src/app/api/export/[type]/route.ts index e532391c..405bc055 100644 --- a/src/app/api/export/[type]/route.ts +++ b/src/app/api/export/[type]/route.ts @@ -84,6 +84,15 @@ export async function GET( include: { coverImage: true, bezirk: true, + // Konten reisen nicht mit; die Adresse ist das Einzige, woran der + // Import dieselbe Person im Zielbestand wiedererkennt. + author: { + select: { + id: true, + displayName: true, + email: true, + }, + }, createdBy: { select: { id: true, @@ -108,6 +117,7 @@ export async function GET( jsonData = { posts: posts.map((post) => ({ ...post, + authorEmail: post.author?.email, coverImageUrl: post.coverImage?.url, bezirkName: post.bezirk?.name, createdByEmail: post.createdBy?.email, diff --git a/src/app/api/import/[type]/route.ts b/src/app/api/import/[type]/route.ts index e6a2ad1f..de38b3e4 100644 --- a/src/app/api/import/[type]/route.ts +++ b/src/app/api/import/[type]/route.ts @@ -31,15 +31,16 @@ import { readEventPriceOptions, } from "@/server/utils/event-import"; import { readText } from "@/server/utils/import-values"; +import { readPostContent } from "@/server/utils/post-import"; import { importCourseSlug, importEventSlug, + importPostSlug, } from "@/server/api/helpers/content-slug"; import { normalizeCourseNumber } from "@/lib/invoice-document"; import { formatPhoneNumberOrNull } from "@/lib/phone-number"; import { ContentStatus, - PostCategory, DownloadCategory, FileType, HistoryCategory, @@ -233,22 +234,26 @@ export async function POST( ? mediaIdMap[coverImageId] || coverImageId : null; + const content = readPostContent(postData); + results.push( await db.post.create({ data: { - title: postData.title as string, - excerpt: (postData.excerpt as string) || null, - content: postData.content as string, - category: postData.category as string as PostCategory, + ...content, + slug: await importPostSlug(db, content.title, postData.slug), bezirkId: await references.bezirkId( postData.bezirkId, postData.bezirk, - (postData.title as string) || "ohne Titel", + content.title, + ), + // Die verfasste Person wird über ihre Adresse gesucht; fehlt + // sie im Zielbestand, trägt der Beitrag weiter ihren Namen. + authorId: await references.userId( + postData.authorId, + postData.authorEmail, + content.title, + "authorId", ), - pinned: (postData.pinned as boolean) || false, - status: - (postData.status as string as ContentStatus) || - ContentStatus.DRAFT, coverImageId: newCoverImageId, createdById: session.user.id, }, diff --git a/src/server/api/helpers/content-slug.ts b/src/server/api/helpers/content-slug.ts index d374aee1..317de926 100644 --- a/src/server/api/helpers/content-slug.ts +++ b/src/server/api/helpers/content-slug.ts @@ -188,6 +188,17 @@ async function importedSlug( return uniqueSlug(base, isTaken, fallback); } +export async function importPostSlug( + db: Db, + title: string, + requested: unknown, +): Promise { + const isTaken: IsTaken = async (candidate) => + (await db.post.count({ where: { slug: candidate } })) > 0; + + return importedSlug(requested, slugify(title), "beitrag", isTaken); +} + export async function importEventSlug( db: Db, title: string, diff --git a/src/server/utils/__tests__/post-import.test.ts b/src/server/utils/__tests__/post-import.test.ts new file mode 100644 index 00000000..9a3ad402 --- /dev/null +++ b/src/server/utils/__tests__/post-import.test.ts @@ -0,0 +1,106 @@ +import { readPostContent } from "../post-import"; + +/** + * Der Beitrags-Import schrieb bisher nur einen Teil der Felder zurück. Diese + * Tests halten fest, was aus einem Export übernommen wird — und was bewusst + * im Zielbestand neu entsteht. + */ +describe("readPostContent", () => { + const vollstaendig = { + id: "alte-id", + slug: "ein-beitrag", + title: "Ein Beitrag", + excerpt: "Kurzfassung", + content: "# Überschrift\n\nText", + category: "MAGAZIN", + pinned: true, + authorName: "A. Bläser", + authorId: "fremde-id", + coverImagePositionX: 25.5, + coverImagePositionY: 80, + status: "APPROVED", + publishedAt: "2026-03-01T10:00:00.000Z", + reviewNotes: "geprüft von X", + reviewDate: "2026-02-28T10:00:00.000Z", + reviewerId: "pruefer-id", + createdById: "ersteller-id", + createdAt: "2026-01-01T00:00:00.000Z", + }; + + it("übernimmt die Inhaltsfelder", () => { + const gelesen = readPostContent(vollstaendig); + + expect(gelesen).toMatchObject({ + title: "Ein Beitrag", + excerpt: "Kurzfassung", + content: "# Überschrift\n\nText", + category: "MAGAZIN", + pinned: true, + authorName: "A. Bläser", + coverImagePositionX: 25.5, + coverImagePositionY: 80, + status: "APPROVED", + }); + expect(gelesen.publishedAt).toEqual(new Date("2026-03-01T10:00:00.000Z")); + }); + + it("übernimmt keine Prüfvermerke und keine Identität", () => { + const gelesen = readPostContent(vollstaendig) as Record; + + for (const feld of [ + "id", + "slug", + "reviewNotes", + "reviewDate", + "reviewerId", + "createdById", + "createdAt", + "authorId", + ]) { + expect(gelesen[feld]).toBeUndefined(); + } + }); + + it("lässt das Veröffentlichungsdatum dem Status folgen", () => { + expect( + readPostContent({ ...vollstaendig, status: "DRAFT" }).publishedAt, + ).toBeNull(); + + const ohneDatum = readPostContent({ + ...vollstaendig, + publishedAt: undefined, + }); + expect(ohneDatum.publishedAt).toBeInstanceOf(Date); + }); + + it("verwirft einen Bildausschnitt außerhalb von 0 bis 100", () => { + const gelesen = readPostContent({ + ...vollstaendig, + coverImagePositionX: 140, + coverImagePositionY: -3, + }); + + expect(gelesen.coverImagePositionX).toBeNull(); + expect(gelesen.coverImagePositionY).toBeNull(); + }); + + it("fällt bei unbekannten Werten auf Standardwerte zurück, statt zu scheitern", () => { + const gelesen = readPostContent({ + title: " ", + category: "GIBT-ES-NICHT", + status: "IRGENDWAS", + pinned: "ja", + }); + + expect(gelesen).toMatchObject({ + title: "unbenannt", + content: "", + category: "ANDERE", + status: "DRAFT", + pinned: false, + excerpt: null, + authorName: null, + }); + expect(gelesen.publishedAt).toBeNull(); + }); +}); diff --git a/src/server/utils/post-import.ts b/src/server/utils/post-import.ts new file mode 100644 index 00000000..89138261 --- /dev/null +++ b/src/server/utils/post-import.ts @@ -0,0 +1,74 @@ +import { ContentStatus, PostCategory } from "~/generated/prisma/enums"; +import { + readBoolean, + readEnum, + readNumber, + readPublishedAt, + readText, +} from "./import-values"; + +/** + * Reine Leseregeln für einen Beitrag aus einem Export-ZIP — das Gegenstück zu + * `course-import.ts` und `event-import.ts`. Was die Datenbank braucht (Bezirk, + * Titelbild, Slug, verfasste Person), setzt die Route davor. + */ + +export type PostContentImport = { + title: string; + excerpt: string | null; + content: string; + category: PostCategory; + pinned: boolean; + authorName: string | null; + coverImagePositionX: number | null; + coverImagePositionY: number | null; + status: ContentStatus; + publishedAt: Date | null; +}; + +/** + * Die Beitragsfelder, die ohne Datenbank aus dem Export zu lesen sind. + * + * Bisher schrieb der Import nur Titel, Auszug, Inhalt, Rubrik, Bezirk, + * Anheftung, Status und Titelbild zurück. Verloren gingen der frei + * geschriebene Name der verfassenden Person und der Bildausschnitt — ein + * Titelbild landete nach dem Wiedereinspielen also anders im Rahmen als + * vorher. + * + * Nicht übernommen werden — wie bei Kurs und Termin — `reviewNotes`, + * `reviewDate` und `reviewerId` (Prüfvermerke einer Freigabe, die hier nicht + * stattgefunden hat) sowie `id`, `createdById`, `createdAt` und `updatedAt`. + */ +export function readPostContent( + raw: Record, +): PostContentImport { + const status = readEnum(raw.status, ContentStatus, ContentStatus.DRAFT); + + return { + title: readText(raw.title) ?? "unbenannt", + excerpt: readText(raw.excerpt), + content: readText(raw.content) ?? "", + category: readEnum(raw.category, PostCategory, PostCategory.ANDERE), + pinned: readBoolean(raw.pinned, false), + authorName: readText(raw.authorName), + /** + * Fokuspunkt des Titelbilds in Prozent. Außerhalb von 0–100 ist er + * sinnlos, dann rahmt die Anzeige lieber mittig wie ohne Angabe. + */ + coverImagePositionX: readPercent(raw.coverImagePositionX), + coverImagePositionY: readPercent(raw.coverImagePositionY), + status, + /** + * Wie bei Kurs und Termin: Das Veröffentlichungsdatum folgt dem Status, + * statt blind kopiert zu werden — sonst entstünde ein Entwurf mit + * Veröffentlichungsdatum oder ein freigegebener Beitrag ohne. + */ + publishedAt: readPublishedAt(raw.publishedAt, status === "APPROVED"), + }; +} + +function readPercent(value: unknown): number | null { + const zahl = readNumber(value); + if (zahl === null || zahl < 0 || zahl > 100) return null; + return zahl; +} From d4dd2efeda31067e08122ab80fb90b30396eb8c1 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Fri, 18 Sep 2026 00:49:43 +0200 Subject: [PATCH 121/139] fix(kalender): Ausgewaehlter Tag im Nachtdruck lesbar Im mobilen Kalender stand die Schriftfarbe als Grundwert an der Schaltflaeche und noch einmal im ausgewaehlten Zweig. Beide sind gleich spezifisch, es gilt also die Reihenfolge im Stylesheet -- und dort gewann dark:text-night-text. Der ausgewaehlte Tag zeigte im Nachtdruck helle Ziffern auf heller Flaeche. Die Farbe steht jetzt in jedem Zweig statt davor. Gemessen in Chromium und WebKit: Nacht #141517 auf #ecebe8, hell weiss auf #1c1d1f. Die Desktop-Fassung war nicht betroffen, dort schliessen sich die Zweige aus. --- .../events/calendar/calendar-view.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/app/_components/events/calendar/calendar-view.tsx b/src/app/_components/events/calendar/calendar-view.tsx index 5776a40a..96756503 100644 --- a/src/app/_components/events/calendar/calendar-view.tsx +++ b/src/app/_components/events/calendar/calendar-view.tsx @@ -271,14 +271,22 @@ export default function CalendarView({ items }: CalendarViewProps) { ), ) } - className={`text-ink dark:text-night-text relative flex aspect-square flex-col items-center justify-center transition-colors ${ + /* + * Die Schriftfarbe steht in jedem Zweig, nicht als Grundwert + * davor: `cn` und die Klassenliste entscheiden nichts, es + * gilt die Reihenfolge im Stylesheet. Ein vorangestelltes + * `dark:text-night-text` gewann deshalb gegen das + * `dark:text-night` des ausgewählten Tages — im Nachtdruck + * stand die helle Ziffer auf der hellen Fläche. + */ + className={`relative flex aspect-square flex-col items-center justify-center transition-colors ${ selected ? "bg-ink text-paper dark:bg-night-text dark:text-night font-bold" : today - ? "bg-ink/[0.06] dark:bg-night-text/[0.08] font-bold" + ? "text-ink dark:text-night-text bg-ink/[0.06] dark:bg-night-text/[0.08] font-bold" : courseStatus - ? "bg-rule/20 dark:bg-night-rule/20" - : "hover:bg-rule/20 dark:hover:bg-night-rule/20" + ? "text-ink dark:text-night-text bg-rule/20 dark:bg-night-rule/20" + : "text-ink dark:text-night-text hover:bg-rule/20 dark:hover:bg-night-rule/20" }`} > {/* Cancelled Indicator oben links */} From 74a081722494b6eb1955d24ead9e541a360614a2 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Fri, 18 Sep 2026 02:15:48 +0200 Subject: [PATCH 122/139] fix(anmeldung): Aufteilung wirkt auch im oeffentlichen Formular Wer "freie Plaetze jetzt nutzen" waehlte, bekam trotzdem die ganze Gruppe auf die Warteliste: Die Auswahl wurde berechnet, aber nur in den Aufruf des Teams gespreizt -- der oeffentliche Aufruf liess sie weg. Der Server kann es laengst, die oeffentliche Mutation kennt das Feld. Das ist genau die Funktion aus #305. Gemessen ueber das Formular: 1 freier Platz, 2 Personen, Auswahl auf Person eins -> zwei verknuepfte Anmeldungen mit derselben registrationGroupId, eine CONFIRMED mit 1 Teilnehmer, eine WAITLIST mit 1. Vorher: eine einzige WAITLIST-Anmeldung mit beiden Personen und ohne Gruppen-id. Dazu: Ohne Preiskategorie tat "Hinzufuegen" in Schritt 2 gar nichts, die Begruendung stand nur in der Entwicklerkonsole und "Weiter" blieb gesperrt. Jetzt steht dort, dass eine Anmeldung ohne hinterlegte Preiskategorien nicht moeglich ist. Betroffen sind im Bestand fuenf freigegebene Kurse. Ausserdem die fehlende Jest-Typenzeile im Beitrags-Test nachgezogen; ohne sie meldete tsc 15 Fehler. --- .../events/course-registration-form.tsx | 6 +++++- .../step-2-participants.tsx | 19 +++++++++++++++++++ .../utils/__tests__/post-import.test.ts | 1 + 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/app/_components/events/course-registration-form.tsx b/src/app/_components/events/course-registration-form.tsx index 605a7515..8a54b87b 100644 --- a/src/app/_components/events/course-registration-form.tsx +++ b/src/app/_components/events/course-registration-form.tsx @@ -551,8 +551,12 @@ export default function CourseRegistrationForm({ return; } + // `splitPayload` gehört auch hierher: Ohne es verwarf der öffentliche + // Weg die Auswahl stillschweigend — die Anmeldenden hatten „freie Plätze + // nutzen" gewählt und bekamen trotzdem die ganze Gruppe auf die + // Warteliste. Nur der Team-Weg reichte die Auswahl weiter. registrationMutation.mutate( - { ...payload, downPaymentAcknowledged }, + { ...payload, ...splitPayload, downPaymentAcknowledged }, handlers, ); }; diff --git a/src/app/_components/events/course-registration-form/step-2-participants.tsx b/src/app/_components/events/course-registration-form/step-2-participants.tsx index 17bf1934..1d6b675f 100644 --- a/src/app/_components/events/course-registration-form/step-2-participants.tsx +++ b/src/app/_components/events/course-registration-form/step-2-participants.tsx @@ -394,6 +394,25 @@ export function Step2Participants({ ); + /* + * Ohne Preiskategorie kann sich niemand anmelden: Der Server verlangt für + * jede Person eine Kategorie (`priceOptionId`). Bisher tat „Hinzufügen“ in + * diesem Fall schlicht nichts — die Begründung stand nur in der + * Entwicklerkonsole, „Weiter“ blieb gesperrt, und niemand erfuhr, warum. + * Betroffen sind im Bestand fünf freigegebene Kurse. + */ + if (!course.priceOptions || course.priceOptions.length === 0) { + return ( + +

        + Für diesen Kurs sind noch keine Preiskategorien hinterlegt, und ohne + sie lässt sich niemand anmelden. Bitte wenden Sie sich an das + Posaunenwerk. +

        +
        + ); + } + return (
        {/* Actions live in the header, like the edit page. They used to sit in a diff --git a/src/server/utils/__tests__/post-import.test.ts b/src/server/utils/__tests__/post-import.test.ts index 9a3ad402..98b8650b 100644 --- a/src/server/utils/__tests__/post-import.test.ts +++ b/src/server/utils/__tests__/post-import.test.ts @@ -1,3 +1,4 @@ +import { describe, expect, it } from "@jest/globals"; import { readPostContent } from "../post-import"; /** From 96014db89e57e47a99fe9ba4983ca32429916c99 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Fri, 18 Sep 2026 05:39:03 +0200 Subject: [PATCH 123/139] fix(anmeldungen): Bearbeiten laesst die Warteliste nachruecken Streicht jemand Teilnehmende aus einer bestaetigten Anmeldung, wird ein Platz frei -- aber niemand rueckte nach. Stornieren, Statuswechsel und Loeschen rufen promoteFromWaitlist auf, updateMyRegistration nicht. Der Cron half nicht, er sieht nur abgelaufene Angebote. Jetzt wie dort: nach dem Speichern wird nachgerueckt. Das deckt auch den Fall, dass eine wartende Anmeldung sich so verkleinert, dass sie passt. Gemessen mit leerem SMTP: Kurs mit 2 Plaetzen, A bestaetigt mit 2 Personen, B wartet mit 1. A streicht eine Person -> B ist sofort bestaetigt. Vorher blieb B auf der Warteliste. --- src/server/api/routers/registrations.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/server/api/routers/registrations.ts b/src/server/api/routers/registrations.ts index bd31768a..c224b587 100644 --- a/src/server/api/routers/registrations.ts +++ b/src/server/api/routers/registrations.ts @@ -2023,6 +2023,16 @@ export const registrationsRouter = createTRPCRouter({ }); }); + // Bearbeiten kann Plätze freigeben (weniger Teilnehmende, eine andere + // Preiskategorie) oder eine wartende Anmeldung so verkleinern, dass sie + // jetzt passt. Beides schiebt die Warteliste weiter, wie Stornieren, + // Statuswechsel und Löschen es tun. Ohne diesen Aufruf blieb ein frei + // gewordener Platz leer: gemessen 2 → 1 Teilnehmer im vollen Kurs, die + // Wartende stand weiter auf der Liste. Der Cron hilft dabei nicht — er + // sieht nur abgelaufene Angebote. + const promoted = await promoteFromWaitlist(ctx.db, registration.courseId); + await sendPromotionEmails(promoted); + return updatedRegistration; }), From db315c5d835d8bb694687fd961434f3c593c3aa8 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Fri, 18 Sep 2026 05:44:50 +0200 Subject: [PATCH 124/139] revert: Bearbeiten laesst die Warteliste wieder nicht automatisch nachruecken Nimmt #338 zurueck. Automatisches Nachruecken beim Bearbeiten verschenkt Plaetze, die nur kurz frei sind: Loescht jemand versehentlich eine Person aus seiner Anmeldung, ist der Platz weg, bevor er es merkt. Und wenn das Team Anmeldungen umbaut -- etwa zwei getrennte Anmeldungen zu einer zusammenfuehrt, damit ein Geschwisterrabatt greift --, entsteht dabei zwischendurch ein freier Platz, den die Warteliste sofort bekaeme. Entscheidung des Eigentuemers. Wie mit den uebrigen automatischen Stellen (Stornieren, Statuswechsel, Loeschen) umgegangen wird, ist noch offen. --- src/server/api/routers/registrations.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/server/api/routers/registrations.ts b/src/server/api/routers/registrations.ts index c224b587..bd31768a 100644 --- a/src/server/api/routers/registrations.ts +++ b/src/server/api/routers/registrations.ts @@ -2023,16 +2023,6 @@ export const registrationsRouter = createTRPCRouter({ }); }); - // Bearbeiten kann Plätze freigeben (weniger Teilnehmende, eine andere - // Preiskategorie) oder eine wartende Anmeldung so verkleinern, dass sie - // jetzt passt. Beides schiebt die Warteliste weiter, wie Stornieren, - // Statuswechsel und Löschen es tun. Ohne diesen Aufruf blieb ein frei - // gewordener Platz leer: gemessen 2 → 1 Teilnehmer im vollen Kurs, die - // Wartende stand weiter auf der Liste. Der Cron hilft dabei nicht — er - // sieht nur abgelaufene Angebote. - const promoted = await promoteFromWaitlist(ctx.db, registration.courseId); - await sendPromotionEmails(promoted); - return updatedRegistration; }), From abeb617db4016ca5f608d65b570904d3bb1bf444 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Fri, 18 Sep 2026 08:52:42 +0200 Subject: [PATCH 125/139] feat(warteliste): Nachruecken nur noch von Hand, Wartende haben Vorrang Entscheidung des Eigentuemers: Es rueckt nirgends mehr automatisch nach -- nicht beim Stornieren, Statuswechsel, Loeschen, Bearbeiten, nach einem angenommenen oder abgelehnten Angebot, und nicht im Cron. Versehentlich gestrichene Personen und Umbauten durch das Team (zwei Anmeldungen zusammenfuehren, damit ein Geschwisterrabatt greift) erzeugen Plaetze, die nur kurz frei sind; die Automatik hat sie sofort vergeben. - Der Cron schliesst abgelaufene Angebote nur noch (closeExpiredPromotionOffers) und erinnert das Kursteam wie bisher. - Neue Mutation registrations.promoteWaitlist und Abfrage getWaitlistOverview; Rechte wie updateStatus, Audit-Eintrag. - Das Nachruecken ist als reine Funktion planPromotion formuliert und nennt den Grund, wenn niemand nachruecken konnte -- auch den Fall, dass die erste Wartende in keinen freien Platz passt und die Schlange haelt. - Dashboard: Panel auf der Teilnehmendenseite mit freien Plaetzen, Wartenden, Knopf mit Rueckfrage und Ergebnis; bei laufendem Angebot stattdessen dessen Frist. Vorrang: Neue Anmeldungen bekommen nur Plaetze, die keine Wartende nutzen koennte (seatsLeftForNewRegistrations). Die Wartenden reservieren der Reihe nach, was sie nutzen koennten, ohne an der ersten nicht passenden anzuhalten -- wer auf ein volles Einzelzimmer wartet, blockiert keine Doppelzimmer. Gilt fuer oeffentliche Anmeldungen samt Aufteilen, fuer "automatisch" vom Team erfasste, fuer die oeffentliche Platzanzeige und fuer Aenderungen der Anmeldenden. Das Team darf reservierte Plaetze bewusst vergeben. Mails und Angebotskarte versprechen nicht mehr, dass Plaetze an die Naechsten gehen, sondern dass das Kursteam sie anbieten kann. Geprueft: 72 Rundlauf-Pruefungen ueber direkte Aufrufe mit leerem SMTP, 30 neue Unit-Tests, Browser in Chromium und WebKit. --- .../dashboard/waitlist-promotion-panel.tsx | 307 ++++++++++++++ .../events/promotion-offer-card.tsx | 12 +- src/app/api/cron/waitlist-offers/route.ts | 10 +- .../courses/[id]/participants/new/page.tsx | 43 +- .../courses/[id]/participants/page.tsx | 13 + src/app/registrations/[id]/edit/page.tsx | 33 +- src/lib/__tests__/waitlist-offer.test.ts | 240 +++++++++++ src/lib/__tests__/waitlist-priority.test.ts | 179 ++++++++ src/lib/waitlist-offer.ts | 196 ++++++++- src/lib/waitlist-priority.ts | 113 +++++ src/server/api/helpers/course-capacity.ts | 168 +++++++- src/server/api/helpers/waitlist-offer.ts | 24 +- src/server/api/helpers/waitlist-promotion.ts | 401 +++++++++++------- src/server/api/routers/courses.ts | 67 +-- src/server/api/routers/registrations.ts | 361 +++++++++++++--- .../templates/waitlist-promotion-offer.tsx | 18 +- src/server/jobs/waitlist-promotion-offers.ts | 25 +- 17 files changed, 1904 insertions(+), 306 deletions(-) create mode 100644 src/app/_components/dashboard/waitlist-promotion-panel.tsx create mode 100644 src/lib/__tests__/waitlist-priority.test.ts create mode 100644 src/lib/waitlist-priority.ts diff --git a/src/app/_components/dashboard/waitlist-promotion-panel.tsx b/src/app/_components/dashboard/waitlist-promotion-panel.tsx new file mode 100644 index 00000000..14e22374 --- /dev/null +++ b/src/app/_components/dashboard/waitlist-promotion-panel.tsx @@ -0,0 +1,307 @@ +"use client"; + +import { useEffect, useId, useRef, useState, type Ref } from "react"; +import Link from "next/link"; +import { api, type RouterOutputs } from "@/trpc/react"; +import { Note } from "@/app/_components/programmheft/note"; +import { + ScrollableModal, + ScrollableModalBody, + ScrollableModalCard, + ScrollableModalFooter, +} from "@/app/_components/ui/scrollable-modal"; +import { PROMOTION_OFFER_DAYS, promotionHaltText } from "@/lib/waitlist-offer"; + +type PromotionSummary = RouterOutputs["registrations"]["promoteWaitlist"]; + +/** Knöpfe wie in der Kopfzeile der Teilnehmerseite. */ +const BTN_PRIMARY = + "bg-ink text-paper hover:bg-primary hover:text-ink dark:bg-night-text dark:text-night dark:hover:bg-primary dark:hover:text-ink inline-flex min-h-11 items-center justify-center gap-2 px-4 py-2 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50"; +const BTN_OUTLINE = + "border-rule dark:border-night-rule text-ink dark:text-night-text hover:bg-rule/25 dark:hover:bg-night-raised inline-flex min-h-11 items-center justify-center gap-2 border px-4 py-2 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50"; + +const formatDateTime = (date: Date) => + `${new Date(date).toLocaleString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + })} Uhr`; + +function seatsLabel(free: number): string { + if (!Number.isFinite(free)) return "Plätze frei (unbegrenzt)"; + if (free <= 0) return "Kein Platz frei"; + return free === 1 ? "1 Platz frei" : `${free} Plätze frei`; +} + +const registrationsLabel = (count: number) => + count === 1 ? "1 Anmeldung wartet" : `${count} Anmeldungen warten`; + +const participantsLabel = (count: number) => + `${count} ${count === 1 ? "Teilnehmer" : "Teilnehmer"}`; + +/** + * Was mit den freien Plätzen bis zum Knopfdruck geschieht: Die Wartenden + * reservieren, was sie nutzen könnten, neue Anmeldungen bekommen den Rest. + */ +function reservationText(free: number, openToNew: number): string { + const manual = "Automatisch rückt niemand nach – das löst du hier aus."; + if (!Number.isFinite(free) || free <= 0) return manual; + const reserved = free - openToNew; + if (reserved <= 0) { + return `${manual} Die Wartenden können keinen der freien Plätze nutzen; sie stehen neuen Anmeldungen offen.`; + } + return `${manual} ${reserved === 1 ? "1 freier Platz bleibt" : `${reserved} freie Plätze bleiben`} bis dahin den Wartenden vorbehalten, ${openToNew === 0 ? "neue Anmeldungen kommen auf die Warteliste" : `neue Anmeldungen bekommen nur ${openToNew === 1 ? "den übrigen" : `die übrigen ${openToNew}`}`}.`; +} + +/** + * Warteliste auf der Teilnehmerseite eines Kurses: wie viele Plätze frei sind, + * wer wartet, und der Knopf, der sie nachrücken lässt. + * + * Seit dem 18.09.2026 rückt niemand mehr automatisch nach — frei werdende + * Plätze sind oft nur ein Zwischenstand des Teams (versehentlich gestrichen, + * zwei Anmeldungen zusammengeführt). Bis zum Knopfdruck bleiben den Wartenden + * die Plätze vorbehalten, die sie nutzen könnten; neue Anmeldungen bekommen + * nur die übrigen (`@/lib/waitlist-priority`). Das Ergebnis bleibt stehen, + * bis erneut nachgerückt wird, und sagt auch, warum niemand nachrücken + * konnte — sonst bliebe etwa eine blockierte Warteliste unsichtbar. + */ +export function WaitlistPromotionPanel({ + courseId, + onPromoted, +}: { + courseId: string; + /** Nach dem Nachrücken, etwa um die Teilnehmerliste neu zu laden. */ + onPromoted?: () => void; +}) { + const utils = api.useUtils(); + // Immer frisch beim Öffnen der Seite: Statuswechsel auf der Detailseite + // ändern freie Plätze und Warteliste, ohne diese Abfrage zu kennen. + const { data: overview } = api.registrations.getWaitlistOverview.useQuery( + { courseId }, + { retry: false, staleTime: 0 }, + ); + const [confirming, setConfirming] = useState(false); + const [result, setResult] = useState(null); + const triggerRef = useRef(null); + const resultRef = useRef(null); + const dialogRef = useRef(null); + const titleId = useId(); + const dialogTitleId = useId(); + + const promote = api.registrations.promoteWaitlist.useMutation({ + onSuccess: (summary) => { + setResult(summary); + setConfirming(false); + void utils.registrations.getWaitlistOverview.invalidate({ courseId }); + void utils.courses.getAvailableSlots.invalidate(); + onPromoted?.(); + }, + }); + + // Der Dialog nimmt den Fokus auf, damit Screenreader Titel und Erklärung + // vorlesen; danach geht er ans Ergebnis — der Knopf kann dann fehlen, etwa + // wenn jetzt ein Angebot läuft. + useEffect(() => { + if (confirming) dialogRef.current?.focus(); + }, [confirming]); + useEffect(() => { + if (result) resultRef.current?.focus(); + }, [result]); + + if (!overview?.allowWaitingList) return null; + if (overview.waitingRegistrations === 0 && !result) return null; + + const free = overview.seats.availableSlots; + const offer = overview.runningOffer; + + const closeDialog = () => { + if (promote.isPending) return; + setConfirming(false); + promote.reset(); + triggerRef.current?.focus(); + }; + + return ( +
        + +

        + Warteliste +

        +

        + {seatsLabel(free)} ·{" "} + {overview.waitingRegistrations === 0 + ? "Niemand wartet" + : `${registrationsLabel(overview.waitingRegistrations)} (${participantsLabel(overview.waitingParticipants)})`} +

        +

        + {reservationText( + free, + overview.seatsForNewRegistrations.availableSlots, + )} +

        + +
        + {offer ? ( +

        + Angebot an {offer.registrantName} läuft bis{" "} + + . Bis dahin hält die Warteliste an.{" "} + + Zur Anmeldung + +

        + ) : overview.courseStarted ? ( +

        + Der Kurs hat begonnen – von der Warteliste wird nicht mehr + nachgerückt. +

        + ) : overview.waitingRegistrations > 0 && free > 0 ? ( + + ) : overview.waitingRegistrations > 0 ? ( +

        + Sobald Plätze frei werden, kannst du die Warteliste hier + nachrücken lassen. +

        + ) : null} +
        + + {result && } +
        + + {confirming && ( + + +
        + +

        + Warteliste nachrücken lassen? +

        +

        + {seatsLabel(free)},{" "} + {registrationsLabel(overview.waitingRegistrations)}. Die + Warteliste wird streng in der Reihenfolge der Anmeldung + durchgegangen: +

        +
          +
        • + Passt eine Anmeldung ganz in die freien Plätze, wird sie + bestätigt und bekommt eine Bestätigungsmail. +
        • +
        • + Passt sie nur teilweise, bekommt sie ein Angebot: Die + Anmeldenden wählen innerhalb von {PROMOTION_OFFER_DAYS}{" "} + Tagen (höchstens bis Kursbeginn), wer nachrückt. Bis dahin + hält die Warteliste an. +
        • +
        • + Passt von ihr niemand, etwa weil ihre Preiskategorie voll + ist, bleibt alles stehen – auch die Anmeldungen dahinter. +
        • +
        + {promote.error && ( + +

        {promote.error.message}

        +
        + )} +
        + +
        + + +
        +
        +
        +
        +
        + )} +
        + ); +} + +/** Was der letzte Durchgang bewirkt hat — und warum er dort aufhörte. */ +function PromotionResult({ + result, + ref, +}: { + result: PromotionSummary; + ref: Ref; +}) { + const anyoneMoved = result.promoted.length + result.offered.length > 0; + const reason = promotionHaltText(result.halt, anyoneMoved, formatDateTime); + + return ( +
        +

        + {anyoneMoved ? "Nachgerückt" : "Niemand ist nachgerückt"} +

        + {anyoneMoved && ( +
          + {result.promoted.map((r) => ( +
        • + {r.registrantName} + {` (${participantsLabel(r.participants)}) ist bestätigt und bekommt eine Bestätigungsmail.`} +
        • + ))} + {result.offered.map((r) => ( +
        • + {r.registrantName} + {` bekommt ein Angebot: ${r.seats} von ${r.participants} Teilnehmern ${r.seats === 1 ? "kann" : "können"} nachrücken, Antwort bis ${formatDateTime(r.expiresAt)}. Die Anmeldenden bekommen dazu eine Mail.`} +
        • + ))} +
        + )} + {result.expired.length > 0 && ( +

        + Abgelaufen und geschlossen:{" "} + {result.expired.map((r) => r.registrantName).join(", ")}. +

        + )} + {reason &&

        {reason}

        } +
        + ); +} diff --git a/src/app/_components/events/promotion-offer-card.tsx b/src/app/_components/events/promotion-offer-card.tsx index 45976b5b..b74415a9 100644 --- a/src/app/_components/events/promotion-offer-card.tsx +++ b/src/app/_components/events/promotion-offer-card.tsx @@ -49,8 +49,9 @@ interface PromotionOfferCardProps { /** * Nachrück-Angebot auf der Detailseite: Es sind Plätze frei, aber nicht für - * alle. Die Anmeldenden wählen, wer nachrückt — oder lehnen ab, dann gehen - * die Plätze an die Nächsten und die Anmeldung behält ihren Platz. + * alle. Die Anmeldenden wählen, wer nachrückt — oder lehnen ab, dann behält + * die Anmeldung ihren Platz, und das Kursteam kann die Plätze den Nächsten + * anbieten (automatisch rückt seit dem 18.09.2026 niemand mehr nach). */ export function PromotionOfferCard({ registrationId, @@ -115,7 +116,8 @@ export function PromotionOfferCard({ Bis {formatDateTime(offer.expiresAt)} Uhr kannst du wählen, wer nachrückt. Die Gewählten sind dann bestätigt, die übrigen bleiben auf der Warteliste. Lehnst du ab oder verstreicht die Frist, - gehen die Plätze an die Nächsten – deine Anmeldung behält ihren Platz. + kann das Kursteam die Plätze den Nächsten anbieten – deine Anmeldung + behält ihren Platz.

        @@ -142,8 +144,8 @@ export function PromotionOfferCard({ {confirmDecline ? (

        - Angebot wirklich ablehnen? Die freien Plätze gehen dann an die - Nächsten auf der Warteliste. + Angebot wirklich ablehnen? Das Kursteam kann die freien Plätze dann + den Nächsten auf der Warteliste anbieten.

        )} + {waitlistFirst && ( + // Das Formular kennt den Vorrang der Warteliste nicht und kündigt + // „Automatisch“ aus den tatsächlichen Plätzen an; der Server gibt + // Neuen nur die übrigen. Der Hinweis sagt es vorher. + +

        + {reservedForWaitlist === 1 + ? "1 der freien Plätze steht" + : `${reservedForWaitlist} der freien Plätze stehen`}{" "} + Anmeldungen auf der Warteliste zu. „Automatisch“ und „Aufteilen“ + nutzen nur die übrigen – reichen sie nicht, kommt diese Anmeldung + auf die Warteliste. Mit dem Status „Bestätigt“ vergibst du auch die + vorbehaltenen Plätze. +

        +
        + )} + { void utils.courses.getRegistrations.invalidate({ courseId }); void utils.courses.getAvailableSlots.invalidate({ id: courseId }); + void utils.registrations.getWaitlistOverview.invalidate({ + courseId, + }); router.push(participantsUrl); }} /> diff --git a/src/app/dashboard/courses/[id]/participants/page.tsx b/src/app/dashboard/courses/[id]/participants/page.tsx index 33fad903..ab5b0078 100644 --- a/src/app/dashboard/courses/[id]/participants/page.tsx +++ b/src/app/dashboard/courses/[id]/participants/page.tsx @@ -34,6 +34,7 @@ import { } from "@/app/_components/ui/data-table"; import { RegistrationPaymentBadge } from "@/app/_components/dashboard/invoice-payment-badge"; import { DownPaymentBadge } from "@/app/_components/dashboard/down-payment-panel"; +import { WaitlistPromotionPanel } from "@/app/_components/dashboard/waitlist-promotion-panel"; import { DOWN_PAYMENT_STATE_LABELS, downPaymentReceived, @@ -265,6 +266,7 @@ export default function CourseParticipantsPage() { setBulkAction(null); setSelectedIds(new Set()); void utils.courses.getRegistrations.invalidate({ courseId }); + void utils.registrations.getWaitlistOverview.invalidate({ courseId }); const failed = ids.length - succeeded; if (failed === 0) { toast.success( @@ -974,6 +976,17 @@ export default function CourseParticipantsPage() {
        + {/* Warteliste: freie Plätze, Wartende und der Knopf zum Nachrücken — + automatisch rückt niemand mehr nach. */} + {canManageRegistrations && ( + + void utils.courses.getRegistrations.invalidate({ courseId }) + } + /> + )} + {/* View Mode Toggle & Filters */}
        diff --git a/src/app/registrations/[id]/edit/page.tsx b/src/app/registrations/[id]/edit/page.tsx index 60ad787d..e5c68101 100644 --- a/src/app/registrations/[id]/edit/page.tsx +++ b/src/app/registrations/[id]/edit/page.tsx @@ -168,11 +168,27 @@ export default function EditRegistrationPage() { { enabled: !!session?.user && !!registrationId }, ); - const { data: availability, isLoading: availabilityLoading } = + const { data: publicAvailability, isLoading: availabilityLoading } = api.courses.getAvailableSlots.useQuery( { id: registration?.course?.id ?? "" }, { enabled: !!registration?.course?.id }, ); + // Die öffentlichen Plätze sind die, die eine neue Anmeldung nutzen dürfte — + // ohne die, die Wartende nutzen könnten. Das Kursteam darf freie Plätze trotzdem + // vergeben (etwa zwei Anmeldungen zusammenführen) und sieht deshalb die + // tatsächlichen; der Server hält es genauso. + const { data: teamOverview, isLoading: teamOverviewLoading } = + api.registrations.getWaitlistOverview.useQuery( + { courseId: registration?.course?.id ?? "" }, + { + enabled: !!registration?.course?.id && !!management?.isStaff, + staleTime: 0, + }, + ); + const availability = + management?.isStaff && teamOverview + ? teamOverview.seats + : publicAvailability; const updateMutation = api.registrations.updateMyRegistration.useMutation({ onSuccess: () => { @@ -831,7 +847,8 @@ export default function EditRegistrationPage() { sessionLoading || registrationLoading || waitingForManagement || - availabilityLoading + availabilityLoading || + (isStaff && teamOverviewLoading) ) { return (
        @@ -1212,6 +1229,18 @@ export default function EditRegistrationPage() { nur das Kursteam.

        )} + {!isStaff && holdsSeats && availability?.hasWaitingList && ( + // Derselbe Vorrang, den der Server beim Speichern prüft — + // hier vorab, damit niemand erst nach dem Absenden erfährt, + // dass ein freier Platz schon den Wartenden zusteht. Die + // Plätze oben sind bereits ohne sie gezählt. +

        + Für diesen Kurs warten Anmeldungen auf der Warteliste. + Freie Plätze, die sie nutzen könnten, gehen zuerst an sie; + Teilnehmer hinzufügen oder die Preiskategorie wechseln + geht nur, soweit die übrigen reichen. +

        + )}
    - Bis dahin hält die Warteliste an. Antwortet niemand, gehen die Plätze - danach automatisch an die Nächsten. Ihr könnt nachfragen oder die - Anmeldung im Dashboard selbst bestätigen. + Bis dahin hält die Warteliste an. Antwortet niemand, wird das Angebot + geschlossen; die Plätze bleiben frei, bis ihr die Warteliste im + Dashboard nachrücken lasst – automatisch rückt niemand nach. Ihr könnt + nachfragen oder die Anmeldung im Dashboard selbst bestätigen.
    diff --git a/src/server/jobs/waitlist-promotion-offers.ts b/src/server/jobs/waitlist-promotion-offers.ts index 931aa7a0..9a9b869e 100644 --- a/src/server/jobs/waitlist-promotion-offers.ts +++ b/src/server/jobs/waitlist-promotion-offers.ts @@ -8,7 +8,7 @@ import { } from "~/generated/prisma/client"; import { loadSeatAvailability } from "@/server/api/helpers/course-capacity"; import { - promoteFromWaitlist, + closeExpiredPromotionOffers, sendPromotionEmails, } from "@/server/api/helpers/waitlist-promotion"; import { resolveParticipantPriceOption } from "@/lib/course-price-options"; @@ -23,11 +23,9 @@ import { createLogger } from "@/server/utils/logger"; const log = createLogger("Waitlist Offers"); export type WaitlistOfferRunResult = { - /** Kurse, in denen ein abgelaufenes Angebot das Nachrücken angestoßen hat. */ + /** Kurse, in denen abgelaufene Angebote geschlossen wurden. */ courses: number; expired: number; - promoted: number; - offered: number; reminded: number; errors: Array<{ id: string; error: string }>; }; @@ -35,9 +33,10 @@ export type WaitlistOfferRunResult = { /** * Stündlicher Lauf für Nachrück-Angebote: * - * 1. Abgelaufene Angebote: Nachrücken für den Kurs anstoßen — das schließt - * das Angebot, gibt die Plätze an die Nächsten weiter und verschickt die - * Mails. + * 1. Abgelaufene Angebote schließen und die Anmeldenden benachrichtigen. Die + * Plätze gelten als weitergegeben, gehen aber an niemanden: Seit dem + * 18.09.2026 rückt nur nach, wer das Kursteam per Knopf nachrücken lässt — + * auch hier nicht automatisch. * 2. Angebote mit höchstens noch zwei Tagen: das Kursteam einmal erinnern. */ export async function processWaitlistPromotionOffers( @@ -46,8 +45,6 @@ export async function processWaitlistPromotionOffers( const result: WaitlistOfferRunResult = { courses: 0, expired: 0, - promoted: 0, - offered: 0, reminded: 0, errors: [], }; @@ -64,14 +61,12 @@ export async function processWaitlistPromotionOffers( for (const { courseId } of expiredCourses) { try { - const run = await promoteFromWaitlist(db, courseId); - await sendPromotionEmails(run); + const expired = await closeExpiredPromotionOffers(db, courseId, now); + await sendPromotionEmails({ promoted: [], offered: [], expired }); result.courses += 1; - result.expired += run.expired.length; - result.promoted += run.promoted.length; - result.offered += run.offered.length; + result.expired += expired.length; } catch (error) { - log.error(`Promotion run for course ${courseId} failed:`, error); + log.error(`Closing expired offers for course ${courseId} failed:`, error); result.errors.push({ id: courseId, error: error instanceof Error ? error.message : String(error), From 876710c60a555fffdfb760f95b6ca74ad87a7e95 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Fri, 18 Sep 2026 08:53:21 +0200 Subject: [PATCH 126/139] chore: Von next dev erzeugte Agent-Dateien wieder entfernen AGENTS.md und CLAUDE.md schreibt next dev selbst in den Arbeitsbaum. Sie sind versehentlich mit in den Release geraten: Beim Nachziehen der Dependabot-Branches (#301) wurde die Sperrdatei mit git add -A aufgeloest, und das hat die unversionierten Dateien mitgenommen. In main standen sie nie. --- AGENTS.md | 9 --------- CLAUDE.md | 1 - 2 files changed, 10 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 643577df..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,9 +0,0 @@ - - -# This is NOT the Next.js you know - -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. - -This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. - - diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 43c994c2..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md From 5433addc20ef96cde0c1d9829d008171289730a0 Mon Sep 17 00:00:00 2001 From: nichtLehdev Date: Fri, 18 Sep 2026 08:55:07 +0200 Subject: [PATCH 127/139] fix(anmeldung): Anmeldeformular barrierefrei Befunde aus dem Abschlusstest, alle nachgemessen: - Beschriftungen: 14 Felder in Schritt 1 und 7 im Teilnehmer-Fenster hatten keinen zugaenglichen Namen. Jetzt gekoppelt, Pflichtfelder mit aria-required, Fehler mit aria-invalid und aria-describedby wie bei der E-Mail. - Weiter-Knopf: war nur gesperrt, ohne zu sagen, was fehlt. Jetzt klickbar; bei Luecken eine Meldung mit role=alert, verknuepft mit dem Knopf, und der Fokus springt ins erste fehlerhafte Feld. - Fokus: fiel beim Schrittwechsel und nach dem Schliessen des Teilnehmer-Fensters auf BODY. Jetzt auf die Ueberschrift des neuen Schritts bzw. zurueck zum Ausloeser; Tab bleibt im Fenster. - Trefferflaechen: 3-5 Ziele je Schritt unter 44 px, jetzt keines. - Auswahlliste: gesperrte Eintraege tragen aria-disabled und werden von den Pfeiltasten uebersprungen; aria-activedescendant sagt, welcher Eintrag markiert ist. - Ausgebuchte Preiskategorie schon im Teilnehmer-Fenster gekennzeichnet (gleiche Regel wie der Server): mit Warteliste waehlbar, ohne gesperrt. Dazu drei Texte, die noch automatisches Nachruecken versprachen. --- .../events/course-registration-form.tsx | 266 +++++++-- .../__tests__/utils.test.ts | 215 +++++++ .../down-payment-summary.tsx | 50 +- .../participant-card.tsx | 32 +- .../participant-custom-fields.tsx | 71 ++- .../participant-editor.tsx | 96 +++- .../participant-library-popup.tsx | 2 +- .../participant-price-option-field.tsx | 36 +- .../participant-sheet.tsx | 27 + .../seat-shortage-notice.tsx | 2 +- .../seat-split-choice.tsx | 12 +- .../staff-options.tsx | 10 +- .../step-1-registrant-info.tsx | 540 ++++++++---------- .../step-2-participants.tsx | 123 +++- .../step-3-summary.tsx | 162 ++++-- .../events/course-registration-form/utils.ts | 218 +++++-- .../_components/programmheft/section-head.tsx | 7 + src/app/_components/ui/select.tsx | 73 ++- .../registration-seat-shortage.test.ts | 62 +- src/lib/registration-seat-shortage.ts | 30 + 20 files changed, 1528 insertions(+), 506 deletions(-) create mode 100644 src/app/_components/events/course-registration-form/__tests__/utils.test.ts diff --git a/src/app/_components/events/course-registration-form.tsx b/src/app/_components/events/course-registration-form.tsx index 8a54b87b..789b5ba5 100644 --- a/src/app/_components/events/course-registration-form.tsx +++ b/src/app/_components/events/course-registration-form.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ "use client"; -import { useState, useEffect, useRef, useMemo } from "react"; +import { useState, useEffect, useId, useRef, useMemo } from "react"; import { api } from "@/trpc/react"; import { useToast } from "@/app/_components/ui/toast"; import { cn } from "@/lib/utils"; @@ -19,9 +19,19 @@ import type { StaffRegistrationOptions, } from "./course-registration-form/types"; import { Step1RegistrantInfo } from "./course-registration-form/step-1-registrant-info"; -import { Step2Participants } from "./course-registration-form/step-2-participants"; +import { + ADD_PARTICIPANT_FOCUS_KEY, + participantFocusKey, + Step2Participants, +} from "./course-registration-form/step-2-participants"; import { Step3Summary } from "./course-registration-form/step-3-summary"; -import { validateStep as validateStepUtil } from "./course-registration-form/utils"; +import { + problemSummary, + registrantProblems, + summaryProblems, + validateStep as validateStepUtil, + type FormProblem, +} from "./course-registration-form/utils"; import { registrationErrorMessage } from "@/lib/registration-error-message"; import { registrationSeatShortage } from "@/lib/registration-seat-shortage"; import { @@ -39,6 +49,44 @@ import { import { Heading } from "@/app/_components/programmheft/section-head"; import { Note } from "@/app/_components/programmheft/note"; +/** + * Wie weit feste Leisten oben hineinragen: Kopfleiste samt Banner und der + * mitlaufende Kolumnentitel. Als `scroll-margin-top` der Sprungziele gesetzt, + * damit der Browser den Wert aus den CSS-Variablen der Seite ausrechnet. + */ +const FOCUS_TARGET_MARGIN = + "[&_:is([data-focus-key],h3)]:scroll-mt-[calc(var(--main-padding-top,5rem)+var(--kolumnentitel-hoehe,0px))]"; + +/** + * Fokus setzen und das Element nur dann verschieben, wenn es unter den festen + * Leisten oben oder der klebenden Fußleiste läge. Auf großen Bildschirmen, wo + * alles schon zu sehen ist, springt beim Schrittwechsel so nichts. + * + * Sofort statt weich und auf eine feste Position: Lief noch das weiche + * Scrollen vom letzten Tab-Schritt, setzte es sich sonst gegen den Sprung + * durch, und das Ziel landete unter der Kopfleiste. Derselbe Aufruf hält + * dieses Scrollen auch an, wenn das Ziel gerade schon sichtbar ist. + */ +function focusVisibly( + element: HTMLElement, + footer: HTMLElement | null, + align: "start" | "center", +) { + element.focus({ preventScroll: true }); + const rect = element.getBoundingClientRect(); + const top = parseFloat(getComputedStyle(element).scrollMarginTop) || 0; + const bottom = + window.innerHeight - (footer?.getBoundingClientRect().height ?? 0); + const margin = 16; + const visible = rect.top >= top + margin && rect.bottom <= bottom - margin; + const shift = visible + ? 0 + : align === "start" + ? rect.top - top - margin + : rect.top + rect.height / 2 - (top + bottom) / 2; + window.scrollTo({ top: window.scrollY + shift, behavior: "instant" }); +} + export default function CourseRegistrationForm({ course, onClose, @@ -70,6 +118,26 @@ export default function CourseRegistrationForm({ }); const [currentStep, setCurrentStep] = useState(1); + /** + * Der letzte Versuch, mit Lücken weiterzugehen: ab dann nennt das Formular + * die Lücken am Knopf und markiert die Felder — nur die, die bei diesem + * Versuch fehlten (`fields`). Was erst danach dazukommt, etwa die gerade + * aufgeklappte Rechnungsadresse, wird nicht sofort rot, sondern beim + * nächsten Versuch genannt. `count` hängt die Meldung bei jedem Versuch neu + * ein, damit sie auch unverändert wieder vorgelesen wird. + */ + const [attempt, setAttempt] = useState<{ + step: Step; + count: number; + fields: string[]; + } | null>(null); + const stepHeadingId = useId(); + const problemSummaryId = useId(); + const formRootRef = useRef(null); + const footerRef = useRef(null); + /** Feld, in das der Fokus nach dem nächsten Rendern springt. */ + const pendingProblemFocus = useRef(null); + const previousStepRef = useRef(1); const groupIdCounterRef = useRef(0); const [validationErrors, setValidationErrors] = useState< Record @@ -325,7 +393,26 @@ export default function CourseRegistrationForm({ ); }; - const canProceed = validateStep(currentStep); + // Beim Schrittwechsel auf die Überschrift des neuen Schritts: Vorher fiel + // der Fokus auf `BODY` (der gedrückte Knopf wurde gesperrt oder + // verschwand), und Tastatur wie Vorlesegerät begannen wieder oben auf der + // Seite. + useEffect(() => { + if (previousStepRef.current === currentStep) return; + previousStepRef.current = currentStep; + const heading = document.getElementById(stepHeadingId); + if (heading) focusVisibly(heading, footerRef.current, "start"); + }, [currentStep, stepHeadingId]); + + useEffect(() => { + const key = pendingProblemFocus.current; + if (!key) return; + pendingProblemFocus.current = null; + const field = formRootRef.current?.querySelector( + `[data-focus-key="${key}"]`, + ); + if (field) focusVisibly(field, footerRef.current, "center"); + }); const participantPriceOptionIds = registrationData.participants.map( (p) => p.priceOptionId, @@ -415,6 +502,106 @@ export default function CourseRegistrationForm({ staffResolvedStatus === "CONFIRMED" && !staffOptions.allowOverbooking; + /** + * Was dem aktuellen Schritt fehlt, in der Reihenfolge der Seite. Leer heißt: + * es darf weitergehen. Dieselben Regeln wie `validateStep`. + */ + const stepProblems = (step: Step): FormProblem[] => { + if (step === 1) return registrantProblems(registrationData, staffMode); + if (step === 2) { + if (registrationData.participants.length === 0) { + return [ + { + field: ADD_PARTICIPANT_FOCUS_KEY, + label: "mindestens ein Teilnehmer", + message: "", + }, + ]; + } + const problems = registrationData.participants.flatMap( + (participant, index): FormProblem[] => { + const error = validationErrors[index]; + if (!error) return []; + const name = + [participant.firstName, participant.lastName] + .map((part) => part?.trim()) + .filter(Boolean) + .join(" ") || `Teilnehmer ${index + 1}`; + return [ + { + field: participantFocusKey(index), + label: `Angaben zu ${name}`, + message: error, + }, + ]; + }, + ); + // Die Prüfung der Karten läuft einen Bildaufbau später; bis dahin + // bleibt es bei der allgemeinen Nennung. + return problems.length > 0 || validateStep(2) + ? problems + : [{ field: "", label: "Angaben zu den Teilnehmern", message: "" }]; + } + const summary = summaryProblems(registrationData, course, { + termsAccepted, + staffMode, + downPaymentAcknowledged, + }); + const terms = summary.filter((p) => p.field === "termsAccepted"); + return [ + ...(seatSelectionIssue + ? [ + { + field: "seatSelection", + label: "Auswahl für die freien Plätze", + message: "", + }, + ] + : []), + ...summary.filter((p) => p.field !== "termsAccepted"), + ...(blockedByFull + ? [ + { + field: "allowOverbooking", + label: "Überbuchung zulassen oder Status ändern", + message: "", + }, + ] + : []), + ...terms, + ]; + }; + + const currentProblems = stepProblems(currentStep); + const shownProblems = + attempt?.step === currentStep + ? currentProblems.filter((p) => attempt.fields.includes(p.field)) + : []; + const showProblems = shownProblems.length > 0; + + const goToStep = (step: Step) => { + setAttempt(null); + setCurrentStep(step); + }; + + /** + * Weiter bzw. Absenden. Der Knopf ist nicht mehr gesperrt: mit Lücken nennt + * er sie, markiert die Felder und springt ins erste — vorher war er nur + * ausgegraut und sagte nicht, was fehlt. + */ + const advance = (proceed: () => void) => { + if (currentProblems.length > 0) { + setAttempt((prev) => ({ + step: currentStep, + count: (prev?.count ?? 0) + 1, + fields: currentProblems.map((p) => p.field), + })); + pendingProblemFocus.current = currentProblems[0]?.field || null; + return; + } + proceed(); + }; + const handleSubmit = async () => { setSubmitError(""); @@ -584,7 +771,7 @@ export default function CourseRegistrationForm({ @@ -594,7 +781,7 @@ export default function CourseRegistrationForm({ setShowDiscardConfirm(false); onClose(); }} - className="semi-condensed flex-1 bg-red-700 px-4 py-2 font-semibold text-white transition-colors hover:bg-red-800 dark:bg-red-600 dark:hover:bg-red-700" + className="semi-condensed min-h-11 flex-1 bg-red-700 px-4 py-2 font-semibold text-white transition-colors hover:bg-red-800 dark:bg-red-600 dark:hover:bg-red-700" > Verwerfen @@ -617,6 +804,8 @@ export default function CourseRegistrationForm({ registrationData={registrationData} setRegistrationData={setRegistrationData} staffMode={staffMode} + headingId={stepHeadingId} + problems={shownProblems} /> )} {currentStep === 2 && ( @@ -634,6 +823,8 @@ export default function CourseRegistrationForm({ groupIdCounterRef={groupIdCounterRef} siblingDiscountError={siblingDiscountError} staffMode={staffMode} + headingId={stepHeadingId} + capacityByPriceOption={capacityByPriceOption} /> )} {currentStep === 3 && ( @@ -654,6 +845,8 @@ export default function CourseRegistrationForm({ } isWaitlist={expectsWaitlist} seatShortage={seatShortage} + headingId={stepHeadingId} + problems={shownProblems} seatSplit={{ availability: seatAvailability, canSplit, @@ -687,11 +880,9 @@ export default function CourseRegistrationForm({ <> @@ -701,31 +892,21 @@ export default function CourseRegistrationForm({ {currentStep < 3 ? ( ) : ( @@ -810,9 +991,26 @@ export default function CourseRegistrationForm({ {stepBody}
    -
    -
    - {footerButtons} +
    +
    + {/* Am Knopf, der gerade gedrückt wurde: was noch fehlt. Die Felder + selbst tragen ihre Meldung zusätzlich. */} + {showProblems ? ( + + ) : null} +
    + {footerButtons} +
    {discardConfirm} diff --git a/src/app/_components/events/course-registration-form/__tests__/utils.test.ts b/src/app/_components/events/course-registration-form/__tests__/utils.test.ts new file mode 100644 index 00000000..8db693ef --- /dev/null +++ b/src/app/_components/events/course-registration-form/__tests__/utils.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "@jest/globals"; +import type { CourseWithRelations, RegistrationData } from "../types"; +import { + EMAIL_HINT, + problemSummary, + registrantProblems, + summaryProblems, + validateStep, +} from "../utils"; + +const complete: RegistrationData = { + registrantEmail: "anna@example.com", + registrantFirstName: "Anna", + registrantLastName: "Muster", + registrantPhone: "0211 123456", + registrantStreet: "Musterstraße 1", + registrantZipCode: "40210", + registrantCity: "Düsseldorf", + useSeparateBilling: false, + billingStreet: "", + billingZipCode: "", + billingCity: "", + billingCompany: "", + billingFirstName: "", + billingLastName: "", + billingEmail: "", + participants: [], + siblingDiscountApplied: false, + paymentMethod: undefined, +}; + +const empty: RegistrationData = { + ...complete, + registrantEmail: "", + registrantFirstName: "", + registrantLastName: "", + registrantPhone: "", + registrantStreet: "", + registrantZipCode: "", + registrantCity: "", +}; + +/** Nur die Felder, die Schritt 3 liest. */ +const course = (overrides: Record = {}) => + ({ + isFree: false, + paymentCashAllowed: true, + paymentInvoiceAllowed: true, + downPaymentMode: "NONE", + downPaymentAmount: null, + priceOptions: [{ id: "po", price: 100, downPaymentAmount: null }], + customFields: [], + ...overrides, + }) as unknown as CourseWithRelations; + +const fields = (problems: { field: string }[]) => problems.map((p) => p.field); + +describe("registrantProblems", () => { + it("names every missing field of an empty step in field order", () => { + expect(fields(registrantProblems(empty))).toEqual([ + "registrantFirstName", + "registrantLastName", + "registrantEmail", + "registrantPhone", + "registrantStreet", + "registrantZipCode", + "registrantCity", + ]); + }); + + it("reports nothing for a complete step", () => { + expect(registrantProblems(complete)).toEqual([]); + }); + + it("asks for a valid address instead of a missing one when the e-mail is malformed", () => { + expect( + registrantProblems({ ...complete, registrantEmail: "anna@" }), + ).toEqual([ + { + field: "registrantEmail", + label: "gültige E-Mail-Adresse", + message: EMAIL_HINT, + }, + ]); + }); + + it("leaves phone and address optional for the course team", () => { + expect(fields(registrantProblems(empty, true))).toEqual([ + "registrantFirstName", + "registrantLastName", + "registrantEmail", + ]); + }); + + it("asks for the billing address instead of the own one when it is separate", () => { + expect( + fields( + registrantProblems({ + ...complete, + registrantStreet: "", + registrantZipCode: "", + registrantCity: "", + useSeparateBilling: true, + billingEmail: "rechnung@", + }), + ), + ).toEqual([ + "billingStreet", + "billingZipCode", + "billingCity", + "billingEmail", + ]); + }); + + it("agrees with validateStep for every combination it distinguishes", () => { + const variants: RegistrationData[] = [ + empty, + complete, + { ...complete, registrantPhone: "" }, + { ...complete, registrantEmail: "kein-at" }, + { ...complete, useSeparateBilling: true }, + { + ...complete, + useSeparateBilling: true, + billingStreet: "Kirchplatz 1", + billingZipCode: "40210", + billingCity: "Düsseldorf", + }, + ]; + for (const data of variants) { + for (const staff of [false, true]) { + expect(validateStep(1, data, course(), {}, false, staff)).toBe( + registrantProblems(data, staff).length === 0, + ); + } + } + }); +}); + +describe("summaryProblems", () => { + it("asks for payment method and consent while both are open", () => { + expect(fields(summaryProblems(complete, course(), {}))).toEqual([ + "paymentMethod", + "termsAccepted", + ]); + }); + + it("needs no payment method for a free course or a single accepted one", () => { + expect( + fields( + summaryProblems(complete, course({ isFree: true }), { + termsAccepted: true, + }), + ), + ).toEqual([]); + expect( + fields( + summaryProblems(complete, course({ paymentInvoiceAllowed: false }), { + termsAccepted: true, + }), + ), + ).toEqual([]); + }); + + it("asks the registrant, not the course team, to acknowledge a down payment", () => { + const withDownPayment = course({ + downPaymentMode: "COURSE", + downPaymentAmount: 30, + }); + const data = { + ...complete, + paymentMethod: "CASH" as const, + participants: [ + { + firstName: "Ben", + lastName: "Muster", + birthDate: new Date("2000-01-01"), + city: "Essen", + priceOptionId: "po", + }, + ], + }; + expect( + fields(summaryProblems(data, withDownPayment, { termsAccepted: true })), + ).toEqual(["downPaymentAcknowledged"]); + expect( + fields( + summaryProblems(data, withDownPayment, { + termsAccepted: true, + staffMode: true, + }), + ), + ).toEqual([]); + }); + + it("agrees with validateStep", () => { + const data = { ...complete, paymentMethod: "INVOICE" as const }; + for (const terms of [false, true]) { + expect(validateStep(3, data, course(), {}, terms)).toBe( + summaryProblems(data, course(), { termsAccepted: terms }).length === 0, + ); + } + }); +}); + +describe("problemSummary", () => { + it("lists the short labels in order", () => { + expect( + problemSummary([ + { field: "a", label: "Vorname", message: "" }, + { field: "b", label: "Telefon", message: "" }, + ]), + ).toBe("Noch offen: Vorname, Telefon."); + }); +}); diff --git a/src/app/_components/events/course-registration-form/down-payment-summary.tsx b/src/app/_components/events/course-registration-form/down-payment-summary.tsx index b2b5515d..e2b7f101 100644 --- a/src/app/_components/events/course-registration-form/down-payment-summary.tsx +++ b/src/app/_components/events/course-registration-form/down-payment-summary.tsx @@ -1,5 +1,6 @@ "use client"; +import { useId } from "react"; import type { CourseWithRelations, RegistrationData } from "./types"; import { formatEuro } from "@/lib/invoice-document"; import { roundMoney } from "@/lib/sibling-discount"; @@ -35,6 +36,8 @@ interface DownPaymentSummaryProps { acknowledgement?: { checked: boolean; onChange: (checked: boolean) => void; + /** Meldung am Kästchen, wenn „Verbindlich anmelden“ ohne Haken scheitert. */ + problem?: string; }; /** * Die Anmeldung erscheint unter „Meine Anmeldungen“: angemeldet und mit der @@ -59,6 +62,7 @@ export function DownPaymentSummary({ acknowledgement, listedInMyRegistrations, }: DownPaymentSummaryProps) { + const problemId = useId(); const reference = downPaymentReference( course.courseNumber, registrationData.registrantFirstName, @@ -127,22 +131,36 @@ export function DownPaymentSummary({ )} {acknowledgement ? ( - +
    + {/* Die ganze Beschriftung ist Trefferfläche, mindestens 44px hoch. */} + + {acknowledgement.problem ? ( +

    + {acknowledgement.problem} +

    + ) : null} +
    ) : ( refundNotice && (

    diff --git a/src/app/_components/events/course-registration-form/participant-card.tsx b/src/app/_components/events/course-registration-form/participant-card.tsx index af4e78e6..c3952310 100644 --- a/src/app/_components/events/course-registration-form/participant-card.tsx +++ b/src/app/_components/events/course-registration-form/participant-card.tsx @@ -1,5 +1,6 @@ "use client"; +import { useId } from "react"; import { AlertCircle, Save, Trash2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { @@ -34,6 +35,12 @@ interface ParticipantCardProps { /** Only offered to signed-in registrants, who have a participant library. */ onSaveToLibrary?: () => void; saveToLibraryPending?: boolean; + /** + * Schlüssel (`data-focus-key`) des Bearbeiten-Knopfs, damit das Formular + * den Fokus nach dem Schließen des Fensters oder bei fehlenden Angaben + * hierher zurückholen kann. + */ + focusKey?: string; } /** @@ -58,7 +65,9 @@ export function ParticipantCard({ children, onSaveToLibrary, saveToLibraryPending, + focusKey, }: ParticipantCardProps) { + const errorId = useId(); const isInGroup = siblingGroupSize > 1; const hasError = !!validationError; @@ -90,9 +99,11 @@ export function ParticipantCard({ !!participant.birthDate; const hasActions = !!onSaveToLibrary || !!onRemove; + // Rechts Platz für die 44px-Knöpfe (6px Rand), damit der Name nicht + // darunter läuft. const summaryLayout = cn( "flex w-full items-start gap-3 p-4 text-left", - hasActions && (onSaveToLibrary ? "pr-[5.5rem]" : "pr-14"), + hasActions && (onSaveToLibrary ? "pr-24" : "pr-14"), ); const summary = ( @@ -161,7 +172,11 @@ export function ParticipantCard({ @@ -198,7 +222,7 @@ export function ParticipantCard({ : "Mindestens ein Teilnehmer muss bleiben" } aria-label={`Teilnehmer ${index + 1} entfernen`} - className="hover:text-paper dark:hover:text-night flex h-9 w-9 items-center justify-center text-red-700 transition-colors hover:bg-red-700 disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-red-700 dark:text-red-400 dark:hover:bg-red-400 dark:disabled:hover:text-red-400" + className="hover:text-paper dark:hover:text-night flex h-11 w-11 items-center justify-center text-red-700 transition-colors hover:bg-red-700 disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-red-700 dark:text-red-400 dark:hover:bg-red-400 dark:disabled:hover:text-red-400" > diff --git a/src/app/_components/events/course-registration-form/participant-custom-fields.tsx b/src/app/_components/events/course-registration-form/participant-custom-fields.tsx index 83b6237d..5a9a8f3e 100644 --- a/src/app/_components/events/course-registration-form/participant-custom-fields.tsx +++ b/src/app/_components/events/course-registration-form/participant-custom-fields.tsx @@ -1,5 +1,6 @@ "use client"; +import { useId } from "react"; import { Input, Label, Textarea, Select } from "@/app/_components/ui"; import type { SelectProps } from "@/app/_components/ui/select"; import { @@ -72,6 +73,11 @@ type ParticipantCustomFieldsProps = { selectFieldSize?: SelectProps["fieldSize"]; /** Background/border wrapper for CHECKBOX and MULTISELECT boxes (step 2 tints sibling groups green). */ choiceContainerClassName?: string; + /** + * Meldung außerhalb der Felder, die fehlende Angaben nennt — markierte + * Felder werden mit ihr per `aria-describedby` verknüpft. + */ + errorDescriptionId?: string; }; export function ParticipantCustomFields({ @@ -83,7 +89,9 @@ export function ParticipantCustomFields({ inputClassName = CUSTOM_FIELD_INPUT_CLASS, selectFieldSize, choiceContainerClassName = "bg-paper dark:bg-night", + errorDescriptionId, }: ParticipantCustomFieldsProps) { + const uid = useId(); if (fields.length === 0) { return null; } @@ -96,18 +104,53 @@ export function ParticipantCustomFields({ return ( <> - {fields.map((field) => { + {fields.map((field, index) => { const isInvalid = invalidFieldNames.includes(field.fieldName); const fieldValue = getCustomFieldValue(customFields, field.fieldName); const optionValues = parseSelectOptionValues(field.options); + // Nach Position statt Feldname: Namen dürfen Leerzeichen enthalten. + const controlId = `${uid}-${index}`; + const labelId = `${controlId}-label`; + const helpId = `${controlId}-hilfe`; + const showsHelp = !!field.helpText && field.fieldType !== "CHECKBOX"; + // Beschriftung und Fehlerzustand für das einzelne Feld; Kästchen- + // gruppen tragen sie als Gruppe (siehe unten). + const describedBy = + [showsHelp ? helpId : null, isInvalid ? errorDescriptionId : null] + .filter(Boolean) + .join(" ") || undefined; + const controlA11y = { + id: controlId, + required: field.isRequired, + "aria-invalid": isInvalid || undefined, + "aria-describedby": describedBy, + }; + const isGroup = + field.fieldType === "CHECKBOX" || + (field.fieldType === "MULTISELECT" && optionValues.length > 0); return (

    -