Rollen Freigaben Push und Beleg Upload ueberarbeiten
All checks were successful
CI / Build (push) Successful in 2m6s
CI / Deploy (push) Successful in 2m11s

This commit is contained in:
jan
2026-05-01 15:50:37 +02:00
parent f947908f0e
commit 549c8f16c6
34 changed files with 1354 additions and 172 deletions

View File

@@ -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"

View File

@@ -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.

View File

@@ -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
View File

@@ -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",

View File

@@ -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": {

View File

@@ -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;

View File

@@ -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

View File

@@ -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]
}); });

View File

@@ -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);
})
);
});

View File

@@ -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: {

View File

@@ -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({

View File

@@ -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);

View File

@@ -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({

View File

@@ -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({

View 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 });
}

View File

@@ -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" &&

View File

@@ -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",

View File

@@ -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([

View File

@@ -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);

View File

@@ -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({

View File

@@ -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);

View File

@@ -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);

View 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 });
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 });
} }
} }

View File

@@ -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
); );

View File

@@ -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({

View File

@@ -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);

View File

@@ -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>

View File

@@ -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}

View File

@@ -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
View 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`;
}

View 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
}
});
}
}
})
);
}