Compare commits
5
Commits
v0.4.0
..
b622c1bb28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b622c1bb28 | ||
|
|
e0e11c85b2 | ||
|
|
ddb13d7dd0 | ||
|
|
24f613d372 | ||
|
|
ef79b24a96 |
@@ -74,6 +74,29 @@ GRAPH_TENANT_ID=organizations
|
|||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# --- Benachrichtigungen ----------------------------------------------------
|
||||||
|
# Beides ist freiwillig. Fehlt es, laeuft der Planer ohne Benachrichtigungen
|
||||||
|
# weiter und sagt es in den Einstellungen.
|
||||||
|
|
||||||
|
# Web-Push. Schluesselpaar einmalig erzeugen mit:
|
||||||
|
# npx web-push generate-vapid-keys
|
||||||
|
# Der oeffentliche Schluessel geht an den Browser, der private bleibt hier.
|
||||||
|
# Wird das Paar getauscht, muessen sich alle Geraete neu anmelden.
|
||||||
|
VAPID_OEFFENTLICH=
|
||||||
|
VAPID_PRIVAT=
|
||||||
|
# Kontakt fuer den Push-Dienst, als mailto: oder https:-Adresse.
|
||||||
|
VAPID_KONTAKT=
|
||||||
|
|
||||||
|
# E-Mail. Push braucht SMTP nicht und umgekehrt.
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_BENUTZER=
|
||||||
|
SMTP_PASSWORT=
|
||||||
|
# Absender, z. B. "Planer <planer@example.de>".
|
||||||
|
SMTP_ABSENDER=
|
||||||
|
# true bei Port 465 (SMTPS), false bei 587 mit STARTTLS.
|
||||||
|
SMTP_TLS=false
|
||||||
|
|
||||||
# --- Sonstiges -------------------------------------------------------------
|
# --- Sonstiges -------------------------------------------------------------
|
||||||
# Zeitzone fuer Anzeigen und Tagesgrenzen.
|
# Zeitzone fuer Anzeigen und Tagesgrenzen.
|
||||||
TZ=Europe/Berlin
|
TZ=Europe/Berlin
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ test-results
|
|||||||
playwright-report
|
playwright-report
|
||||||
coverage
|
coverage
|
||||||
devlog/zeiten.csv
|
devlog/zeiten.csv
|
||||||
|
public/sw.js
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export default tseslint.config(
|
|||||||
// Vom Generator erzeugt, nicht von Hand gepflegt.
|
// Vom Generator erzeugt, nicht von Hand gepflegt.
|
||||||
'src/generated/**',
|
'src/generated/**',
|
||||||
'next-env.d.ts',
|
'next-env.d.ts',
|
||||||
|
// Service Worker: laeuft im Browser, nicht in unserer Werkzeugkette.
|
||||||
|
'public/sw.js',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
...naechstesWebVitals,
|
...naechstesWebVitals,
|
||||||
|
|||||||
Generated
+148
-4
@@ -38,6 +38,7 @@
|
|||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"sonner": "2.0.8",
|
"sonner": "2.0.8",
|
||||||
"tailwind-merge": "3.6.0",
|
"tailwind-merge": "3.6.0",
|
||||||
|
"web-push": "3.6.7",
|
||||||
"zod": "4.6.1"
|
"zod": "4.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"@types/pg": "8.23.1",
|
"@types/pg": "8.23.1",
|
||||||
"@types/react": "19.3.0",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "19.3.0",
|
"@types/react-dom": "19.3.0",
|
||||||
|
"@types/web-push": "3.6.4",
|
||||||
"@vitejs/plugin-react": "6.1.1",
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
"@vitest/coverage-v8": "5.0.0",
|
"@vitest/coverage-v8": "5.0.0",
|
||||||
"dotenv": "17.4.2",
|
"dotenv": "17.4.2",
|
||||||
@@ -4545,6 +4547,16 @@
|
|||||||
"@types/react": "^19.3.0"
|
"@types/react": "^19.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.70.0",
|
"version": "8.70.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz",
|
||||||
@@ -5446,6 +5458,15 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
@@ -5678,6 +5699,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/asn1js": {
|
"node_modules/asn1js": {
|
||||||
"version": "3.0.10",
|
"version": "3.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||||
@@ -5832,6 +5865,12 @@
|
|||||||
"require-from-string": "^2.0.2"
|
"require-from-string": "^2.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
|
||||||
|
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.9",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
@@ -5892,6 +5931,12 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/c12": {
|
"node_modules/c12": {
|
||||||
"version": "3.3.4",
|
"version": "3.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
|
||||||
@@ -6413,7 +6458,6 @@
|
|||||||
"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",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -6597,6 +6641,15 @@
|
|||||||
"node": ">= 0.4"
|
"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/effect": {
|
"node_modules/effect": {
|
||||||
"version": "3.20.0",
|
"version": "3.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
|
||||||
@@ -8085,6 +8138,28 @@
|
|||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.3",
|
"version": "0.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||||
@@ -8139,6 +8214,12 @@
|
|||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||||
@@ -8804,6 +8885,27 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -9280,6 +9382,12 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -9300,7 +9408,6 @@
|
|||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -9310,7 +9417,6 @@
|
|||||||
"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",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/mysql2": {
|
"node_modules/mysql2": {
|
||||||
@@ -10731,6 +10837,26 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/safe-push-apply": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
@@ -10793,7 +10919,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/saxes": {
|
"node_modules/saxes": {
|
||||||
@@ -12398,6 +12523,25 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"sonner": "2.0.8",
|
"sonner": "2.0.8",
|
||||||
"tailwind-merge": "3.6.0",
|
"tailwind-merge": "3.6.0",
|
||||||
|
"web-push": "3.6.7",
|
||||||
"zod": "4.6.1"
|
"zod": "4.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
"@types/pg": "8.23.1",
|
"@types/pg": "8.23.1",
|
||||||
"@types/react": "19.3.0",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "19.3.0",
|
"@types/react-dom": "19.3.0",
|
||||||
|
"@types/web-push": "3.6.4",
|
||||||
"@vitejs/plugin-react": "6.1.1",
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
"@vitest/coverage-v8": "5.0.0",
|
"@vitest/coverage-v8": "5.0.0",
|
||||||
"dotenv": "17.4.2",
|
"dotenv": "17.4.2",
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "benachrichtigungseinstellung" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"benutzerId" TEXT NOT NULL,
|
||||||
|
"anlaesse" TEXT NOT NULL DEFAULT 'tagesueberblick,faelligkeit',
|
||||||
|
"perPush" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"perMail" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"ueberblickUm" INTEGER NOT NULL DEFAULT 420,
|
||||||
|
"ruheVon" INTEGER NOT NULL DEFAULT 1200,
|
||||||
|
"ruheBis" INTEGER NOT NULL DEFAULT 420,
|
||||||
|
"ruheAmWochenende" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"zeitblockVorlaufMinuten" INTEGER NOT NULL DEFAULT 10,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "benachrichtigungseinstellung_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "push_geraet" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"benutzerId" TEXT NOT NULL,
|
||||||
|
"endpunkt" TEXT NOT NULL,
|
||||||
|
"p256dh" TEXT NOT NULL,
|
||||||
|
"auth" TEXT NOT NULL,
|
||||||
|
"bezeichnung" TEXT,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"zuletztBenutzt" TIMESTAMP(3),
|
||||||
|
"fehlversuche" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "push_geraet_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "meldungsnachweis" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"benutzerId" TEXT NOT NULL,
|
||||||
|
"schluessel" TEXT NOT NULL,
|
||||||
|
"gesendetAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "meldungsnachweis_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "benachrichtigungseinstellung_benutzerId_key" ON "benachrichtigungseinstellung"("benutzerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "push_geraet_endpunkt_key" ON "push_geraet"("endpunkt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "push_geraet_benutzerId_idx" ON "push_geraet"("benutzerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "meldungsnachweis_gesendetAm_idx" ON "meldungsnachweis"("gesendetAm");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "meldungsnachweis_benutzerId_schluessel_key" ON "meldungsnachweis"("benutzerId", "schluessel");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "benachrichtigungseinstellung" ADD CONSTRAINT "benachrichtigungseinstellung_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "push_geraet" ADD CONSTRAINT "push_geraet_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "meldungsnachweis" ADD CONSTRAINT "meldungsnachweis_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "kalenderkonto" ADD COLUMN "fehlversuche" INTEGER NOT NULL DEFAULT 0;
|
||||||
+74
-4
@@ -31,10 +31,13 @@ model Benutzer {
|
|||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
aktualisiertAm DateTime @updatedAt
|
aktualisiertAm DateTime @updatedAt
|
||||||
|
|
||||||
konten Konto[]
|
konten Konto[]
|
||||||
passkeys Passkey[]
|
passkeys Passkey[]
|
||||||
audit AuditEintrag[]
|
audit AuditEintrag[]
|
||||||
kalenderkonten Kalenderkonto[]
|
kalenderkonten Kalenderkonto[]
|
||||||
|
benachrichtigung Benachrichtigungseinstellung?
|
||||||
|
pushGeraete PushGeraet[]
|
||||||
|
meldungen Meldungsnachweis[]
|
||||||
|
|
||||||
@@index([aktiv])
|
@@index([aktiv])
|
||||||
@@map("benutzer")
|
@@map("benutzer")
|
||||||
@@ -300,6 +303,9 @@ model Kalenderkonto {
|
|||||||
letzterAbgleich DateTime?
|
letzterAbgleich DateTime?
|
||||||
/// Klartextmeldung des letzten Fehlschlags, ohne Tokens.
|
/// Klartextmeldung des letzten Fehlschlags, ohne Tokens.
|
||||||
letzterFehler String?
|
letzterFehler String?
|
||||||
|
/// Wie oft der Abgleich hintereinander scheiterte. Ein Aussetzer ist keine
|
||||||
|
/// Nachricht wert; gemeldet wird erst ab mehreren.
|
||||||
|
fehlversuche Int @default(0)
|
||||||
|
|
||||||
aktiv Boolean @default(true)
|
aktiv Boolean @default(true)
|
||||||
|
|
||||||
@@ -378,3 +384,67 @@ model Zeitblock {
|
|||||||
@@index([aufgabeId])
|
@@index([aufgabeId])
|
||||||
@@map("zeitblock")
|
@@map("zeitblock")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wie und worueber ein Benutzer benachrichtigt werden will.
|
||||||
|
model Benachrichtigungseinstellung {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
benutzerId String @unique
|
||||||
|
|
||||||
|
/// Kommagetrennte Anlaesse, siehe src/lib/benachrichtigung.ts.
|
||||||
|
anlaesse String @default("tagesueberblick,faelligkeit")
|
||||||
|
perPush Boolean @default(false)
|
||||||
|
perMail Boolean @default(false)
|
||||||
|
|
||||||
|
/// Alles als Minuten seit Mitternacht, Europe/Berlin.
|
||||||
|
ueberblickUm Int @default(420)
|
||||||
|
ruheVon Int @default(1200)
|
||||||
|
ruheBis Int @default(420)
|
||||||
|
|
||||||
|
ruheAmWochenende Boolean @default(false)
|
||||||
|
zeitblockVorlaufMinuten Int @default(10)
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
aktualisiertAm DateTime @updatedAt
|
||||||
|
|
||||||
|
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("benachrichtigungseinstellung")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Browser, der Push-Nachrichten empfangen darf.
|
||||||
|
model PushGeraet {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
benutzerId String
|
||||||
|
|
||||||
|
/// Vom Browser vergebene Adresse; eindeutig je Geraet.
|
||||||
|
endpunkt String @unique
|
||||||
|
/// Schluessel aus der Push-Anmeldung, verschluesselt abgelegt.
|
||||||
|
p256dh String
|
||||||
|
auth String
|
||||||
|
|
||||||
|
bezeichnung String?
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
zuletztBenutzt DateTime?
|
||||||
|
/// Wie oft der Versand hintereinander scheiterte; ab einer Grenze weg.
|
||||||
|
fehlversuche Int @default(0)
|
||||||
|
|
||||||
|
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([benutzerId])
|
||||||
|
@@map("push_geraet")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nachweis, dass eine Meldung schon hinausging – verhindert Wiederholungen.
|
||||||
|
model Meldungsnachweis {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
benutzerId String
|
||||||
|
/// anlass:sache:tag, siehe meldungsSchluessel().
|
||||||
|
schluessel String
|
||||||
|
gesendetAm DateTime @default(now())
|
||||||
|
|
||||||
|
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([benutzerId, schluessel])
|
||||||
|
@@index([gesendetAm])
|
||||||
|
@@map("meldungsnachweis")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* Service Worker des Planers.
|
||||||
|
*
|
||||||
|
* Er tut genau eine Sache: Push-Nachrichten anzeigen und beim Klick die
|
||||||
|
* richtige Seite öffnen. Kein Zwischenspeichern von Seiten — ein halb
|
||||||
|
* veralteter Planer wäre schlimmer als gar keiner.
|
||||||
|
*/
|
||||||
|
|
||||||
|
self.addEventListener('install', () => {
|
||||||
|
// Sofort übernehmen, nicht auf das Schließen aller Tabs warten.
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('activate', (ereignis) => {
|
||||||
|
ereignis.waitUntil(self.clients.claim())
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('push', (ereignis) => {
|
||||||
|
let daten = { titel: 'Planer', text: '', pfad: '/' }
|
||||||
|
try {
|
||||||
|
if (ereignis.data) daten = { ...daten, ...ereignis.data.json() }
|
||||||
|
} catch {
|
||||||
|
// Kaputte Nutzlast: lieber eine karge Meldung als gar keine.
|
||||||
|
}
|
||||||
|
|
||||||
|
ereignis.waitUntil(
|
||||||
|
self.registration.showNotification(daten.titel, {
|
||||||
|
body: daten.text,
|
||||||
|
lang: 'de',
|
||||||
|
// Gleiche Anlässe überschreiben einander, statt sich zu stapeln.
|
||||||
|
tag: daten.anlass || 'planer',
|
||||||
|
data: { pfad: daten.pfad },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (ereignis) => {
|
||||||
|
ereignis.notification.close()
|
||||||
|
const pfad = (ereignis.notification.data && ereignis.notification.data.pfad) || '/'
|
||||||
|
|
||||||
|
ereignis.waitUntil(
|
||||||
|
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((fenster) => {
|
||||||
|
// Ist der Planer schon offen, dorthin wechseln statt ein zweites
|
||||||
|
// Fenster aufzumachen.
|
||||||
|
for (const f of fenster) {
|
||||||
|
if (f.url.includes(self.location.origin)) {
|
||||||
|
return f.focus().then(() => f.navigate(pfad))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self.clients.openWindow(pfad)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useTransition } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Bell, BellOff, Loader2, Send, Trash2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Kennzeichen } from '@/components/ui/kennzeichen'
|
||||||
|
import {
|
||||||
|
ANLAESSE,
|
||||||
|
ANLASS_BESCHREIBUNG,
|
||||||
|
ANLASS_NAME,
|
||||||
|
type Anlass,
|
||||||
|
} from '@/lib/benachrichtigung'
|
||||||
|
import {
|
||||||
|
einstellungSpeichern,
|
||||||
|
geraetEntfernen,
|
||||||
|
probeSenden,
|
||||||
|
type Rueckmeldung,
|
||||||
|
} from '@/server/aktionen/benachrichtigungen'
|
||||||
|
|
||||||
|
export type GeraetAnzeige = {
|
||||||
|
id: string
|
||||||
|
bezeichnung: string | null
|
||||||
|
erstelltAm: string
|
||||||
|
zuletztBenutzt: string | null
|
||||||
|
/** Dieses Gerät ist der Browser, in dem die Seite gerade offen ist. */
|
||||||
|
diesesGeraet?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EinstellungAnzeige = {
|
||||||
|
anlaesse: Anlass[]
|
||||||
|
perPush: boolean
|
||||||
|
perMail: boolean
|
||||||
|
ueberblickUm: string
|
||||||
|
ruheVon: string
|
||||||
|
ruheBis: string
|
||||||
|
ruheAmWochenende: boolean
|
||||||
|
zeitblockVorlaufMinuten: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Eigenschaften = {
|
||||||
|
einstellung: EinstellungAnzeige
|
||||||
|
geraete: GeraetAnzeige[]
|
||||||
|
pushMoeglich: boolean
|
||||||
|
mailMoeglich: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt den base64url-Schlüssel in die Form, die der Browser erwartet.
|
||||||
|
*
|
||||||
|
* Der Puffer wird ausdrücklich angelegt, damit der Typ `Uint8Array<ArrayBuffer>`
|
||||||
|
* lautet — `pushManager.subscribe` nimmt keinen geteilten Puffer an.
|
||||||
|
*/
|
||||||
|
function schluesselAlsBytes(base64url: string): Uint8Array<ArrayBuffer> {
|
||||||
|
const fuellung = '='.repeat((4 - (base64url.length % 4)) % 4)
|
||||||
|
const base64 = (base64url + fuellung).replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const roh = atob(base64)
|
||||||
|
const bytes = new Uint8Array(new ArrayBuffer(roh.length))
|
||||||
|
for (let i = 0; i < roh.length; i += 1) bytes[i] = roh.charCodeAt(i)
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Benachrichtigungen({
|
||||||
|
einstellung,
|
||||||
|
geraete,
|
||||||
|
pushMoeglich,
|
||||||
|
mailMoeglich,
|
||||||
|
}: Eigenschaften) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [laeuft, starte] = useTransition()
|
||||||
|
const [felder, setFelder] = useState<Record<string, string>>({})
|
||||||
|
const [pushZustand, setPushZustand] = useState<
|
||||||
|
'unbekannt' | 'nicht-unterstuetzt' | 'aus' | 'an' | 'verweigert'
|
||||||
|
>('unbekannt')
|
||||||
|
const [pushLaeuft, setPushLaeuft] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let abgebrochen = false
|
||||||
|
|
||||||
|
async function zustandPruefen() {
|
||||||
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||||
|
if (!abgebrochen) setPushZustand('nicht-unterstuetzt')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Notification.permission === 'denied') {
|
||||||
|
if (!abgebrochen) setPushZustand('verweigert')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const registrierung = await navigator.serviceWorker.getRegistration()
|
||||||
|
const abo = await registrierung?.pushManager.getSubscription()
|
||||||
|
if (!abgebrochen) setPushZustand(abo ? 'an' : 'aus')
|
||||||
|
}
|
||||||
|
|
||||||
|
void zustandPruefen()
|
||||||
|
return () => {
|
||||||
|
abgebrochen = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
|
||||||
|
starte(async () => {
|
||||||
|
const antwort = await tun()
|
||||||
|
if (antwort.ok) {
|
||||||
|
setFelder({})
|
||||||
|
toast.success(antwort.meldung)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
setFelder(antwort.felder ?? {})
|
||||||
|
toast.error(antwort.fehler)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushAnmelden() {
|
||||||
|
setPushLaeuft(true)
|
||||||
|
try {
|
||||||
|
const erlaubnis = await Notification.requestPermission()
|
||||||
|
if (erlaubnis !== 'granted') {
|
||||||
|
setPushZustand(erlaubnis === 'denied' ? 'verweigert' : 'aus')
|
||||||
|
toast.error('Ohne Erlaubnis des Browsers geht es nicht.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const antwort = await fetch('/api/push')
|
||||||
|
const daten = (await antwort.json()) as {
|
||||||
|
aktiv: boolean
|
||||||
|
oeffentlicherSchluessel: string | null
|
||||||
|
}
|
||||||
|
if (!daten.aktiv || !daten.oeffentlicherSchluessel) {
|
||||||
|
toast.error('Push ist auf diesem Server nicht eingerichtet.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrierung = await navigator.serviceWorker.register('/sw.js')
|
||||||
|
await navigator.serviceWorker.ready
|
||||||
|
|
||||||
|
const abo = await registrierung.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: schluesselAlsBytes(daten.oeffentlicherSchluessel),
|
||||||
|
})
|
||||||
|
|
||||||
|
const roh = abo.toJSON() as { endpoint?: string; keys?: Record<string, string> }
|
||||||
|
const gespeichert = await fetch('/api/push', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
endpunkt: roh.endpoint,
|
||||||
|
p256dh: roh.keys?.p256dh,
|
||||||
|
auth: roh.keys?.auth,
|
||||||
|
bezeichnung: geraetename(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!gespeichert.ok) {
|
||||||
|
const fehler = (await gespeichert.json()) as { fehler?: string }
|
||||||
|
toast.error(fehler.fehler ?? 'Die Anmeldung ließ sich nicht speichern.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPushZustand('an')
|
||||||
|
toast.success('Dieses Gerät empfängt jetzt Benachrichtigungen.')
|
||||||
|
router.refresh()
|
||||||
|
} catch {
|
||||||
|
toast.error('Die Anmeldung hat nicht geklappt.')
|
||||||
|
} finally {
|
||||||
|
setPushLaeuft(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushAbmelden() {
|
||||||
|
setPushLaeuft(true)
|
||||||
|
try {
|
||||||
|
const registrierung = await navigator.serviceWorker.getRegistration()
|
||||||
|
const abo = await registrierung?.pushManager.getSubscription()
|
||||||
|
if (abo) {
|
||||||
|
await fetch('/api/push', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ endpunkt: abo.endpoint }),
|
||||||
|
})
|
||||||
|
await abo.unsubscribe()
|
||||||
|
}
|
||||||
|
setPushZustand('aus')
|
||||||
|
toast.success('Dieses Gerät empfängt keine Benachrichtigungen mehr.')
|
||||||
|
router.refresh()
|
||||||
|
} catch {
|
||||||
|
toast.error('Das Abmelden hat nicht geklappt.')
|
||||||
|
} finally {
|
||||||
|
setPushLaeuft(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Dieses Gerät */}
|
||||||
|
<div className="border-rand flex flex-wrap items-center gap-3 rounded-md border p-3">
|
||||||
|
{pushZustand === 'an' ? (
|
||||||
|
<Bell className="text-erfolg size-5 shrink-0" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<BellOff className="text-gedaempft-vordergrund size-5 shrink-0" aria-hidden />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">Dieses Gerät</p>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
{pushZustand === 'nicht-unterstuetzt' &&
|
||||||
|
'Dieser Browser kann keine Push-Nachrichten empfangen.'}
|
||||||
|
{pushZustand === 'verweigert' &&
|
||||||
|
'Der Browser hat Benachrichtigungen blockiert. Das lässt sich nur in seinen Einstellungen ändern.'}
|
||||||
|
{pushZustand === 'an' && 'Empfängt Push-Nachrichten.'}
|
||||||
|
{pushZustand === 'aus' && 'Empfängt keine Push-Nachrichten.'}
|
||||||
|
{pushZustand === 'unbekannt' && '…'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!pushMoeglich ? (
|
||||||
|
<Kennzeichen art="warnung">Auf dem Server nicht eingerichtet</Kennzeichen>
|
||||||
|
) : pushZustand === 'an' ? (
|
||||||
|
<Button art="umriss" disabled={pushLaeuft} onClick={() => void pushAbmelden()}>
|
||||||
|
{pushLaeuft && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
Abmelden
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
pushZustand !== 'nicht-unterstuetzt' &&
|
||||||
|
pushZustand !== 'verweigert' && (
|
||||||
|
<Button disabled={pushLaeuft} onClick={() => void pushAnmelden()}>
|
||||||
|
{pushLaeuft && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
Anmelden
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Einstellungen */}
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-5"
|
||||||
|
onSubmit={(ereignis) => {
|
||||||
|
ereignis.preventDefault()
|
||||||
|
const daten = new FormData(ereignis.currentTarget)
|
||||||
|
mitRueckmeldung(() => einstellungSpeichern(daten))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<fieldset className="flex flex-col gap-3">
|
||||||
|
<legend className="mb-1 text-sm font-medium">Worüber</legend>
|
||||||
|
{ANLAESSE.map((anlass) => (
|
||||||
|
<label key={anlass} className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="anlaesse"
|
||||||
|
value={anlass}
|
||||||
|
defaultChecked={einstellung.anlaesse.includes(anlass)}
|
||||||
|
className="accent-betont mt-0.5 size-4"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="text-sm">{ANLASS_NAME[anlass]}</span>
|
||||||
|
<span className="text-gedaempft-vordergrund block text-xs">
|
||||||
|
{ANLASS_BESCHREIBUNG[anlass]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="flex flex-col gap-3">
|
||||||
|
<legend className="mb-1 text-sm font-medium">Wie</legend>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="perPush"
|
||||||
|
defaultChecked={einstellung.perPush}
|
||||||
|
className="accent-betont size-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">
|
||||||
|
Push an angemeldete Geräte
|
||||||
|
{!pushMoeglich && (
|
||||||
|
<span className="text-gedaempft-vordergrund">
|
||||||
|
{' '}
|
||||||
|
— auf diesem Server nicht eingerichtet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="perMail"
|
||||||
|
defaultChecked={einstellung.perMail}
|
||||||
|
className="accent-betont size-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">
|
||||||
|
E-Mail
|
||||||
|
{!mailMoeglich && (
|
||||||
|
<span className="text-gedaempft-vordergrund">
|
||||||
|
{' '}
|
||||||
|
— auf diesem Server nicht eingerichtet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="ueberblickUm">Tagesüberblick um</Label>
|
||||||
|
<Input
|
||||||
|
id="ueberblickUm"
|
||||||
|
name="ueberblickUm"
|
||||||
|
type="time"
|
||||||
|
defaultValue={einstellung.ueberblickUm}
|
||||||
|
aria-invalid={Boolean(felder.ueberblickUm)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="zeitblockVorlaufMinuten">
|
||||||
|
Vorlauf vor einem Zeitblock (Minuten)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="zeitblockVorlaufMinuten"
|
||||||
|
name="zeitblockVorlaufMinuten"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={120}
|
||||||
|
step={5}
|
||||||
|
defaultValue={einstellung.zeitblockVorlaufMinuten}
|
||||||
|
aria-invalid={Boolean(felder.zeitblockVorlaufMinuten)}
|
||||||
|
/>
|
||||||
|
{felder.zeitblockVorlaufMinuten && (
|
||||||
|
<p className="text-gefahr text-xs">{felder.zeitblockVorlaufMinuten}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="ruheVon">Ruhe von</Label>
|
||||||
|
<Input
|
||||||
|
id="ruheVon"
|
||||||
|
name="ruheVon"
|
||||||
|
type="time"
|
||||||
|
defaultValue={einstellung.ruheVon}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="ruheBis">bis</Label>
|
||||||
|
<Input
|
||||||
|
id="ruheBis"
|
||||||
|
name="ruheBis"
|
||||||
|
type="time"
|
||||||
|
defaultValue={einstellung.ruheBis}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="ruheAmWochenende"
|
||||||
|
defaultChecked={einstellung.ruheAmWochenende}
|
||||||
|
className="accent-betont size-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Am Wochenende gar nichts melden</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
In der Ruhezeit bleibt alles still — bis auf den Tagesüberblick. Der käme sonst
|
||||||
|
nie, wenn du früh aufstehst.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="submit" disabled={laeuft}>
|
||||||
|
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
art="umriss"
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => mitRueckmeldung(() => probeSenden())}
|
||||||
|
>
|
||||||
|
<Send aria-hidden />
|
||||||
|
Probe schicken
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Angemeldete Geräte */}
|
||||||
|
{geraete.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-medium">Angemeldete Geräte</h3>
|
||||||
|
<ul className="divide-rand divide-y">
|
||||||
|
{geraete.map((geraet) => (
|
||||||
|
<li key={geraet.id} className="flex items-center gap-3 py-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm">{geraet.bezeichnung ?? 'Unbenanntes Gerät'}</p>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
Angemeldet {geraet.erstelltAm}
|
||||||
|
{geraet.zuletztBenutzt
|
||||||
|
? ` · zuletzt benachrichtigt ${geraet.zuletztBenutzt}`
|
||||||
|
: ' · noch nichts empfangen'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
groesse="quadrat"
|
||||||
|
title={`${geraet.bezeichnung ?? 'Gerät'} abmelden`}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => {
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', geraet.id)
|
||||||
|
mitRueckmeldung(() => geraetEntfernen(daten))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden />
|
||||||
|
<span className="sr-only">Abmelden</span>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grobe Kennung des Browsers, damit sich Geräte auseinanderhalten lassen. */
|
||||||
|
function geraetename(): string {
|
||||||
|
const kennung = navigator.userAgent
|
||||||
|
if (/Android/i.test(kennung)) return 'Android-Gerät'
|
||||||
|
if (/iPhone|iPad/i.test(kennung)) return 'iPhone oder iPad'
|
||||||
|
if (/Macintosh/i.test(kennung)) return 'Mac'
|
||||||
|
if (/Windows/i.test(kennung)) return 'Windows-Rechner'
|
||||||
|
if (/Linux/i.test(kennung)) return 'Linux-Rechner'
|
||||||
|
return 'Unbekanntes Gerät'
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
|
import { Benachrichtigungen } from '@/app/(app)/einstellungen/benachrichtigungen'
|
||||||
import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung'
|
import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -8,6 +9,9 @@ import {
|
|||||||
CardTitel,
|
CardTitel,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { ROLLEN_NAME } from '@/lib/rollen'
|
import { ROLLEN_NAME } from '@/lib/rollen'
|
||||||
|
import { alsEinstellung } from '@/server/benachrichtigung/versand'
|
||||||
|
import { alsUhrzeit } from '@/lib/zeit'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
import { alsDatumUhrzeit } from '@/lib/zeit'
|
import { alsDatumUhrzeit } from '@/lib/zeit'
|
||||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
import { db } from '@/server/db'
|
import { db } from '@/server/db'
|
||||||
@@ -17,6 +21,35 @@ export const metadata: Metadata = { title: 'Einstellungen' }
|
|||||||
export default async function EinstellungenSeite() {
|
export default async function EinstellungenSeite() {
|
||||||
const benutzer = await verlangeAnmeldung('/einstellungen')
|
const benutzer = await verlangeAnmeldung('/einstellungen')
|
||||||
|
|
||||||
|
const einst = umgebung()
|
||||||
|
|
||||||
|
const [benachrichtigung, pushGeraete] = await Promise.all([
|
||||||
|
db.benachrichtigungseinstellung.findUnique({
|
||||||
|
where: { benutzerId: benutzer.id },
|
||||||
|
select: {
|
||||||
|
anlaesse: true,
|
||||||
|
perPush: true,
|
||||||
|
perMail: true,
|
||||||
|
ueberblickUm: true,
|
||||||
|
ruheVon: true,
|
||||||
|
ruheBis: true,
|
||||||
|
ruheAmWochenende: true,
|
||||||
|
zeitblockVorlaufMinuten: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.pushGeraet.findMany({
|
||||||
|
where: { benutzerId: benutzer.id },
|
||||||
|
orderBy: { erstelltAm: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
bezeichnung: true,
|
||||||
|
erstelltAm: true,
|
||||||
|
zuletztBenutzt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
const einstellung = alsEinstellung(benachrichtigung)
|
||||||
|
|
||||||
const passkeys = await db.passkey.findMany({
|
const passkeys = await db.passkey.findMany({
|
||||||
where: { benutzerId: benutzer.id },
|
where: { benutzerId: benutzer.id },
|
||||||
orderBy: { erstelltAm: 'asc' },
|
orderBy: { erstelltAm: 'asc' },
|
||||||
@@ -72,6 +105,39 @@ export default async function EinstellungenSeite() {
|
|||||||
/>
|
/>
|
||||||
</CardInhalt>
|
</CardInhalt>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card data-bild="benachrichtigungen">
|
||||||
|
<CardKopf>
|
||||||
|
<CardTitel className="text-base">Benachrichtigungen</CardTitel>
|
||||||
|
<CardBeschreibung>
|
||||||
|
Ab Werk meldet der Planer gar nichts. Was du hier einschaltest, kommt — alles
|
||||||
|
andere nicht. Eine Anwendung, die zu oft meldet, wird stummgeschaltet und ist
|
||||||
|
dann wertlos.
|
||||||
|
</CardBeschreibung>
|
||||||
|
</CardKopf>
|
||||||
|
<CardInhalt>
|
||||||
|
<Benachrichtigungen
|
||||||
|
pushMoeglich={einst.pushAktiv}
|
||||||
|
mailMoeglich={einst.mailAktiv}
|
||||||
|
einstellung={{
|
||||||
|
anlaesse: [...einstellung.anlaesse],
|
||||||
|
perPush: einstellung.perPush,
|
||||||
|
perMail: einstellung.perMail,
|
||||||
|
ueberblickUm: alsUhrzeit(einstellung.ueberblickUm),
|
||||||
|
ruheVon: alsUhrzeit(einstellung.ruheVon),
|
||||||
|
ruheBis: alsUhrzeit(einstellung.ruheBis),
|
||||||
|
ruheAmWochenende: einstellung.ruheAmWochenende,
|
||||||
|
zeitblockVorlaufMinuten: einstellung.zeitblockVorlaufMinuten,
|
||||||
|
}}
|
||||||
|
geraete={pushGeraete.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
bezeichnung: g.bezeichnung,
|
||||||
|
erstelltAm: alsDatumUhrzeit(g.erstelltAm),
|
||||||
|
zuletztBenutzt: g.zuletztBenutzt ? alsDatumUhrzeit(g.zuletztBenutzt) : null,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { auditSchreiben } from '@/server/audit'
|
||||||
|
import { verlangeAnmeldungFuerApi } from '@/server/berechtigung'
|
||||||
|
import { geraetAbmelden, geraetAnmelden } from '@/server/benachrichtigung/push'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An- und Abmelden eines Browsers für Push-Nachrichten.
|
||||||
|
*
|
||||||
|
* Der öffentliche VAPID-Schlüssel kommt über GET; er ist nicht geheim, wird
|
||||||
|
* aber nur an Angemeldete herausgegeben — es gibt keinen Grund, die
|
||||||
|
* Einrichtung nach außen zu zeigen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const anmeldung = z.object({
|
||||||
|
endpunkt: z.url(),
|
||||||
|
p256dh: z.string().min(1).max(500),
|
||||||
|
auth: z.string().min(1).max(500),
|
||||||
|
bezeichnung: z.string().trim().max(60).nullable().default(null),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const benutzer = await verlangeAnmeldungFuerApi()
|
||||||
|
if (!benutzer) {
|
||||||
|
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const einst = umgebung()
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
aktiv: einst.pushAktiv,
|
||||||
|
oeffentlicherSchluessel: einst.pushAktiv ? einst.VAPID_OEFFENTLICH : null,
|
||||||
|
},
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(anfrage: Request) {
|
||||||
|
const benutzer = await verlangeAnmeldungFuerApi()
|
||||||
|
if (!benutzer) {
|
||||||
|
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
|
||||||
|
}
|
||||||
|
if (!umgebung().pushAktiv) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ fehler: 'Push ist auf diesem Server nicht eingerichtet.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let inhalt: unknown
|
||||||
|
try {
|
||||||
|
inhalt = await anfrage.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const gelesen = anmeldung.safeParse(inhalt)
|
||||||
|
if (!gelesen.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ fehler: 'Die Anmeldung war unvollständig.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const geraet = await geraetAnmelden(
|
||||||
|
benutzer.id,
|
||||||
|
{
|
||||||
|
endpunkt: gelesen.data.endpunkt,
|
||||||
|
p256dh: gelesen.data.p256dh,
|
||||||
|
auth: gelesen.data.auth,
|
||||||
|
},
|
||||||
|
gelesen.data.bezeichnung,
|
||||||
|
)
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: benutzer.id, email: benutzer.email },
|
||||||
|
aktion: 'push.angemeldet',
|
||||||
|
objektTyp: 'PushGeraet',
|
||||||
|
objektId: geraet.id,
|
||||||
|
// Weder Endpunkt noch Schlüssel – beides taugt zum Zustellen.
|
||||||
|
details: { bezeichnung: gelesen.data.bezeichnung },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const abmeldung = z.object({ endpunkt: z.url() })
|
||||||
|
|
||||||
|
export async function DELETE(anfrage: Request) {
|
||||||
|
const benutzer = await verlangeAnmeldungFuerApi()
|
||||||
|
if (!benutzer) {
|
||||||
|
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let inhalt: unknown
|
||||||
|
try {
|
||||||
|
inhalt = await anfrage.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const gelesen = abmeldung.safeParse(inhalt)
|
||||||
|
if (!gelesen.success) {
|
||||||
|
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await geraetAbmelden(gelesen.data.endpunkt)
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: benutzer.id, email: benutzer.email },
|
||||||
|
aktion: 'push.abgemeldet',
|
||||||
|
objektTyp: 'PushGeraet',
|
||||||
|
objektId: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
VOREINSTELLUNG,
|
||||||
|
einstellungSchema,
|
||||||
|
inRuhezeit,
|
||||||
|
meldungsSchluessel,
|
||||||
|
ruhezeitText,
|
||||||
|
sendenEntscheiden,
|
||||||
|
ueberblickFaellig,
|
||||||
|
zeitblockErinnerungFaellig,
|
||||||
|
type Einstellung,
|
||||||
|
type Meldung,
|
||||||
|
} from './benachrichtigung'
|
||||||
|
|
||||||
|
/** Dienstag, 17. März 2026. Berlin liegt dann auf UTC+1. */
|
||||||
|
function berlin(stunde: number, minute = 0): Date {
|
||||||
|
return new Date(Date.UTC(2026, 2, 17, stunde - 1, minute))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Samstag, 21. März 2026. */
|
||||||
|
function samstag(stunde: number): Date {
|
||||||
|
return new Date(Date.UTC(2026, 2, 21, stunde - 1, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
const einstellung: Einstellung = {
|
||||||
|
...VOREINSTELLUNG,
|
||||||
|
anlaesse: ['tagesueberblick', 'faelligkeit', 'zeitblock'],
|
||||||
|
perPush: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
function meldung(teil: Partial<Meldung> = {}): Meldung {
|
||||||
|
return {
|
||||||
|
anlass: 'faelligkeit',
|
||||||
|
schluessel: 'faelligkeit:a1:2026-03-17',
|
||||||
|
titel: 'Fällig heute',
|
||||||
|
text: 'Angebot schreiben',
|
||||||
|
pfad: '/aufgaben',
|
||||||
|
...teil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('VOREINSTELLUNG', () => {
|
||||||
|
it('meldet ab Werk über keinen Weg', () => {
|
||||||
|
// Wer beim ersten Start überschüttet wird, schaltet alles ab.
|
||||||
|
expect(VOREINSTELLUNG.perPush).toBe(false)
|
||||||
|
expect(VOREINSTELLUNG.perMail).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hat eine Ruhezeit über Nacht', () => {
|
||||||
|
expect(VOREINSTELLUNG.ruheVon).toBe(20 * 60)
|
||||||
|
expect(VOREINSTELLUNG.ruheBis).toBe(7 * 60)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('inRuhezeit', () => {
|
||||||
|
it('erkennt die Nacht, obwohl sie über Mitternacht läuft', () => {
|
||||||
|
expect(inRuhezeit(einstellung, berlin(21))).toBe(true)
|
||||||
|
expect(inRuhezeit(einstellung, berlin(2))).toBe(true)
|
||||||
|
expect(inRuhezeit(einstellung, berlin(6, 59))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt den Tag frei', () => {
|
||||||
|
expect(inRuhezeit(einstellung, berlin(7))).toBe(false)
|
||||||
|
expect(inRuhezeit(einstellung, berlin(12))).toBe(false)
|
||||||
|
expect(inRuhezeit(einstellung, berlin(19, 59))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('beginnt pünktlich mit der Ruhe', () => {
|
||||||
|
expect(inRuhezeit(einstellung, berlin(20))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('kennt auch eine Ruhezeit innerhalb eines Tages', () => {
|
||||||
|
const mittags: Einstellung = { ...einstellung, ruheVon: 12 * 60, ruheBis: 13 * 60 }
|
||||||
|
expect(inRuhezeit(mittags, berlin(12, 30))).toBe(true)
|
||||||
|
expect(inRuhezeit(mittags, berlin(11))).toBe(false)
|
||||||
|
expect(inRuhezeit(mittags, berlin(14))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deutet gleiche Zeiten als "keine Ruhezeit"', () => {
|
||||||
|
const ohne: Einstellung = { ...einstellung, ruheVon: 0, ruheBis: 0 }
|
||||||
|
expect(inRuhezeit(ohne, berlin(3))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schweigt das Wochenende, wenn gewünscht', () => {
|
||||||
|
const still: Einstellung = { ...einstellung, ruheAmWochenende: true }
|
||||||
|
expect(inRuhezeit(still, samstag(12))).toBe(true)
|
||||||
|
expect(inRuhezeit(still, berlin(12))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt das Wochenende sonst in Ruhe', () => {
|
||||||
|
expect(inRuhezeit(einstellung, samstag(12))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendenEntscheiden', () => {
|
||||||
|
const leer = new Set<string>()
|
||||||
|
|
||||||
|
it('lässt eine gewöhnliche Meldung durch', () => {
|
||||||
|
expect(sendenEntscheiden(einstellung, meldung(), leer, berlin(10))).toEqual({
|
||||||
|
senden: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schweigt bei abgeschaltetem Anlass', () => {
|
||||||
|
const ohne: Einstellung = { ...einstellung, anlaesse: ['tagesueberblick'] }
|
||||||
|
expect(sendenEntscheiden(ohne, meldung(), leer, berlin(10))).toEqual({
|
||||||
|
senden: false,
|
||||||
|
grund: 'abgeschaltet',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schweigt, wenn es keinen Weg hinaus gibt', () => {
|
||||||
|
const stumm: Einstellung = { ...einstellung, perPush: false, perMail: false }
|
||||||
|
expect(sendenEntscheiden(stumm, meldung(), leer, berlin(10))).toEqual({
|
||||||
|
senden: false,
|
||||||
|
grund: 'kein-weg',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schweigt in der Ruhezeit', () => {
|
||||||
|
expect(sendenEntscheiden(einstellung, meldung(), leer, berlin(22))).toEqual({
|
||||||
|
senden: false,
|
||||||
|
grund: 'ruhezeit',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('meldet dieselbe Sache nicht zweimal', () => {
|
||||||
|
const gemeldet = new Set(['faelligkeit:a1:2026-03-17'])
|
||||||
|
expect(sendenEntscheiden(einstellung, meldung(), gemeldet, berlin(10))).toEqual({
|
||||||
|
senden: false,
|
||||||
|
grund: 'schon-gemeldet',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt den Tagesüberblick auch in der Ruhezeit hinaus', () => {
|
||||||
|
// Sonst käme er nie, wenn jemand früh aufsteht und die Ruhe bis 07:00 läuft.
|
||||||
|
const ueberblick = meldung({
|
||||||
|
anlass: 'tagesueberblick',
|
||||||
|
schluessel: 'tagesueberblick:tag:2026-03-17',
|
||||||
|
})
|
||||||
|
expect(sendenEntscheiden(einstellung, ueberblick, leer, berlin(6, 30))).toEqual({
|
||||||
|
senden: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prüft die Abschaltung vor der Ruhezeit', () => {
|
||||||
|
// Ein abgeschalteter Anlass ist abgeschaltet, nicht "gerade still".
|
||||||
|
const ohne: Einstellung = { ...einstellung, anlaesse: [] }
|
||||||
|
expect(sendenEntscheiden(ohne, meldung(), leer, berlin(22)).senden).toBe(false)
|
||||||
|
expect(
|
||||||
|
sendenEntscheiden(ohne, meldung(), leer, berlin(22)) as { grund: string },
|
||||||
|
).toMatchObject({ grund: 'abgeschaltet' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('meldungsSchluessel', () => {
|
||||||
|
it('trennt Anlass, Sache und Tag', () => {
|
||||||
|
expect(meldungsSchluessel('faelligkeit', 'a1', '2026-03-17')).toBe(
|
||||||
|
'faelligkeit:a1:2026-03-17',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erlaubt dieselbe Sache an einem anderen Tag erneut', () => {
|
||||||
|
// Eine Aufgabe, die heute fällig war, ist es morgen immer noch.
|
||||||
|
expect(meldungsSchluessel('faelligkeit', 'a1', '2026-03-17')).not.toBe(
|
||||||
|
meldungsSchluessel('faelligkeit', 'a1', '2026-03-18'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ueberblickFaellig', () => {
|
||||||
|
it('trifft das Zeitfenster', () => {
|
||||||
|
expect(ueberblickFaellig(einstellung, berlin(7))).toBe(true)
|
||||||
|
expect(ueberblickFaellig(einstellung, berlin(7, 14))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verpasst das Fenster davor und danach', () => {
|
||||||
|
expect(ueberblickFaellig(einstellung, berlin(6, 59))).toBe(false)
|
||||||
|
expect(ueberblickFaellig(einstellung, berlin(7, 15))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('zeitblockErinnerungFaellig', () => {
|
||||||
|
const beginn = berlin(10)
|
||||||
|
|
||||||
|
it('erinnert im Vorlauffenster', () => {
|
||||||
|
// Vorlauf 10 Minuten, Toleranz 15: von 25 bis 10 Minuten vorher.
|
||||||
|
expect(zeitblockErinnerungFaellig(einstellung, beginn, berlin(9, 55))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erinnert nicht zu früh', () => {
|
||||||
|
expect(zeitblockErinnerungFaellig(einstellung, beginn, berlin(9, 30))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erinnert nicht mehr, wenn der Block schon läuft', () => {
|
||||||
|
expect(zeitblockErinnerungFaellig(einstellung, beginn, berlin(10, 5))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('einstellungSchema', () => {
|
||||||
|
const gueltig = {
|
||||||
|
anlaesse: ['tagesueberblick'],
|
||||||
|
perPush: 'true',
|
||||||
|
perMail: '',
|
||||||
|
ueberblickUm: '07:00',
|
||||||
|
ruheVon: '20:00',
|
||||||
|
ruheBis: '07:00',
|
||||||
|
ruheAmWochenende: '',
|
||||||
|
zeitblockVorlaufMinuten: '10',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('liest Uhrzeiten in Minuten um', () => {
|
||||||
|
const ergebnis = einstellungSchema.parse(gueltig)
|
||||||
|
expect(ergebnis.ueberblickUm).toBe(420)
|
||||||
|
expect(ergebnis.ruheVon).toBe(1200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist unsinnige Uhrzeiten ab', () => {
|
||||||
|
expect(einstellungSchema.safeParse({ ...gueltig, ruheVon: '25:00' }).success).toBe(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('begrenzt den Vorlauf', () => {
|
||||||
|
expect(
|
||||||
|
einstellungSchema.safeParse({ ...gueltig, zeitblockVorlaufMinuten: '121' }).success,
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
einstellungSchema.safeParse({ ...gueltig, zeitblockVorlaufMinuten: '120' }).success,
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist unbekannte Anlässe ab', () => {
|
||||||
|
expect(
|
||||||
|
einstellungSchema.safeParse({ ...gueltig, anlaesse: ['geburtstag'] }).success,
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ruhezeitText', () => {
|
||||||
|
it('beschreibt die Nachtruhe', () => {
|
||||||
|
expect(ruhezeitText(einstellung)).toBe('Still von 20:00 bis 07:00.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nennt das Wochenende mit', () => {
|
||||||
|
expect(ruhezeitText({ ...einstellung, ruheAmWochenende: true })).toBe(
|
||||||
|
'Still von 20:00 bis 07:00 und am ganzen Wochenende.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sagt es auch, wenn es keine Ruhezeit gibt', () => {
|
||||||
|
expect(ruhezeitText({ ...einstellung, ruheVon: 0, ruheBis: 0 })).toBe(
|
||||||
|
'Keine Ruhezeit.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import {
|
||||||
|
alsUhrzeit,
|
||||||
|
ausUhrzeit,
|
||||||
|
minutenSeitMitternacht,
|
||||||
|
wochentagIndex,
|
||||||
|
} from '@/lib/zeit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regeln für Benachrichtigungen.
|
||||||
|
*
|
||||||
|
* Der heikle Teil ist nicht die Technik, sondern das Maß: Eine Anwendung, die
|
||||||
|
* zu oft meldet, wird stummgeschaltet und ist dann wertlos. Deshalb sind die
|
||||||
|
* Voreinstellungen zurückhaltend, es gibt Ruhezeiten, und dieselbe Sache wird
|
||||||
|
* nie zweimal gemeldet.
|
||||||
|
*
|
||||||
|
* Reine Logik ohne Datenbank und ohne Systemzeit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ANLAESSE = [
|
||||||
|
'tagesueberblick',
|
||||||
|
'faelligkeit',
|
||||||
|
'zeitblock',
|
||||||
|
'abgleichfehler',
|
||||||
|
] as const
|
||||||
|
export type Anlass = (typeof ANLAESSE)[number]
|
||||||
|
|
||||||
|
export const ANLASS_NAME: Record<Anlass, string> = {
|
||||||
|
tagesueberblick: 'Tagesüberblick',
|
||||||
|
faelligkeit: 'Fällige Aufgaben',
|
||||||
|
zeitblock: 'Gleich beginnt ein Zeitblock',
|
||||||
|
abgleichfehler: 'Ein Kalender lässt sich nicht abgleichen',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ANLASS_BESCHREIBUNG: Record<Anlass, string> = {
|
||||||
|
tagesueberblick:
|
||||||
|
'Einmal morgens: was heute ansteht, was überfällig ist, wie voll der Tag wird.',
|
||||||
|
faelligkeit:
|
||||||
|
'Wenn eine Aufgabe an diesem Tag fällig wird — höchstens einmal je Aufgabe.',
|
||||||
|
zeitblock: 'Kurz bevor ein eingeplanter Zeitblock beginnt.',
|
||||||
|
abgleichfehler:
|
||||||
|
'Wenn ein Kalender mehrfach hintereinander nicht erreichbar war. Nicht beim ersten Mal.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Einstellung = {
|
||||||
|
/** Welche Anlässe überhaupt melden. */
|
||||||
|
anlaesse: readonly Anlass[]
|
||||||
|
perPush: boolean
|
||||||
|
perMail: boolean
|
||||||
|
/** Uhrzeit des Tagesüberblicks, Minuten seit Mitternacht. */
|
||||||
|
ueberblickUm: number
|
||||||
|
/** Ruhezeit: von hier bis … nichts melden. */
|
||||||
|
ruheVon: number
|
||||||
|
ruheBis: number
|
||||||
|
/** Am Wochenende nichts melden. */
|
||||||
|
ruheAmWochenende: boolean
|
||||||
|
/** Vorlauf vor einem Zeitblock, in Minuten. */
|
||||||
|
zeitblockVorlaufMinuten: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voreinstellung: bewusst wenig.
|
||||||
|
*
|
||||||
|
* Wer mehr will, schaltet es zu. Wer beim ersten Start mit Meldungen
|
||||||
|
* überschüttet wird, schaltet alles ab — und dann auch das Nützliche.
|
||||||
|
*/
|
||||||
|
export const VOREINSTELLUNG: Einstellung = {
|
||||||
|
anlaesse: ['tagesueberblick', 'faelligkeit'],
|
||||||
|
perPush: false,
|
||||||
|
perMail: false,
|
||||||
|
ueberblickUm: 7 * 60,
|
||||||
|
ruheVon: 20 * 60,
|
||||||
|
ruheBis: 7 * 60,
|
||||||
|
ruheAmWochenende: false,
|
||||||
|
zeitblockVorlaufMinuten: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liegt der Zeitpunkt in der Ruhezeit?
|
||||||
|
*
|
||||||
|
* Die Ruhezeit läuft über Mitternacht hinweg — 20:00 bis 07:00 ist der
|
||||||
|
* Normalfall, nicht die Ausnahme.
|
||||||
|
*/
|
||||||
|
export function inRuhezeit(einstellung: Einstellung, zeitpunkt: Date): boolean {
|
||||||
|
if (einstellung.ruheAmWochenende && wochentagIndex(zeitpunkt) >= 5) return true
|
||||||
|
|
||||||
|
const minute = minutenSeitMitternacht(zeitpunkt)
|
||||||
|
const { ruheVon, ruheBis } = einstellung
|
||||||
|
|
||||||
|
// Gleiche Zeiten heißen: keine Ruhezeit.
|
||||||
|
if (ruheVon === ruheBis) return false
|
||||||
|
|
||||||
|
return ruheVon < ruheBis
|
||||||
|
? minute >= ruheVon && minute < ruheBis
|
||||||
|
: minute >= ruheVon || minute < ruheBis
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Meldung = {
|
||||||
|
anlass: Anlass
|
||||||
|
/** Eindeutig je Sache und Tag; verhindert doppelte Meldungen. */
|
||||||
|
schluessel: string
|
||||||
|
titel: string
|
||||||
|
text: string
|
||||||
|
/** Wohin der Klick führt. */
|
||||||
|
pfad: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soll diese Meldung jetzt hinaus?
|
||||||
|
*
|
||||||
|
* Drei Gründe, es zu lassen: der Anlass ist abgeschaltet, es ist Ruhezeit,
|
||||||
|
* oder genau diese Meldung ging schon einmal hinaus.
|
||||||
|
*/
|
||||||
|
export type Entscheidung =
|
||||||
|
| { senden: true }
|
||||||
|
| { senden: false; grund: 'abgeschaltet' | 'ruhezeit' | 'schon-gemeldet' | 'kein-weg' }
|
||||||
|
|
||||||
|
export function sendenEntscheiden(
|
||||||
|
einstellung: Einstellung,
|
||||||
|
meldung: Meldung,
|
||||||
|
bereitsGemeldet: ReadonlySet<string>,
|
||||||
|
zeitpunkt: Date,
|
||||||
|
): Entscheidung {
|
||||||
|
if (!einstellung.anlaesse.includes(meldung.anlass)) {
|
||||||
|
return { senden: false, grund: 'abgeschaltet' }
|
||||||
|
}
|
||||||
|
if (!einstellung.perPush && !einstellung.perMail) {
|
||||||
|
return { senden: false, grund: 'kein-weg' }
|
||||||
|
}
|
||||||
|
if (bereitsGemeldet.has(meldung.schluessel)) {
|
||||||
|
return { senden: false, grund: 'schon-gemeldet' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Tagesüberblick kommt zu seiner festen Zeit; ihn wegen der Ruhezeit zu
|
||||||
|
// unterdrücken hieße, ihn nie zu schicken, wenn jemand früh aufsteht.
|
||||||
|
if (meldung.anlass !== 'tagesueberblick' && inRuhezeit(einstellung, zeitpunkt)) {
|
||||||
|
return { senden: false, grund: 'ruhezeit' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { senden: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schlüssel einer Meldung: Anlass, Sache und Tag.
|
||||||
|
*
|
||||||
|
* Der Tag gehört dazu, damit dieselbe Aufgabe morgen wieder gemeldet werden
|
||||||
|
* darf — sie ist dann ja immer noch fällig.
|
||||||
|
*/
|
||||||
|
export function meldungsSchluessel(
|
||||||
|
anlass: Anlass,
|
||||||
|
sache: string,
|
||||||
|
tagesschluessel: string,
|
||||||
|
): string {
|
||||||
|
return `${anlass}:${sache}:${tagesschluessel}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist jetzt die Zeit für den Tagesüberblick? */
|
||||||
|
export function ueberblickFaellig(
|
||||||
|
einstellung: Einstellung,
|
||||||
|
zeitpunkt: Date,
|
||||||
|
toleranzMinuten = 15,
|
||||||
|
): boolean {
|
||||||
|
const minute = minutenSeitMitternacht(zeitpunkt)
|
||||||
|
return (
|
||||||
|
minute >= einstellung.ueberblickUm &&
|
||||||
|
minute < einstellung.ueberblickUm + toleranzMinuten
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beginnt der Zeitblock bald genug für eine Erinnerung? */
|
||||||
|
export function zeitblockErinnerungFaellig(
|
||||||
|
einstellung: Einstellung,
|
||||||
|
beginn: Date,
|
||||||
|
zeitpunkt: Date,
|
||||||
|
toleranzMinuten = 15,
|
||||||
|
): boolean {
|
||||||
|
const minutenBis = (beginn.getTime() - zeitpunkt.getTime()) / 60_000
|
||||||
|
return (
|
||||||
|
minutenBis <= einstellung.zeitblockVorlaufMinuten &&
|
||||||
|
minutenBis > einstellung.zeitblockVorlaufMinuten - toleranzMinuten
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ab wann ein Abgleichfehler gemeldet wird.
|
||||||
|
*
|
||||||
|
* Nicht beim ersten Mal: ein Netz hustet, ein Anbieter hat eine Wartung.
|
||||||
|
* Erst wenn es mehrfach hintereinander nicht klappt, ist es eine Nachricht
|
||||||
|
* wert.
|
||||||
|
*/
|
||||||
|
export const ABGLEICHFEHLER_AB_VERSUCH = 3
|
||||||
|
|
||||||
|
// --- Eingaben --------------------------------------------------------------
|
||||||
|
|
||||||
|
const uhrzeit = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((w) => ausUhrzeit(w) !== null, 'Bitte eine Uhrzeit wie 07:00 angeben.')
|
||||||
|
.transform((w) => ausUhrzeit(w) as number)
|
||||||
|
|
||||||
|
export const einstellungSchema = z.object({
|
||||||
|
anlaesse: z.array(z.enum(ANLAESSE)).default([]),
|
||||||
|
perPush: z.coerce.boolean().default(false),
|
||||||
|
perMail: z.coerce.boolean().default(false),
|
||||||
|
ueberblickUm: uhrzeit,
|
||||||
|
ruheVon: uhrzeit,
|
||||||
|
ruheBis: uhrzeit,
|
||||||
|
ruheAmWochenende: z.coerce.boolean().default(false),
|
||||||
|
zeitblockVorlaufMinuten: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0, 'Nicht weniger als null Minuten.')
|
||||||
|
.max(120, 'Mehr als zwei Stunden Vorlauf ist keine Erinnerung mehr.'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type EinstellungEingabe = z.infer<typeof einstellungSchema>
|
||||||
|
|
||||||
|
/** Lesbare Beschreibung der Ruhezeit für die Oberfläche. */
|
||||||
|
export function ruhezeitText(einstellung: Einstellung): string {
|
||||||
|
if (einstellung.ruheVon === einstellung.ruheBis) {
|
||||||
|
return einstellung.ruheAmWochenende
|
||||||
|
? 'Keine Ruhezeit, aber am Wochenende still.'
|
||||||
|
: 'Keine Ruhezeit.'
|
||||||
|
}
|
||||||
|
const zeiten = `${alsUhrzeit(einstellung.ruheVon)} bis ${alsUhrzeit(einstellung.ruheBis)}`
|
||||||
|
return einstellung.ruheAmWochenende
|
||||||
|
? `Still von ${zeiten} und am ganzen Wochenende.`
|
||||||
|
: `Still von ${zeiten}.`
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { einstellungSchema } from '@/lib/benachrichtigung'
|
||||||
|
import { alsTagesschluessel } from '@/lib/zeit'
|
||||||
|
import { auditSchreiben } from '@/server/audit'
|
||||||
|
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
||||||
|
import { mailSenden } from '@/server/benachrichtigung/mail'
|
||||||
|
import { pushSenden } from '@/server/benachrichtigung/push'
|
||||||
|
import { alsEinstellung } from '@/server/benachrichtigung/versand'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
|
export type Rueckmeldung =
|
||||||
|
| { ok: true; meldung: string }
|
||||||
|
| { ok: false; fehler: string; felder?: Record<string, string> }
|
||||||
|
|
||||||
|
async function akteurOderFehler() {
|
||||||
|
try {
|
||||||
|
return { akteur: await pruefeRolle('BENUTZER') }
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof NichtErlaubt) {
|
||||||
|
return { antwort: { ok: false as const, fehler: fehler.message } }
|
||||||
|
}
|
||||||
|
throw fehler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Speichert die Benachrichtigungseinstellung des angemeldeten Benutzers. */
|
||||||
|
export async function einstellungSpeichern(daten: FormData): Promise<Rueckmeldung> {
|
||||||
|
const geprueft = await akteurOderFehler()
|
||||||
|
if ('antwort' in geprueft) return geprueft.antwort
|
||||||
|
const { akteur } = geprueft
|
||||||
|
|
||||||
|
const gelesen = einstellungSchema.safeParse({
|
||||||
|
anlaesse: daten.getAll('anlaesse'),
|
||||||
|
perPush: daten.get('perPush') !== null,
|
||||||
|
perMail: daten.get('perMail') !== null,
|
||||||
|
ueberblickUm: daten.get('ueberblickUm'),
|
||||||
|
ruheVon: daten.get('ruheVon'),
|
||||||
|
ruheBis: daten.get('ruheBis'),
|
||||||
|
ruheAmWochenende: daten.get('ruheAmWochenende') !== null,
|
||||||
|
zeitblockVorlaufMinuten: daten.get('zeitblockVorlaufMinuten'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!gelesen.success) {
|
||||||
|
const felder: Record<string, string> = {}
|
||||||
|
for (const problem of gelesen.error.issues) {
|
||||||
|
const feld = problem.path.join('.')
|
||||||
|
felder[feld] ??= problem.message
|
||||||
|
}
|
||||||
|
return { ok: false, fehler: 'Bitte die markierten Felder prüfen.', felder }
|
||||||
|
}
|
||||||
|
|
||||||
|
const werte = {
|
||||||
|
anlaesse: gelesen.data.anlaesse.join(','),
|
||||||
|
perPush: gelesen.data.perPush,
|
||||||
|
perMail: gelesen.data.perMail,
|
||||||
|
ueberblickUm: gelesen.data.ueberblickUm,
|
||||||
|
ruheVon: gelesen.data.ruheVon,
|
||||||
|
ruheBis: gelesen.data.ruheBis,
|
||||||
|
ruheAmWochenende: gelesen.data.ruheAmWochenende,
|
||||||
|
zeitblockVorlaufMinuten: gelesen.data.zeitblockVorlaufMinuten,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.benachrichtigungseinstellung.upsert({
|
||||||
|
where: { benutzerId: akteur.id },
|
||||||
|
update: werte,
|
||||||
|
create: { benutzerId: akteur.id, ...werte },
|
||||||
|
})
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: 'benachrichtigung.geaendert',
|
||||||
|
objektTyp: 'Benachrichtigungseinstellung',
|
||||||
|
objektId: akteur.id,
|
||||||
|
details: {
|
||||||
|
anlaesse: werte.anlaesse,
|
||||||
|
perPush: werte.perPush,
|
||||||
|
perMail: werte.perMail,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/einstellungen')
|
||||||
|
return { ok: true, meldung: 'Einstellungen gespeichert.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schickt eine Probemeldung über alle eingeschalteten Wege.
|
||||||
|
*
|
||||||
|
* Sie umgeht Ruhezeit und Wiederholungssperre bewusst — wer auf den Knopf
|
||||||
|
* drückt, will jetzt wissen, ob es ankommt.
|
||||||
|
*/
|
||||||
|
export async function probeSenden(): Promise<Rueckmeldung> {
|
||||||
|
const geprueft = await akteurOderFehler()
|
||||||
|
if ('antwort' in geprueft) return geprueft.antwort
|
||||||
|
const { akteur } = geprueft
|
||||||
|
|
||||||
|
const zeile = await db.benachrichtigungseinstellung.findUnique({
|
||||||
|
where: { benutzerId: akteur.id },
|
||||||
|
select: {
|
||||||
|
anlaesse: true,
|
||||||
|
perPush: true,
|
||||||
|
perMail: true,
|
||||||
|
ueberblickUm: true,
|
||||||
|
ruheVon: true,
|
||||||
|
ruheBis: true,
|
||||||
|
ruheAmWochenende: true,
|
||||||
|
zeitblockVorlaufMinuten: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const einstellung = alsEinstellung(zeile)
|
||||||
|
|
||||||
|
if (!einstellung.perPush && !einstellung.perMail) {
|
||||||
|
return { ok: false, fehler: 'Erst einen Weg einschalten — Push oder E-Mail.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const meldung = {
|
||||||
|
anlass: 'tagesueberblick' as const,
|
||||||
|
// Die Probe umgeht Sperre und Ruhezeit; der Schlüssel dient nur der Form.
|
||||||
|
schluessel: `probe:${akteur.id}:${alsTagesschluessel(jetzt())}`,
|
||||||
|
titel: 'Probemeldung',
|
||||||
|
text: 'Wenn du das liest, kommen Benachrichtigungen an.',
|
||||||
|
pfad: '/einstellungen',
|
||||||
|
}
|
||||||
|
|
||||||
|
const wege: string[] = []
|
||||||
|
const einst = umgebung()
|
||||||
|
|
||||||
|
if (einstellung.perPush) {
|
||||||
|
if (!einst.pushAktiv) {
|
||||||
|
wege.push('Push ist auf diesem Server nicht eingerichtet')
|
||||||
|
} else {
|
||||||
|
const bericht = await pushSenden(akteur.id, meldung)
|
||||||
|
wege.push(
|
||||||
|
bericht.zugestellt > 0
|
||||||
|
? `Push an ${bericht.zugestellt} Gerät(e)`
|
||||||
|
: 'kein Gerät für Push angemeldet',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (einstellung.perMail) {
|
||||||
|
if (!einst.mailAktiv) {
|
||||||
|
wege.push('E-Mail ist auf diesem Server nicht eingerichtet')
|
||||||
|
} else {
|
||||||
|
wege.push(
|
||||||
|
(await mailSenden(akteur.email, meldung))
|
||||||
|
? 'E-Mail verschickt'
|
||||||
|
: 'E-Mail fehlgeschlagen',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: 'benachrichtigung.probe',
|
||||||
|
objektTyp: 'Benachrichtigungseinstellung',
|
||||||
|
objektId: akteur.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ok: true, meldung: wege.join(' · ') }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt ein angemeldetes Push-Gerät. */
|
||||||
|
export async function geraetEntfernen(daten: FormData): Promise<Rueckmeldung> {
|
||||||
|
const geprueft = await akteurOderFehler()
|
||||||
|
if ('antwort' in geprueft) return geprueft.antwort
|
||||||
|
const { akteur } = geprueft
|
||||||
|
|
||||||
|
const id = daten.get('id')
|
||||||
|
if (typeof id !== 'string' || id.length === 0) {
|
||||||
|
return { ok: false, fehler: 'Ungültige Anfrage.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const geraet = await db.pushGeraet.findFirst({
|
||||||
|
where: { id, benutzerId: akteur.id },
|
||||||
|
select: { id: true, bezeichnung: true },
|
||||||
|
})
|
||||||
|
if (!geraet) return { ok: false, fehler: 'Dieses Gerät gibt es nicht.' }
|
||||||
|
|
||||||
|
await db.pushGeraet.delete({ where: { id: geraet.id } })
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: 'push.abgemeldet',
|
||||||
|
objektTyp: 'PushGeraet',
|
||||||
|
objektId: geraet.id,
|
||||||
|
details: { bezeichnung: geraet.bezeichnung },
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/einstellungen')
|
||||||
|
return { ok: true, meldung: 'Gerät abgemeldet.' }
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import nodemailer, { type Transporter } from 'nodemailer'
|
||||||
|
import type { Meldung } from '@/lib/benachrichtigung'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E-Mail-Versand.
|
||||||
|
*
|
||||||
|
* Jede Mail geht als reiner Text **und** als HTML hinaus. Wer in einem
|
||||||
|
* Textclient liest, bekommt etwas Lesbares; wer HTML liest, etwas
|
||||||
|
* Aufgeräumtes.
|
||||||
|
*
|
||||||
|
* Der Inhalt bleibt knapp: Titel, ein paar Zeilen, ein Link. Eine Mail, die
|
||||||
|
* alles wiederholt, was ohnehin in der Anwendung steht, liest niemand.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let versand: Transporter | null = null
|
||||||
|
|
||||||
|
function transporter(): Transporter | null {
|
||||||
|
const einst = umgebung()
|
||||||
|
if (!einst.mailAktiv) return null
|
||||||
|
if (versand) return versand
|
||||||
|
|
||||||
|
versand = nodemailer.createTransport({
|
||||||
|
host: einst.SMTP_HOST,
|
||||||
|
port: einst.SMTP_PORT,
|
||||||
|
secure: einst.SMTP_TLS,
|
||||||
|
...(einst.SMTP_BENUTZER
|
||||||
|
? { auth: { user: einst.SMTP_BENUTZER, pass: einst.SMTP_PASSWORT } }
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
return versand
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur für Tests und nach einer Konfigurationsänderung. */
|
||||||
|
export function versandVergessen(): void {
|
||||||
|
versand = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function alsHtml(meldung: Meldung, adresse: string): string {
|
||||||
|
// Bewusst schlichtes HTML ohne Bilder und ohne externe Stile: Mailclients
|
||||||
|
// blockieren beides ohnehin, und ein Text, der nur mit CSS lesbar ist,
|
||||||
|
// ist kaputt.
|
||||||
|
const zeilen = meldung.text
|
||||||
|
.split('\n')
|
||||||
|
.filter((z) => z.trim().length > 0)
|
||||||
|
.map((z) => `<p style="margin:0 0 8px">${maskieren(z)}</p>`)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
return [
|
||||||
|
'<div style="font-family:system-ui,sans-serif;font-size:15px;line-height:1.5;color:#1f2430">',
|
||||||
|
`<h1 style="font-size:18px;margin:0 0 12px">${maskieren(meldung.titel)}</h1>`,
|
||||||
|
zeilen,
|
||||||
|
`<p style="margin:16px 0 0"><a href="${adresse}" style="color:#2563eb">Im Planer ansehen</a></p>`,
|
||||||
|
'</div>',
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskieren(text: string): string {
|
||||||
|
return text
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schickt eine Meldung per Mail. Gibt zurück, ob es geklappt hat. */
|
||||||
|
export async function mailSenden(empfaenger: string, meldung: Meldung): Promise<boolean> {
|
||||||
|
const versender = transporter()
|
||||||
|
if (!versender) return false
|
||||||
|
|
||||||
|
const einst = umgebung()
|
||||||
|
const adresse = `${einst.APP_URL}${meldung.pfad}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
await versender.sendMail({
|
||||||
|
from: einst.SMTP_ABSENDER,
|
||||||
|
to: empfaenger,
|
||||||
|
subject: `Planer: ${meldung.titel}`,
|
||||||
|
text: `${meldung.text}\n\nIm Planer ansehen: ${adresse}\n`,
|
||||||
|
html: alsHtml(meldung, adresse),
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (fehler) {
|
||||||
|
// Nie den Mailinhalt ins Protokoll – nur, dass es nicht ging.
|
||||||
|
console.error(
|
||||||
|
'Mailversand fehlgeschlagen:',
|
||||||
|
fehler instanceof Error ? fehler.message : 'unbekannter Fehler',
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prüft die Verbindung, ohne etwas zu verschicken. */
|
||||||
|
export async function mailVerbindungPruefen(): Promise<
|
||||||
|
{ ok: true } | { ok: false; fehler: string }
|
||||||
|
> {
|
||||||
|
const versender = transporter()
|
||||||
|
if (!versender) return { ok: false, fehler: 'SMTP ist nicht eingerichtet.' }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await versender.verify()
|
||||||
|
return { ok: true }
|
||||||
|
} catch (fehler) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
fehler: fehler instanceof Error ? fehler.message : 'Verbindung fehlgeschlagen.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import webpush from 'web-push'
|
||||||
|
import type { Meldung } from '@/lib/benachrichtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { entschluesseln, verschluesseln } from '@/server/krypto'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web-Push.
|
||||||
|
*
|
||||||
|
* Die Schlüssel eines Push-Abos liegen verschlüsselt in der Datenbank: Wer
|
||||||
|
* sie hat, kann Nachrichten an fremde Browser schicken. Das ist kein
|
||||||
|
* Vollzugriff, aber auch nichts, was offen in einer Sicherung liegen sollte.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Ab so vielen Fehlversuchen hintereinander wird das Gerät entfernt. */
|
||||||
|
const FEHLVERSUCHE_GRENZE = 5
|
||||||
|
|
||||||
|
let eingerichtet = false
|
||||||
|
|
||||||
|
function vapidEinrichten(): boolean {
|
||||||
|
const einst = umgebung()
|
||||||
|
if (!einst.pushAktiv) return false
|
||||||
|
if (eingerichtet) return true
|
||||||
|
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
einst.VAPID_KONTAKT,
|
||||||
|
einst.VAPID_OEFFENTLICH,
|
||||||
|
einst.VAPID_PRIVAT,
|
||||||
|
)
|
||||||
|
eingerichtet = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PushAbo = {
|
||||||
|
endpunkt: string
|
||||||
|
p256dh: string
|
||||||
|
auth: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt ein Gerät an oder frischt es auf. */
|
||||||
|
export async function geraetAnmelden(
|
||||||
|
benutzerId: string,
|
||||||
|
abo: PushAbo,
|
||||||
|
bezeichnung: string | null,
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
return db.pushGeraet.upsert({
|
||||||
|
where: { endpunkt: abo.endpunkt },
|
||||||
|
update: {
|
||||||
|
benutzerId,
|
||||||
|
p256dh: verschluesseln(abo.p256dh),
|
||||||
|
auth: verschluesseln(abo.auth),
|
||||||
|
fehlversuche: 0,
|
||||||
|
...(bezeichnung ? { bezeichnung } : {}),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
benutzerId,
|
||||||
|
endpunkt: abo.endpunkt,
|
||||||
|
p256dh: verschluesseln(abo.p256dh),
|
||||||
|
auth: verschluesseln(abo.auth),
|
||||||
|
bezeichnung,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function geraetAbmelden(endpunkt: string): Promise<void> {
|
||||||
|
await db.pushGeraet.deleteMany({ where: { endpunkt } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PushBericht = { zugestellt: number; entfernt: number; gescheitert: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schickt eine Meldung an alle Geräte eines Benutzers.
|
||||||
|
*
|
||||||
|
* Antwortet der Push-Dienst mit 404 oder 410, gibt es das Abo nicht mehr —
|
||||||
|
* das Gerät wird sofort entfernt. Andere Fehler werden gezählt; erst nach
|
||||||
|
* mehreren hintereinander fliegt es raus, denn ein Aussetzer ist kein Grund,
|
||||||
|
* jemanden abzumelden.
|
||||||
|
*/
|
||||||
|
export async function pushSenden(
|
||||||
|
benutzerId: string,
|
||||||
|
meldung: Meldung,
|
||||||
|
): Promise<PushBericht> {
|
||||||
|
if (!vapidEinrichten()) return { zugestellt: 0, entfernt: 0, gescheitert: 0 }
|
||||||
|
|
||||||
|
const geraete = await db.pushGeraet.findMany({
|
||||||
|
where: { benutzerId },
|
||||||
|
select: { id: true, endpunkt: true, p256dh: true, auth: true, fehlversuche: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const inhalt = JSON.stringify({
|
||||||
|
titel: meldung.titel,
|
||||||
|
text: meldung.text,
|
||||||
|
pfad: meldung.pfad,
|
||||||
|
anlass: meldung.anlass,
|
||||||
|
})
|
||||||
|
|
||||||
|
let zugestellt = 0
|
||||||
|
let entfernt = 0
|
||||||
|
let gescheitert = 0
|
||||||
|
|
||||||
|
for (const geraet of geraete) {
|
||||||
|
const p256dh = entschluesseln(geraet.p256dh)
|
||||||
|
const auth = entschluesseln(geraet.auth)
|
||||||
|
|
||||||
|
if (!p256dh || !auth) {
|
||||||
|
// Mit gewechseltem AUTH_SECRET sind die Schlüssel nicht mehr lesbar.
|
||||||
|
await db.pushGeraet.delete({ where: { id: geraet.id } })
|
||||||
|
entfernt++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(
|
||||||
|
{ endpoint: geraet.endpunkt, keys: { p256dh, auth } },
|
||||||
|
inhalt,
|
||||||
|
{ TTL: 60 * 60 },
|
||||||
|
)
|
||||||
|
await db.pushGeraet.update({
|
||||||
|
where: { id: geraet.id },
|
||||||
|
data: { zuletztBenutzt: new Date(), fehlversuche: 0 },
|
||||||
|
})
|
||||||
|
zugestellt++
|
||||||
|
} catch (fehler) {
|
||||||
|
const status =
|
||||||
|
typeof fehler === 'object' && fehler !== null && 'statusCode' in fehler
|
||||||
|
? Number(fehler.statusCode)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
if (status === 404 || status === 410) {
|
||||||
|
await db.pushGeraet.delete({ where: { id: geraet.id } })
|
||||||
|
entfernt++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const neu = geraet.fehlversuche + 1
|
||||||
|
if (neu >= FEHLVERSUCHE_GRENZE) {
|
||||||
|
await db.pushGeraet.delete({ where: { id: geraet.id } })
|
||||||
|
entfernt++
|
||||||
|
} else {
|
||||||
|
await db.pushGeraet.update({
|
||||||
|
where: { id: geraet.id },
|
||||||
|
data: { fehlversuche: neu },
|
||||||
|
})
|
||||||
|
gescheitert++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { zugestellt, entfernt, gescheitert }
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import {
|
||||||
|
ABGLEICHFEHLER_AB_VERSUCH,
|
||||||
|
VOREINSTELLUNG,
|
||||||
|
meldungsSchluessel,
|
||||||
|
sendenEntscheiden,
|
||||||
|
ueberblickFaellig,
|
||||||
|
zeitblockErinnerungFaellig,
|
||||||
|
type Anlass,
|
||||||
|
type Einstellung,
|
||||||
|
type Meldung,
|
||||||
|
} from '@/lib/benachrichtigung'
|
||||||
|
import {
|
||||||
|
alsDauer,
|
||||||
|
alsTagesschluessel,
|
||||||
|
alsUhrzeitAnzeige,
|
||||||
|
tagesBeginn,
|
||||||
|
tagesEnde,
|
||||||
|
} from '@/lib/zeit'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { mailSenden } from '@/server/benachrichtigung/mail'
|
||||||
|
import { pushSenden } from '@/server/benachrichtigung/push'
|
||||||
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was wann an wen hinausgeht.
|
||||||
|
*
|
||||||
|
* Die Entscheidung selbst trifft die reine Logik in src/lib; hier wird nur
|
||||||
|
* beschafft, zugestellt und nachgehalten. Jede tatsächlich verschickte
|
||||||
|
* Meldung hinterlässt einen Nachweis — dieselbe Sache geht nie zweimal raus.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Wie lange Nachweise aufgehoben werden. Danach räumt der Job sie weg. */
|
||||||
|
export const NACHWEIS_TAGE = 30
|
||||||
|
|
||||||
|
type BenutzerMitEinstellung = {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string | null
|
||||||
|
einstellung: Einstellung
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest die Einstellung; fehlt sie, gilt die zurückhaltende Voreinstellung. */
|
||||||
|
export function alsEinstellung(
|
||||||
|
zeile: {
|
||||||
|
anlaesse: string
|
||||||
|
perPush: boolean
|
||||||
|
perMail: boolean
|
||||||
|
ueberblickUm: number
|
||||||
|
ruheVon: number
|
||||||
|
ruheBis: number
|
||||||
|
ruheAmWochenende: boolean
|
||||||
|
zeitblockVorlaufMinuten: number
|
||||||
|
} | null,
|
||||||
|
): Einstellung {
|
||||||
|
if (!zeile) return VOREINSTELLUNG
|
||||||
|
|
||||||
|
const anlaesse = zeile.anlaesse
|
||||||
|
.split(',')
|
||||||
|
.map((a) => a.trim())
|
||||||
|
.filter((a): a is Anlass =>
|
||||||
|
['tagesueberblick', 'faelligkeit', 'zeitblock', 'abgleichfehler'].includes(a),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
anlaesse,
|
||||||
|
perPush: zeile.perPush,
|
||||||
|
perMail: zeile.perMail,
|
||||||
|
ueberblickUm: zeile.ueberblickUm,
|
||||||
|
ruheVon: zeile.ruheVon,
|
||||||
|
ruheBis: zeile.ruheBis,
|
||||||
|
ruheAmWochenende: zeile.ruheAmWochenende,
|
||||||
|
zeitblockVorlaufMinuten: zeile.zeitblockVorlaufMinuten,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function empfaengerLaden(): Promise<BenutzerMitEinstellung[]> {
|
||||||
|
const zeilen = await db.benutzer.findMany({
|
||||||
|
where: { aktiv: true },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
benachrichtigung: {
|
||||||
|
select: {
|
||||||
|
anlaesse: true,
|
||||||
|
perPush: true,
|
||||||
|
perMail: true,
|
||||||
|
ueberblickUm: true,
|
||||||
|
ruheVon: true,
|
||||||
|
ruheBis: true,
|
||||||
|
ruheAmWochenende: true,
|
||||||
|
zeitblockVorlaufMinuten: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return zeilen.map((z) => ({
|
||||||
|
id: z.id,
|
||||||
|
email: z.email,
|
||||||
|
name: z.name,
|
||||||
|
einstellung: alsEinstellung(z.benachrichtigung),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt eine Meldung zu, wenn sie darf, und hält den Nachweis fest.
|
||||||
|
*
|
||||||
|
* Der Nachweis wird **vor** dem Versand geschrieben. Bricht der Versand ab,
|
||||||
|
* gilt die Meldung trotzdem als erledigt — lieber eine verpasste Nachricht
|
||||||
|
* als eine, die im Minutentakt erneut versucht wird.
|
||||||
|
*/
|
||||||
|
export async function meldungZustellen(
|
||||||
|
benutzer: BenutzerMitEinstellung,
|
||||||
|
meldung: Meldung,
|
||||||
|
zeitpunkt: Date,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const gemeldet = await db.meldungsnachweis.findUnique({
|
||||||
|
where: {
|
||||||
|
benutzerId_schluessel: { benutzerId: benutzer.id, schluessel: meldung.schluessel },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const entscheidung = sendenEntscheiden(
|
||||||
|
benutzer.einstellung,
|
||||||
|
meldung,
|
||||||
|
new Set(gemeldet ? [meldung.schluessel] : []),
|
||||||
|
zeitpunkt,
|
||||||
|
)
|
||||||
|
if (!entscheidung.senden) return false
|
||||||
|
|
||||||
|
await db.meldungsnachweis.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
schluessel: meldung.schluessel,
|
||||||
|
gesendetAm: zeitpunkt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (benutzer.einstellung.perPush) await pushSenden(benutzer.id, meldung)
|
||||||
|
if (benutzer.einstellung.perMail) await mailSenden(benutzer.email, meldung)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Die einzelnen Anlässe -------------------------------------------------
|
||||||
|
|
||||||
|
/** Baut den Tagesüberblick: was heute ansteht. */
|
||||||
|
async function tagesueberblickBauen(
|
||||||
|
benutzerId: string,
|
||||||
|
zeitpunkt: Date,
|
||||||
|
): Promise<Meldung | null> {
|
||||||
|
const heute = tagesBeginn(zeitpunkt)
|
||||||
|
const heuteEnde = tagesEnde(zeitpunkt)
|
||||||
|
|
||||||
|
const [ueberfaellig, heuteFaellig, zeitbloecke] = await Promise.all([
|
||||||
|
db.aufgabe.count({
|
||||||
|
where: {
|
||||||
|
status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] },
|
||||||
|
typ: { not: 'IDEE' },
|
||||||
|
faelligAm: { lt: heute },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.aufgabe.count({
|
||||||
|
where: {
|
||||||
|
status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] },
|
||||||
|
typ: { not: 'IDEE' },
|
||||||
|
faelligAm: { gte: heute, lte: heuteEnde },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.zeitblock.findMany({
|
||||||
|
where: {
|
||||||
|
kalenderkonto: { benutzerId },
|
||||||
|
beginn: { gte: heute, lte: heuteEnde },
|
||||||
|
},
|
||||||
|
orderBy: { beginn: 'asc' },
|
||||||
|
select: { beginn: true, ende: true, aufgabe: { select: { titel: true } } },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Ein Überblick, der nichts zu sagen hat, ist keine Nachricht wert.
|
||||||
|
if (ueberfaellig === 0 && heuteFaellig === 0 && zeitbloecke.length === 0) return null
|
||||||
|
|
||||||
|
const teile: string[] = []
|
||||||
|
if (ueberfaellig > 0) teile.push(`${ueberfaellig} überfällig`)
|
||||||
|
if (heuteFaellig > 0) teile.push(`${heuteFaellig} heute fällig`)
|
||||||
|
|
||||||
|
const geplanteMinuten = zeitbloecke.reduce(
|
||||||
|
(summe, z) => summe + (z.ende.getTime() - z.beginn.getTime()) / 60_000,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if (zeitbloecke.length > 0) {
|
||||||
|
teile.push(`${zeitbloecke.length} Zeitblöcke (${alsDauer(geplanteMinuten)})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeilen = [teile.join(', ') + '.']
|
||||||
|
for (const block of zeitbloecke.slice(0, 5)) {
|
||||||
|
zeilen.push(`${alsUhrzeitAnzeige(block.beginn)} ${block.aufgabe.titel}`)
|
||||||
|
}
|
||||||
|
if (zeitbloecke.length > 5) zeilen.push(`… und ${zeitbloecke.length - 5} weitere.`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
anlass: 'tagesueberblick',
|
||||||
|
schluessel: meldungsSchluessel(
|
||||||
|
'tagesueberblick',
|
||||||
|
'tag',
|
||||||
|
alsTagesschluessel(zeitpunkt),
|
||||||
|
),
|
||||||
|
titel: 'Dein Tag',
|
||||||
|
text: zeilen.join('\n'),
|
||||||
|
pfad: '/',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aufgaben, die heute fällig werden. */
|
||||||
|
async function faelligkeitenBauen(zeitpunkt: Date): Promise<Meldung[]> {
|
||||||
|
const aufgaben = await db.aufgabe.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] },
|
||||||
|
typ: { not: 'IDEE' },
|
||||||
|
faelligAm: { gte: tagesBeginn(zeitpunkt), lte: tagesEnde(zeitpunkt) },
|
||||||
|
},
|
||||||
|
select: { id: true, titel: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const tag = alsTagesschluessel(zeitpunkt)
|
||||||
|
return aufgaben.map((aufgabe) => ({
|
||||||
|
anlass: 'faelligkeit' as const,
|
||||||
|
schluessel: meldungsSchluessel('faelligkeit', aufgabe.id, tag),
|
||||||
|
titel: 'Heute fällig',
|
||||||
|
text: aufgabe.titel,
|
||||||
|
pfad: '/aufgaben',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeitblöcke, die gleich beginnen. */
|
||||||
|
async function zeitblockErinnerungenBauen(
|
||||||
|
benutzer: BenutzerMitEinstellung,
|
||||||
|
zeitpunkt: Date,
|
||||||
|
): Promise<Meldung[]> {
|
||||||
|
const bloecke = await db.zeitblock.findMany({
|
||||||
|
where: {
|
||||||
|
kalenderkonto: { benutzerId: benutzer.id },
|
||||||
|
beginn: {
|
||||||
|
gte: zeitpunkt,
|
||||||
|
lte: new Date(
|
||||||
|
zeitpunkt.getTime() +
|
||||||
|
(benutzer.einstellung.zeitblockVorlaufMinuten + 15) * 60_000,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true, beginn: true, aufgabe: { select: { titel: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const tag = alsTagesschluessel(zeitpunkt)
|
||||||
|
return bloecke
|
||||||
|
.filter((b) => zeitblockErinnerungFaellig(benutzer.einstellung, b.beginn, zeitpunkt))
|
||||||
|
.map((block) => ({
|
||||||
|
anlass: 'zeitblock' as const,
|
||||||
|
schluessel: meldungsSchluessel('zeitblock', block.id, tag),
|
||||||
|
titel: `${alsUhrzeitAnzeige(block.beginn)} geht es los`,
|
||||||
|
text: block.aufgabe.titel,
|
||||||
|
pfad: '/woche',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kalender, die mehrfach hintereinander nicht abgleichbar waren. */
|
||||||
|
async function abgleichfehlerBauen(
|
||||||
|
benutzerId: string,
|
||||||
|
zeitpunkt: Date,
|
||||||
|
): Promise<Meldung[]> {
|
||||||
|
const konten = await db.kalenderkonto.findMany({
|
||||||
|
where: {
|
||||||
|
benutzerId,
|
||||||
|
aktiv: true,
|
||||||
|
letzterFehler: { not: null },
|
||||||
|
// Ein Aussetzer ist keine Nachricht wert – ein Netz hustet, ein
|
||||||
|
// Anbieter hat Wartung. Erst mehrere Fehlschläge hintereinander.
|
||||||
|
fehlversuche: { gte: ABGLEICHFEHLER_AB_VERSUCH },
|
||||||
|
},
|
||||||
|
select: { id: true, anzeigename: true, letzterFehler: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const tag = alsTagesschluessel(zeitpunkt)
|
||||||
|
return konten.map((konto) => ({
|
||||||
|
anlass: 'abgleichfehler' as const,
|
||||||
|
schluessel: meldungsSchluessel('abgleichfehler', konto.id, tag),
|
||||||
|
titel: 'Ein Kalender lässt sich nicht abgleichen',
|
||||||
|
text: `„${konto.anzeigename}": ${konto.letzterFehler ?? 'unbekannter Fehler'}`,
|
||||||
|
pfad: '/kalenderkonten',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VersandBericht = { geprueft: number; gesendet: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Durchlauf: für jeden Benutzer alles zusammentragen, was jetzt fällig
|
||||||
|
* wäre, und zustellen, was darf.
|
||||||
|
*/
|
||||||
|
export async function benachrichtigungenVerschicken(
|
||||||
|
zeitpunkt: Date = jetzt(),
|
||||||
|
): Promise<VersandBericht> {
|
||||||
|
const empfaenger = await empfaengerLaden()
|
||||||
|
let gesendet = 0
|
||||||
|
|
||||||
|
for (const benutzer of empfaenger) {
|
||||||
|
// Wer keinen Weg hinaus eingerichtet hat, braucht auch nichts gebaut.
|
||||||
|
if (!benutzer.einstellung.perPush && !benutzer.einstellung.perMail) continue
|
||||||
|
|
||||||
|
const meldungen: Meldung[] = []
|
||||||
|
|
||||||
|
if (
|
||||||
|
benutzer.einstellung.anlaesse.includes('tagesueberblick') &&
|
||||||
|
ueberblickFaellig(benutzer.einstellung, zeitpunkt)
|
||||||
|
) {
|
||||||
|
const ueberblick = await tagesueberblickBauen(benutzer.id, zeitpunkt)
|
||||||
|
if (ueberblick) meldungen.push(ueberblick)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (benutzer.einstellung.anlaesse.includes('faelligkeit')) {
|
||||||
|
meldungen.push(...(await faelligkeitenBauen(zeitpunkt)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (benutzer.einstellung.anlaesse.includes('zeitblock')) {
|
||||||
|
meldungen.push(...(await zeitblockErinnerungenBauen(benutzer, zeitpunkt)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (benutzer.einstellung.anlaesse.includes('abgleichfehler')) {
|
||||||
|
meldungen.push(...(await abgleichfehlerBauen(benutzer.id, zeitpunkt)))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const meldung of meldungen) {
|
||||||
|
if (await meldungZustellen(benutzer, meldung, zeitpunkt)) gesendet++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { geprueft: empfaenger.length, gesendet }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alte Nachweise wegräumen; sonst wächst die Tabelle unbegrenzt. */
|
||||||
|
export async function alteNachweiseEntfernen(zeitpunkt: Date = jetzt()): Promise<number> {
|
||||||
|
const grenze = new Date(zeitpunkt.getTime() - NACHWEIS_TAGE * 86_400_000)
|
||||||
|
const ergebnis = await db.meldungsnachweis.deleteMany({
|
||||||
|
where: { gesendetAm: { lt: grenze } },
|
||||||
|
})
|
||||||
|
return ergebnis.count
|
||||||
|
}
|
||||||
@@ -41,6 +41,22 @@ const schema = z.object({
|
|||||||
GOOGLE_CLIENT_ID: z.string().trim().default(''),
|
GOOGLE_CLIENT_ID: z.string().trim().default(''),
|
||||||
GOOGLE_CLIENT_SECRET: z.string().trim().default(''),
|
GOOGLE_CLIENT_SECRET: z.string().trim().default(''),
|
||||||
|
|
||||||
|
// --- Benachrichtigungen (M5) ---
|
||||||
|
// VAPID-Schluessel fuer Web-Push. Erzeugen mit:
|
||||||
|
// npx web-push generate-vapid-keys
|
||||||
|
VAPID_OEFFENTLICH: z.string().trim().default(''),
|
||||||
|
VAPID_PRIVAT: z.string().trim().default(''),
|
||||||
|
/// Kontaktadresse fuer den Push-Dienst, als mailto:.
|
||||||
|
VAPID_KONTAKT: z.string().trim().default(''),
|
||||||
|
|
||||||
|
SMTP_HOST: z.string().trim().default(''),
|
||||||
|
SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587),
|
||||||
|
SMTP_BENUTZER: z.string().trim().default(''),
|
||||||
|
SMTP_PASSWORT: z.string().default(''),
|
||||||
|
SMTP_ABSENDER: z.string().trim().default(''),
|
||||||
|
/// Implizites TLS (Port 465). Sonst wird STARTTLS versucht.
|
||||||
|
SMTP_TLS: jaNein.default(false),
|
||||||
|
|
||||||
DEMO_MODUS: jaNein.default(false),
|
DEMO_MODUS: jaNein.default(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -51,6 +67,9 @@ export type Umgebung = z.infer<typeof schema> & {
|
|||||||
/** Graph laesst sich verbinden, sobald Kennung und Geheimnis stehen. */
|
/** Graph laesst sich verbinden, sobald Kennung und Geheimnis stehen. */
|
||||||
graphAktiv: boolean
|
graphAktiv: boolean
|
||||||
googleAktiv: boolean
|
googleAktiv: boolean
|
||||||
|
/** Web-Push ist erst mit vollstaendigem VAPID-Satz moeglich. */
|
||||||
|
pushAktiv: boolean
|
||||||
|
mailAktiv: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
let zwischengespeichert: Umgebung | null = null
|
let zwischengespeichert: Umgebung | null = null
|
||||||
@@ -72,6 +91,10 @@ function lesen(): Umgebung {
|
|||||||
),
|
),
|
||||||
graphAktiv: Boolean(werte.GRAPH_CLIENT_ID && werte.GRAPH_CLIENT_SECRET),
|
graphAktiv: Boolean(werte.GRAPH_CLIENT_ID && werte.GRAPH_CLIENT_SECRET),
|
||||||
googleAktiv: Boolean(werte.GOOGLE_CLIENT_ID && werte.GOOGLE_CLIENT_SECRET),
|
googleAktiv: Boolean(werte.GOOGLE_CLIENT_ID && werte.GOOGLE_CLIENT_SECRET),
|
||||||
|
pushAktiv: Boolean(
|
||||||
|
werte.VAPID_OEFFENTLICH && werte.VAPID_PRIVAT && werte.VAPID_KONTAKT,
|
||||||
|
),
|
||||||
|
mailAktiv: Boolean(werte.SMTP_HOST && werte.SMTP_ABSENDER),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user