Rollen Freigaben Push und Beleg Upload ueberarbeiten
This commit is contained in:
@@ -4,3 +4,9 @@ POSTGRES_PASSWORD="change-this-db-password"
|
|||||||
DATABASE_URL="postgresql://postgres:postgres@db:5432/rave_budget_control?schema=public"
|
DATABASE_URL="postgresql://postgres:postgres@db:5432/rave_budget_control?schema=public"
|
||||||
NEXTAUTH_URL="http://localhost:3000"
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
NEXTAUTH_SECRET="replace-this-with-a-long-random-secret"
|
NEXTAUTH_SECRET="replace-this-with-a-long-random-secret"
|
||||||
|
NEXT_PUBLIC_VAPID_PUBLIC_KEY="replace-with-vapid-public-key"
|
||||||
|
VAPID_PRIVATE_KEY="replace-with-vapid-private-key"
|
||||||
|
VAPID_SUBJECT="mailto:finanzen@example.org"
|
||||||
|
GOOGLE_DRIVE_FOLDER_ID="12zMANi_J0uvie16LUxSmfeqwGjKawEhJ"
|
||||||
|
GOOGLE_SERVICE_ACCOUNT_EMAIL="service-account@example-project.iam.gserviceaccount.com"
|
||||||
|
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nreplace-with-private-key\n-----END PRIVATE KEY-----\n"
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ Material-3-orientierte Finanzübersicht für Vereins-AGs mit rollenbasierter Fre
|
|||||||
|
|
||||||
- Horizontale Budget-Übersicht pro AG
|
- Horizontale Budget-Übersicht pro AG
|
||||||
- Mehrstufige Freigaben mit Rollenlogik
|
- Mehrstufige Freigaben mit Rollenlogik
|
||||||
|
- Web-Push-Benachrichtigungen für Freigabeanforderungen
|
||||||
- Budgets, Zeiträume, Nutzerverwaltung und Audit-Log
|
- Budgets, Zeiträume, Nutzerverwaltung und Audit-Log
|
||||||
- Statusaktionen für Freigeben, Bezahlt und Dokumentiert
|
- Statusaktionen für Freigeben, Bezahlt und Dokumentiert
|
||||||
|
- Beleg-Upload für Bilder und PDFs in Google Drive
|
||||||
- CSV-Backup mit Import und Restore-Grundlage
|
- CSV-Backup mit Import und Restore-Grundlage
|
||||||
- PWA-Manifest und Service-Worker-Basis
|
- PWA-Manifest und Service-Worker-Basis
|
||||||
|
|
||||||
@@ -36,6 +38,6 @@ Der Seed legt die Grundeinstellungen, den aktiven Zeitraum, AGs, Budgets und Bas
|
|||||||
|
|
||||||
## Hinweise
|
## Hinweise
|
||||||
|
|
||||||
- Die Dokumentation eines Belegs ist aktuell als Beleg-URL umgesetzt.
|
- Für Web Push müssen `NEXT_PUBLIC_VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` und `VAPID_SUBJECT` gesetzt sein.
|
||||||
- Web-Push ist architektonisch vorbereitet, aber noch nicht implementiert.
|
- Für Beleg-Uploads müssen `GOOGLE_DRIVE_FOLDER_ID`, `GOOGLE_SERVICE_ACCOUNT_EMAIL` und `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY` gesetzt sein. Der Drive-Ordner muss für die Service-Account-Mail freigegeben sein.
|
||||||
- Für Produktion sollten `NEXTAUTH_SECRET`, Datenbank-Zugangsdaten und Reverse-Proxy/SSL sauber gesetzt werden.
|
- Für Produktion sollten `NEXTAUTH_SECRET`, Datenbank-Zugangsdaten und Reverse-Proxy/SSL sauber gesetzt werden.
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ services:
|
|||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-rave_budget_control}?schema=public
|
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-rave_budget_control}?schema=public
|
||||||
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
|
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
|
||||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
||||||
|
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${NEXT_PUBLIC_VAPID_PUBLIC_KEY}
|
||||||
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||||
|
VAPID_SUBJECT: ${VAPID_SUBJECT}
|
||||||
|
GOOGLE_DRIVE_FOLDER_ID: ${GOOGLE_DRIVE_FOLDER_ID:-12zMANi_J0uvie16LUxSmfeqwGjKawEhJ}
|
||||||
|
GOOGLE_SERVICE_ACCOUNT_EMAIL: ${GOOGLE_SERVICE_ACCOUNT_EMAIL}
|
||||||
|
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: ${GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY}
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|
||||||
|
|||||||
622
package-lock.json
generated
622
package-lock.json
generated
@@ -13,11 +13,14 @@
|
|||||||
"@mui/icons-material": "^6.1.3",
|
"@mui/icons-material": "^6.1.3",
|
||||||
"@mui/material": "^6.1.3",
|
"@mui/material": "^6.1.3",
|
||||||
"@prisma/client": "^5.20.0",
|
"@prisma/client": "^5.20.0",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
"googleapis": "^171.4.0",
|
||||||
"next": "^15.5.15",
|
"next": "^15.5.15",
|
||||||
"next-auth": "^4.24.8",
|
"next-auth": "^4.24.8",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1314,7 +1317,6 @@
|
|||||||
"version": "22.19.17",
|
"version": "22.19.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
||||||
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
|
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@@ -1361,6 +1363,36 @@
|
|||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/web-push": {
|
||||||
|
"version": "3.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||||
|
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "7.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/asn1.js": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/babel-plugin-macros": {
|
"node_modules/babel-plugin-macros": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
|
||||||
@@ -1376,12 +1408,82 @@
|
|||||||
"npm": ">=6"
|
"npm": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/bcryptjs": {
|
"node_modules/bcryptjs": {
|
||||||
"version": "2.4.3",
|
"version": "2.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||||
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bignumber.js": {
|
||||||
|
"version": "9.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||||
|
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||||
|
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bound": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"get-intrinsic": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/callsites": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
@@ -1463,6 +1565,15 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/data-uri-to-buffer": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -1500,6 +1611,29 @@
|
|||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/error-ex": {
|
"node_modules/error-ex": {
|
||||||
"version": "1.3.4",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||||
@@ -1509,6 +1643,15 @@
|
|||||||
"is-arrayish": "^0.2.1"
|
"is-arrayish": "^0.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-errors": {
|
"node_modules/es-errors": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
@@ -1518,6 +1661,18 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.7",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||||
@@ -1572,12 +1727,53 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/extend": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fetch-blob": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/jimmywarting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-domexception": "^1.0.0",
|
||||||
|
"web-streams-polyfill": "^3.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20 || >= 14.13"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/find-root": {
|
"node_modules/find-root": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
|
||||||
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
|
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/formdata-polyfill": {
|
||||||
|
"version": "4.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||||
|
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fetch-blob": "^3.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -1587,6 +1783,71 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gaxios": {
|
||||||
|
"version": "7.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz",
|
||||||
|
"integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"extend": "^3.0.2",
|
||||||
|
"https-proxy-agent": "^7.0.1",
|
||||||
|
"node-fetch": "^3.3.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gcp-metadata": {
|
||||||
|
"version": "8.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
|
||||||
|
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"gaxios": "^7.0.0",
|
||||||
|
"google-logging-utils": "^1.0.0",
|
||||||
|
"json-bigint": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-tsconfig": {
|
"node_modules/get-tsconfig": {
|
||||||
"version": "4.14.0",
|
"version": "4.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||||
@@ -1600,6 +1861,85 @@
|
|||||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/google-auth-library": {
|
||||||
|
"version": "10.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
|
||||||
|
"integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.0",
|
||||||
|
"ecdsa-sig-formatter": "^1.0.11",
|
||||||
|
"gaxios": "^7.1.4",
|
||||||
|
"gcp-metadata": "8.1.2",
|
||||||
|
"google-logging-utils": "1.1.3",
|
||||||
|
"jws": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/google-logging-utils": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/googleapis": {
|
||||||
|
"version": "171.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/googleapis/-/googleapis-171.4.0.tgz",
|
||||||
|
"integrity": "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"google-auth-library": "^10.2.0",
|
||||||
|
"googleapis-common": "^8.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/googleapis-common": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"extend": "^3.0.2",
|
||||||
|
"gaxios": "^7.0.0-rc.4",
|
||||||
|
"google-auth-library": "^10.1.0",
|
||||||
|
"qs": "^6.7.0",
|
||||||
|
"url-template": "^2.0.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||||
@@ -1627,6 +1967,28 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/http_ece": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -1643,6 +2005,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/is-arrayish": {
|
"node_modules/is-arrayish": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||||
@@ -1691,12 +2059,42 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/json-bigint": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bignumber.js": "^9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-parse-even-better-errors": {
|
"node_modules/json-parse-even-better-errors": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
|
||||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lines-and-columns": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
@@ -1727,6 +2125,30 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -1835,6 +2257,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-domexception": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||||
|
"deprecated": "Use your platform's native DOMException instead",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://paypal.me/jimmywarting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"data-uri-to-buffer": "^4.0.0",
|
||||||
|
"fetch-blob": "^3.1.4",
|
||||||
|
"formdata-polyfill": "^4.0.10"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/node-fetch"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/oauth": {
|
"node_modules/oauth": {
|
||||||
"version": "0.9.15",
|
"version": "0.9.15",
|
||||||
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
||||||
@@ -1859,6 +2319,18 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-inspect": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/oidc-token-hash": {
|
"node_modules/oidc-token-hash": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
|
||||||
@@ -2027,6 +2499,21 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/qs": {
|
||||||
|
"version": "6.15.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||||
|
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"side-channel": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
@@ -2114,6 +2601,32 @@
|
|||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
@@ -2181,6 +2694,78 @@
|
|||||||
"@img/sharp-win32-x64": "0.34.5"
|
"@img/sharp-win32-x64": "0.34.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/side-channel": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-list": "^1.0.0",
|
||||||
|
"side-channel-map": "^1.0.1",
|
||||||
|
"side-channel-weakmap": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-list": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-map": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-weakmap": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-map": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.5.7",
|
"version": "0.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||||
@@ -2284,9 +2869,14 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/url-template": {
|
||||||
|
"version": "2.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
|
||||||
|
"integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==",
|
||||||
|
"license": "BSD"
|
||||||
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "8.3.2",
|
"version": "8.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
@@ -2296,6 +2886,34 @@
|
|||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-push": {
|
||||||
|
"version": "3.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||||
|
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"asn1.js": "^5.3.0",
|
||||||
|
"http_ece": "1.2.0",
|
||||||
|
"https-proxy-agent": "^7.0.0",
|
||||||
|
"jws": "^4.0.0",
|
||||||
|
"minimist": "^1.2.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"web-push": "src/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/web-streams-polyfill": {
|
||||||
|
"version": "3.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||||
|
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
|||||||
@@ -19,11 +19,14 @@
|
|||||||
"@mui/icons-material": "^6.1.3",
|
"@mui/icons-material": "^6.1.3",
|
||||||
"@mui/material": "^6.1.3",
|
"@mui/material": "^6.1.3",
|
||||||
"@prisma/client": "^5.20.0",
|
"@prisma/client": "^5.20.0",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
"googleapis": "^171.4.0",
|
||||||
"next": "^15.5.15",
|
"next": "^15.5.15",
|
||||||
"next-auth": "^4.24.8",
|
"next-auth": "^4.24.8",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
ALTER TYPE "Role" RENAME VALUE 'ADMIN' TO 'BOARD';
|
||||||
|
ALTER TYPE "Role" ADD VALUE IF NOT EXISTS 'ORGA';
|
||||||
|
|
||||||
|
CREATE TABLE "push_subscriptions" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"endpoint" TEXT NOT NULL,
|
||||||
|
"p256dh" TEXT NOT NULL,
|
||||||
|
"auth" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "push_subscriptions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "push_subscriptions_endpoint_key" ON "push_subscriptions"("endpoint");
|
||||||
|
CREATE INDEX "push_subscriptions_user_id_idx" ON "push_subscriptions"("user_id");
|
||||||
|
|
||||||
|
ALTER TABLE "push_subscriptions"
|
||||||
|
ADD CONSTRAINT "push_subscriptions_user_id_fkey"
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
output = "../node_modules/.prisma/client"
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
@@ -8,7 +9,8 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum Role {
|
enum Role {
|
||||||
ADMIN
|
BOARD
|
||||||
|
ORGA
|
||||||
FINANCE
|
FINANCE
|
||||||
MEMBER
|
MEMBER
|
||||||
}
|
}
|
||||||
@@ -43,12 +45,27 @@ model User {
|
|||||||
createdExpenses Expense[] @relation("ExpenseCreator")
|
createdExpenses Expense[] @relation("ExpenseCreator")
|
||||||
approvals Approval[]
|
approvals Approval[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
|
pushSubscriptions PushSubscription[]
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PushSubscription {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
endpoint String @unique
|
||||||
|
p256dh String
|
||||||
|
auth String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("push_subscriptions")
|
||||||
|
}
|
||||||
|
|
||||||
model AccountingPeriod {
|
model AccountingPeriod {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
|
|||||||
@@ -131,29 +131,33 @@ async function main() {
|
|||||||
const deko = await upsertWorkingGroup("AG Deko");
|
const deko = await upsertWorkingGroup("AG Deko");
|
||||||
const awareness = await upsertWorkingGroup("AG Awareness");
|
const awareness = await upsertWorkingGroup("AG Awareness");
|
||||||
const technik = await upsertWorkingGroup("AG Technik");
|
const technik = await upsertWorkingGroup("AG Technik");
|
||||||
|
const orga = await upsertWorkingGroup("AG Orga");
|
||||||
|
const finanzen = await upsertWorkingGroup("AG Finanzen");
|
||||||
|
|
||||||
await upsertBudget(deko.id, currentPeriod.id, "Deko Hauptbudget", 0, "#FFB94A", 0);
|
await upsertBudget(deko.id, currentPeriod.id, "Deko Hauptbudget", 0, "#FFB94A", 0);
|
||||||
await upsertBudget(awareness.id, currentPeriod.id, "Awareness Hauptbudget", 800, "#68A35D", 250);
|
await upsertBudget(awareness.id, currentPeriod.id, "Awareness Hauptbudget", 800, "#68A35D", 250);
|
||||||
await upsertBudget(technik.id, currentPeriod.id, "Technik Infrastruktur", 1500, "#5677F6", 500);
|
await upsertBudget(technik.id, currentPeriod.id, "Technik Infrastruktur", 1500, "#5677F6", 500);
|
||||||
|
|
||||||
await upsertUser({
|
await upsertUser({
|
||||||
username: "vorstand-a",
|
username: "vorstand",
|
||||||
role: Role.ADMIN,
|
role: Role.BOARD,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
approvalPermissions: [ApprovalType.CHAIR_A]
|
approvalPermissions: [ApprovalType.CHAIR_B]
|
||||||
});
|
});
|
||||||
|
|
||||||
await upsertUser({
|
await upsertUser({
|
||||||
username: "vorstand-b",
|
username: "orga",
|
||||||
role: Role.ADMIN,
|
role: Role.ORGA,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
approvalPermissions: [ApprovalType.CHAIR_B]
|
workingGroupId: orga.id,
|
||||||
|
approvalPermissions: [ApprovalType.CHAIR_A]
|
||||||
});
|
});
|
||||||
|
|
||||||
await upsertUser({
|
await upsertUser({
|
||||||
username: "finanzen",
|
username: "finanzen",
|
||||||
role: Role.FINANCE,
|
role: Role.FINANCE,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
|
workingGroupId: finanzen.id,
|
||||||
approvalPermissions: [ApprovalType.FINANCE]
|
approvalPermissions: [ApprovalType.FINANCE]
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -184,4 +188,4 @@ main()
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
34
public/sw.js
34
public/sw.js
@@ -43,3 +43,37 @@ self.addEventListener("fetch", (event) => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
const payload = event.data?.json() ?? {};
|
||||||
|
const title = payload.title || "RFP Finanzen";
|
||||||
|
const options = {
|
||||||
|
body: payload.body || "Es gibt eine neue Benachrichtigung.",
|
||||||
|
icon: "/icon-192.png",
|
||||||
|
badge: "/icon-192.png",
|
||||||
|
tag: payload.tag || "rfp-finance",
|
||||||
|
data: {
|
||||||
|
url: payload.url || "/"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(self.registration.showNotification(title, options));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const targetUrl = new URL(event.notification.data?.url || "/", self.location.origin).href;
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
|
||||||
|
for (const client of clientList) {
|
||||||
|
if ("focus" in client) {
|
||||||
|
client.navigate(targetUrl);
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clients.openWindow(targetUrl);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ function asApprovalPermissions(value: unknown) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asRole(value: unknown) {
|
||||||
|
const role = asString(value, "Rolle");
|
||||||
|
|
||||||
|
switch (role) {
|
||||||
|
case "ADMIN":
|
||||||
|
case "BOARD":
|
||||||
|
return "BOARD";
|
||||||
|
case "ORGA":
|
||||||
|
return "ORGA";
|
||||||
|
case "FINANCE":
|
||||||
|
return "FINANCE";
|
||||||
|
case "MEMBER":
|
||||||
|
return "MEMBER";
|
||||||
|
default:
|
||||||
|
throw new Error("Rolle ist im Änderungsverlauf ungültig.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(_: Request, { params }: Context) {
|
export async function POST(_: Request, { params }: Context) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const viewer = await getCurrentViewer();
|
const viewer = await getCurrentViewer();
|
||||||
@@ -73,7 +91,7 @@ export async function POST(_: Request, { params }: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageUsers(viewer.role)) {
|
if (!canManageUsers(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG dürfen Zustände zurücksetzen." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Zustände zurücksetzen." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const auditLog = await prisma.auditLog.findUnique({
|
const auditLog = await prisma.auditLog.findUnique({
|
||||||
@@ -383,10 +401,10 @@ export async function POST(_: Request, { params }: Context) {
|
|||||||
throw new Error("Der Nutzer hat bereits Ausgaben oder Freigaben und kann nicht automatisch entfernt werden.");
|
throw new Error("Der Nutzer hat bereits Ausgaben oder Freigaben und kann nicht automatisch entfernt werden.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.role === "ADMIN") {
|
if (user.role === "BOARD") {
|
||||||
const adminCount = await tx.user.count({
|
const adminCount = await tx.user.count({
|
||||||
where: {
|
where: {
|
||||||
role: "ADMIN"
|
role: "BOARD"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -411,7 +429,7 @@ export async function POST(_: Request, { params }: Context) {
|
|||||||
username: asString(deleted.username, "Login-Name"),
|
username: asString(deleted.username, "Login-Name"),
|
||||||
email: asNullableString(deleted.email),
|
email: asNullableString(deleted.email),
|
||||||
passwordHash: asString(deleted.passwordHash, "Passworthash"),
|
passwordHash: asString(deleted.passwordHash, "Passworthash"),
|
||||||
role: asString(deleted.role, "Rolle") as "ADMIN" | "FINANCE" | "MEMBER",
|
role: asRole(deleted.role),
|
||||||
approvalPreference: asNullableString(deleted.approvalPreference) as "CHAIR_A" | "CHAIR_B" | "FINANCE" | null,
|
approvalPreference: asNullableString(deleted.approvalPreference) as "CHAIR_A" | "CHAIR_B" | "FINANCE" | null,
|
||||||
approvalPermissions: asApprovalPermissions(deleted.approvalPermissions),
|
approvalPermissions: asApprovalPermissions(deleted.approvalPermissions),
|
||||||
workingGroupId: asNullableString(deleted.workingGroupId),
|
workingGroupId: asNullableString(deleted.workingGroupId),
|
||||||
@@ -423,7 +441,7 @@ export async function POST(_: Request, { params }: Context) {
|
|||||||
|
|
||||||
case "user.update": {
|
case "user.update": {
|
||||||
const previous = asRecord(rollback.previous, "Nutzer");
|
const previous = asRecord(rollback.previous, "Nutzer");
|
||||||
const role = asString(previous.role, "Rolle") as "ADMIN" | "FINANCE" | "MEMBER";
|
const role = asRole(previous.role);
|
||||||
|
|
||||||
await tx.user.update({
|
await tx.user.update({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function PATCH(request: Request, { params }: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageBudgets(viewer.role)) {
|
if (!canManageBudgets(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Budgets aendern." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen duerfen Budgets aendern." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const budget = await prisma.budget.findUnique({
|
const budget = await prisma.budget.findUnique({
|
||||||
@@ -115,7 +115,7 @@ export async function DELETE(_: Request, { params }: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageBudgets(viewer.role)) {
|
if (!canManageBudgets(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Budgets loeschen." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen duerfen Budgets loeschen." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const budget = await prisma.budget.findUnique({
|
const budget = await prisma.budget.findUnique({
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageBudgets(viewer.role)) {
|
if (!canManageBudgets(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Budgets verwalten." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen duerfen Budgets verwalten." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export async function POST(request: Request, { params }: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canDocumentExpense(viewer.role)) {
|
if (!canDocumentExpense(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen dokumentieren." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein oder AG Finanzen duerfen dokumentieren." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const expense = await prisma.expense.findUnique({
|
const expense = await prisma.expense.findUnique({
|
||||||
@@ -50,7 +50,7 @@ export async function POST(request: Request, { params }: Context) {
|
|||||||
const parsed = documentedSchema.safeParse(body);
|
const parsed = documentedSchema.safeParse(body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: "Beleg-URL ist ungueltig." }, { status: 400 });
|
return NextResponse.json({ error: "Beleg-Link ist ungueltig." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedExpense = await prisma.expense.update({
|
const updatedExpense = await prisma.expense.update({
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export async function POST(_: Request, { params }: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canMarkPaid(viewer.role)) {
|
if (!canMarkPaid(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Bezahlt setzen." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein oder AG Finanzen duerfen Bezahlt setzen." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const expense = await prisma.expense.findUnique({
|
const expense = await prisma.expense.findUnique({
|
||||||
|
|||||||
67
src/app/api/expenses/[id]/proof/route.ts
Normal file
67
src/app/api/expenses/[id]/proof/route.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { canDocumentExpense } from "@/lib/domain";
|
||||||
|
import { uploadExpenseProofToDrive } from "@/lib/google-drive";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { getCurrentViewer } from "@/lib/session";
|
||||||
|
|
||||||
|
const ACCEPTED_MIME_TYPES = new Set(["application/pdf", "image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"]);
|
||||||
|
const MAX_FILE_SIZE = 12 * 1024 * 1024;
|
||||||
|
|
||||||
|
type Context = {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: Request, { params }: Context) {
|
||||||
|
const { id } = await params;
|
||||||
|
const viewer = await getCurrentViewer();
|
||||||
|
|
||||||
|
if (!viewer) {
|
||||||
|
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expense = await prisma.expense.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!expense) {
|
||||||
|
return NextResponse.json({ error: "Ausgabe nicht gefunden." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expense.creatorId !== viewer.id && !canDocumentExpense(viewer.role)) {
|
||||||
|
return NextResponse.json({ error: "Du darfst fuer diese Ausgabe keinen Beleg hochladen." }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData().catch(() => null);
|
||||||
|
const file = formData?.get("file");
|
||||||
|
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return NextResponse.json({ error: "Bitte einen Beleg als Bild oder PDF auswaehlen." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ACCEPTED_MIME_TYPES.has(file.type)) {
|
||||||
|
return NextResponse.json({ error: "Nur Bilder und PDFs sind als Beleg erlaubt." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return NextResponse.json({ error: "Der Beleg darf maximal 12 MB gross sein." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const proofUrl = await uploadExpenseProofToDrive({
|
||||||
|
title: expense.title,
|
||||||
|
fileName: file.name,
|
||||||
|
mimeType: file.type,
|
||||||
|
buffer: Buffer.from(await file.arrayBuffer())
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedExpense = await prisma.expense.update({
|
||||||
|
where: { id: expense.id },
|
||||||
|
data: {
|
||||||
|
proofUrl
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ proofUrl, expense: updatedExpense });
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
|
|
||||||
import { snapshotExpense } from "@/lib/audit-snapshots";
|
import { snapshotExpense } from "@/lib/audit-snapshots";
|
||||||
import { createAuditLog } from "@/lib/audit-log";
|
import { createAuditLog } from "@/lib/audit-log";
|
||||||
|
import { hasAdministrativeAccess } from "@/lib/domain";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { getCurrentViewer } from "@/lib/session";
|
import { getCurrentViewer } from "@/lib/session";
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ export async function DELETE(_: Request, { params }: Context) {
|
|||||||
return NextResponse.json({ error: "Ausgabe nicht gefunden." }, { status: 404 });
|
return NextResponse.json({ error: "Ausgabe nicht gefunden." }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdminDelete = viewer.role === "ADMIN" || viewer.role === "FINANCE";
|
const isAdminDelete = hasAdministrativeAccess(viewer.role);
|
||||||
const isOwnPendingExpense =
|
const isOwnPendingExpense =
|
||||||
viewer.id === expense.creatorId &&
|
viewer.id === expense.creatorId &&
|
||||||
expense.approvalStatus === "PENDING" &&
|
expense.approvalStatus === "PENDING" &&
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { z } from "zod";
|
|||||||
import { getAppSettings, toApprovalThresholdNumber } from "@/lib/app-settings";
|
import { getAppSettings, toApprovalThresholdNumber } from "@/lib/app-settings";
|
||||||
import { snapshotExpense } from "@/lib/audit-snapshots";
|
import { snapshotExpense } from "@/lib/audit-snapshots";
|
||||||
import { createAuditLog } from "@/lib/audit-log";
|
import { createAuditLog } from "@/lib/audit-log";
|
||||||
import { canCreateExpenseForGroup, requiresManualApproval } from "@/lib/domain";
|
import { APPROVAL_FLOW, canCreateExpenseForGroup, requiresManualApproval } from "@/lib/domain";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
|
import { notifyApprovalRequest } from "@/lib/push-notifications";
|
||||||
import { getCurrentViewer } from "@/lib/session";
|
import { getCurrentViewer } from "@/lib/session";
|
||||||
|
|
||||||
function parseDateInput(value: string) {
|
function parseDateInput(value: string) {
|
||||||
@@ -100,6 +101,7 @@ export async function POST(request: Request) {
|
|||||||
? parsed.data.recurrenceStartAt
|
? parsed.data.recurrenceStartAt
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const needsManualApproval = requiresManualApproval(parsed.data.amount, approvalThreshold);
|
||||||
const expense = await prisma.expense.create({
|
const expense = await prisma.expense.create({
|
||||||
data: {
|
data: {
|
||||||
title: parsed.data.title,
|
title: parsed.data.title,
|
||||||
@@ -112,10 +114,21 @@ export async function POST(request: Request) {
|
|||||||
proofUrl: parsed.data.proofUrl,
|
proofUrl: parsed.data.proofUrl,
|
||||||
recurrence: parsed.data.recurrence,
|
recurrence: parsed.data.recurrence,
|
||||||
recurrenceStartAt,
|
recurrenceStartAt,
|
||||||
approvalStatus: requiresManualApproval(parsed.data.amount, approvalThreshold) ? "PENDING" : "APPROVED"
|
approvalStatus: needsManualApproval ? "PENDING" : "APPROVED"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (needsManualApproval) {
|
||||||
|
await notifyApprovalRequest(
|
||||||
|
{
|
||||||
|
id: expense.id,
|
||||||
|
title: expense.title,
|
||||||
|
amount: Number(expense.amount)
|
||||||
|
},
|
||||||
|
[...APPROVAL_FLOW]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await createAuditLog(prisma, {
|
await createAuditLog(prisma, {
|
||||||
actorId: viewer.id,
|
actorId: viewer.id,
|
||||||
action: "expense.create",
|
action: "expense.create",
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageUsers(viewer.role)) {
|
if (!canManageUsers(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen CSV-Backups herunterladen." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen duerfen CSV-Backups herunterladen." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [appSettings, users, accountingPeriods, workingGroups, auditLogs] = await Promise.all([
|
const [appSettings, users, accountingPeriods, workingGroups, auditLogs] = await Promise.all([
|
||||||
|
|||||||
@@ -28,9 +28,24 @@ function toNumber(value: string | undefined) {
|
|||||||
return Number.isFinite(parsed) ? parsed : null;
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toRole(value: string | undefined): "BOARD" | "ORGA" | "FINANCE" | "MEMBER" {
|
||||||
|
switch (value) {
|
||||||
|
case "ADMIN":
|
||||||
|
case "BOARD":
|
||||||
|
return "BOARD";
|
||||||
|
case "ORGA":
|
||||||
|
return "ORGA";
|
||||||
|
case "FINANCE":
|
||||||
|
return "FINANCE";
|
||||||
|
case "MEMBER":
|
||||||
|
default:
|
||||||
|
return "MEMBER";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toApprovalPermissions(
|
function toApprovalPermissions(
|
||||||
value: string | undefined,
|
value: string | undefined,
|
||||||
role: "ADMIN" | "FINANCE" | "MEMBER",
|
role: "BOARD" | "ORGA" | "FINANCE" | "MEMBER",
|
||||||
approvalPreference: "CHAIR_A" | "CHAIR_B" | "FINANCE" | null
|
approvalPreference: "CHAIR_A" | "CHAIR_B" | "FINANCE" | null
|
||||||
) {
|
) {
|
||||||
const explicitPermissions = value
|
const explicitPermissions = value
|
||||||
@@ -51,7 +66,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageUsers(viewer.role)) {
|
if (!canManageUsers(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Backups einspielen." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen duerfen Backups einspielen." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData().catch(() => null);
|
const formData = await request.formData().catch(() => null);
|
||||||
@@ -145,7 +160,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const row of userRows) {
|
for (const row of userRows) {
|
||||||
const role = row.role as "ADMIN" | "FINANCE" | "MEMBER";
|
const role = toRole(row.role);
|
||||||
const approvalPreference = toNullable(row.approvalPreference) as "CHAIR_A" | "CHAIR_B" | "FINANCE" | null;
|
const approvalPreference = toNullable(row.approvalPreference) as "CHAIR_A" | "CHAIR_B" | "FINANCE" | null;
|
||||||
const approvalPermissions = toApprovalPermissions(row.approvalPermissions, role, approvalPreference);
|
const approvalPermissions = toApprovalPermissions(row.approvalPermissions, role, approvalPreference);
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export async function PATCH(request: Request, { params }: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageBudgets(viewer.role)) {
|
if (!canManageBudgets(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG dürfen Zeiträume bearbeiten." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Zeiträume bearbeiten." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
@@ -110,7 +110,7 @@ export async function DELETE(_: Request, { params }: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageBudgets(viewer.role)) {
|
if (!canManageBudgets(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG dürfen Zeiträume löschen." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Zeiträume löschen." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const period = await prisma.accountingPeriod.findUnique({
|
const period = await prisma.accountingPeriod.findUnique({
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export async function PATCH(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageBudgets(viewer.role)) {
|
if (!canManageBudgets(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG dürfen den aktuellen Zeitraum wechseln." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen den aktuellen Zeitraum wechseln." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageBudgets(viewer.role)) {
|
if (!canManageBudgets(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG dürfen Zeiträume verwalten." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Zeiträume verwalten." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
|
|||||||
71
src/app/api/push-subscriptions/route.ts
Normal file
71
src/app/api/push-subscriptions/route.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { getCurrentViewer } from "@/lib/session";
|
||||||
|
|
||||||
|
const subscriptionSchema = z.object({
|
||||||
|
endpoint: z.string().url(),
|
||||||
|
keys: z.object({
|
||||||
|
p256dh: z.string().min(1),
|
||||||
|
auth: z.string().min(1)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const viewer = await getCurrentViewer();
|
||||||
|
|
||||||
|
if (!viewer) {
|
||||||
|
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = subscriptionSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Push-Subscription ist ungueltig." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.pushSubscription.upsert({
|
||||||
|
where: {
|
||||||
|
endpoint: parsed.data.endpoint
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
userId: viewer.id,
|
||||||
|
p256dh: parsed.data.keys.p256dh,
|
||||||
|
auth: parsed.data.keys.auth
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: viewer.id,
|
||||||
|
endpoint: parsed.data.endpoint,
|
||||||
|
p256dh: parsed.data.keys.p256dh,
|
||||||
|
auth: parsed.data.keys.auth
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
const viewer = await getCurrentViewer();
|
||||||
|
|
||||||
|
if (!viewer) {
|
||||||
|
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = z.object({ endpoint: z.string().url() }).safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Push-Subscription ist ungueltig." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.pushSubscription.deleteMany({
|
||||||
|
where: {
|
||||||
|
endpoint: parsed.data.endpoint,
|
||||||
|
userId: viewer.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export async function PATCH(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageUsers(viewer.role)) {
|
if (!canManageUsers(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Einstellungen aendern." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen duerfen Einstellungen aendern." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function POST(request: Request, { params }: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageUsers(viewer.role)) {
|
if (!canManageUsers(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG dürfen Passwörter neu setzen." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Passwörter neu setzen." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
|
|||||||
@@ -3,29 +3,22 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { snapshotUser } from "@/lib/audit-snapshots";
|
import { snapshotUser } from "@/lib/audit-snapshots";
|
||||||
import { createAuditLog } from "@/lib/audit-log";
|
import { createAuditLog } from "@/lib/audit-log";
|
||||||
import {
|
import { canManageUsers, getLegacyApprovalPreference, normalizeApprovalPermissions } from "@/lib/domain";
|
||||||
APPROVAL_FLOW,
|
|
||||||
canManageUsers,
|
|
||||||
getLegacyApprovalPreference,
|
|
||||||
normalizeApprovalPermissions
|
|
||||||
} from "@/lib/domain";
|
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { getCurrentViewer } from "@/lib/session";
|
import { getCurrentViewer } from "@/lib/session";
|
||||||
|
|
||||||
const userRoleSchema = z.enum(["ADMIN", "FINANCE", "MEMBER"]);
|
const userRoleSchema = z.enum(["BOARD", "ORGA", "FINANCE", "MEMBER"]);
|
||||||
const approvalPermissionSchema = z.enum(APPROVAL_FLOW);
|
|
||||||
|
|
||||||
const updateUserSchema = z.object({
|
const updateUserSchema = z.object({
|
||||||
role: userRoleSchema,
|
role: userRoleSchema,
|
||||||
workingGroupId: z.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()]),
|
workingGroupId: z.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()])
|
||||||
approvalPermissions: z.array(approvalPermissionSchema).default([])
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function serializeManagedUser(user: {
|
function serializeManagedUser(user: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: "ADMIN" | "FINANCE" | "MEMBER";
|
role: "BOARD" | "ORGA" | "FINANCE" | "MEMBER";
|
||||||
workingGroupId: string | null;
|
workingGroupId: string | null;
|
||||||
workingGroup: { name: string } | null;
|
workingGroup: { name: string } | null;
|
||||||
approvalPreference: "CHAIR_A" | "CHAIR_B" | "FINANCE" | null;
|
approvalPreference: "CHAIR_A" | "CHAIR_B" | "FINANCE" | null;
|
||||||
@@ -63,14 +56,14 @@ export async function PATCH(request: Request, { params }: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageUsers(viewer.role)) {
|
if (!canManageUsers(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Nutzer bearbeiten." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen duerfen Nutzer bearbeiten." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
const parsed = updateUserSchema.safeParse(body);
|
const parsed = updateUserSchema.safeParse(body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: "Bitte Rolle, AG und Freigaberollen korrekt angeben." }, { status: 400 });
|
return NextResponse.json({ error: "Bitte Rolle und AG korrekt angeben." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
@@ -99,9 +92,9 @@ export async function PATCH(request: Request, { params }: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.role === "ADMIN" && parsed.data.role !== "ADMIN") {
|
if (user.role === "BOARD" && parsed.data.role !== "BOARD") {
|
||||||
const adminCount = await prisma.user.count({
|
const adminCount = await prisma.user.count({
|
||||||
where: { role: "ADMIN" }
|
where: { role: "BOARD" }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (adminCount <= 1) {
|
if (adminCount <= 1) {
|
||||||
@@ -109,7 +102,7 @@ export async function PATCH(request: Request, { params }: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const approvalPermissions = normalizeApprovalPermissions(parsed.data.role, parsed.data.approvalPermissions, null);
|
const approvalPermissions = normalizeApprovalPermissions(parsed.data.role, null, null);
|
||||||
const approvalPreference = getLegacyApprovalPreference(approvalPermissions);
|
const approvalPreference = getLegacyApprovalPreference(approvalPermissions);
|
||||||
const previousSnapshot = snapshotUser(user);
|
const previousSnapshot = snapshotUser(user);
|
||||||
|
|
||||||
@@ -172,7 +165,7 @@ export async function DELETE(_: Request, { params }: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageUsers(viewer.role)) {
|
if (!canManageUsers(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG dürfen Nutzer löschen." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Nutzer löschen." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewer.id === id) {
|
if (viewer.id === id) {
|
||||||
@@ -202,13 +195,13 @@ export async function DELETE(_: Request, { params }: Context) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.role === "ADMIN") {
|
if (user.role === "BOARD") {
|
||||||
const adminCount = await prisma.user.count({
|
const adminCount = await prisma.user.count({
|
||||||
where: { role: "ADMIN" }
|
where: { role: "BOARD" }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (adminCount <= 1) {
|
if (adminCount <= 1) {
|
||||||
return NextResponse.json({ error: "Mindestens ein Admin muss erhalten bleiben." }, { status: 400 });
|
return NextResponse.json({ error: "Mindestens ein Vorstandskonto muss erhalten bleiben." }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,31 +4,24 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { snapshotUser } from "@/lib/audit-snapshots";
|
import { snapshotUser } from "@/lib/audit-snapshots";
|
||||||
import { createAuditLog } from "@/lib/audit-log";
|
import { createAuditLog } from "@/lib/audit-log";
|
||||||
import {
|
import { canManageUsers, getLegacyApprovalPreference, normalizeApprovalPermissions } from "@/lib/domain";
|
||||||
APPROVAL_FLOW,
|
|
||||||
canManageUsers,
|
|
||||||
getLegacyApprovalPreference,
|
|
||||||
normalizeApprovalPermissions
|
|
||||||
} from "@/lib/domain";
|
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { getCurrentViewer } from "@/lib/session";
|
import { getCurrentViewer } from "@/lib/session";
|
||||||
|
|
||||||
const userRoleSchema = z.enum(["ADMIN", "FINANCE", "MEMBER"]);
|
const userRoleSchema = z.enum(["BOARD", "ORGA", "FINANCE", "MEMBER"]);
|
||||||
const approvalPermissionSchema = z.enum(APPROVAL_FLOW);
|
|
||||||
|
|
||||||
const createUserSchema = z.object({
|
const createUserSchema = z.object({
|
||||||
username: z.string().trim().min(2).max(40),
|
username: z.string().trim().min(2).max(40),
|
||||||
password: z.string().min(8).max(128),
|
password: z.string().min(8).max(128),
|
||||||
role: userRoleSchema,
|
role: userRoleSchema,
|
||||||
workingGroupId: z.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()]),
|
workingGroupId: z.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()])
|
||||||
approvalPermissions: z.array(approvalPermissionSchema).default([])
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function serializeManagedUser(user: {
|
function serializeManagedUser(user: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: "ADMIN" | "FINANCE" | "MEMBER";
|
role: "BOARD" | "ORGA" | "FINANCE" | "MEMBER";
|
||||||
workingGroupId: string | null;
|
workingGroupId: string | null;
|
||||||
workingGroup: { name: string } | null;
|
workingGroup: { name: string } | null;
|
||||||
approvalPreference: "CHAIR_A" | "CHAIR_B" | "FINANCE" | null;
|
approvalPreference: "CHAIR_A" | "CHAIR_B" | "FINANCE" | null;
|
||||||
@@ -59,7 +52,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageUsers(viewer.role)) {
|
if (!canManageUsers(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Nutzer anlegen." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen duerfen Nutzer anlegen." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
@@ -75,7 +68,7 @@ export async function POST(request: Request) {
|
|||||||
: null;
|
: null;
|
||||||
const approvalPermissions = normalizeApprovalPermissions(
|
const approvalPermissions = normalizeApprovalPermissions(
|
||||||
parsed.data.role,
|
parsed.data.role,
|
||||||
parsed.data.approvalPermissions,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function PATCH(request: Request, { params }: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageBudgets(viewer.role)) {
|
if (!canManageBudgets(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG dürfen AGs bearbeiten." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen AGs bearbeiten." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
@@ -97,7 +97,7 @@ export async function DELETE(_: Request, { params }: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageBudgets(viewer.role)) {
|
if (!canManageBudgets(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG dürfen AGs löschen." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen AGs löschen." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const workingGroup = await prisma.workingGroup.findUnique({
|
const workingGroup = await prisma.workingGroup.findUnique({
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canManageBudgets(viewer.role)) {
|
if (!canManageBudgets(viewer.role)) {
|
||||||
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG dürfen AGs verwalten." }, { status: 403 });
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen AGs verwalten." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import ExpandMoreRoundedIcon from "@mui/icons-material/ExpandMoreRounded";
|
|||||||
import EuroRoundedIcon from "@mui/icons-material/EuroRounded";
|
import EuroRoundedIcon from "@mui/icons-material/EuroRounded";
|
||||||
import ReceiptLongRoundedIcon from "@mui/icons-material/ReceiptLongRounded";
|
import ReceiptLongRoundedIcon from "@mui/icons-material/ReceiptLongRounded";
|
||||||
import TaskAltRoundedIcon from "@mui/icons-material/TaskAltRounded";
|
import TaskAltRoundedIcon from "@mui/icons-material/TaskAltRounded";
|
||||||
|
import UploadFileRoundedIcon from "@mui/icons-material/UploadFileRounded";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -49,6 +50,7 @@ type BudgetColumnProps = {
|
|||||||
onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise<void>;
|
onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise<void>;
|
||||||
onMarkPaid: (expenseId: string) => Promise<void>;
|
onMarkPaid: (expenseId: string) => Promise<void>;
|
||||||
onDocument: (expenseId: string, proofUrl?: string) => Promise<void>;
|
onDocument: (expenseId: string, proofUrl?: string) => Promise<void>;
|
||||||
|
onUploadProof: (expenseId: string, file: File) => Promise<string>;
|
||||||
onSaveWorkingGroup: (groupId: string, name: string) => Promise<void>;
|
onSaveWorkingGroup: (groupId: string, name: string) => Promise<void>;
|
||||||
onDeleteWorkingGroup: (groupId: string, groupName: string) => Promise<void>;
|
onDeleteWorkingGroup: (groupId: string, groupName: string) => Promise<void>;
|
||||||
onSaveBudget: (budgetId: string, name: string, totalBudget: string, colorCode: string) => Promise<void>;
|
onSaveBudget: (budgetId: string, name: string, totalBudget: string, colorCode: string) => Promise<void>;
|
||||||
@@ -140,6 +142,7 @@ export function BudgetColumn({
|
|||||||
onApprove,
|
onApprove,
|
||||||
onMarkPaid,
|
onMarkPaid,
|
||||||
onDocument,
|
onDocument,
|
||||||
|
onUploadProof,
|
||||||
onSaveWorkingGroup,
|
onSaveWorkingGroup,
|
||||||
onDeleteWorkingGroup,
|
onDeleteWorkingGroup,
|
||||||
onSaveBudget,
|
onSaveBudget,
|
||||||
@@ -153,6 +156,7 @@ export function BudgetColumn({
|
|||||||
const [isEditingGroup, setIsEditingGroup] = useState(false);
|
const [isEditingGroup, setIsEditingGroup] = useState(false);
|
||||||
const [groupDraftName, setGroupDraftName] = useState(group.name);
|
const [groupDraftName, setGroupDraftName] = useState(group.name);
|
||||||
const [proofUrlDrafts, setProofUrlDrafts] = useState<Record<string, string>>({});
|
const [proofUrlDrafts, setProofUrlDrafts] = useState<Record<string, string>>({});
|
||||||
|
const [proofFileDrafts, setProofFileDrafts] = useState<Record<string, File | null>>({});
|
||||||
const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({});
|
const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const budgetCardWidth = 352;
|
const budgetCardWidth = 352;
|
||||||
@@ -771,7 +775,17 @@ export function BudgetColumn({
|
|||||||
size="small"
|
size="small"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => onApprove(expense.id, approvalType)}
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Freigabe wirklich setzen?\n\nAusgabe: ${expense.title}\nBetrag: ${formatCurrency(expense.amount)}\nRolle: ${approvalLabel(approvalType)}\n\nMit deiner Freigabe bestaetigst du, dass du die Ausgabe plausibel geprueft hast und die Verantwortung fuer diesen Freigabeschritt uebernimmst.`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onApprove(expense.id, approvalType);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Freigeben als {approvalLabel(approvalType)}
|
Freigeben als {approvalLabel(approvalType)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -818,25 +832,54 @@ export function BudgetColumn({
|
|||||||
{expense.paidAt && !expense.documentedAt && canDocumentExpense(viewer.role) ? (
|
{expense.paidAt && !expense.documentedAt && canDocumentExpense(viewer.role) ? (
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} gap={1}>
|
<Stack direction={{ xs: "column", sm: "row" }} gap={1}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Beleg-URL"
|
label="Beleg"
|
||||||
value={proofUrlDrafts[expense.id] ?? expense.proofUrl ?? ""}
|
value={proofFileDrafts[expense.id]?.name ?? expense.proofUrl ?? ""}
|
||||||
onChange={(event) =>
|
InputProps={{ readOnly: true }}
|
||||||
setProofUrlDrafts((current) => ({
|
|
||||||
...current,
|
|
||||||
[expense.id]: event.target.value
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
size="small"
|
size="small"
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
<Button component="label" size="small" variant="outlined" startIcon={<UploadFileRoundedIcon />} disabled={busy}>
|
||||||
|
Datei
|
||||||
|
<input
|
||||||
|
hidden
|
||||||
|
type="file"
|
||||||
|
accept="image/*,application/pdf"
|
||||||
|
onChange={(event) =>
|
||||||
|
setProofFileDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[expense.id]: event.target.files?.[0] ?? null
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button component="label" size="small" variant="outlined" disabled={busy}>
|
||||||
|
Kamera
|
||||||
|
<input
|
||||||
|
hidden
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
onChange={(event) =>
|
||||||
|
setProofFileDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[expense.id]: event.target.files?.[0] ?? null
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="success"
|
color="success"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() =>
|
onClick={async () => {
|
||||||
onDocument(expense.id, proofUrlDrafts[expense.id] ?? expense.proofUrl ?? undefined)
|
const proofFile = proofFileDrafts[expense.id];
|
||||||
}
|
const proofUrl = proofFile
|
||||||
|
? await onUploadProof(expense.id, proofFile)
|
||||||
|
: proofUrlDrafts[expense.id] ?? expense.proofUrl ?? undefined;
|
||||||
|
|
||||||
|
await onDocument(expense.id, proofUrl);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Dokumentieren
|
Dokumentieren
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded";
|
|||||||
import EditRoundedIcon from "@mui/icons-material/EditRounded";
|
import EditRoundedIcon from "@mui/icons-material/EditRounded";
|
||||||
import KeyRoundedIcon from "@mui/icons-material/KeyRounded";
|
import KeyRoundedIcon from "@mui/icons-material/KeyRounded";
|
||||||
import LogoutRoundedIcon from "@mui/icons-material/LogoutRounded";
|
import LogoutRoundedIcon from "@mui/icons-material/LogoutRounded";
|
||||||
|
import NotificationsActiveRoundedIcon from "@mui/icons-material/NotificationsActiveRounded";
|
||||||
import SavingsRoundedIcon from "@mui/icons-material/SavingsRounded";
|
import SavingsRoundedIcon from "@mui/icons-material/SavingsRounded";
|
||||||
|
import UploadFileRoundedIcon from "@mui/icons-material/UploadFileRounded";
|
||||||
import VerifiedRoundedIcon from "@mui/icons-material/VerifiedRounded";
|
import VerifiedRoundedIcon from "@mui/icons-material/VerifiedRounded";
|
||||||
import WalletRoundedIcon from "@mui/icons-material/WalletRounded";
|
import WalletRoundedIcon from "@mui/icons-material/WalletRounded";
|
||||||
import {
|
import {
|
||||||
@@ -67,7 +69,6 @@ type ExpenseFormState = {
|
|||||||
budgetId: string;
|
budgetId: string;
|
||||||
recurrence: "NONE" | "MONTHLY";
|
recurrence: "NONE" | "MONTHLY";
|
||||||
recurrenceStartAt: string;
|
recurrenceStartAt: string;
|
||||||
proofUrl: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type BudgetFormState = {
|
type BudgetFormState = {
|
||||||
@@ -92,15 +93,13 @@ type ApprovalPermissionValue = (typeof APPROVAL_FLOW)[number];
|
|||||||
type UserFormState = {
|
type UserFormState = {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
role: "ADMIN" | "FINANCE" | "MEMBER";
|
role: "BOARD" | "ORGA" | "FINANCE" | "MEMBER";
|
||||||
workingGroupId: string;
|
workingGroupId: string;
|
||||||
approvalPermissions: ApprovalPermissionValue[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ManagedUserDraft = {
|
type ManagedUserDraft = {
|
||||||
role: "ADMIN" | "FINANCE" | "MEMBER";
|
role: "BOARD" | "ORGA" | "FINANCE" | "MEMBER";
|
||||||
workingGroupId: string;
|
workingGroupId: string;
|
||||||
approvalPermissions: ApprovalPermissionValue[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type PeriodFormState = {
|
type PeriodFormState = {
|
||||||
@@ -135,9 +134,10 @@ function toggleApprovalPermission(
|
|||||||
}
|
}
|
||||||
function sortManagedUsersList(users: DashboardManagedUser[]) {
|
function sortManagedUsersList(users: DashboardManagedUser[]) {
|
||||||
const roleOrder: Record<DashboardManagedUser["role"], number> = {
|
const roleOrder: Record<DashboardManagedUser["role"], number> = {
|
||||||
ADMIN: 0,
|
BOARD: 0,
|
||||||
FINANCE: 1,
|
ORGA: 1,
|
||||||
MEMBER: 2
|
FINANCE: 2,
|
||||||
|
MEMBER: 3
|
||||||
};
|
};
|
||||||
|
|
||||||
return [...users].sort((left, right) => {
|
return [...users].sort((left, right) => {
|
||||||
@@ -235,6 +235,13 @@ async function parseResponse(response: Response) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(value: string) {
|
||||||
|
const padding = "=".repeat((4 - (value.length % 4)) % 4);
|
||||||
|
const base64 = `${value}${padding}`.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
return Uint8Array.from([...rawData], (character) => character.charCodeAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardShell({
|
export function DashboardShell({
|
||||||
viewer,
|
viewer,
|
||||||
workingGroups,
|
workingGroups,
|
||||||
@@ -279,8 +286,7 @@ export function DashboardShell({
|
|||||||
agId: defaultEditableGroup?.id ?? "",
|
agId: defaultEditableGroup?.id ?? "",
|
||||||
budgetId: defaultBudget?.id ?? "",
|
budgetId: defaultBudget?.id ?? "",
|
||||||
recurrence: "NONE",
|
recurrence: "NONE",
|
||||||
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()),
|
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString())
|
||||||
proofUrl: ""
|
|
||||||
});
|
});
|
||||||
const [budgetForm, setBudgetForm] = useState<BudgetFormState>({
|
const [budgetForm, setBudgetForm] = useState<BudgetFormState>({
|
||||||
workingGroupId: visibleGroups[0]?.id ?? "",
|
workingGroupId: visibleGroups[0]?.id ?? "",
|
||||||
@@ -300,8 +306,7 @@ export function DashboardShell({
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
role: "MEMBER",
|
role: "MEMBER",
|
||||||
workingGroupId: visibleGroups[0]?.id ?? "",
|
workingGroupId: visibleGroups[0]?.id ?? ""
|
||||||
approvalPermissions: []
|
|
||||||
});
|
});
|
||||||
const [message, setMessage] = useState<DashboardMessage | null>(null);
|
const [message, setMessage] = useState<DashboardMessage | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -320,6 +325,8 @@ export function DashboardShell({
|
|||||||
const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2));
|
const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2));
|
||||||
const [periodForm, setPeriodForm] = useState<PeriodFormState>(getSuggestedPeriodDraft(currentPeriod));
|
const [periodForm, setPeriodForm] = useState<PeriodFormState>(getSuggestedPeriodDraft(currentPeriod));
|
||||||
const [periodEditForm, setPeriodEditForm] = useState<PeriodEditFormState>(getPeriodEditDraft(currentPeriod));
|
const [periodEditForm, setPeriodEditForm] = useState<PeriodEditFormState>(getPeriodEditDraft(currentPeriod));
|
||||||
|
const [expenseProofFile, setExpenseProofFile] = useState<File | null>(null);
|
||||||
|
const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visibleGroups.length === 0) {
|
if (visibleGroups.length === 0) {
|
||||||
setSelectedMobileGroupId("");
|
setSelectedMobileGroupId("");
|
||||||
@@ -505,8 +512,7 @@ export function DashboardShell({
|
|||||||
function getManagedUserDraft(user: DashboardManagedUser): ManagedUserDraft {
|
function getManagedUserDraft(user: DashboardManagedUser): ManagedUserDraft {
|
||||||
return userDrafts[user.id] ?? {
|
return userDrafts[user.id] ?? {
|
||||||
role: user.role,
|
role: user.role,
|
||||||
workingGroupId: user.workingGroupId ?? "",
|
workingGroupId: user.workingGroupId ?? ""
|
||||||
approvalPermissions: sortApprovalPermissions(user.approvalPermissions)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,8 +531,7 @@ export function DashboardShell({
|
|||||||
...current,
|
...current,
|
||||||
[user.id]: {
|
[user.id]: {
|
||||||
role: user.role,
|
role: user.role,
|
||||||
workingGroupId: user.workingGroupId ?? "",
|
workingGroupId: user.workingGroupId ?? ""
|
||||||
approvalPermissions: sortApprovalPermissions(user.approvalPermissions)
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -621,7 +626,7 @@ export function DashboardShell({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await runAction(async () => {
|
await runAction(async () => {
|
||||||
await parseResponse(
|
const result = (await parseResponse(
|
||||||
await fetch("/api/expenses", {
|
await fetch("/api/expenses", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -634,11 +639,22 @@ export function DashboardShell({
|
|||||||
agId: expenseForm.agId,
|
agId: expenseForm.agId,
|
||||||
budgetId: expenseForm.budgetId,
|
budgetId: expenseForm.budgetId,
|
||||||
recurrence: expenseForm.recurrence,
|
recurrence: expenseForm.recurrence,
|
||||||
recurrenceStartAt: expenseForm.recurrence === "MONTHLY" ? expenseForm.recurrenceStartAt : "",
|
recurrenceStartAt: expenseForm.recurrence === "MONTHLY" ? expenseForm.recurrenceStartAt : ""
|
||||||
proofUrl: expenseForm.proofUrl
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
);
|
)) as { expense?: { id: string } };
|
||||||
|
|
||||||
|
if (expenseProofFile && result.expense?.id) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set("file", expenseProofFile);
|
||||||
|
|
||||||
|
await parseResponse(
|
||||||
|
await fetch(`/api/expenses/${result.expense.id}/proof`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const resetGroup = defaultEditableGroup?.id ?? "";
|
const resetGroup = defaultEditableGroup?.id ?? "";
|
||||||
const resetBudget = defaultEditableGroup?.budgets[0]?.id ?? "";
|
const resetBudget = defaultEditableGroup?.budgets[0]?.id ?? "";
|
||||||
@@ -650,9 +666,9 @@ export function DashboardShell({
|
|||||||
agId: resetGroup,
|
agId: resetGroup,
|
||||||
budgetId: resetBudget,
|
budgetId: resetBudget,
|
||||||
recurrence: "NONE",
|
recurrence: "NONE",
|
||||||
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()),
|
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString())
|
||||||
proofUrl: ""
|
|
||||||
});
|
});
|
||||||
|
setExpenseProofFile(null);
|
||||||
}, "Ausgabe wurde gespeichert.");
|
}, "Ausgabe wurde gespeichert.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,6 +767,20 @@ export function DashboardShell({
|
|||||||
}, "Ausgabe wurde dokumentiert.");
|
}, "Ausgabe wurde dokumentiert.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUploadProof(expenseId: string, file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set("file", file);
|
||||||
|
|
||||||
|
const result = (await parseResponse(
|
||||||
|
await fetch(`/api/expenses/${expenseId}/proof`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
)) as { proofUrl: string };
|
||||||
|
|
||||||
|
return result.proofUrl;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSaveBudget(budgetId: string, name: string, totalBudget: string, colorCode: string) {
|
async function handleSaveBudget(budgetId: string, name: string, totalBudget: string, colorCode: string) {
|
||||||
await runAction(async () => {
|
await runAction(async () => {
|
||||||
await parseResponse(
|
await parseResponse(
|
||||||
@@ -945,8 +975,7 @@ export function DashboardShell({
|
|||||||
username: createdUsername,
|
username: createdUsername,
|
||||||
password: userForm.password,
|
password: userForm.password,
|
||||||
role: userForm.role,
|
role: userForm.role,
|
||||||
workingGroupId: userForm.workingGroupId,
|
workingGroupId: userForm.workingGroupId
|
||||||
approvalPermissions: sortApprovalPermissions(userForm.approvalPermissions)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
)) as { user?: DashboardManagedUser };
|
)) as { user?: DashboardManagedUser };
|
||||||
@@ -961,8 +990,7 @@ export function DashboardShell({
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
role: "MEMBER",
|
role: "MEMBER",
|
||||||
workingGroupId: visibleGroups[0]?.id ?? "",
|
workingGroupId: visibleGroups[0]?.id ?? ""
|
||||||
approvalPermissions: []
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -987,8 +1015,7 @@ export function DashboardShell({
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
role: draft.role,
|
role: draft.role,
|
||||||
workingGroupId: draft.workingGroupId,
|
workingGroupId: draft.workingGroupId
|
||||||
approvalPermissions: sortApprovalPermissions(draft.approvalPermissions)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
)) as { user?: DashboardManagedUser };
|
)) as { user?: DashboardManagedUser };
|
||||||
@@ -1028,6 +1055,51 @@ export function DashboardShell({
|
|||||||
);
|
);
|
||||||
}, `Freigabe-Schwelle wurde auf ${nextThreshold.toFixed(2)} EUR gesetzt.`);
|
}, `Freigabe-Schwelle wurde auf ${nextThreshold.toFixed(2)} EUR gesetzt.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleEnablePushNotifications() {
|
||||||
|
if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) {
|
||||||
|
setPushStatus("unsupported");
|
||||||
|
setMessage({ type: "error", text: "Dieser Browser unterstuetzt Web Push nicht." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
||||||
|
if (!publicKey) {
|
||||||
|
setMessage({ type: "error", text: "VAPID Public Key ist nicht konfiguriert." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== "granted") {
|
||||||
|
setPushStatus("blocked");
|
||||||
|
setMessage({ type: "error", text: "Benachrichtigungen wurden nicht erlaubt." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runAction(async () => {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const existingSubscription = await registration.pushManager.getSubscription();
|
||||||
|
const subscription =
|
||||||
|
existingSubscription ??
|
||||||
|
(await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(publicKey)
|
||||||
|
}));
|
||||||
|
|
||||||
|
await parseResponse(
|
||||||
|
await fetch("/api/push-subscriptions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(subscription.toJSON())
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setPushStatus("enabled");
|
||||||
|
}, "Web Push ist für dieses Gerät aktiviert.");
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDeleteUser(userId: string) {
|
async function handleDeleteUser(userId: string) {
|
||||||
await runAction(async () => {
|
await runAction(async () => {
|
||||||
await parseResponse(
|
await parseResponse(
|
||||||
@@ -1206,7 +1278,7 @@ export function DashboardShell({
|
|||||||
Zeitraum wechseln
|
Zeitraum wechseln
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
{"Nur Vorstand und Finanz-AG können die aktuelle Übersicht global umstellen."}
|
{"Nur Vorstand allgemein, AG Orga und AG Finanzen können die aktuelle Übersicht global umstellen."}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
@@ -1413,6 +1485,17 @@ export function DashboardShell({
|
|||||||
Neue Ausgabe
|
Neue Ausgabe
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
{viewer.approvalPermissions.length > 0 ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={pushStatus === "enabled" ? "contained" : "outlined"}
|
||||||
|
startIcon={<NotificationsActiveRoundedIcon />}
|
||||||
|
disabled={busy || pushStatus === "unsupported"}
|
||||||
|
onClick={handleEnablePushNotifications}
|
||||||
|
>
|
||||||
|
{pushStatus === "enabled" ? "Web Push aktiv" : "Freigabe-Push aktivieren"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Box component="form" onSubmit={handleCreateExpense}>
|
<Box component="form" onSubmit={handleCreateExpense}>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
@@ -1512,11 +1595,33 @@ export function DashboardShell({
|
|||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
<TextField
|
<TextField
|
||||||
label="Beleg-URL (optional)"
|
label="Beleg"
|
||||||
value={expenseForm.proofUrl}
|
value={expenseProofFile?.name ?? ""}
|
||||||
onChange={(event) => setExpenseForm((current) => ({ ...current, proofUrl: event.target.value }))}
|
|
||||||
fullWidth
|
fullWidth
|
||||||
|
InputProps={{ readOnly: true }}
|
||||||
|
helperText="Optional: Bild oder PDF auswählen. Auf Mobilgeräten kann die Kamera angeboten werden."
|
||||||
/>
|
/>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} gap={1} useFlexGap flexWrap="wrap">
|
||||||
|
<Button component="label" variant="outlined" startIcon={<UploadFileRoundedIcon />} disabled={busy}>
|
||||||
|
Beleg auswählen
|
||||||
|
<input
|
||||||
|
hidden
|
||||||
|
type="file"
|
||||||
|
accept="image/*,application/pdf"
|
||||||
|
onChange={(event) => setExpenseProofFile(event.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button component="label" variant="outlined" disabled={busy}>
|
||||||
|
Kamera öffnen
|
||||||
|
<input
|
||||||
|
hidden
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
onChange={(event) => setExpenseProofFile(event.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
@@ -1853,8 +1958,9 @@ export function DashboardShell({
|
|||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<MenuItem value="ADMIN">Vorstand</MenuItem>
|
<MenuItem value="BOARD">Vorstand allgemein</MenuItem>
|
||||||
<MenuItem value="FINANCE">Finanz-AG</MenuItem>
|
<MenuItem value="ORGA">AG Orga</MenuItem>
|
||||||
|
<MenuItem value="FINANCE">AG Finanzen</MenuItem>
|
||||||
<MenuItem value="MEMBER">AG-Mitglied</MenuItem>
|
<MenuItem value="MEMBER">AG-Mitglied</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -1872,7 +1978,7 @@ export function DashboardShell({
|
|||||||
? "Lege zuerst eine AG an."
|
? "Lege zuerst eine AG an."
|
||||||
: userForm.role === "MEMBER"
|
: userForm.role === "MEMBER"
|
||||||
? "AG-Mitglieder brauchen eine feste AG-Zuordnung."
|
? "AG-Mitglieder brauchen eine feste AG-Zuordnung."
|
||||||
: "Optional: Auch Vorstand und Finanz-AG können einer AG zugeordnet werden."
|
: "Optional: Verwaltungsrollen können einer AG zugeordnet werden."
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{userForm.role !== "MEMBER" ? <MenuItem value="">Ohne AG</MenuItem> : null}
|
{userForm.role !== "MEMBER" ? <MenuItem value="">Ohne AG</MenuItem> : null}
|
||||||
@@ -1882,16 +1988,11 @@ export function DashboardShell({
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
{renderApprovalPermissionSelector(
|
<Typography variant="body2" color="text.secondary">
|
||||||
userForm.approvalPermissions,
|
{getAvailableApprovalRoles(userForm.role).length > 0
|
||||||
(approvalType) =>
|
? `Freigabe automatisch: ${getAvailableApprovalRoles(userForm.role).map(approvalLabel).join(", ")}`
|
||||||
setUserForm((current) => ({
|
: "Diese Rolle kann keine Ausgaben freigeben."}
|
||||||
...current,
|
</Typography>
|
||||||
approvalPermissions: toggleApprovalPermission(current.approvalPermissions, approvalType)
|
|
||||||
})),
|
|
||||||
"Lege fest, für welche Freigabeschritte dieses Konto zeichnen darf.",
|
|
||||||
getAvailableApprovalRoles(userForm.role)
|
|
||||||
)}
|
|
||||||
<Button type="submit" variant="outlined" disabled={busy}>
|
<Button type="submit" variant="outlined" disabled={busy}>
|
||||||
Nutzer speichern
|
Nutzer speichern
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1938,7 +2039,7 @@ export function DashboardShell({
|
|||||||
Nutzer verwalten
|
Nutzer verwalten
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography color="text.secondary">
|
<Typography color="text.secondary">
|
||||||
{"Bestehende Passwörter bleiben sicher gehasht. Hier kannst du Rolle, AG-Zuordnung, Freigaberollen und Passwörter pflegen."}
|
{"Bestehende Passwörter bleiben sicher gehasht. Hier kannst du Rolle, AG-Zuordnung und Passwörter pflegen."}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Stack spacing={1.4}>
|
<Stack spacing={1.4}>
|
||||||
@@ -2049,8 +2150,9 @@ export function DashboardShell({
|
|||||||
}}
|
}}
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
<MenuItem value="ADMIN">Vorstand</MenuItem>
|
<MenuItem value="BOARD">Vorstand allgemein</MenuItem>
|
||||||
<MenuItem value="FINANCE">Finanz-AG</MenuItem>
|
<MenuItem value="ORGA">AG Orga</MenuItem>
|
||||||
|
<MenuItem value="FINANCE">AG Finanzen</MenuItem>
|
||||||
<MenuItem value="MEMBER">AG-Mitglied</MenuItem>
|
<MenuItem value="MEMBER">AG-Mitglied</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -2066,7 +2168,7 @@ export function DashboardShell({
|
|||||||
? "Lege zuerst eine AG an."
|
? "Lege zuerst eine AG an."
|
||||||
: draft.role === "MEMBER"
|
: draft.role === "MEMBER"
|
||||||
? "AG-Mitglieder brauchen eine feste AG-Zuordnung."
|
? "AG-Mitglieder brauchen eine feste AG-Zuordnung."
|
||||||
: "Optional: Auch Vorstand und Finanz-AG können einer AG zugeordnet werden."
|
: "Optional: Verwaltungsrollen können einer AG zugeordnet werden."
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{draft.role !== "MEMBER" ? <MenuItem value="">Ohne AG</MenuItem> : null}
|
{draft.role !== "MEMBER" ? <MenuItem value="">Ohne AG</MenuItem> : null}
|
||||||
@@ -2076,15 +2178,11 @@ export function DashboardShell({
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
{renderApprovalPermissionSelector(
|
<Typography variant="body2" color="text.secondary">
|
||||||
draft.approvalPermissions,
|
{getAvailableApprovalRoles(draft.role).length > 0
|
||||||
(approvalType) =>
|
? `Freigabe automatisch: ${getAvailableApprovalRoles(draft.role).map(approvalLabel).join(", ")}`
|
||||||
updateManagedUserDraft(user, {
|
: "Diese Rolle kann keine Ausgaben freigeben."}
|
||||||
approvalPermissions: toggleApprovalPermission(draft.approvalPermissions, approvalType)
|
</Typography>
|
||||||
}),
|
|
||||||
"Lege fest, welche Freigabeschritte dieses Konto autorisieren darf.",
|
|
||||||
getAvailableApprovalRoles(draft.role)
|
|
||||||
)}
|
|
||||||
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||||
<Button type="button" variant="contained" disabled={busy} onClick={() => handleUpdateUser(user)}>
|
<Button type="button" variant="contained" disabled={busy} onClick={() => handleUpdateUser(user)}>
|
||||||
Nutzer speichern
|
Nutzer speichern
|
||||||
@@ -2321,6 +2419,7 @@ export function DashboardShell({
|
|||||||
onApprove={handleApprove}
|
onApprove={handleApprove}
|
||||||
onMarkPaid={handleMarkPaid}
|
onMarkPaid={handleMarkPaid}
|
||||||
onDocument={handleDocument}
|
onDocument={handleDocument}
|
||||||
|
onUploadProof={handleUploadProof}
|
||||||
onSaveWorkingGroup={handleSaveWorkingGroup}
|
onSaveWorkingGroup={handleSaveWorkingGroup}
|
||||||
onDeleteWorkingGroup={handleDeleteWorkingGroup}
|
onDeleteWorkingGroup={handleDeleteWorkingGroup}
|
||||||
onSaveBudget={handleSaveBudget}
|
onSaveBudget={handleSaveBudget}
|
||||||
@@ -2363,6 +2462,7 @@ export function DashboardShell({
|
|||||||
onApprove={handleApprove}
|
onApprove={handleApprove}
|
||||||
onMarkPaid={handleMarkPaid}
|
onMarkPaid={handleMarkPaid}
|
||||||
onDocument={handleDocument}
|
onDocument={handleDocument}
|
||||||
|
onUploadProof={handleUploadProof}
|
||||||
onSaveWorkingGroup={handleSaveWorkingGroup}
|
onSaveWorkingGroup={handleSaveWorkingGroup}
|
||||||
onDeleteWorkingGroup={handleDeleteWorkingGroup}
|
onDeleteWorkingGroup={handleDeleteWorkingGroup}
|
||||||
onSaveBudget={handleSaveBudget}
|
onSaveBudget={handleSaveBudget}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const COLOR_PRESETS = [
|
|||||||
"#3FAF88"
|
"#3FAF88"
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type AppRole = "ADMIN" | "FINANCE" | "MEMBER";
|
export type AppRole = "BOARD" | "ORGA" | "FINANCE" | "MEMBER";
|
||||||
export type ApprovalTypeValue = (typeof APPROVAL_FLOW)[number];
|
export type ApprovalTypeValue = (typeof APPROVAL_FLOW)[number];
|
||||||
export type ApprovalStatusValue = "PENDING" | "APPROVED";
|
export type ApprovalStatusValue = "PENDING" | "APPROVED";
|
||||||
export type ExpenseRecurrenceValue = "NONE" | "MONTHLY";
|
export type ExpenseRecurrenceValue = "NONE" | "MONTHLY";
|
||||||
@@ -25,10 +25,12 @@ export function requiresManualApproval(amount: number, approvalThreshold = DEFAU
|
|||||||
|
|
||||||
export function roleLabel(role: AppRole) {
|
export function roleLabel(role: AppRole) {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case "ADMIN":
|
case "BOARD":
|
||||||
return "Vorstand";
|
return "Vorstand allgemein";
|
||||||
|
case "ORGA":
|
||||||
|
return "AG Orga";
|
||||||
case "FINANCE":
|
case "FINANCE":
|
||||||
return "Finanz-AG";
|
return "AG Finanzen";
|
||||||
case "MEMBER":
|
case "MEMBER":
|
||||||
return "AG-Mitglied";
|
return "AG-Mitglied";
|
||||||
}
|
}
|
||||||
@@ -37,11 +39,11 @@ export function roleLabel(role: AppRole) {
|
|||||||
export function approvalLabel(approvalType: ApprovalTypeValue) {
|
export function approvalLabel(approvalType: ApprovalTypeValue) {
|
||||||
switch (approvalType) {
|
switch (approvalType) {
|
||||||
case "CHAIR_A":
|
case "CHAIR_A":
|
||||||
return "Vorstand A";
|
return "AG Orga";
|
||||||
case "CHAIR_B":
|
case "CHAIR_B":
|
||||||
return "Vorstand B";
|
return "Vorstand allgemein";
|
||||||
case "FINANCE":
|
case "FINANCE":
|
||||||
return "Finanz-AG";
|
return "AG Finanzen";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +57,7 @@ export function recurrenceLabel(recurrence: ExpenseRecurrenceValue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function hasAdministrativeAccess(role: AppRole) {
|
export function hasAdministrativeAccess(role: AppRole) {
|
||||||
return role === "ADMIN" || role === "FINANCE";
|
return role === "BOARD" || role === "ORGA" || role === "FINANCE";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canManageBudgets(role: AppRole) {
|
export function canManageBudgets(role: AppRole) {
|
||||||
@@ -67,11 +69,11 @@ export function canManageUsers(role: AppRole) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function canMarkPaid(role: AppRole) {
|
export function canMarkPaid(role: AppRole) {
|
||||||
return hasAdministrativeAccess(role);
|
return role === "BOARD" || role === "FINANCE";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canDocumentExpense(role: AppRole) {
|
export function canDocumentExpense(role: AppRole) {
|
||||||
return hasAdministrativeAccess(role);
|
return role === "BOARD" || role === "FINANCE";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canCreateExpenseForGroup(role: AppRole, viewerGroupId: string | null, targetGroupId: string) {
|
export function canCreateExpenseForGroup(role: AppRole, viewerGroupId: string | null, targetGroupId: string) {
|
||||||
@@ -90,7 +92,7 @@ export function canDeleteExpense(
|
|||||||
paidAt: string | null,
|
paidAt: string | null,
|
||||||
documentedAt: string | null
|
documentedAt: string | null
|
||||||
) {
|
) {
|
||||||
if (role === "ADMIN" || role === "FINANCE") {
|
if (hasAdministrativeAccess(role)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,8 +101,10 @@ export function canDeleteExpense(
|
|||||||
|
|
||||||
export function getAvailableApprovalRoles(role: AppRole): ApprovalTypeValue[] {
|
export function getAvailableApprovalRoles(role: AppRole): ApprovalTypeValue[] {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case "ADMIN":
|
case "BOARD":
|
||||||
return ["CHAIR_A", "CHAIR_B"];
|
return ["CHAIR_B"];
|
||||||
|
case "ORGA":
|
||||||
|
return ["CHAIR_A"];
|
||||||
case "FINANCE":
|
case "FINANCE":
|
||||||
return ["FINANCE"];
|
return ["FINANCE"];
|
||||||
case "MEMBER":
|
case "MEMBER":
|
||||||
@@ -109,24 +113,11 @@ export function getAvailableApprovalRoles(role: AppRole): ApprovalTypeValue[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeApprovalPermissions(
|
export function normalizeApprovalPermissions(
|
||||||
_role: AppRole,
|
role: AppRole,
|
||||||
approvalPermissions: ApprovalTypeValue[] | null | undefined,
|
_approvalPermissions: ApprovalTypeValue[] | null | undefined,
|
||||||
approvalPreference: ApprovalTypeValue | null | undefined = null
|
_approvalPreference: ApprovalTypeValue | null | undefined = null
|
||||||
) {
|
) {
|
||||||
const rawPermissions = approvalPermissions ?? (approvalPreference ? [approvalPreference] : []);
|
return getAvailableApprovalRoles(role);
|
||||||
|
|
||||||
// Deduplizierung: behalte jeden Eintrag nur beim ersten Vorkommen
|
|
||||||
const seen = new Set<ApprovalTypeValue>();
|
|
||||||
const unique: ApprovalTypeValue[] = [];
|
|
||||||
for (const perm of rawPermissions) {
|
|
||||||
if (!seen.has(perm)) {
|
|
||||||
seen.add(perm);
|
|
||||||
unique.push(perm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sortiere nach der Reihenfolge in APPROVAL_FLOW
|
|
||||||
return APPROVAL_FLOW.filter((type) => unique.includes(type)) as ApprovalTypeValue[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLegacyApprovalPreference(approvalPermissions: ApprovalTypeValue[]) {
|
export function getLegacyApprovalPreference(approvalPermissions: ApprovalTypeValue[]) {
|
||||||
|
|||||||
72
src/lib/google-drive.ts
Normal file
72
src/lib/google-drive.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { google } from "googleapis";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
|
||||||
|
const DEFAULT_DRIVE_FOLDER_ID = "12zMANi_J0uvie16LUxSmfeqwGjKawEhJ";
|
||||||
|
|
||||||
|
function getDriveClient() {
|
||||||
|
const clientEmail = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL;
|
||||||
|
const privateKey = process.env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY?.replace(/\\n/g, "\n");
|
||||||
|
|
||||||
|
if (!clientEmail || !privateKey) {
|
||||||
|
throw new Error("Google-Drive-Service-Account ist nicht konfiguriert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = new google.auth.JWT({
|
||||||
|
email: clientEmail,
|
||||||
|
key: privateKey,
|
||||||
|
scopes: ["https://www.googleapis.com/auth/drive.file"]
|
||||||
|
});
|
||||||
|
|
||||||
|
return google.drive({ version: "v3", auth });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeDriveFileName(title: string, fallback = "beleg") {
|
||||||
|
const sanitized = title
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 80);
|
||||||
|
|
||||||
|
return sanitized || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadExpenseProofToDrive(input: {
|
||||||
|
title: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
buffer: Buffer;
|
||||||
|
}) {
|
||||||
|
const drive = getDriveClient();
|
||||||
|
const folderId = process.env.GOOGLE_DRIVE_FOLDER_ID || DEFAULT_DRIVE_FOLDER_ID;
|
||||||
|
const extension = input.fileName.includes(".") ? `.${input.fileName.split(".").pop()}` : "";
|
||||||
|
const baseName = sanitizeDriveFileName(input.title);
|
||||||
|
const uniqueSuffix = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||||
|
const name = `${baseName}-${uniqueSuffix}${extension}`;
|
||||||
|
|
||||||
|
const response = await drive.files.create({
|
||||||
|
requestBody: {
|
||||||
|
name,
|
||||||
|
parents: [folderId]
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
mimeType: input.mimeType,
|
||||||
|
body: Readable.from(input.buffer)
|
||||||
|
},
|
||||||
|
fields: "id, webViewLink"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.data.id) {
|
||||||
|
throw new Error("Google Drive hat keine Datei-ID zurueckgegeben.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await drive.permissions.create({
|
||||||
|
fileId: response.data.id,
|
||||||
|
requestBody: {
|
||||||
|
type: "anyone",
|
||||||
|
role: "reader"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.webViewLink ?? `https://drive.google.com/file/d/${response.data.id}/view`;
|
||||||
|
}
|
||||||
94
src/lib/push-notifications.ts
Normal file
94
src/lib/push-notifications.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import webpush from "web-push";
|
||||||
|
import type { ApprovalType } from "@prisma/client";
|
||||||
|
|
||||||
|
import { approvalLabel } from "@/lib/domain";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
type PushTargetExpense = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
amount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function configureWebPush() {
|
||||||
|
const publicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
||||||
|
const privateKey = process.env.VAPID_PRIVATE_KEY;
|
||||||
|
const subject = process.env.VAPID_SUBJECT;
|
||||||
|
|
||||||
|
if (!publicKey || !privateKey || !subject) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
webpush.setVapidDetails(subject, publicKey, privateKey);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApprovalRole(approvalType: ApprovalType) {
|
||||||
|
switch (approvalType) {
|
||||||
|
case "CHAIR_A":
|
||||||
|
return "ORGA";
|
||||||
|
case "CHAIR_B":
|
||||||
|
return "BOARD";
|
||||||
|
case "FINANCE":
|
||||||
|
return "FINANCE";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyApprovalRequest(expense: PushTargetExpense, approvalTypes: ApprovalType[]) {
|
||||||
|
if (!configureWebPush() || approvalTypes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = approvalTypes.map(getApprovalRole);
|
||||||
|
const subscriptions = await prisma.pushSubscription.findMany({
|
||||||
|
where: {
|
||||||
|
user: {
|
||||||
|
role: {
|
||||||
|
in: roles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
role: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
subscriptions.map(async (subscription) => {
|
||||||
|
const approvalType = approvalTypes.find((type) => getApprovalRole(type) === subscription.user.role);
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title: "Freigabe angefragt",
|
||||||
|
body: `${expense.title} (${expense.amount.toFixed(2)} EUR) braucht ${approvalType ? approvalLabel(approvalType) : "deine Freigabe"}.`,
|
||||||
|
url: `/?expense=${encodeURIComponent(expense.id)}`,
|
||||||
|
tag: `approval-${expense.id}-${subscription.user.role}`
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(
|
||||||
|
{
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
keys: {
|
||||||
|
p256dh: subscription.p256dh,
|
||||||
|
auth: subscription.auth
|
||||||
|
}
|
||||||
|
},
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const statusCode = typeof error === "object" && error && "statusCode" in error ? error.statusCode : null;
|
||||||
|
|
||||||
|
if (statusCode === 404 || statusCode === 410) {
|
||||||
|
await prisma.pushSubscription.delete({
|
||||||
|
where: {
|
||||||
|
endpoint: subscription.endpoint
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user