diff --git a/.env.example b/.env.example index 997a2b3..f59843d 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,9 @@ POSTGRES_PASSWORD="change-this-db-password" DATABASE_URL="postgresql://postgres:postgres@db:5432/rave_budget_control?schema=public" NEXTAUTH_URL="http://localhost:3000" 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" diff --git a/README.md b/README.md index 08ddd02..e0bea9f 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,10 @@ Material-3-orientierte Finanzübersicht für Vereins-AGs mit rollenbasierter Fre - Horizontale Budget-Übersicht pro AG - Mehrstufige Freigaben mit Rollenlogik +- Web-Push-Benachrichtigungen für Freigabeanforderungen - Budgets, Zeiträume, Nutzerverwaltung und Audit-Log - Statusaktionen für Freigeben, Bezahlt und Dokumentiert +- Beleg-Upload für Bilder und PDFs in Google Drive - CSV-Backup mit Import und Restore-Grundlage - PWA-Manifest und Service-Worker-Basis @@ -36,6 +38,6 @@ Der Seed legt die Grundeinstellungen, den aktiven Zeitraum, AGs, Budgets und Bas ## Hinweise -- Die Dokumentation eines Belegs ist aktuell als Beleg-URL umgesetzt. -- Web-Push ist architektonisch vorbereitet, aber noch nicht implementiert. +- Für Web Push müssen `NEXT_PUBLIC_VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` und `VAPID_SUBJECT` gesetzt sein. +- 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. diff --git a/docker-compose.yml b/docker-compose.yml index 27f5b38..4450305 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,12 @@ services: 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_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: - "3000:3000" diff --git a/package-lock.json b/package-lock.json index 6446c2f..08b91ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,14 @@ "@mui/icons-material": "^6.1.3", "@mui/material": "^6.1.3", "@prisma/client": "^5.20.0", + "@types/web-push": "^3.6.4", "bcryptjs": "^2.4.3", + "googleapis": "^171.4.0", "next": "^15.5.15", "next-auth": "^4.24.8", "react": "^18.3.1", "react-dom": "^18.3.1", + "web-push": "^3.6.7", "zod": "^3.23.8" }, "devDependencies": { @@ -1314,7 +1317,6 @@ "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1361,6 +1363,36 @@ "@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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -1376,12 +1408,82 @@ "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": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1463,6 +1565,15 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "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": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1500,6 +1611,29 @@ "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": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -1509,6 +1643,15 @@ "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": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -1518,6 +1661,18 @@ "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": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -1572,12 +1727,53 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", "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": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1587,6 +1783,71 @@ "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": { "version": "4.14.0", "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" } }, + "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": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -1627,6 +1967,28 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "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": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -1643,6 +2005,12 @@ "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": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -1691,12 +2059,42 @@ "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": { "version": "2.3.1", "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==", "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": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -1727,6 +2125,30 @@ "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": { "version": "2.1.3", "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": { "version": "0.9.15", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", @@ -1859,6 +2319,18 @@ "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": { "version": "5.2.0", "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==", "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": { "version": "18.3.1", "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" } }, + "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": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -2181,6 +2694,78 @@ "@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": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -2284,9 +2869,14 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "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": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -2296,6 +2886,34 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/package.json b/package.json index 3c446a2..ae49e33 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,14 @@ "@mui/icons-material": "^6.1.3", "@mui/material": "^6.1.3", "@prisma/client": "^5.20.0", + "@types/web-push": "^3.6.4", "bcryptjs": "^2.4.3", + "googleapis": "^171.4.0", "next": "^15.5.15", "next-auth": "^4.24.8", "react": "^18.3.1", "react-dom": "^18.3.1", + "web-push": "^3.6.7", "zod": "^3.23.8" }, "devDependencies": { diff --git a/prisma/migrations/202605011200_roles_push_uploads/migration.sql b/prisma/migrations/202605011200_roles_push_uploads/migration.sql new file mode 100644 index 0000000..e822fae --- /dev/null +++ b/prisma/migrations/202605011200_roles_push_uploads/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7f45b58..526504e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,5 +1,6 @@ generator client { provider = "prisma-client-js" + output = "../node_modules/.prisma/client" } datasource db { @@ -8,7 +9,8 @@ datasource db { } enum Role { - ADMIN + BOARD + ORGA FINANCE MEMBER } @@ -43,12 +45,27 @@ model User { createdExpenses Expense[] @relation("ExpenseCreator") approvals Approval[] auditLogs AuditLog[] + pushSubscriptions PushSubscription[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@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 { id String @id @default(cuid()) name String @unique diff --git a/prisma/seed.ts b/prisma/seed.ts index 9fd8f37..825658a 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -131,29 +131,33 @@ async function main() { const deko = await upsertWorkingGroup("AG Deko"); const awareness = await upsertWorkingGroup("AG Awareness"); 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(awareness.id, currentPeriod.id, "Awareness Hauptbudget", 800, "#68A35D", 250); await upsertBudget(technik.id, currentPeriod.id, "Technik Infrastruktur", 1500, "#5677F6", 500); await upsertUser({ - username: "vorstand-a", - role: Role.ADMIN, + username: "vorstand", + role: Role.BOARD, passwordHash, - approvalPermissions: [ApprovalType.CHAIR_A] + approvalPermissions: [ApprovalType.CHAIR_B] }); await upsertUser({ - username: "vorstand-b", - role: Role.ADMIN, + username: "orga", + role: Role.ORGA, passwordHash, - approvalPermissions: [ApprovalType.CHAIR_B] + workingGroupId: orga.id, + approvalPermissions: [ApprovalType.CHAIR_A] }); await upsertUser({ username: "finanzen", role: Role.FINANCE, passwordHash, + workingGroupId: finanzen.id, approvalPermissions: [ApprovalType.FINANCE] }); @@ -184,4 +188,4 @@ main() console.error(error); await prisma.$disconnect(); process.exit(1); - }); \ No newline at end of file + }); diff --git a/public/sw.js b/public/sw.js index 93e4f29..43565bf 100644 --- a/public/sw.js +++ b/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); + }) + ); +}); diff --git a/src/app/api/audit-logs/[id]/restore/route.ts b/src/app/api/audit-logs/[id]/restore/route.ts index 490142f..bde2513 100644 --- a/src/app/api/audit-logs/[id]/restore/route.ts +++ b/src/app/api/audit-logs/[id]/restore/route.ts @@ -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) { const { id } = await params; const viewer = await getCurrentViewer(); @@ -73,7 +91,7 @@ export async function POST(_: Request, { params }: Context) { } 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({ @@ -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."); } - if (user.role === "ADMIN") { + if (user.role === "BOARD") { const adminCount = await tx.user.count({ where: { - role: "ADMIN" + role: "BOARD" } }); @@ -411,7 +429,7 @@ export async function POST(_: Request, { params }: Context) { username: asString(deleted.username, "Login-Name"), email: asNullableString(deleted.email), 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, approvalPermissions: asApprovalPermissions(deleted.approvalPermissions), workingGroupId: asNullableString(deleted.workingGroupId), @@ -423,7 +441,7 @@ export async function POST(_: Request, { params }: Context) { case "user.update": { 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({ where: { diff --git a/src/app/api/budgets/[id]/route.ts b/src/app/api/budgets/[id]/route.ts index dc89fde..4dafa77 100644 --- a/src/app/api/budgets/[id]/route.ts +++ b/src/app/api/budgets/[id]/route.ts @@ -40,7 +40,7 @@ export async function PATCH(request: Request, { params }: Context) { } 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({ @@ -115,7 +115,7 @@ export async function DELETE(_: Request, { params }: Context) { } 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({ diff --git a/src/app/api/budgets/route.ts b/src/app/api/budgets/route.ts index 928ffe7..43bcc76 100644 --- a/src/app/api/budgets/route.ts +++ b/src/app/api/budgets/route.ts @@ -34,7 +34,7 @@ export async function POST(request: Request) { } 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); diff --git a/src/app/api/expenses/[id]/documented/route.ts b/src/app/api/expenses/[id]/documented/route.ts index d9b30fe..2127d79 100644 --- a/src/app/api/expenses/[id]/documented/route.ts +++ b/src/app/api/expenses/[id]/documented/route.ts @@ -27,7 +27,7 @@ export async function POST(request: Request, { params }: Context) { } 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({ @@ -50,7 +50,7 @@ export async function POST(request: Request, { params }: Context) { const parsed = documentedSchema.safeParse(body); 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({ diff --git a/src/app/api/expenses/[id]/paid/route.ts b/src/app/api/expenses/[id]/paid/route.ts index 884371f..210e2f2 100644 --- a/src/app/api/expenses/[id]/paid/route.ts +++ b/src/app/api/expenses/[id]/paid/route.ts @@ -20,7 +20,7 @@ export async function POST(_: Request, { params }: Context) { } 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({ diff --git a/src/app/api/expenses/[id]/proof/route.ts b/src/app/api/expenses/[id]/proof/route.ts new file mode 100644 index 0000000..62942f6 --- /dev/null +++ b/src/app/api/expenses/[id]/proof/route.ts @@ -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 }); +} diff --git a/src/app/api/expenses/[id]/route.ts b/src/app/api/expenses/[id]/route.ts index 4ae69d8..5290c57 100644 --- a/src/app/api/expenses/[id]/route.ts +++ b/src/app/api/expenses/[id]/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { snapshotExpense } from "@/lib/audit-snapshots"; import { createAuditLog } from "@/lib/audit-log"; +import { hasAdministrativeAccess } from "@/lib/domain"; import prisma from "@/lib/prisma"; 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 }); } - const isAdminDelete = viewer.role === "ADMIN" || viewer.role === "FINANCE"; + const isAdminDelete = hasAdministrativeAccess(viewer.role); const isOwnPendingExpense = viewer.id === expense.creatorId && expense.approvalStatus === "PENDING" && diff --git a/src/app/api/expenses/route.ts b/src/app/api/expenses/route.ts index 6b7e980..3942d1e 100644 --- a/src/app/api/expenses/route.ts +++ b/src/app/api/expenses/route.ts @@ -4,8 +4,9 @@ import { z } from "zod"; import { getAppSettings, toApprovalThresholdNumber } from "@/lib/app-settings"; import { snapshotExpense } from "@/lib/audit-snapshots"; 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 { notifyApprovalRequest } from "@/lib/push-notifications"; import { getCurrentViewer } from "@/lib/session"; function parseDateInput(value: string) { @@ -100,6 +101,7 @@ export async function POST(request: Request) { ? parsed.data.recurrenceStartAt : null; + const needsManualApproval = requiresManualApproval(parsed.data.amount, approvalThreshold); const expense = await prisma.expense.create({ data: { title: parsed.data.title, @@ -112,10 +114,21 @@ export async function POST(request: Request) { proofUrl: parsed.data.proofUrl, recurrence: parsed.data.recurrence, 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, { actorId: viewer.id, action: "expense.create", diff --git a/src/app/api/export/csv/route.ts b/src/app/api/export/csv/route.ts index 41dc291..062b20e 100644 --- a/src/app/api/export/csv/route.ts +++ b/src/app/api/export/csv/route.ts @@ -67,7 +67,7 @@ export async function GET() { } 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([ diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index f7a047c..96f1135 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -28,9 +28,24 @@ function toNumber(value: string | undefined) { 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( value: string | undefined, - role: "ADMIN" | "FINANCE" | "MEMBER", + role: "BOARD" | "ORGA" | "FINANCE" | "MEMBER", approvalPreference: "CHAIR_A" | "CHAIR_B" | "FINANCE" | null ) { const explicitPermissions = value @@ -51,7 +66,7 @@ export async function POST(request: Request) { } 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); @@ -145,7 +160,7 @@ export async function POST(request: Request) { } 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 approvalPermissions = toApprovalPermissions(row.approvalPermissions, role, approvalPreference); diff --git a/src/app/api/periods/[id]/route.ts b/src/app/api/periods/[id]/route.ts index 864f9dd..b5bd82f 100644 --- a/src/app/api/periods/[id]/route.ts +++ b/src/app/api/periods/[id]/route.ts @@ -28,7 +28,7 @@ export async function PATCH(request: Request, { params }: Context) { } 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); @@ -110,7 +110,7 @@ export async function DELETE(_: Request, { params }: Context) { } 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({ diff --git a/src/app/api/periods/current/route.ts b/src/app/api/periods/current/route.ts index 13e3577..936c8c5 100644 --- a/src/app/api/periods/current/route.ts +++ b/src/app/api/periods/current/route.ts @@ -18,7 +18,7 @@ export async function PATCH(request: Request) { } 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); diff --git a/src/app/api/periods/route.ts b/src/app/api/periods/route.ts index a4d0425..f1cd20f 100644 --- a/src/app/api/periods/route.ts +++ b/src/app/api/periods/route.ts @@ -22,7 +22,7 @@ export async function POST(request: Request) { } 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); diff --git a/src/app/api/push-subscriptions/route.ts b/src/app/api/push-subscriptions/route.ts new file mode 100644 index 0000000..e94a719 --- /dev/null +++ b/src/app/api/push-subscriptions/route.ts @@ -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 }); +} diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index ccc17c0..68312af 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -20,7 +20,7 @@ export async function PATCH(request: Request) { } 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); diff --git a/src/app/api/users/[id]/password/route.ts b/src/app/api/users/[id]/password/route.ts index 7feaa7d..0bac0ba 100644 --- a/src/app/api/users/[id]/password/route.ts +++ b/src/app/api/users/[id]/password/route.ts @@ -26,7 +26,7 @@ export async function POST(request: Request, { params }: Context) { } 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); diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts index a423e08..7b24bc9 100644 --- a/src/app/api/users/[id]/route.ts +++ b/src/app/api/users/[id]/route.ts @@ -3,29 +3,22 @@ import { z } from "zod"; import { snapshotUser } from "@/lib/audit-snapshots"; import { createAuditLog } from "@/lib/audit-log"; -import { - APPROVAL_FLOW, - canManageUsers, - getLegacyApprovalPreference, - normalizeApprovalPermissions -} from "@/lib/domain"; +import { canManageUsers, getLegacyApprovalPreference, normalizeApprovalPermissions } from "@/lib/domain"; import prisma from "@/lib/prisma"; import { getCurrentViewer } from "@/lib/session"; -const userRoleSchema = z.enum(["ADMIN", "FINANCE", "MEMBER"]); -const approvalPermissionSchema = z.enum(APPROVAL_FLOW); +const userRoleSchema = z.enum(["BOARD", "ORGA", "FINANCE", "MEMBER"]); const updateUserSchema = z.object({ role: userRoleSchema, - workingGroupId: z.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()]), - approvalPermissions: z.array(approvalPermissionSchema).default([]) + workingGroupId: z.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()]) }); function serializeManagedUser(user: { id: string; name: string; username: string; - role: "ADMIN" | "FINANCE" | "MEMBER"; + role: "BOARD" | "ORGA" | "FINANCE" | "MEMBER"; workingGroupId: string | null; workingGroup: { name: string } | null; approvalPreference: "CHAIR_A" | "CHAIR_B" | "FINANCE" | null; @@ -63,14 +56,14 @@ export async function PATCH(request: Request, { params }: Context) { } 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 parsed = updateUserSchema.safeParse(body); 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({ @@ -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({ - where: { role: "ADMIN" } + where: { role: "BOARD" } }); 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 previousSnapshot = snapshotUser(user); @@ -172,7 +165,7 @@ export async function DELETE(_: Request, { params }: Context) { } 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) { @@ -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({ - where: { role: "ADMIN" } + where: { role: "BOARD" } }); 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 }); } } diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index b4c1a56..748a86a 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -4,31 +4,24 @@ import { z } from "zod"; import { snapshotUser } from "@/lib/audit-snapshots"; import { createAuditLog } from "@/lib/audit-log"; -import { - APPROVAL_FLOW, - canManageUsers, - getLegacyApprovalPreference, - normalizeApprovalPermissions -} from "@/lib/domain"; +import { canManageUsers, getLegacyApprovalPreference, normalizeApprovalPermissions } from "@/lib/domain"; import prisma from "@/lib/prisma"; import { getCurrentViewer } from "@/lib/session"; -const userRoleSchema = z.enum(["ADMIN", "FINANCE", "MEMBER"]); -const approvalPermissionSchema = z.enum(APPROVAL_FLOW); +const userRoleSchema = z.enum(["BOARD", "ORGA", "FINANCE", "MEMBER"]); const createUserSchema = z.object({ username: z.string().trim().min(2).max(40), password: z.string().min(8).max(128), role: userRoleSchema, - workingGroupId: z.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()]), - approvalPermissions: z.array(approvalPermissionSchema).default([]) + workingGroupId: z.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()]) }); function serializeManagedUser(user: { id: string; name: string; username: string; - role: "ADMIN" | "FINANCE" | "MEMBER"; + role: "BOARD" | "ORGA" | "FINANCE" | "MEMBER"; workingGroupId: string | null; workingGroup: { name: string } | null; approvalPreference: "CHAIR_A" | "CHAIR_B" | "FINANCE" | null; @@ -59,7 +52,7 @@ export async function POST(request: Request) { } 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); @@ -75,7 +68,7 @@ export async function POST(request: Request) { : null; const approvalPermissions = normalizeApprovalPermissions( parsed.data.role, - parsed.data.approvalPermissions, + null, null ); diff --git a/src/app/api/working-groups/[id]/route.ts b/src/app/api/working-groups/[id]/route.ts index 0151fe2..fe21b8e 100644 --- a/src/app/api/working-groups/[id]/route.ts +++ b/src/app/api/working-groups/[id]/route.ts @@ -26,7 +26,7 @@ export async function PATCH(request: Request, { params }: Context) { } 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); @@ -97,7 +97,7 @@ export async function DELETE(_: Request, { params }: Context) { } 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({ diff --git a/src/app/api/working-groups/route.ts b/src/app/api/working-groups/route.ts index 7300ffd..54ce252 100644 --- a/src/app/api/working-groups/route.ts +++ b/src/app/api/working-groups/route.ts @@ -19,7 +19,7 @@ export async function POST(request: Request) { } 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); diff --git a/src/components/dashboard/budget-column.tsx b/src/components/dashboard/budget-column.tsx index 86f044f..e32c45f 100644 --- a/src/components/dashboard/budget-column.tsx +++ b/src/components/dashboard/budget-column.tsx @@ -10,6 +10,7 @@ import ExpandMoreRoundedIcon from "@mui/icons-material/ExpandMoreRounded"; import EuroRoundedIcon from "@mui/icons-material/EuroRounded"; import ReceiptLongRoundedIcon from "@mui/icons-material/ReceiptLongRounded"; import TaskAltRoundedIcon from "@mui/icons-material/TaskAltRounded"; +import UploadFileRoundedIcon from "@mui/icons-material/UploadFileRounded"; import { Box, Button, @@ -49,6 +50,7 @@ type BudgetColumnProps = { onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise; onMarkPaid: (expenseId: string) => Promise; onDocument: (expenseId: string, proofUrl?: string) => Promise; + onUploadProof: (expenseId: string, file: File) => Promise; onSaveWorkingGroup: (groupId: string, name: string) => Promise; onDeleteWorkingGroup: (groupId: string, groupName: string) => Promise; onSaveBudget: (budgetId: string, name: string, totalBudget: string, colorCode: string) => Promise; @@ -140,6 +142,7 @@ export function BudgetColumn({ onApprove, onMarkPaid, onDocument, + onUploadProof, onSaveWorkingGroup, onDeleteWorkingGroup, onSaveBudget, @@ -153,6 +156,7 @@ export function BudgetColumn({ const [isEditingGroup, setIsEditingGroup] = useState(false); const [groupDraftName, setGroupDraftName] = useState(group.name); const [proofUrlDrafts, setProofUrlDrafts] = useState>({}); + const [proofFileDrafts, setProofFileDrafts] = useState>({}); const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState>({}); const budgetCardWidth = 352; @@ -771,7 +775,17 @@ export function BudgetColumn({ size="small" variant="contained" 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)} @@ -818,25 +832,54 @@ export function BudgetColumn({ {expense.paidAt && !expense.documentedAt && canDocumentExpense(viewer.role) ? ( - setProofUrlDrafts((current) => ({ - ...current, - [expense.id]: event.target.value - })) - } + label="Beleg" + value={proofFileDrafts[expense.id]?.name ?? expense.proofUrl ?? ""} + InputProps={{ readOnly: true }} size="small" fullWidth /> + + diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index c017672..2df6b96 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -6,7 +6,9 @@ import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded"; import EditRoundedIcon from "@mui/icons-material/EditRounded"; import KeyRoundedIcon from "@mui/icons-material/KeyRounded"; import LogoutRoundedIcon from "@mui/icons-material/LogoutRounded"; +import NotificationsActiveRoundedIcon from "@mui/icons-material/NotificationsActiveRounded"; import SavingsRoundedIcon from "@mui/icons-material/SavingsRounded"; +import UploadFileRoundedIcon from "@mui/icons-material/UploadFileRounded"; import VerifiedRoundedIcon from "@mui/icons-material/VerifiedRounded"; import WalletRoundedIcon from "@mui/icons-material/WalletRounded"; import { @@ -67,7 +69,6 @@ type ExpenseFormState = { budgetId: string; recurrence: "NONE" | "MONTHLY"; recurrenceStartAt: string; - proofUrl: string; }; type BudgetFormState = { @@ -92,15 +93,13 @@ type ApprovalPermissionValue = (typeof APPROVAL_FLOW)[number]; type UserFormState = { username: string; password: string; - role: "ADMIN" | "FINANCE" | "MEMBER"; + role: "BOARD" | "ORGA" | "FINANCE" | "MEMBER"; workingGroupId: string; - approvalPermissions: ApprovalPermissionValue[]; }; type ManagedUserDraft = { - role: "ADMIN" | "FINANCE" | "MEMBER"; + role: "BOARD" | "ORGA" | "FINANCE" | "MEMBER"; workingGroupId: string; - approvalPermissions: ApprovalPermissionValue[]; }; type PeriodFormState = { @@ -135,9 +134,10 @@ function toggleApprovalPermission( } function sortManagedUsersList(users: DashboardManagedUser[]) { const roleOrder: Record = { - ADMIN: 0, - FINANCE: 1, - MEMBER: 2 + BOARD: 0, + ORGA: 1, + FINANCE: 2, + MEMBER: 3 }; return [...users].sort((left, right) => { @@ -235,6 +235,13 @@ async function parseResponse(response: Response) { 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({ viewer, workingGroups, @@ -279,8 +286,7 @@ export function DashboardShell({ agId: defaultEditableGroup?.id ?? "", budgetId: defaultBudget?.id ?? "", recurrence: "NONE", - recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()), - proofUrl: "" + recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()) }); const [budgetForm, setBudgetForm] = useState({ workingGroupId: visibleGroups[0]?.id ?? "", @@ -300,8 +306,7 @@ export function DashboardShell({ username: "", password: "", role: "MEMBER", - workingGroupId: visibleGroups[0]?.id ?? "", - approvalPermissions: [] + workingGroupId: visibleGroups[0]?.id ?? "" }); const [message, setMessage] = useState(null); const [busy, setBusy] = useState(false); @@ -320,6 +325,8 @@ export function DashboardShell({ const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2)); const [periodForm, setPeriodForm] = useState(getSuggestedPeriodDraft(currentPeriod)); const [periodEditForm, setPeriodEditForm] = useState(getPeriodEditDraft(currentPeriod)); + const [expenseProofFile, setExpenseProofFile] = useState(null); + const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle"); useEffect(() => { if (visibleGroups.length === 0) { setSelectedMobileGroupId(""); @@ -505,8 +512,7 @@ export function DashboardShell({ function getManagedUserDraft(user: DashboardManagedUser): ManagedUserDraft { return userDrafts[user.id] ?? { role: user.role, - workingGroupId: user.workingGroupId ?? "", - approvalPermissions: sortApprovalPermissions(user.approvalPermissions) + workingGroupId: user.workingGroupId ?? "" }; } @@ -525,8 +531,7 @@ export function DashboardShell({ ...current, [user.id]: { role: user.role, - workingGroupId: user.workingGroupId ?? "", - approvalPermissions: sortApprovalPermissions(user.approvalPermissions) + workingGroupId: user.workingGroupId ?? "" } })); } @@ -621,7 +626,7 @@ export function DashboardShell({ } await runAction(async () => { - await parseResponse( + const result = (await parseResponse( await fetch("/api/expenses", { method: "POST", headers: { @@ -634,11 +639,22 @@ export function DashboardShell({ agId: expenseForm.agId, budgetId: expenseForm.budgetId, recurrence: expenseForm.recurrence, - recurrenceStartAt: expenseForm.recurrence === "MONTHLY" ? expenseForm.recurrenceStartAt : "", - proofUrl: expenseForm.proofUrl + recurrenceStartAt: expenseForm.recurrence === "MONTHLY" ? expenseForm.recurrenceStartAt : "" }) }) - ); + )) 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 resetBudget = defaultEditableGroup?.budgets[0]?.id ?? ""; @@ -650,9 +666,9 @@ export function DashboardShell({ agId: resetGroup, budgetId: resetBudget, recurrence: "NONE", - recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()), - proofUrl: "" + recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()) }); + setExpenseProofFile(null); }, "Ausgabe wurde gespeichert."); } @@ -751,6 +767,20 @@ export function DashboardShell({ }, "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) { await runAction(async () => { await parseResponse( @@ -945,8 +975,7 @@ export function DashboardShell({ username: createdUsername, password: userForm.password, role: userForm.role, - workingGroupId: userForm.workingGroupId, - approvalPermissions: sortApprovalPermissions(userForm.approvalPermissions) + workingGroupId: userForm.workingGroupId }) }) )) as { user?: DashboardManagedUser }; @@ -961,8 +990,7 @@ export function DashboardShell({ username: "", password: "", role: "MEMBER", - workingGroupId: visibleGroups[0]?.id ?? "", - approvalPermissions: [] + workingGroupId: visibleGroups[0]?.id ?? "" }); return { @@ -987,8 +1015,7 @@ export function DashboardShell({ }, body: JSON.stringify({ role: draft.role, - workingGroupId: draft.workingGroupId, - approvalPermissions: sortApprovalPermissions(draft.approvalPermissions) + workingGroupId: draft.workingGroupId }) }) )) as { user?: DashboardManagedUser }; @@ -1028,6 +1055,51 @@ export function DashboardShell({ ); }, `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) { await runAction(async () => { await parseResponse( @@ -1206,7 +1278,7 @@ export function DashboardShell({ Zeitraum wechseln - {"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."} + {viewer.approvalPermissions.length > 0 ? ( + + ) : null} @@ -1512,11 +1595,33 @@ export function DashboardShell({ ))} setExpenseForm((current) => ({ ...current, proofUrl: event.target.value }))} + label="Beleg" + value={expenseProofFile?.name ?? ""} fullWidth + InputProps={{ readOnly: true }} + helperText="Optional: Bild oder PDF auswählen. Auf Mobilgeräten kann die Kamera angeboten werden." /> + + + + @@ -1938,7 +2039,7 @@ export function DashboardShell({ Nutzer verwalten - {"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."} @@ -2049,8 +2150,9 @@ export function DashboardShell({ }} fullWidth > - Vorstand - Finanz-AG + Vorstand allgemein + AG Orga + AG Finanzen AG-Mitglied {draft.role !== "MEMBER" ? Ohne AG : null} @@ -2076,15 +2178,11 @@ export function DashboardShell({ ))} - {renderApprovalPermissionSelector( - draft.approvalPermissions, - (approvalType) => - updateManagedUserDraft(user, { - approvalPermissions: toggleApprovalPermission(draft.approvalPermissions, approvalType) - }), - "Lege fest, welche Freigabeschritte dieses Konto autorisieren darf.", - getAvailableApprovalRoles(draft.role) - )} + + {getAvailableApprovalRoles(draft.role).length > 0 + ? `Freigabe automatisch: ${getAvailableApprovalRoles(draft.role).map(approvalLabel).join(", ")}` + : "Diese Rolle kann keine Ausgaben freigeben."} +