Harden authentication, tenant isolation and security boundaries
This commit is contained in:
+12
-7
@@ -1,4 +1,4 @@
|
||||
FROM node:22-alpine AS base
|
||||
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -28,6 +28,10 @@ COPY . .
|
||||
|
||||
RUN npm run build:prod
|
||||
|
||||
# Separate administrative image; never ship the migration CLI in the app runner.
|
||||
FROM builder AS migrator
|
||||
CMD ["npm", "run", "db:migrate:deploy"]
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
ENV NODE_ENV=production
|
||||
@@ -35,10 +39,11 @@ ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV UPLOAD_DIR=/app/uploads/announcements
|
||||
|
||||
COPY --from=builder /app ./
|
||||
|
||||
RUN mkdir -p /app/uploads/announcements && chmod +x /app/scripts/start-production.sh
|
||||
|
||||
RUN addgroup -S -g 1001 app && adduser -S -u 1001 -G app app
|
||||
COPY --from=builder --chown=app:app /app/.next/standalone ./
|
||||
COPY --from=builder --chown=app:app /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=app:app /app/public ./public
|
||||
RUN mkdir -p /app/uploads/announcements/announcements /app/.next/cache && chown -R app:app /app/uploads /app/.next/cache
|
||||
USER app
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["./scripts/start-production.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
+52
-39
@@ -1,55 +1,68 @@
|
||||
# Kita-Planer Deployment
|
||||
# Sicherer Betrieb und Rollout
|
||||
|
||||
Dieses Verzeichnis enthaelt die ausfuellbaren Vorlagen fuer das Production-Deployment.
|
||||
Die Anwendung startet ohne Schemaänderungen und ohne Demo-Seeding. Datenbankmigrationen laufen separat mit einer administrativen Rolle. Der App-Container verwendet ausschließlich `kita_runtime`, läuft als UID/GID 1001 und hat ein schreibgeschütztes Dateisystem mit persistenten Upload- und Cache-Volumes.
|
||||
|
||||
## Dateien
|
||||
## Konfiguration
|
||||
|
||||
- `production.env.example`: Vorlage fuer alle benoetigten Environment Variables.
|
||||
- `production-checklist.md`: Checkliste fuer Domain, Datenbank, Auth, Mail, Uploads und Rechtliches.
|
||||
- `../Dockerfile`: Container-Build fuer Coolify oder andere Docker-Hosts.
|
||||
- `../docker-compose.coolify.yml`: Compose-Vorlage fuer Coolify mit required Environment Variables.
|
||||
Werte aus `production.env.example` als Hosting-Secrets hinterlegen. `APP_DATABASE_URL` enthält die eingeschränkte Rolle; `MIGRATION_DATABASE_URL` den Datenbankeigentümer. Für lokale CLI-Befehle heißt die jeweils benötigte URL `DATABASE_URL`. `APP_DATABASE_PASSWORD` ist das separate Runtime-Passwort (24–256 Zeichen); in URLs muss es URL-kodiert werden. Keine echten Secrets im Repository speichern.
|
||||
|
||||
## Empfohlener Ablauf mit Coolify und Gitea
|
||||
`AUTH_URL`, `NEXTAUTH_URL` und `NEXT_PUBLIC_SITE_URL` müssen dieselbe kanonische HTTPS-Origin verwenden. Der Reverse Proxy terminiert TLS, routet intern auf App-Port 3000 und überschreibt weitergeleitete Host-/IP-Header. `TRUSTED_CLIENT_IP_HEADER` nur setzen, wenn der Proxy den gewählten Header (`x-real-ip` oder `cf-connecting-ip`) zuverlässig überschreibt. Ohne diese Einstellung gelten weiterhin gemeinsame globale und identitätsbezogene Datenbanklimits. Proxyseitig Body-Limit von höchstens 26 MiB, Timeouts und zusätzliche Request-Limits setzen.
|
||||
|
||||
1. Repository in deine Gitea-Instanz pushen.
|
||||
2. In Coolify eine neue Application aus diesem Gitea-Repository anlegen.
|
||||
3. Als Build Pack `Docker Compose` waehlen.
|
||||
4. Als Compose-Datei `docker-compose.coolify.yml` eintragen.
|
||||
5. Die bestehende Coolify-Postgres-Datenbank ueber ihre interne `DATABASE_URL` verbinden.
|
||||
6. Environment Variables aus `production.env.example` in Coolify eintragen.
|
||||
7. Domain auf den `app`-Service mit internem Port `3000` zeigen lassen.
|
||||
8. Deploy starten.
|
||||
Compose startet PostgreSQL standardmäßig intern. Bei einer bereits extern verwalteten Coolify-Datenbank den Stack entsprechend anpassen und deren interne URL verwenden; niemals versehentlich einen neuen leeren Datenbankdienst als Ersatz anschließen. pgAdmin ist nur im Profil `maintenance` und nur auf Loopback verfügbar; Zugriff über SSH-Tunnel.
|
||||
|
||||
Beim Container-Start fuehrt `scripts/start-production.sh` automatisch aus:
|
||||
## Bestehende Installation aktualisieren
|
||||
|
||||
```bash
|
||||
npx prisma db push
|
||||
1. Datenbank **und** Upload-Volume sichern und Wiederherstellung überprüfen. Wartungsfenster ankündigen und alte App stoppen: das neue Schema und der neue Code gehören zusammen.
|
||||
2. Den Zustand der bestehenden Datenbank mit `prisma/migrations/20261009000000_baseline/migration.sql` vergleichen. Die Baseline bildet das bisherige Schema ab. Bei Abweichungen zuerst die Unterschiede gezielt klären; eine Baseline-Markierung prüft das Schema nicht automatisch.
|
||||
3. `scripts/check-tenant-integrity.sql` mit `psql "$MIGRATION_DATABASE_URL" -v ON_ERROR_STOP=1 -f scripts/check-tenant-integrity.sql` ausführen. **Keine Ergebniszeilen** bedeutet, dass keine der geprüften widersprüchlichen Tenant-Verknüpfungen vorliegt. Ergebnisse vor der Migration fachlich korrigieren; keine Daten automatisch einem anderen Mandanten zuweisen.
|
||||
4. **Nur für eine bestehende, zur Baseline passende Datenbank** einmal die Baseline als bereits angewendet markieren. Bei einer leeren neuen Datenbank diesen Schritt auslassen.
|
||||
5. Alle weiteren Migrationen anwenden, Runtime-Rolle bereitstellen und Größenmetadaten vorhandener Anhänge nachtragen.
|
||||
6. Eigentümer bestehender Upload-/Cache-Volumes auf UID/GID 1001 vorbereiten, dann die neue App starten. Stichproben mit zwei verschiedenen Kitas und den echten E-Mail-Flows prüfen.
|
||||
|
||||
Compose-Beispiele (Environment-Secrets vorher bereitstellen):
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.coolify.yml build app migrate
|
||||
# NUR bei bestehender, zuvor geprüfter Baseline-Datenbank:
|
||||
docker compose -f docker-compose.coolify.yml --profile maintenance run --rm migrate npx prisma migrate resolve --applied 20261009000000_baseline
|
||||
# Bei neuen und bestehenden Datenbanken:
|
||||
docker compose -f docker-compose.coolify.yml --profile maintenance run --rm migrate npm run db:migrate:deploy
|
||||
docker compose -f docker-compose.coolify.yml --profile maintenance run --rm migrate npm run db:provision-runtime
|
||||
docker compose -f docker-compose.coolify.yml --profile maintenance run --rm migrate npm run uploads:backfill
|
||||
# Nur auf dem vorgesehenen App-Upload-Volume:
|
||||
docker compose -f docker-compose.coolify.yml --profile maintenance run --rm migrate chown -R 1001:1001 /app/uploads
|
||||
# Falls ein bereits vorhandenes Cache-Volume root gehört: dessen Eigentümer separat korrigieren.
|
||||
docker compose -f docker-compose.coolify.yml up -d app
|
||||
```
|
||||
|
||||
Coolify erkennt Variablen aus `docker-compose.coolify.yml` und zeigt required Variablen
|
||||
wie `DATABASE_URL`, `AUTH_SECRET`, `AUTH_URL`, `NEXTAUTH_URL`, `NEXT_PUBLIC_SITE_URL`,
|
||||
`RESEND_API_KEY`, `EMAIL_FROM` und `ADMIN_EMAIL` in der UI an.
|
||||
Bei einem Rollback nach Migration zuerst Kompatibilität prüfen; das alte Image allein zurückzusetzen reicht wegen neuer Pflichtfelder nicht aus. Im Zweifel das geprüfte Backup von Datenbank und Dateien gemeinsam wiederherstellen.
|
||||
|
||||
## Welche Coolify-Postgres-URL?
|
||||
## Auswirkungen auf bestehende Konten
|
||||
|
||||
Wenn die App und die Postgres-DB im selben Coolify-Projekt/Netzwerk laufen, nimm
|
||||
die interne Datenbank-URL aus Coolify, nicht die oeffentliche externe URL. Das ist
|
||||
stabiler und vermeidet unnoetigen Traffic ueber den Host.
|
||||
Alle JWT-Sitzungen vor diesem Release werden ungültig; erneute Anmeldung ist erforderlich. Administrative Sperren entwerten auch bei späterer Entsperrung dauerhaft die alten Sitzungen. Gründerkonten ohne Kita und ohne echte E-Mail-Prüfung registrieren sich mit derselben Adresse erneut und bestätigen den Link. Bereits bestehende Kita-Gründer behalten ihre bisherige Verifizierung; deren historische Adressen werden dadurch nicht nachträglich als echte Mailbox-Bestätigung nachgewiesen.
|
||||
|
||||
Das Format bleibt:
|
||||
Neue Registrierungen bestätigen zuerst die E-Mail-Adresse, wählen anschließend ihr Passwort und erstellen erst danach eine Kita. Beim Ändern einer Elternadresse werden Passwort und bisherige Sitzungen entwertet; die neue Adresse erhält eine Einladung zum Festlegen eines neuen Passworts. Kita-Admins können die Einladung erneut senden. Neue Registrierungslinks gelten 30 Minuten, Einladungen sieben Tage und Notdienst-Bestätigungslinks höchstens 24 Stunden sowie nur für offene, noch relevante Einsätze. Bestehende UUID-Links bleiben innerhalb ihrer Laufzeit kompatibel. Zwei-Faktor-Anmeldung ist auf ausdrücklichen Wunsch vorerst nicht eingeführt.
|
||||
|
||||
```txt
|
||||
postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
|
||||
## Dateien und Wartung
|
||||
|
||||
`UPLOAD_DIR` bezeichnet die bisherige Basis; die Anwendung ergänzt `announcements`. Compose verwendet unverändert `/app/uploads/announcements/announcements`. Backfill muss genau dieselben Dateien sehen. Fehlende Dateien werden mit Größe 0 erfasst und als Anzahl gemeldet: Ursache und Datenverlust prüfen. Solange Größenwerte `NULL` sind, blockiert die Anwendung neue Anhänge, damit das Gesamtlimit nicht umgangen werden kann.
|
||||
|
||||
Grenzen: 10 MiB pro Datei, fünf Dateien und 25 MiB pro Nachricht; standardmäßig 256 MiB pro Kita und 10 GiB insgesamt, konfigurierbar über die beiden Quota-Variablen. Erlaubt sind PDF, PNG und JPEG mit passenden Dateisignaturen und Endungen. Das ist keine Virenprüfung oder vollständige PDF-Bereinigung. Downloads erfordern Sitzung, Consent und die passende Kita; sie werden als Anhang ausgeliefert.
|
||||
|
||||
`npm run uploads:cleanup` zeigt zunächst nur verwaiste Dateien mit mindestens 24 Stunden Alter. Nach Prüfung entfernt `npm run uploads:cleanup -- --delete` diese Dateien. Backups, Speicherüberwachung und eine Aufbewahrungs-/Löschstrategie für Backups separat einrichten. Dateien gelöschter Datensätze werden auch unmittelbar bestmöglich entfernt.
|
||||
|
||||
Die SQL-Migrationen ergänzen **31 zusammengesetzte Tenant-Fremdschlüssel**, die Prisma wegen der vorhandenen optionalen Cascade-/SetNull-Beziehungen nicht vollständig abbildet. Zukünftige generierte Migrationen auf unbeabsichtigtes Entfernen dieser Constraints prüfen. Kein `prisma db push` auf verwalteten Datenbanken einsetzen. Runtime-Grants nach zukünftigen Migrationen erneut bereitstellen.
|
||||
|
||||
## Verifikation und Updates
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run build:prod
|
||||
npx tsc --noEmit
|
||||
npm run lint -- --quiet
|
||||
npm audit --omit=dev
|
||||
```
|
||||
|
||||
## Wichtige Hinweise
|
||||
Die Integrationstests setzen eine ausdrücklich isolierte lokale PostgreSQL-Datenbank namens `kita_security_test` voraus; `SECURITY_TEST_DATABASE_URL` darf keine Anwendungsdaten enthalten. `npm run test:security` prüft Aktionen und echte DB-Transaktionen. `npm run test:security:http` benötigt den Production-Server auf localhost:3057 mit derselben Testdatenbank, `UPLOAD_DIR` und dem im Test dokumentierten Test-Secret. Niemals dieses Test-Secret in Produktion verwenden.
|
||||
|
||||
- Das aktuelle Projekt nutzt noch `prisma db push` statt versionierter Prisma-Migrationen.
|
||||
Fuer den ersten MVP-Deploy ist das praktikabel, fuer laengerfristigen Betrieb sollten
|
||||
Migrationen eingefuehrt werden.
|
||||
- `UPLOAD_DIR` muss persistent sein. Die Coolify-Compose-Datei mountet dafuer ein
|
||||
Docker-Volume nach `/app/uploads`.
|
||||
- `NEXT_PUBLIC_SITE_URL` muss die echte Domain enthalten, damit OpenGraph-Bilder in
|
||||
WhatsApp, LinkedIn und Co. korrekt als absolute URL aufgeloest werden.
|
||||
- `NEXT_PUBLIC_SITE_URL` wird auch als Build-Arg an Docker uebergeben. In Coolify sollte
|
||||
diese Variable deshalb fuer Build und Runtime verfuegbar sein.
|
||||
Docker-Images sind auf Digests fixiert. Neue Sicherheitsreleases regelmäßig prüfen und die Digests bewusst aktualisieren. Zum Prüfzeitpunkt verbleibt eine ungepatchte `braces`-Schwachstelle in der Entwicklungs-Lint-Kette; `npm audit --force` löst sie nicht sinnvoll. Der Runtime-Audit ist ohne bekannte Findings. NextAuth bleibt eine Beta-Abhängigkeit und benötigt weiterhin Update-Beobachtung.
|
||||
|
||||
@@ -1,60 +1,37 @@
|
||||
# Production Checklist
|
||||
# Production-Checkliste
|
||||
|
||||
## 1. Domain & Basis-URLs
|
||||
## Vor dem Rollout
|
||||
|
||||
- [ ] Produktionsdomain festlegen: `https://[deine-domain.de]`
|
||||
- [ ] `NEXT_PUBLIC_SITE_URL` auf die Produktionsdomain setzen
|
||||
- [ ] `AUTH_URL` und `NEXTAUTH_URL` auf die Produktionsdomain setzen
|
||||
- [ ] Nach Deployment die WhatsApp-/LinkedIn-Vorschau mit der finalen URL testen
|
||||
- [ ] Datenbank und Uploads gesichert; Wiederherstellung geprüft.
|
||||
- [ ] Alte App gestoppt; Wartungsfenster und erneute Anmeldung angekündigt.
|
||||
- [ ] Bestehendes Schema mit Baseline verglichen; Tenant-Preflight ohne Befunde.
|
||||
- [ ] Baseline nur bei bestehender passender DB markiert; anschließend alle Migrationen angewendet.
|
||||
- [ ] Runtime-Rolle mit separatem Passwort bereitgestellt; App verwendet `APP_DATABASE_URL`.
|
||||
- [ ] Upload-Größen nachgetragen; gemeldete fehlende Dateien geprüft.
|
||||
- [ ] Upload- und Cache-Volumes für UID/GID 1001 beschreibbar; Upload-Pfad unverändert.
|
||||
|
||||
## 2. Datenbank
|
||||
## Hosting und Konfiguration
|
||||
|
||||
- [ ] Interne `DATABASE_URL` der bestehenden Coolify-Postgres-DB kopieren
|
||||
- [ ] `DATABASE_URL` in der Coolify-App eintragen
|
||||
- [ ] Beim ersten App-Start wird das Schema automatisch mit `npx prisma db push --skip-generate` angewendet
|
||||
- [ ] Fuer spaetere Releases idealerweise Prisma-Migrationen einfuehren
|
||||
- [ ] Drei öffentliche Basis-URLs auf dieselbe kanonische HTTPS-Origin gesetzt.
|
||||
- [ ] Starkes `AUTH_SECRET`; echte Secrets nur in Hosting-Konfiguration.
|
||||
- [ ] TLS und Proxy-Header korrekt; kein öffentlicher DB-/App-/pgAdmin-Port.
|
||||
- [ ] Request-Limits, Timeouts und Upload-Body-Limit am Proxy eingerichtet.
|
||||
- [ ] Trusted-IP-Header nur bei garantierter Überschreibung aktiviert.
|
||||
- [ ] Maildomain bei Resend verifiziert; Produktionsabsender gesetzt.
|
||||
- [ ] Optionale Analytics nur auf öffentlichen Marketingseiten; keine Token-/Login-/Dashboard-Daten.
|
||||
- [ ] Upload-Speicher und Backups überwacht; Löschfristen festgelegt.
|
||||
- [ ] Impressum und Datenschutztexte an tatsächliche Betreiber/Anbieter angepasst.
|
||||
|
||||
## 3. Auth & Sicherheit
|
||||
## Nach dem Rollout
|
||||
|
||||
- [ ] `AUTH_SECRET` neu generieren: `openssl rand -base64 32`
|
||||
- [ ] Keine `.env`-Dateien mit echten Secrets committen
|
||||
- [ ] Dashboard ist per Metadata auf `noindex,nofollow` gesetzt
|
||||
- [ ] Produktionsdomain auf HTTPS betreiben
|
||||
- [ ] Anmeldung und Registrierung mit Mailbox-Bestätigung getestet.
|
||||
- [ ] Einladungen, Adressänderung und erneutes Senden getestet.
|
||||
- [ ] Administrative Sperre entwertet bestehende Sitzung; Entsperrung verlangt neue Anmeldung.
|
||||
- [ ] Zwei getrennte Kitas: Anhänge, Kalender und Mutationen bleiben isoliert.
|
||||
- [ ] Anhänge heruntergeladen; Quotas und persistentes Volume geprüft.
|
||||
- [ ] Privates Dashboard ohne Analytics und ohne Cache; CSP ohne Browserfehler.
|
||||
- [ ] Kontakt-/Notdienst-Mailfluss und Fehlerbehandlung geprüft.
|
||||
- [ ] Build, Lint, Typprüfung, Sicherheitstests und Runtime-Audit erfolgreich.
|
||||
- [ ] Offenes Entwicklungs-Advisory und fixierte Image-Digests weiter beobachten.
|
||||
|
||||
## 4. Mail
|
||||
|
||||
- [ ] Eigene Versanddomain bei Resend verifizieren
|
||||
- [ ] `RESEND_API_KEY` fuer Produktion setzen
|
||||
- [ ] `EMAIL_FROM` auf eine verifizierte Absenderadresse setzen
|
||||
- [ ] `ADMIN_EMAIL` fuer Kontaktformular setzen
|
||||
- [ ] Kontaktformular und Einladung/E-Mail-Flows einmal in Produktion testen
|
||||
|
||||
## 5. Uploads
|
||||
|
||||
- [ ] Coolify-Volume `kita_planer_uploads` fuer `/app/uploads` verwenden
|
||||
- [ ] Sicherstellen, dass Uploads nicht aus Versehen oeffentlich ausgeliefert werden
|
||||
- [ ] Backup-Strategie fuer Uploads klaeren
|
||||
|
||||
## 6. Rechtliches
|
||||
|
||||
- [ ] Platzhalter im Impressum ersetzen
|
||||
- [ ] Platzhalter in der Datenschutzerklaerung ersetzen
|
||||
- [ ] Hosting-Anbieter und Mail-Anbieter in Datenschutztext eintragen
|
||||
- [ ] Rechtliche Texte vor Livegang pruefen lassen
|
||||
|
||||
## 7. Build & Smoke Test
|
||||
|
||||
- [ ] Dependencies installieren: `npm ci`
|
||||
- [ ] Production Build erstellen: `npm run build:prod`
|
||||
- [ ] App starten: `npm run start:prod`
|
||||
- [ ] Startseite, Login, Dashboard und Kontaktformular testen
|
||||
|
||||
## 8. Coolify
|
||||
|
||||
- [ ] Code nach Gitea pushen
|
||||
- [ ] In Coolify eine Application aus dem Gitea-Repository anlegen
|
||||
- [ ] Build Pack `Docker Compose` waehlen
|
||||
- [ ] Compose-Datei `docker-compose.coolify.yml` verwenden
|
||||
- [ ] Environment Variables in Coolify eintragen, nicht ins Repo committen
|
||||
- [ ] Domain auf den `app`-Service und Container-Port `3000` routen
|
||||
- [ ] Upload-Volume `kita_planer_uploads` bleibt zwischen Deployments erhalten
|
||||
Details und Befehle stehen in [README.md](README.md). Lokale Tests ersetzen nicht die Kontrolle der produktiven TLS-, Proxy-, Backup-, Mail- und Container-Konfiguration.
|
||||
|
||||
@@ -11,6 +11,11 @@ POSTGRES_DB="kita_planer"
|
||||
POSTGRES_USER="kita_planer"
|
||||
POSTGRES_PASSWORD="[GENERATE_ME]"
|
||||
|
||||
# Unterschiedliche DB-Rollen für Runtime und Migration. URL-Passwörter müssen URL-kodiert sein.
|
||||
APP_DATABASE_PASSWORD="[GENERATE_ME]"
|
||||
APP_DATABASE_URL="postgresql://kita_runtime:[URL_ENCODED_APP_PASSWORD]@postgres:5432/kita_planer?schema=public"
|
||||
MIGRATION_DATABASE_URL="postgresql://kita_planer:[URL_ENCODED_OWNER_PASSWORD]@postgres:5432/kita_planer?schema=public"
|
||||
|
||||
# pgAdmin im Coolify-Compose-Stack
|
||||
PGADMIN_DEFAULT_EMAIL="[admin@deine-domain.de]"
|
||||
PGADMIN_DEFAULT_PASSWORD="[GENERATE_ME]"
|
||||
@@ -40,5 +45,14 @@ ADMIN_EMAIL="[kontakt@deine-domain.de]"
|
||||
# Muss auf dem Server dauerhaft erhalten bleiben und darf nicht im Repo liegen.
|
||||
UPLOAD_DIR="/var/lib/kita-planer/uploads/announcements"
|
||||
|
||||
# Optional: Nur bewusst fuer initiales Demo-Seeding in Produktion setzen.
|
||||
# ALLOW_DATABASE_SEED="true"
|
||||
# Standard: 256 MiB je Kita, 10 GiB insgesamt. Compose verwendet diese Werte.
|
||||
UPLOAD_TENANT_QUOTA_BYTES="268435456"
|
||||
UPLOAD_GLOBAL_QUOTA_BYTES="10737418240"
|
||||
|
||||
# Compose setzt UPLOAD_DIR fest auf /app/uploads/announcements.
|
||||
# Die Anwendung ergänzt /announcements: bestehende Dateien liegen dort unter
|
||||
# /app/uploads/announcements/announcements. Diesen Pfad beim Backfill beibehalten.
|
||||
|
||||
# Demo-Seeding ist in Produktion deaktiviert.
|
||||
# Nur hinter einem Proxy setzen, der diesen Header überschreibt:
|
||||
# TRUSTED_CLIENT_IP_HEADER="x-real-ip"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-kita_planer}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-kita_planer}
|
||||
@@ -27,7 +27,7 @@ services:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
HOSTNAME: 0.0.0.0
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-kita_planer}:${POSTGRES_PASSWORD:?}@postgres:5432/${POSTGRES_DB:-kita_planer}?schema=public
|
||||
DATABASE_URL: ${APP_DATABASE_URL:?}
|
||||
AUTH_SECRET: ${AUTH_SECRET:?}
|
||||
AUTH_URL: ${AUTH_URL:?}
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL:?}
|
||||
@@ -39,17 +39,52 @@ services:
|
||||
EMAIL_FROM: ${EMAIL_FROM:?}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:?}
|
||||
UPLOAD_DIR: /app/uploads/announcements
|
||||
UPLOAD_TENANT_QUOTA_BYTES: ${UPLOAD_TENANT_QUOTA_BYTES:-268435456}
|
||||
UPLOAD_GLOBAL_QUOTA_BYTES: ${UPLOAD_GLOBAL_QUOTA_BYTES:-10737418240}
|
||||
TRUSTED_CLIENT_IP_HEADER: ${TRUSTED_CLIENT_IP_HEADER:-}
|
||||
volumes:
|
||||
- kita_planer_uploads:/app/uploads
|
||||
- kita_planer_next_cache:/app/.next/cache
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
expose:
|
||||
- "3000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=64m,mode=1777
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
pids_limit: 256
|
||||
mem_limit: 1g
|
||||
cpus: "1.0"
|
||||
restart: unless-stopped
|
||||
|
||||
migrate:
|
||||
profiles: ["maintenance"]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: migrator
|
||||
args:
|
||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:?}
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: ${MIGRATION_DATABASE_URL:?}
|
||||
APP_DATABASE_PASSWORD: ${APP_DATABASE_PASSWORD:?}
|
||||
UPLOAD_DIR: /app/uploads/announcements
|
||||
volumes:
|
||||
- kita_planer_uploads:/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3000"
|
||||
restart: unless-stopped
|
||||
restart: "no"
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
profiles: ["maintenance"]
|
||||
image: dpage/pgadmin4@sha256:c332c5f6dfba995d9ebc4af261d93506d6876085d712eaaa3defc8dd1a3f26de
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:?}
|
||||
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:?}
|
||||
@@ -61,10 +96,11 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "80"
|
||||
- "127.0.0.1:5050:80"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
kita_planer_postgres_data:
|
||||
kita_planer_uploads:
|
||||
kita_planer_next_cache:
|
||||
kita_planer_pgadmin_data:
|
||||
|
||||
+2
-2
@@ -2,14 +2,14 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
|
||||
container_name: kita_planer_db
|
||||
environment:
|
||||
POSTGRES_USER: kita
|
||||
POSTGRES_PASSWORD: kita
|
||||
POSTGRES_DB: kita_planer
|
||||
ports:
|
||||
- "5433:5432"
|
||||
- "127.0.0.1:5433:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
+22
-1
@@ -1,12 +1,33 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
experimental: { serverActions: { bodySizeLimit: "26mb" } },
|
||||
async headers() {
|
||||
return [{ source: "/:path*", headers: [
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Referrer-Policy", value: "no-referrer" },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||
...(process.env.NODE_ENV === "production" ? [{ key: "Strict-Transport-Security", value: "max-age=31536000" }] : []),
|
||||
] }];
|
||||
},
|
||||
images: {
|
||||
localPatterns: [
|
||||
{ pathname: "/landing/**", search: "" },
|
||||
{ pathname: "/og-image.png", search: "" },
|
||||
{ pathname: "/_next/static/media/**", search: "" },
|
||||
],
|
||||
maximumDiskCacheSize: 128 * 1024 * 1024,
|
||||
maximumResponseBody: 10 * 1024 * 1024,
|
||||
maximumRedirects: 0,
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
pathname: "/**",
|
||||
port: "",
|
||||
pathname: "/photo-1503454537195-1dcabb73ffb9",
|
||||
search: "?auto=format&fit=crop&w=2400&q=86",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Generated
+572
-520
File diff suppressed because it is too large
Load Diff
+23
-10
@@ -3,23 +3,28 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development prisma db push && NODE_ENV=development prisma db seed && next dev",
|
||||
"build": "next build",
|
||||
"build:prod": "prisma generate && next build",
|
||||
"dev": "next dev",
|
||||
"build": "next build && node scripts/prepare-standalone.mjs",
|
||||
"build:prod": "prisma generate && next build && node scripts/prepare-standalone.mjs",
|
||||
"start": "next start",
|
||||
"start:prod": "next start",
|
||||
"start:prod": "node .next/standalone/server.js",
|
||||
"lint": "eslint",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:studio": "prisma studio",
|
||||
"db:seed": "NODE_ENV=development prisma db seed",
|
||||
"db:seed:reset": "NODE_ENV=development prisma db seed -- --reset"
|
||||
"db:seed": "prisma db seed",
|
||||
"db:seed:reset": "prisma db seed -- --reset",
|
||||
"db:migrate:deploy": "prisma migrate deploy",
|
||||
"test:security": "node --test tests/security.test.cjs",
|
||||
"db:provision-runtime": "node scripts/provision-runtime-role.mjs",
|
||||
"uploads:backfill": "tsx scripts/backfill-attachment-sizes.ts",
|
||||
"uploads:cleanup": "tsx scripts/cleanup-attachments.ts",
|
||||
"test:security:http": "node tests/security-http.cjs"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
@@ -36,8 +41,8 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"next": "16.2.4",
|
||||
"next-auth": "^5.0.0-beta.31",
|
||||
"next": "^16.4.0",
|
||||
"next-auth": "^5.0.0-beta.32",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
@@ -57,10 +62,18 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.4",
|
||||
"eslint-config-next": "^16.4.0",
|
||||
"prisma": "^6.19.3",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"overrides": {
|
||||
"@prisma/config": {
|
||||
"deepmerge-ts": "8.0.0"
|
||||
},
|
||||
"tsx": {
|
||||
"esbuild": "^0.28.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
-- CreateSchema
|
||||
CREATE SCHEMA IF NOT EXISTS "public";
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserRole" AS ENUM ('SUPERADMIN', 'ADMIN', 'KOORDINATOR', 'ERZIEHER', 'ELTERN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "InvitationStatus" AS ENUM ('PENDING', 'ACCEPTED', 'EXPIRED', 'REVOKED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TerminType" AS ENUM ('KITA_FEST', 'SCHLIESSTAG', 'TEAMTAG', 'MITMACH_TAG', 'ELTERNABEND', 'MITGLIEDERVERSAMMLUNG', 'ELTERNCAFE', 'GEBURTSTAG_INTERN', 'GEBURTSTAG_EXTERN', 'SONSTIGES');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TerminStatus" AS ENUM ('PENDING', 'CONFIRMED', 'REJECTED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EventParticipationStatus" AS ENUM ('ATTENDING', 'NOT_ATTENDING');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AuditTargetType" AS ENUM ('KITA', 'USER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NotdienstPlanStatus" AS ENUM ('DRAFT', 'PUBLISHED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NotdienstAlertStatus" AS ENUM ('PENDING', 'CONFIRMED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NotdienstSwapStatus" AS ENUM ('PENDING', 'ACCEPTED', 'DECLINED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DutyAssignmentStatus" AS ENUM ('PLANNED', 'DONE', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AbsenceReason" AS ENUM ('ILLNESS', 'VACATION', 'OTHER');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "kitas" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"notdienstModuleEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"terminModuleEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"adressbuchModuleEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"notdienstMinPerChildPerMonth" INTEGER NOT NULL DEFAULT 2,
|
||||
"notdienstReminderDaysBefore" INTEGER NOT NULL DEFAULT 7,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "kitas_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "families" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "families_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT,
|
||||
"familyId" TEXT,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT NOT NULL,
|
||||
"role" "UserRole" NOT NULL DEFAULT 'ELTERN',
|
||||
"phone" TEXT,
|
||||
"street" TEXT,
|
||||
"postalCode" TEXT,
|
||||
"city" TEXT,
|
||||
"privacyPolicyAcceptedAt" TIMESTAMP(3),
|
||||
"privacyPolicyVersion" TEXT,
|
||||
"directoryOptInAt" TIMESTAMP(3),
|
||||
"emailVerifiedAt" TIMESTAMP(3),
|
||||
"lastLoginAt" TIMESTAMP(3),
|
||||
"failedLoginAttempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"lockedUntil" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "children" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"familyId" TEXT NOT NULL,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT NOT NULL,
|
||||
"dateOfBirth" TIMESTAMP(3),
|
||||
"notes" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "children_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "educators" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT NOT NULL,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "educators_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "parent_duties" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "parent_duties_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "parent_duty_assignments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"dutyId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "parent_duty_assignments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "duty_types" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "duty_types_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "duty_assignments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"familyId" TEXT NOT NULL,
|
||||
"dutyTypeId" TEXT NOT NULL,
|
||||
"startDate" DATE NOT NULL,
|
||||
"endDate" DATE NOT NULL,
|
||||
"status" "DutyAssignmentStatus" NOT NULL DEFAULT 'PLANNED',
|
||||
"reminderSentAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "duty_assignments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "absences" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"childId" TEXT NOT NULL,
|
||||
"startDate" DATE NOT NULL,
|
||||
"endDate" DATE NOT NULL,
|
||||
"reason" "AbsenceReason" NOT NULL,
|
||||
"note" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "absences_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "announcements" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"authorId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "announcements_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "attachments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"announcementId" TEXT NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"fileUrl" TEXT NOT NULL,
|
||||
"fileType" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "attachments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "announcement_reads" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"announcementId" TEXT NOT NULL,
|
||||
"readAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "announcement_reads_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "invitations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"role" "UserRole" NOT NULL DEFAULT 'ELTERN',
|
||||
"token" TEXT NOT NULL,
|
||||
"status" "InvitationStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"acceptedAt" TIMESTAMP(3),
|
||||
"invitedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "invitations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "termine" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"type" "TerminType" NOT NULL,
|
||||
"status" "TerminStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"startDate" TIMESTAMP(3) NOT NULL,
|
||||
"endDate" TIMESTAMP(3) NOT NULL,
|
||||
"allDay" BOOLEAN NOT NULL DEFAULT false,
|
||||
"mitbringselListEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"requiresRsvp" BOOLEAN NOT NULL DEFAULT false,
|
||||
"rsvpDeadline" TIMESTAMP(3),
|
||||
"participantInfo" TEXT,
|
||||
"createdById" TEXT,
|
||||
"approvedById" TEXT,
|
||||
"approvedAt" TIMESTAMP(3),
|
||||
"rejectionReason" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "termine_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "event_participations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"childId" TEXT NOT NULL,
|
||||
"status" "EventParticipationStatus" NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "event_participations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "mitbringsel_items" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"terminId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "mitbringsel_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_plans" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"year" INTEGER NOT NULL,
|
||||
"month" INTEGER NOT NULL,
|
||||
"status" "NotdienstPlanStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"createdById" TEXT,
|
||||
"publishedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "notdienst_plans_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_availabilities" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"childId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "notdienst_availabilities_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_assignments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"planId" TEXT NOT NULL,
|
||||
"childId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "notdienst_assignments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_swap_requests" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"requesterUserId" TEXT NOT NULL,
|
||||
"requesterFamilyId" TEXT NOT NULL,
|
||||
"targetAssignmentId" TEXT NOT NULL,
|
||||
"offeredAssignmentId" TEXT NOT NULL,
|
||||
"status" "NotdienstSwapStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"message" TEXT,
|
||||
"respondedById" TEXT,
|
||||
"respondedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "notdienst_swap_requests_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_alerts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"assignmentId" TEXT NOT NULL,
|
||||
"parentUserId" TEXT NOT NULL,
|
||||
"triggeredById" TEXT,
|
||||
"educatorId" TEXT,
|
||||
"status" "NotdienstAlertStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"confirmationToken" TEXT NOT NULL,
|
||||
"triggeredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"confirmedAt" TIMESTAMP(3),
|
||||
"notes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "notdienst_alerts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "audit_logs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"actorUserId" TEXT,
|
||||
"actorEmail" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"targetType" "AuditTargetType" NOT NULL,
|
||||
"targetId" TEXT NOT NULL,
|
||||
"targetLabel" TEXT NOT NULL,
|
||||
"targetKitaId" TEXT,
|
||||
"metadata" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "verification_tokens" (
|
||||
"identifier" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expires" TIMESTAMP(3) NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "kitas_slug_key" ON "kitas"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "families_kitaId_idx" ON "families"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "users_kitaId_idx" ON "users"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "users_familyId_idx" ON "users"("familyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "users_kitaId_role_idx" ON "users"("kitaId", "role");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "children_kitaId_idx" ON "children"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "children_familyId_idx" ON "children"("familyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "educators_kitaId_idx" ON "educators"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "parent_duties_kitaId_idx" ON "parent_duties"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "parent_duties_kitaId_name_key" ON "parent_duties"("kitaId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "parent_duty_assignments_kitaId_idx" ON "parent_duty_assignments"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "parent_duty_assignments_userId_idx" ON "parent_duty_assignments"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "parent_duty_assignments_dutyId_userId_key" ON "parent_duty_assignments"("dutyId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "duty_types_kitaId_idx" ON "duty_types"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "duty_types_kitaId_name_key" ON "duty_types"("kitaId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "duty_assignments_kitaId_startDate_idx" ON "duty_assignments"("kitaId", "startDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "duty_assignments_familyId_startDate_idx" ON "duty_assignments"("familyId", "startDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "duty_assignments_kitaId_dutyTypeId_startDate_key" ON "duty_assignments"("kitaId", "dutyTypeId", "startDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "absences_kitaId_startDate_endDate_idx" ON "absences"("kitaId", "startDate", "endDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "absences_childId_startDate_idx" ON "absences"("childId", "startDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "announcements_kitaId_createdAt_idx" ON "announcements"("kitaId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "announcements_authorId_idx" ON "announcements"("authorId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "attachments_announcementId_idx" ON "attachments"("announcementId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "announcement_reads_announcementId_idx" ON "announcement_reads"("announcementId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "announcement_reads_userId_announcementId_key" ON "announcement_reads"("userId", "announcementId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "invitations_token_key" ON "invitations"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "invitations_kitaId_idx" ON "invitations"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "invitations_kitaId_email_key" ON "invitations"("kitaId", "email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "termine_kitaId_startDate_idx" ON "termine"("kitaId", "startDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "termine_kitaId_status_idx" ON "termine"("kitaId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "event_participations_kitaId_idx" ON "event_participations"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "event_participations_childId_idx" ON "event_participations"("childId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "event_participations_eventId_childId_key" ON "event_participations"("eventId", "childId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "mitbringsel_items_kitaId_idx" ON "mitbringsel_items"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "mitbringsel_items_terminId_idx" ON "mitbringsel_items"("terminId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_plans_kitaId_idx" ON "notdienst_plans"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_plans_kitaId_year_month_key" ON "notdienst_plans"("kitaId", "year", "month");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_availabilities_kitaId_date_idx" ON "notdienst_availabilities"("kitaId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_availabilities_userId_idx" ON "notdienst_availabilities"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_availabilities_childId_date_key" ON "notdienst_availabilities"("childId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_assignments_kitaId_date_idx" ON "notdienst_assignments"("kitaId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_assignments_planId_date_key" ON "notdienst_assignments"("planId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_swap_requests_kitaId_status_idx" ON "notdienst_swap_requests"("kitaId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_swap_requests_requesterFamilyId_status_idx" ON "notdienst_swap_requests"("requesterFamilyId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_swap_requests_targetAssignmentId_status_idx" ON "notdienst_swap_requests"("targetAssignmentId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_swap_requests_offeredAssignmentId_status_idx" ON "notdienst_swap_requests"("offeredAssignmentId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_alerts_confirmationToken_key" ON "notdienst_alerts"("confirmationToken");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alerts_kitaId_idx" ON "notdienst_alerts"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alerts_assignmentId_idx" ON "notdienst_alerts"("assignmentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alerts_parentUserId_idx" ON "notdienst_alerts"("parentUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_actorUserId_idx" ON "audit_logs"("actorUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_targetType_targetId_idx" ON "audit_logs"("targetType", "targetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_targetKitaId_idx" ON "audit_logs"("targetKitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_createdAt_idx" ON "audit_logs"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "verification_tokens_token_key" ON "verification_tokens"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "verification_tokens_identifier_token_key" ON "verification_tokens"("identifier", "token");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "families" ADD CONSTRAINT "families_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "children" ADD CONSTRAINT "children_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "children" ADD CONSTRAINT "children_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "educators" ADD CONSTRAINT "educators_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "parent_duties" ADD CONSTRAINT "parent_duties_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "parent_duty_assignments" ADD CONSTRAINT "parent_duty_assignments_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "parent_duty_assignments" ADD CONSTRAINT "parent_duty_assignments_dutyId_fkey" FOREIGN KEY ("dutyId") REFERENCES "parent_duties"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "parent_duty_assignments" ADD CONSTRAINT "parent_duty_assignments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "duty_types" ADD CONSTRAINT "duty_types_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "duty_assignments" ADD CONSTRAINT "duty_assignments_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "duty_assignments" ADD CONSTRAINT "duty_assignments_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "duty_assignments" ADD CONSTRAINT "duty_assignments_dutyTypeId_fkey" FOREIGN KEY ("dutyTypeId") REFERENCES "duty_types"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "absences" ADD CONSTRAINT "absences_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "absences" ADD CONSTRAINT "absences_childId_fkey" FOREIGN KEY ("childId") REFERENCES "children"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "announcements" ADD CONSTRAINT "announcements_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "announcements" ADD CONSTRAINT "announcements_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_announcementId_fkey" FOREIGN KEY ("announcementId") REFERENCES "announcements"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "announcement_reads" ADD CONSTRAINT "announcement_reads_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "announcement_reads" ADD CONSTRAINT "announcement_reads_announcementId_fkey" FOREIGN KEY ("announcementId") REFERENCES "announcements"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "termine" ADD CONSTRAINT "termine_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "termine" ADD CONSTRAINT "termine_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "termine" ADD CONSTRAINT "termine_approvedById_fkey" FOREIGN KEY ("approvedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "event_participations" ADD CONSTRAINT "event_participations_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "event_participations" ADD CONSTRAINT "event_participations_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "termine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "event_participations" ADD CONSTRAINT "event_participations_childId_fkey" FOREIGN KEY ("childId") REFERENCES "children"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mitbringsel_items" ADD CONSTRAINT "mitbringsel_items_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mitbringsel_items" ADD CONSTRAINT "mitbringsel_items_terminId_fkey" FOREIGN KEY ("terminId") REFERENCES "termine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mitbringsel_items" ADD CONSTRAINT "mitbringsel_items_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_plans" ADD CONSTRAINT "notdienst_plans_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_plans" ADD CONSTRAINT "notdienst_plans_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_availabilities" ADD CONSTRAINT "notdienst_availabilities_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_availabilities" ADD CONSTRAINT "notdienst_availabilities_childId_fkey" FOREIGN KEY ("childId") REFERENCES "children"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_availabilities" ADD CONSTRAINT "notdienst_availabilities_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_assignments" ADD CONSTRAINT "notdienst_assignments_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_assignments" ADD CONSTRAINT "notdienst_assignments_planId_fkey" FOREIGN KEY ("planId") REFERENCES "notdienst_plans"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_assignments" ADD CONSTRAINT "notdienst_assignments_childId_fkey" FOREIGN KEY ("childId") REFERENCES "children"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_requesterUserId_fkey" FOREIGN KEY ("requesterUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_requesterFamilyId_fkey" FOREIGN KEY ("requesterFamilyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_targetAssignmentId_fkey" FOREIGN KEY ("targetAssignmentId") REFERENCES "notdienst_assignments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_offeredAssignmentId_fkey" FOREIGN KEY ("offeredAssignmentId") REFERENCES "notdienst_assignments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_respondedById_fkey" FOREIGN KEY ("respondedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "notdienst_assignments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_parentUserId_fkey" FOREIGN KEY ("parentUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_triggeredById_fkey" FOREIGN KEY ("triggeredById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_educatorId_fkey" FOREIGN KEY ("educatorId") REFERENCES "educators"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,102 @@
|
||||
BEGIN;
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "disabledAt" TIMESTAMP(3),
|
||||
ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "rate_limit_buckets" (
|
||||
"key" TEXT NOT NULL,
|
||||
"count" INTEGER NOT NULL,
|
||||
"resetAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "rate_limit_buckets_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "pending_registrations" (
|
||||
"email" TEXT NOT NULL,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "pending_registrations_pkey" PRIMARY KEY ("email")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "rate_limit_buckets_resetAt_idx" ON "rate_limit_buckets"("resetAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pending_registrations_tokenHash_key" ON "pending_registrations"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pending_registrations_expiresAt_idx" ON "pending_registrations"("expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "families_id_kitaId_key" ON "families"("id", "kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_id_kitaId_key" ON "users"("id", "kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "children_id_kitaId_key" ON "children"("id", "kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "educators_id_kitaId_key" ON "educators"("id", "kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "parent_duties_id_kitaId_key" ON "parent_duties"("id", "kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "duty_types_id_kitaId_key" ON "duty_types"("id", "kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "termine_id_kitaId_key" ON "termine"("id", "kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_plans_id_kitaId_key" ON "notdienst_plans"("id", "kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_assignments_id_kitaId_key" ON "notdienst_assignments"("id", "kitaId");
|
||||
|
||||
|
||||
-- Additional composite tenant foreign keys deliberately supplement the Prisma single-ID relations.
|
||||
-- NO ACTION preserves their existing Cascade/SetNull behavior; validation rejects inconsistent legacy rows.
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_familyId_tenant_fkey" FOREIGN KEY ("familyId", "kitaId") REFERENCES "families"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "children" ADD CONSTRAINT "children_familyId_tenant_fkey" FOREIGN KEY ("familyId", "kitaId") REFERENCES "families"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "parent_duty_assignments" ADD CONSTRAINT "parent_duty_assignments_dutyId_tenant_fkey" FOREIGN KEY ("dutyId", "kitaId") REFERENCES "parent_duties"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "parent_duty_assignments" ADD CONSTRAINT "parent_duty_assignments_userId_tenant_fkey" FOREIGN KEY ("userId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "duty_assignments" ADD CONSTRAINT "duty_assignments_familyId_tenant_fkey" FOREIGN KEY ("familyId", "kitaId") REFERENCES "families"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "duty_assignments" ADD CONSTRAINT "duty_assignments_dutyTypeId_tenant_fkey" FOREIGN KEY ("dutyTypeId", "kitaId") REFERENCES "duty_types"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "absences" ADD CONSTRAINT "absences_childId_tenant_fkey" FOREIGN KEY ("childId", "kitaId") REFERENCES "children"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "announcements" ADD CONSTRAINT "announcements_authorId_tenant_fkey" FOREIGN KEY ("authorId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_invitedById_tenant_fkey" FOREIGN KEY ("invitedById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "termine" ADD CONSTRAINT "termine_createdById_tenant_fkey" FOREIGN KEY ("createdById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "termine" ADD CONSTRAINT "termine_approvedById_tenant_fkey" FOREIGN KEY ("approvedById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "event_participations" ADD CONSTRAINT "event_participations_eventId_tenant_fkey" FOREIGN KEY ("eventId", "kitaId") REFERENCES "termine"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "event_participations" ADD CONSTRAINT "event_participations_childId_tenant_fkey" FOREIGN KEY ("childId", "kitaId") REFERENCES "children"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "mitbringsel_items" ADD CONSTRAINT "mitbringsel_items_terminId_tenant_fkey" FOREIGN KEY ("terminId", "kitaId") REFERENCES "termine"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "mitbringsel_items" ADD CONSTRAINT "mitbringsel_items_userId_tenant_fkey" FOREIGN KEY ("userId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_plans" ADD CONSTRAINT "notdienst_plans_createdById_tenant_fkey" FOREIGN KEY ("createdById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_availabilities" ADD CONSTRAINT "notdienst_availabilities_childId_tenant_fkey" FOREIGN KEY ("childId", "kitaId") REFERENCES "children"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_availabilities" ADD CONSTRAINT "notdienst_availabilities_userId_tenant_fkey" FOREIGN KEY ("userId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_assignments" ADD CONSTRAINT "notdienst_assignments_planId_tenant_fkey" FOREIGN KEY ("planId", "kitaId") REFERENCES "notdienst_plans"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_assignments" ADD CONSTRAINT "notdienst_assignments_childId_tenant_fkey" FOREIGN KEY ("childId", "kitaId") REFERENCES "children"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_requesterUserId_tenant_fkey" FOREIGN KEY ("requesterUserId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_requesterFamilyId_tenant_fkey" FOREIGN KEY ("requesterFamilyId", "kitaId") REFERENCES "families"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_targetAssignmentId_tenant_fkey" FOREIGN KEY ("targetAssignmentId", "kitaId") REFERENCES "notdienst_assignments"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_offeredAssignmentId_tenant_fkey" FOREIGN KEY ("offeredAssignmentId", "kitaId") REFERENCES "notdienst_assignments"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_respondedById_tenant_fkey" FOREIGN KEY ("respondedById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_assignmentId_tenant_fkey" FOREIGN KEY ("assignmentId", "kitaId") REFERENCES "notdienst_assignments"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_parentUserId_tenant_fkey" FOREIGN KEY ("parentUserId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_triggeredById_tenant_fkey" FOREIGN KEY ("triggeredById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_educatorId_tenant_fkey" FOREIGN KEY ("educatorId", "kitaId") REFERENCES "educators"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- Existing founder registrations previously set this timestamp without verification.
|
||||
UPDATE users SET "emailVerifiedAt" = NULL, "sessionVersion" = "sessionVersion" + 1
|
||||
WHERE role = 'ADMIN' AND "kitaId" IS NULL;
|
||||
-- Preserve existing administrative account locks separately from temporary brute-force lockouts.
|
||||
UPDATE users SET "disabledAt" = NOW(), "sessionVersion" = "sessionVersion" + 1
|
||||
WHERE "lockedUntil" >= TIMESTAMP '2099-01-01';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,20 @@
|
||||
BEGIN;
|
||||
-- AlterTable
|
||||
ALTER TABLE "announcement_reads" ADD COLUMN "kitaId" TEXT;
|
||||
UPDATE announcement_reads AS r SET "kitaId" = a."kitaId" FROM announcements AS a WHERE a.id = r."announcementId";
|
||||
ALTER TABLE announcement_reads ALTER COLUMN "kitaId" SET NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "announcements_id_kitaId_key" ON "announcements"("id", "kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "announcement_reads_kitaId_idx" ON "announcement_reads"("kitaId");
|
||||
|
||||
|
||||
ALTER TABLE users ADD CONSTRAINT users_family_requires_tenant CHECK ("familyId" IS NULL OR "kitaId" IS NOT NULL);
|
||||
ALTER TABLE announcement_reads ADD CONSTRAINT announcement_reads_user_tenant_fkey
|
||||
FOREIGN KEY ("userId", "kitaId") REFERENCES users(id, "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE announcement_reads ADD CONSTRAINT announcement_reads_announcement_tenant_fkey
|
||||
FOREIGN KEY ("announcementId", "kitaId") REFERENCES announcements(id, "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "attachments" ADD COLUMN "sizeBytes" INTEGER;
|
||||
@@ -0,0 +1 @@
|
||||
provider = "postgresql"
|
||||
@@ -170,6 +170,7 @@ model Family {
|
||||
|
||||
@@index([kitaId])
|
||||
@@map("families")
|
||||
@@unique([id, kitaId])
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -217,6 +218,8 @@ model User {
|
||||
lastLoginAt DateTime?
|
||||
failedLoginAttempts Int @default(0)
|
||||
lockedUntil DateTime?
|
||||
disabledAt DateTime?
|
||||
sessionVersion Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -244,6 +247,7 @@ model User {
|
||||
@@index([familyId])
|
||||
@@index([kitaId, role])
|
||||
@@map("users")
|
||||
@@unique([id, kitaId])
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -276,6 +280,7 @@ model Child {
|
||||
@@index([kitaId])
|
||||
@@index([familyId])
|
||||
@@map("children")
|
||||
@@unique([id, kitaId])
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -298,6 +303,7 @@ model Educator {
|
||||
|
||||
@@index([kitaId])
|
||||
@@map("educators")
|
||||
@@unique([id, kitaId])
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -321,6 +327,7 @@ model ParentDuty {
|
||||
@@unique([kitaId, name])
|
||||
@@index([kitaId])
|
||||
@@map("parent_duties")
|
||||
@@unique([id, kitaId])
|
||||
}
|
||||
|
||||
model ParentDutyAssignment {
|
||||
@@ -360,6 +367,7 @@ model DutyType {
|
||||
@@unique([kitaId, name])
|
||||
@@index([kitaId])
|
||||
@@map("duty_types")
|
||||
@@unique([id, kitaId])
|
||||
}
|
||||
|
||||
model DutyAssignment {
|
||||
@@ -435,6 +443,7 @@ model Announcement {
|
||||
|
||||
@@index([kitaId, createdAt])
|
||||
@@index([authorId])
|
||||
@@unique([id, kitaId])
|
||||
@@map("announcements")
|
||||
}
|
||||
|
||||
@@ -446,12 +455,14 @@ model Attachment {
|
||||
fileName String
|
||||
fileUrl String
|
||||
fileType String
|
||||
sizeBytes Int?
|
||||
|
||||
@@index([announcementId])
|
||||
@@map("attachments")
|
||||
}
|
||||
|
||||
model AnnouncementRead {
|
||||
kitaId String
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@ -462,6 +473,7 @@ model AnnouncementRead {
|
||||
|
||||
@@unique([userId, announcementId])
|
||||
@@index([announcementId])
|
||||
@@index([kitaId])
|
||||
@@map("announcement_reads")
|
||||
}
|
||||
|
||||
@@ -536,6 +548,7 @@ model Termin {
|
||||
@@index([kitaId, startDate])
|
||||
@@index([kitaId, status])
|
||||
@@map("termine")
|
||||
@@unique([id, kitaId])
|
||||
}
|
||||
|
||||
model EventParticipation {
|
||||
@@ -612,6 +625,7 @@ model NotdienstPlan {
|
||||
@@unique([kitaId, year, month])
|
||||
@@index([kitaId])
|
||||
@@map("notdienst_plans")
|
||||
@@unique([id, kitaId])
|
||||
}
|
||||
|
||||
/// Verfügbarkeits-Eintrag eines Elternteils für ein Kind an einem Tag.
|
||||
@@ -664,6 +678,7 @@ model NotdienstAssignment {
|
||||
@@unique([planId, date])
|
||||
@@index([kitaId, date])
|
||||
@@map("notdienst_assignments")
|
||||
@@unique([id, kitaId])
|
||||
}
|
||||
|
||||
/// Tauschanfrage zwischen Eltern fuer zwei bereits veroeffentlichte
|
||||
@@ -786,3 +801,21 @@ model VerificationToken {
|
||||
@@unique([identifier, token])
|
||||
@@map("verification_tokens")
|
||||
}
|
||||
|
||||
model RateLimitBucket {
|
||||
key String @id
|
||||
count Int
|
||||
resetAt DateTime
|
||||
@@index([resetAt])
|
||||
@@map("rate_limit_buckets")
|
||||
}
|
||||
|
||||
model PendingRegistration {
|
||||
email String @id
|
||||
firstName String
|
||||
lastName String
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
@@index([expiresAt])
|
||||
@@map("pending_registrations")
|
||||
}
|
||||
|
||||
+7
-15
@@ -19,7 +19,7 @@ import { hash } from "bcryptjs";
|
||||
// noch keine Kita und keine User enthaelt.
|
||||
//
|
||||
// Reset: `npm run db:seed:reset` loescht gezielt die Demo-Daten und baut
|
||||
// sie neu auf. Production wird nur mit ALLOW_DATABASE_SEED=true erlaubt.
|
||||
// sie neu auf. Production-Seeding ist grundsätzlich deaktiviert.
|
||||
// =====================================================================
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
@@ -101,16 +101,8 @@ function getWorkingDays(year: number, month: number, limit?: number) {
|
||||
}
|
||||
|
||||
function isSeedAllowed() {
|
||||
if (
|
||||
process.env.NODE_ENV === "development" ||
|
||||
process.env.ALLOW_DATABASE_SEED === "true"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Seed uebersprungen: NODE_ENV ist nicht development. Setze ALLOW_DATABASE_SEED=true, wenn das bewusst gewuenscht ist.",
|
||||
);
|
||||
if (process.env.NODE_ENV !== "production" && process.env.ALLOW_DATABASE_SEED === "true") return true;
|
||||
console.log("Seed deaktiviert. Nur außerhalb der Produktion mit ALLOW_DATABASE_SEED=true erlaubt.");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -688,10 +680,10 @@ async function createAnnouncements(context: SeedContext) {
|
||||
|
||||
await prisma.announcementRead.createMany({
|
||||
data: [
|
||||
{ userId: koordinator.id, announcementId: sommerfest.id },
|
||||
{ userId: elternSchmidt.id, announcementId: sommerfest.id },
|
||||
{ userId: elternYilmaz.id, announcementId: sommerfest.id },
|
||||
{ userId: elternFischer.id, announcementId: garderobe.id },
|
||||
{ kitaId: kita.id, userId: koordinator.id, announcementId: sommerfest.id },
|
||||
{ kitaId: kita.id, userId: elternSchmidt.id, announcementId: sommerfest.id },
|
||||
{ kitaId: kita.id, userId: elternYilmaz.id, announcementId: sommerfest.id },
|
||||
{ kitaId: kita.id, userId: elternFischer.id, announcementId: garderobe.id },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { attachmentPath } from "../src/lib/attachments";
|
||||
const prisma = new PrismaClient();
|
||||
async function main() {
|
||||
let count = 0, missing = 0;
|
||||
while (true) {
|
||||
const batch = await prisma.attachment.findMany({ where: { sizeBytes: null }, select: { id: true }, take: 100 });
|
||||
if (!batch.length) break;
|
||||
for (const item of batch) {
|
||||
let sizeBytes = 0;
|
||||
try { sizeBytes = (await stat(attachmentPath(item.id))).size; }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
missing++;
|
||||
}
|
||||
await prisma.attachment.update({ where: { id: item.id }, data: { sizeBytes } });
|
||||
count++;
|
||||
}
|
||||
}
|
||||
console.log(`Attachment sizes recorded: ${count}; missing files: ${missing}.`);
|
||||
}
|
||||
main().finally(() => prisma.$disconnect()).catch(() => { console.error("Attachment size backfill failed."); process.exitCode = 1; });
|
||||
@@ -0,0 +1,62 @@
|
||||
-- Read-only preflight for both legacy and upgraded schemas. Empty result means no violations.
|
||||
SELECT * FROM (
|
||||
SELECT 'users.familyId' AS relation, count(*) AS invalid_rows FROM "users" c JOIN "families" p ON c."familyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'children.familyId' AS relation, count(*) AS invalid_rows FROM "children" c JOIN "families" p ON c."familyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'parent_duty_assignments.dutyId' AS relation, count(*) AS invalid_rows FROM "parent_duty_assignments" c JOIN "parent_duties" p ON c."dutyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'parent_duty_assignments.userId' AS relation, count(*) AS invalid_rows FROM "parent_duty_assignments" c JOIN "users" p ON c."userId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'duty_assignments.familyId' AS relation, count(*) AS invalid_rows FROM "duty_assignments" c JOIN "families" p ON c."familyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'duty_assignments.dutyTypeId' AS relation, count(*) AS invalid_rows FROM "duty_assignments" c JOIN "duty_types" p ON c."dutyTypeId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'absences.childId' AS relation, count(*) AS invalid_rows FROM "absences" c JOIN "children" p ON c."childId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'announcements.authorId' AS relation, count(*) AS invalid_rows FROM "announcements" c JOIN "users" p ON c."authorId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'invitations.invitedById' AS relation, count(*) AS invalid_rows FROM "invitations" c JOIN "users" p ON c."invitedById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'termine.createdById' AS relation, count(*) AS invalid_rows FROM "termine" c JOIN "users" p ON c."createdById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'termine.approvedById' AS relation, count(*) AS invalid_rows FROM "termine" c JOIN "users" p ON c."approvedById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'event_participations.eventId' AS relation, count(*) AS invalid_rows FROM "event_participations" c JOIN "termine" p ON c."eventId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'event_participations.childId' AS relation, count(*) AS invalid_rows FROM "event_participations" c JOIN "children" p ON c."childId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'mitbringsel_items.terminId' AS relation, count(*) AS invalid_rows FROM "mitbringsel_items" c JOIN "termine" p ON c."terminId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'mitbringsel_items.userId' AS relation, count(*) AS invalid_rows FROM "mitbringsel_items" c JOIN "users" p ON c."userId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'notdienst_plans.createdById' AS relation, count(*) AS invalid_rows FROM "notdienst_plans" c JOIN "users" p ON c."createdById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'notdienst_availabilities.childId' AS relation, count(*) AS invalid_rows FROM "notdienst_availabilities" c JOIN "children" p ON c."childId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'notdienst_availabilities.userId' AS relation, count(*) AS invalid_rows FROM "notdienst_availabilities" c JOIN "users" p ON c."userId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'notdienst_assignments.planId' AS relation, count(*) AS invalid_rows FROM "notdienst_assignments" c JOIN "notdienst_plans" p ON c."planId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'notdienst_assignments.childId' AS relation, count(*) AS invalid_rows FROM "notdienst_assignments" c JOIN "children" p ON c."childId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'notdienst_swap_requests.requesterUserId' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "users" p ON c."requesterUserId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'notdienst_swap_requests.requesterFamilyId' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "families" p ON c."requesterFamilyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'notdienst_swap_requests.targetAssignmentId' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "notdienst_assignments" p ON c."targetAssignmentId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'notdienst_swap_requests.offeredAssignmentId' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "notdienst_assignments" p ON c."offeredAssignmentId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'notdienst_swap_requests.respondedById' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "users" p ON c."respondedById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'notdienst_alerts.assignmentId' AS relation, count(*) AS invalid_rows FROM "notdienst_alerts" c JOIN "notdienst_assignments" p ON c."assignmentId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'notdienst_alerts.parentUserId' AS relation, count(*) AS invalid_rows FROM "notdienst_alerts" c JOIN "users" p ON c."parentUserId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'notdienst_alerts.triggeredById' AS relation, count(*) AS invalid_rows FROM "notdienst_alerts" c JOIN "users" p ON c."triggeredById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'notdienst_alerts.educatorId' AS relation, count(*) AS invalid_rows FROM "notdienst_alerts" c JOIN "educators" p ON c."educatorId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'announcement_reads.userId' AS relation, count(*) AS invalid_rows FROM announcement_reads r JOIN announcements a ON r."announcementId" = a.id JOIN users u ON r."userId" = u.id WHERE u."kitaId" IS DISTINCT FROM a."kitaId"
|
||||
) violations WHERE invalid_rows > 0;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readdir, stat, rm } from "node:fs/promises";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { attachmentDirectory, attachmentPath } from "../src/lib/attachments";
|
||||
|
||||
// Dry run by default. A 24-hour grace period protects concurrent uploads.
|
||||
const prisma = new PrismaClient();
|
||||
async function main() {
|
||||
const remove = process.argv.includes("--delete");
|
||||
let candidates = 0;
|
||||
for (const entry of await readdir(attachmentDirectory(), { withFileTypes: true })) {
|
||||
if (!entry.isFile()) continue;
|
||||
let file: string;
|
||||
try { file = attachmentPath(entry.name); } catch { continue; }
|
||||
if ((await stat(file)).mtimeMs > Date.now() - 24 * 60 * 60_000) continue;
|
||||
if (await prisma.attachment.findUnique({ where: { id: entry.name }, select: { id: true } })) continue;
|
||||
candidates++;
|
||||
if (remove) await rm(file, { force: true });
|
||||
}
|
||||
console.log(`${candidates} orphan attachments ${remove ? "removed" : "found (dry run)"}`);
|
||||
}
|
||||
main().finally(() => prisma.$disconnect()).catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,3 @@
|
||||
import { cpSync } from 'node:fs';
|
||||
cpSync('.next/static', '.next/standalone/.next/static', { recursive: true });
|
||||
cpSync('public', '.next/standalone/public', { recursive: true });
|
||||
@@ -0,0 +1,34 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const password = process.env.APP_DATABASE_PASSWORD;
|
||||
if (!password || password.length < 24 || password.length > 256) {
|
||||
throw new Error('Set APP_DATABASE_PASSWORD to a dedicated password of 24–256 characters.');
|
||||
}
|
||||
const prisma = new PrismaClient();
|
||||
async function main() {
|
||||
const existing = await prisma.$queryRaw`SELECT 1 FROM pg_roles WHERE rolname = 'kita_runtime'`;
|
||||
if (!existing.length) await prisma.$executeRawUnsafe('CREATE ROLE kita_runtime LOGIN');
|
||||
const owned = await prisma.$queryRaw`SELECT 1 FROM pg_class c JOIN pg_roles r ON c.relowner = r.oid WHERE r.rolname = 'kita_runtime'
|
||||
UNION ALL SELECT 1 FROM pg_namespace n JOIN pg_roles r ON n.nspowner = r.oid WHERE r.rolname = 'kita_runtime'`;
|
||||
if (owned.length) throw new Error('Runtime role must not own tables or schemas.');
|
||||
const memberships = await prisma.$queryRaw`SELECT quote_ident(r.rolname) AS role FROM pg_auth_members m JOIN pg_roles r ON m.roleid = r.oid
|
||||
JOIN pg_roles member ON m.member = member.oid WHERE member.rolname = 'kita_runtime'`;
|
||||
for (const membership of memberships) await prisma.$executeRawUnsafe(`REVOKE ${membership.role} FROM kita_runtime`);
|
||||
// PostgreSQL quotes the secret. Only this server-produced SQL is executed.
|
||||
const [quoted] = await prisma.$queryRaw`SELECT format('ALTER ROLE kita_runtime PASSWORD %L LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS NOREPLICATION', ${password}) AS statement`;
|
||||
await prisma.$executeRawUnsafe(quoted.statement);
|
||||
for (const statement of [
|
||||
'REVOKE CREATE ON SCHEMA public FROM PUBLIC',
|
||||
'GRANT USAGE ON SCHEMA public TO kita_runtime',
|
||||
'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO kita_runtime',
|
||||
'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO kita_runtime',
|
||||
'REVOKE ALL ON TABLE _prisma_migrations FROM kita_runtime',
|
||||
'REVOKE UPDATE, DELETE, TRUNCATE ON TABLE audit_logs FROM kita_runtime',
|
||||
]) await prisma.$executeRawUnsafe(statement);
|
||||
console.log('Runtime database grants configured. Re-run after future migrations.');
|
||||
}
|
||||
main().finally(() => prisma.$disconnect()).catch(error => {
|
||||
// Avoid putting a statement containing credentials into deployment logs.
|
||||
console.error('Runtime role provisioning failed:', error?.code ?? error?.name ?? 'unknown');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,8 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
echo "Applying Prisma schema..."
|
||||
npx prisma db push --skip-generate
|
||||
|
||||
echo "Starting Kita-Planer..."
|
||||
exec npm run start:prod
|
||||
# Apply reviewed migrations separately before starting the runtime.
|
||||
exec node server.js
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AbsenceReason, UserRole } from "@prisma/client";
|
||||
import { dateOnlySchema } from "@/lib/date-schema";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
||||
@@ -9,14 +10,14 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
const reportAbsenceSchema = z.object({
|
||||
childId: z.string().min(1),
|
||||
startDate: z.string().min(1),
|
||||
endDate: z.string().min(1),
|
||||
startDate: dateOnlySchema,
|
||||
endDate: dateOnlySchema,
|
||||
reason: z.nativeEnum(AbsenceReason),
|
||||
note: z.string().trim().max(500).optional(),
|
||||
});
|
||||
|
||||
const dailyAbsenceSchema = z.object({
|
||||
date: z.string().min(1),
|
||||
date: dateOnlySchema,
|
||||
});
|
||||
|
||||
export type DailyAbsenceDto = {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import { mkdir, rm, writeFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { attachmentDirectory, attachmentPath, MAX_ATTACHMENT_SIZE, detectAttachmentType, attachmentExtensionMatches } from "@/lib/attachments";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { createElement } from "react";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { Prisma, UserRole } from "@prisma/client";
|
||||
@@ -13,15 +15,6 @@ import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
|
||||
const ALLOWED_TYPES = new Map([
|
||||
["application/pdf", ".pdf"],
|
||||
["image/jpeg", ".jpg"],
|
||||
["image/png", ".png"],
|
||||
]);
|
||||
|
||||
const ALLOWED_EXTENSIONS = new Set([".pdf", ".jpg", ".jpeg", ".png"]);
|
||||
|
||||
const audienceSchema = z.enum(["ELTERN", "ERZIEHER", "ALLE"]);
|
||||
|
||||
const announcementSchema = z.object({
|
||||
@@ -31,30 +24,10 @@ const announcementSchema = z.object({
|
||||
audience: audienceSchema,
|
||||
});
|
||||
|
||||
function getUploadDir() {
|
||||
return path.resolve(process.env.UPLOAD_DIR ?? "uploads", "announcements");
|
||||
}
|
||||
|
||||
function getSafeFileName(name: string) {
|
||||
return name.replace(/[^\w.\- äöüÄÖÜß]/g, "_").slice(0, 160);
|
||||
}
|
||||
|
||||
function validateAttachment(file: File) {
|
||||
const extension = path.extname(file.name).toLowerCase();
|
||||
const typeAllowed = ALLOWED_TYPES.has(file.type);
|
||||
const extensionAllowed = ALLOWED_EXTENSIONS.has(extension);
|
||||
|
||||
if (!typeAllowed && !extensionAllowed) {
|
||||
return "Nur PDF, JPG und PNG sind erlaubt.";
|
||||
}
|
||||
|
||||
if (file.size > MAX_ATTACHMENT_SIZE) {
|
||||
return "Anhänge dürfen maximal 10 MB groß sein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getRecipients(kitaId: string, audience: z.infer<typeof audienceSchema>) {
|
||||
const where: Prisma.UserWhereInput =
|
||||
audience === "ELTERN"
|
||||
@@ -76,7 +49,7 @@ async function getRecipients(kitaId: string, audience: z.infer<typeof audienceSc
|
||||
};
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where,
|
||||
where: { ...where, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true },
|
||||
});
|
||||
|
||||
@@ -106,56 +79,75 @@ export async function createAnnouncement(formData: FormData) {
|
||||
.getAll("attachments")
|
||||
.filter((value): value is File => value instanceof File && value.size > 0);
|
||||
|
||||
if (attachmentFiles.length > 5 || attachmentFiles.some(file => file.size > MAX_ATTACHMENT_SIZE) ||
|
||||
attachmentFiles.reduce((total, file) => total + file.size, 0) > 25 * 1024 * 1024) {
|
||||
return { error: "Maximal 5 Anhänge, je 10 MB und insgesamt 25 MB sind erlaubt." };
|
||||
}
|
||||
if (!(await rateLimit(`announcements:${kitaId}:${session.user.id}`, 20, 3600)).allowed) {
|
||||
return { error: "Zu viele Ankündigungen. Bitte versuche es später erneut." };
|
||||
}
|
||||
const attachmentInputs: { id: string; bytes: Buffer; fileName: string; fileType: string; fileUrl: string }[] = [];
|
||||
for (const file of attachmentFiles) {
|
||||
const error = validateAttachment(file);
|
||||
if (error) {
|
||||
return { error };
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
const fileType = detectAttachmentType(bytes);
|
||||
if (!fileType || !attachmentExtensionMatches(file.name, fileType) || (file.type && file.type !== fileType)) {
|
||||
return { error: "Nur PDF-, JPG- und PNG-Dateien mit passenden Dateiinhalten sind erlaubt." };
|
||||
}
|
||||
const id = randomUUID();
|
||||
attachmentInputs.push({ id, bytes, fileName: getSafeFileName(file.name), fileType,
|
||||
fileUrl: `/api/announcements/attachments/${id}` });
|
||||
}
|
||||
|
||||
const attachmentInputs = attachmentFiles.map((file) => {
|
||||
const id = randomUUID();
|
||||
return {
|
||||
id,
|
||||
file,
|
||||
fileName: getSafeFileName(file.name),
|
||||
fileType: file.type || "application/octet-stream",
|
||||
fileUrl: `/api/announcements/attachments/${id}`,
|
||||
};
|
||||
});
|
||||
|
||||
const announcement = await prisma.announcement.create({
|
||||
data: {
|
||||
kitaId,
|
||||
title: parsed.data.title,
|
||||
content: parsed.data.content,
|
||||
authorId: session.user.id,
|
||||
attachments: {
|
||||
create: attachmentInputs.map((attachment) => ({
|
||||
id: attachment.id,
|
||||
fileName: attachment.fileName,
|
||||
fileType: attachment.fileType,
|
||||
fileUrl: attachment.fileUrl,
|
||||
})),
|
||||
const announcement = await serializableTransaction(async tx => {
|
||||
if (attachmentInputs.length) {
|
||||
const pending = await tx.attachment.count({ where: { sizeBytes: null } });
|
||||
if (pending) return null;
|
||||
const [tenantUsage, globalUsage] = await Promise.all([
|
||||
tx.attachment.aggregate({ where: { announcement: { kitaId } }, _sum: { sizeBytes: true } }),
|
||||
tx.attachment.aggregate({ _sum: { sizeBytes: true } }),
|
||||
]);
|
||||
const bytes = attachmentInputs.reduce((total, item) => total + item.bytes.length, 0);
|
||||
const quota = (value: string | undefined, fallback: number) => {
|
||||
const parsed = Number(value); return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
};
|
||||
if ((tenantUsage._sum.sizeBytes ?? 0) + bytes > quota(process.env.UPLOAD_TENANT_QUOTA_BYTES, 256 * 1024 ** 2) ||
|
||||
(globalUsage._sum.sizeBytes ?? 0) + bytes > quota(process.env.UPLOAD_GLOBAL_QUOTA_BYTES, 10 * 1024 ** 3)) return null;
|
||||
}
|
||||
return tx.announcement.create({
|
||||
data: {
|
||||
kitaId,
|
||||
title: parsed.data.title,
|
||||
content: parsed.data.content,
|
||||
authorId: session.user.id,
|
||||
attachments: {
|
||||
create: attachmentInputs.map((attachment) => ({
|
||||
id: attachment.id,
|
||||
fileName: attachment.fileName,
|
||||
fileType: attachment.fileType,
|
||||
sizeBytes: attachment.bytes.length,
|
||||
fileUrl: attachment.fileUrl,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
if (!announcement) return { error: "Anhänge können momentan nicht gespeichert werden. Bitte wende dich an die Administration." };
|
||||
|
||||
if (attachmentInputs.length > 0) {
|
||||
const uploadDir = getUploadDir();
|
||||
const uploadDir = attachmentDirectory();
|
||||
try {
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
await Promise.all(
|
||||
attachmentInputs.map(async (attachment) => {
|
||||
const arrayBuffer = await attachment.file.arrayBuffer();
|
||||
await writeFile(
|
||||
path.join(uploadDir, attachment.id),
|
||||
Buffer.from(arrayBuffer),
|
||||
attachmentPath(attachment.id),
|
||||
attachment.bytes,
|
||||
{ flag: "wx", mode: 0o600 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -163,7 +155,7 @@ export async function createAnnouncement(formData: FormData) {
|
||||
await prisma.announcement.delete({ where: { id: announcement.id } });
|
||||
await Promise.all(
|
||||
attachmentInputs.map((attachment) =>
|
||||
rm(path.join(uploadDir, attachment.id), { force: true }),
|
||||
rm(attachmentPath(attachment.id), { force: true }),
|
||||
),
|
||||
);
|
||||
console.error("Ankündigungs-Anhang konnte nicht gespeichert werden:", error);
|
||||
@@ -232,6 +224,7 @@ export async function markAnnouncementRead(announcementId: string) {
|
||||
},
|
||||
},
|
||||
create: {
|
||||
kitaId: session.user.kitaId,
|
||||
userId: session.user.id,
|
||||
announcementId: announcement.id,
|
||||
},
|
||||
|
||||
+5
-27
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { createElement } from "react";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
|
||||
import { ContactConfirmationEmail } from "@/emails/ContactConfirmationEmail";
|
||||
import { ContactRequestEmail } from "@/emails/ContactRequestEmail";
|
||||
@@ -11,39 +11,17 @@ import {
|
||||
type ContactSubmissionPayload,
|
||||
} from "@/lib/contact-schema";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { limitPublicAction } from "@/lib/rate-limit";
|
||||
|
||||
const DEFAULT_ADMIN_EMAIL = "kontakt@kita-planer.local";
|
||||
const RATE_LIMIT_MAX = 5;
|
||||
const RATE_LIMIT_WINDOW_SECONDS = 60 * 60;
|
||||
|
||||
async function resolveClientIp(): Promise<string> {
|
||||
const h = await headers();
|
||||
const forwarded = h.get("x-forwarded-for");
|
||||
if (forwarded) {
|
||||
return forwarded.split(",")[0]?.trim() || "unknown";
|
||||
}
|
||||
return h.get("x-real-ip") ?? "unknown";
|
||||
}
|
||||
|
||||
export async function submitContactRequest(data: ContactSubmissionPayload) {
|
||||
// Honeypot: silently absorb bot submissions without informing them.
|
||||
if (data.website && data.website.trim().length > 0) {
|
||||
if (typeof data?.website === "string" && data.website.trim().length > 0) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const ip = await resolveClientIp();
|
||||
const limit = rateLimit(
|
||||
`contact:${ip}`,
|
||||
RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_SECONDS,
|
||||
);
|
||||
if (!limit.allowed) {
|
||||
const minutes = Math.max(1, Math.ceil(limit.retryAfterSeconds / 60));
|
||||
return {
|
||||
success: false,
|
||||
error: `Zu viele Anfragen. Bitte versuche es in ${minutes} Minuten erneut.`,
|
||||
};
|
||||
if (!(await limitPublicAction("contact", String(data.email ?? "").trim().toLowerCase()))) {
|
||||
return { success: false, error: "Zu viele Anfragen. Bitte versuche es später erneut." };
|
||||
}
|
||||
|
||||
const parsed = contactRequestSchema.safeParse(data);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { removeAttachmentFiles } from "@/lib/attachments";
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AuditTargetType, UserRole } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
@@ -26,6 +28,7 @@ type ActionResult = {
|
||||
|
||||
async function requireSuperadminActor() {
|
||||
const session = await requireRole([UserRole.SUPERADMIN]);
|
||||
if (Date.now() - session.user.authenticatedAt > 15 * 60_000) redirect("/login?reauth=1&callbackUrl=/admin");
|
||||
return {
|
||||
id: session.user.id,
|
||||
email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
|
||||
@@ -92,7 +95,8 @@ export async function deleteKita(
|
||||
|
||||
const counts = await getKitaDeleteCounts(kita.id);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const attachmentIds = await prisma.$transaction(async (tx) => {
|
||||
const attachments = await tx.attachment.findMany({ where: { announcement: { kitaId: kita.id } }, select: { id: true } });
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
actorUserId: actor.id,
|
||||
@@ -107,8 +111,10 @@ export async function deleteKita(
|
||||
});
|
||||
|
||||
await tx.kita.delete({ where: { id: kita.id } });
|
||||
return attachments.map(item => item.id);
|
||||
});
|
||||
|
||||
await removeAttachmentFiles(attachmentIds);
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/admin/audit");
|
||||
return { ok: true, redirectTo: "/admin" };
|
||||
@@ -182,7 +188,8 @@ export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const attachmentIds = await prisma.$transaction(async (tx) => {
|
||||
const attachments = await tx.attachment.findMany({ where: { announcement: { authorId: user.id } }, select: { id: true } });
|
||||
await tx.verificationToken.deleteMany({
|
||||
where: { identifier: user.id },
|
||||
});
|
||||
@@ -206,8 +213,10 @@ export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
|
||||
},
|
||||
});
|
||||
await tx.user.delete({ where: { id: user.id } });
|
||||
return attachments.map(item => item.id);
|
||||
});
|
||||
|
||||
await removeAttachmentFiles(attachmentIds);
|
||||
revalidatePath("/admin");
|
||||
revalidatePath(`/admin/kitas/${user.kitaId}`);
|
||||
revalidatePath("/admin/audit");
|
||||
@@ -219,6 +228,7 @@ async function updateUserSupportState(
|
||||
action: "USER_LOCK" | "USER_UNLOCK" | "USER_FAILED_LOGIN_RESET",
|
||||
data: {
|
||||
lockedUntil?: Date | null;
|
||||
disabledAt?: Date | null;
|
||||
failedLoginAttempts?: number;
|
||||
},
|
||||
): Promise<ActionResult> {
|
||||
@@ -238,7 +248,7 @@ async function updateUserSupportState(
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data,
|
||||
data: { ...data, ...(action === "USER_LOCK" ? { sessionVersion: { increment: 1 } } : {}) },
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
@@ -266,12 +276,14 @@ async function updateUserSupportState(
|
||||
|
||||
export async function lockUser(userId: string): Promise<ActionResult> {
|
||||
return updateUserSupportState(userId, "USER_LOCK", {
|
||||
disabledAt: new Date(),
|
||||
lockedUntil: new Date("2099-12-31T23:59:59.000Z"),
|
||||
});
|
||||
}
|
||||
|
||||
export async function unlockUser(userId: string): Promise<ActionResult> {
|
||||
return updateUserSupportState(userId, "USER_UNLOCK", {
|
||||
disabledAt: null,
|
||||
lockedUntil: null,
|
||||
failedLoginAttempts: 0,
|
||||
});
|
||||
|
||||
@@ -1,34 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { NotdienstAlertStatus } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
import { activeAlertTokenWhere } from "@/lib/alert-tokens";
|
||||
import { hashToken, isPublicToken } from "@/lib/tokens";
|
||||
import { limitPublicAction } from "@/lib/rate-limit";
|
||||
export async function confirmAlertAction(token: string) {
|
||||
try {
|
||||
const alert = await prisma.notdienstAlert.findUnique({
|
||||
where: { confirmationToken: token },
|
||||
});
|
||||
|
||||
if (!alert) {
|
||||
return { error: "Ungültiger Bestätigungs-Link." };
|
||||
}
|
||||
|
||||
if (alert.status !== NotdienstAlertStatus.PENDING) {
|
||||
return { error: "Dieser Alarm wurde bereits bestätigt oder abgebrochen." };
|
||||
}
|
||||
|
||||
await prisma.notdienstAlert.update({
|
||||
where: { id: alert.id },
|
||||
data: {
|
||||
status: NotdienstAlertStatus.CONFIRMED,
|
||||
confirmedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { error: "Fehler bei der Bestätigung." };
|
||||
}
|
||||
if (!isPublicToken(token)) return { error: "Ungültiger Bestätigungs-Link." };
|
||||
if (!(await limitPublicAction("alert-confirm", hashToken(token), 10, 900))) return { error: "Bitte versuche es später erneut." };
|
||||
const updated = await prisma.notdienstAlert.updateMany({
|
||||
where: activeAlertTokenWhere(token),
|
||||
data: { status: NotdienstAlertStatus.CONFIRMED, confirmedAt: new Date() },
|
||||
});
|
||||
if (updated.count !== 1) return { error: "Dieser Link ist abgelaufen oder wurde bereits verwendet." };
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { de } from "date-fns/locale";
|
||||
import { BellRing, ShieldCheck, XCircle } from "lucide-react";
|
||||
import { NotdienstAlertStatus } from "@prisma/client";
|
||||
|
||||
import { activeAlertTokenWhere } from "@/lib/alert-tokens";
|
||||
import { isPublicToken } from "@/lib/tokens";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
Card,
|
||||
@@ -14,7 +16,8 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { AlertConfirmForm } from "./alert-confirm-form";
|
||||
|
||||
export const metadata = { title: "Notdienst Alarm · Kita-Planer" };
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "Notdienst Alarm · Kita-Planer", robots: { index: false, follow: false } };
|
||||
|
||||
export default async function AlertPage({
|
||||
params,
|
||||
@@ -23,8 +26,9 @@ export default async function AlertPage({
|
||||
}) {
|
||||
const { token } = await params;
|
||||
|
||||
const alert = await prisma.notdienstAlert.findUnique({
|
||||
where: { confirmationToken: token },
|
||||
if (!isPublicToken(token)) notFound();
|
||||
const alert = await prisma.notdienstAlert.findFirst({
|
||||
where: activeAlertTokenWhere(token),
|
||||
select: {
|
||||
status: true,
|
||||
parentUser: {
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { open } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { attachmentPath, attachmentDisposition, detectAttachmentType, MAX_ATTACHMENT_SIZE } from "@/lib/attachments";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { getKitaSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function getUploadPath(attachmentId: string) {
|
||||
const uploadDir = path.resolve(
|
||||
process.env.UPLOAD_DIR ?? "uploads",
|
||||
"announcements",
|
||||
);
|
||||
return path.join(uploadDir, attachmentId);
|
||||
}
|
||||
|
||||
function contentDisposition(fileName: string) {
|
||||
const fallback = fileName.replace(/[^\w.\-]/g, "_");
|
||||
return `inline; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth();
|
||||
const session = await getKitaSession();
|
||||
const user = session?.user;
|
||||
|
||||
if (!user?.id || !user.kitaId) {
|
||||
@@ -32,6 +20,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
try { attachmentPath(id); } catch { return NextResponse.json({ error: "Not found" }, { status: 404 }); }
|
||||
const attachment = await prisma.attachment.findFirst({
|
||||
where: {
|
||||
id,
|
||||
@@ -51,12 +40,22 @@ export async function GET(
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await readFile(getUploadPath(attachment.id));
|
||||
const filePath = attachmentPath(attachment.id);
|
||||
const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
let file: Buffer;
|
||||
try {
|
||||
const info = await handle.stat();
|
||||
if (!info.isFile() || info.size > MAX_ATTACHMENT_SIZE) throw new Error("Invalid attachment");
|
||||
file = await handle.readFile();
|
||||
} finally { await handle.close(); }
|
||||
return new Response(new Uint8Array(file), {
|
||||
headers: {
|
||||
"Content-Type": attachment.fileType,
|
||||
"Content-Disposition": contentDisposition(attachment.fileName),
|
||||
"Cache-Control": "private, max-age=300",
|
||||
"Content-Type": detectAttachmentType(file) ?? "application/octet-stream",
|
||||
"Content-Disposition": attachmentDisposition(attachment.fileName),
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import crypto from "crypto";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||
import { hashToken, newToken } from "@/lib/tokens";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { createElement } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
@@ -19,6 +22,7 @@ export async function triggerAlertAction(assignmentId: string) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
|
||||
if (!(await rateLimit(`alerts:${kitaId}:${session.user.id}`, 50, 3600)).allowed) return { error: "Zu viele Alarme. Bitte versuche es später erneut." };
|
||||
try {
|
||||
const [existing, assignment] = await Promise.all([
|
||||
// Prüfen ob es schon einen aktiven Alert für dieses Assignment gibt
|
||||
@@ -26,17 +30,18 @@ export async function triggerAlertAction(assignmentId: string) {
|
||||
where: { assignmentId, kitaId },
|
||||
}),
|
||||
prisma.notdienstAssignment.findFirst({
|
||||
where: { id: assignmentId, kitaId },
|
||||
where: { id: assignmentId, kitaId, plan: { status: "PUBLISHED" } },
|
||||
include: {
|
||||
child: {
|
||||
select: {
|
||||
id: true,
|
||||
familyId: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
family: {
|
||||
select: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
@@ -75,24 +80,22 @@ export async function triggerAlertAction(assignmentId: string) {
|
||||
locale: de,
|
||||
});
|
||||
const childName = `${assignment.child.firstName} ${assignment.child.lastName}`;
|
||||
const alertJobs: { email: string; token: string }[] = [];
|
||||
|
||||
await prisma.$transaction(
|
||||
parents.map((parent) => {
|
||||
const token = crypto.randomUUID();
|
||||
alertJobs.push({ email: parent.email, token });
|
||||
return prisma.notdienstAlert.create({
|
||||
data: {
|
||||
kitaId,
|
||||
assignmentId,
|
||||
parentUserId: parent.id,
|
||||
triggeredById: session.user.id,
|
||||
status: NotdienstAlertStatus.PENDING,
|
||||
confirmationToken: token,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
const alertJobs = parents.map(parent => ({ email: parent.email, token: newToken(), parentId: parent.id }));
|
||||
await serializableTransaction(async tx => {
|
||||
if (await tx.notdienstAlert.findFirst({ where: { assignmentId, kitaId }, select: { id: true } })) {
|
||||
throw new UserFacingError("Für diese Einteilung wurde bereits ein Alarm ausgelöst.");
|
||||
}
|
||||
const current = await tx.notdienstAssignment.findFirst({ where: { id: assignmentId, kitaId, plan: { status: "PUBLISHED" } }, select: { childId: true, date: true } });
|
||||
const today = new Date(); today.setUTCHours(0, 0, 0, 0);
|
||||
if (!current || current.childId !== assignment.child.id || current.date < today) throw new UserFacingError("Die Einteilung hat sich geändert oder liegt in der Vergangenheit. Bitte lade die Seite neu.");
|
||||
for (const job of alertJobs) {
|
||||
const parent = await tx.user.findFirst({ where: { id: job.parentId, kitaId, familyId: assignment.child.familyId,
|
||||
email: job.email, disabledAt: null, emailVerifiedAt: { not: null }, privacyPolicyAcceptedAt: { not: null } }, select: { id: true } });
|
||||
if (!parent) throw new UserFacingError("Die Empfänger haben sich geändert. Bitte lade die Seite neu.");
|
||||
await tx.notdienstAlert.create({ data: { kitaId, assignmentId, parentUserId: parent.id,
|
||||
triggeredById: session.user.id, status: NotdienstAlertStatus.PENDING, confirmationToken: hashToken(job.token) } });
|
||||
}
|
||||
});
|
||||
|
||||
for (const job of alertJobs) {
|
||||
const emailResult = await sendAppEmail({
|
||||
@@ -118,6 +121,6 @@ export async function triggerAlertAction(assignmentId: string) {
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { error: "Fehler beim Auslösen des Alarms." };
|
||||
return { error: publicActionError(error, "Fehler beim Auslösen des Alarms.") };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import { revalidatePath } from "next/cache";
|
||||
import { DutyAssignmentStatus, UserRole } from "@prisma/client";
|
||||
import { endOfWeek, format, startOfDay, startOfWeek } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { dateOnlySchema } from "@/lib/date-schema";
|
||||
import { z } from "zod";
|
||||
|
||||
import { DutyReminderEmail } from "@/emails/DutyReminderEmail";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const dutyTypeSchema = z.object({
|
||||
@@ -20,8 +22,8 @@ const dutyTypeSchema = z.object({
|
||||
const assignmentSchema = z.object({
|
||||
dutyTypeId: z.string().min(1),
|
||||
familyId: z.string().nullable(),
|
||||
startDate: z.string().min(1),
|
||||
endDate: z.string().min(1),
|
||||
startDate: dateOnlySchema,
|
||||
endDate: dateOnlySchema,
|
||||
});
|
||||
|
||||
async function requireDutyAdmin() {
|
||||
@@ -160,6 +162,7 @@ export async function upsertDutyAssignment(rawPayload: unknown) {
|
||||
|
||||
export async function checkAndSendDutyReminders() {
|
||||
const { kitaId } = await requireDutyAdmin();
|
||||
if (!(await rateLimit(`duty-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
|
||||
const configError = getAppEmailConfigError();
|
||||
|
||||
if (configError) {
|
||||
@@ -195,6 +198,8 @@ export async function checkAndSendDutyReminders() {
|
||||
users: {
|
||||
where: {
|
||||
emailVerifiedAt: { not: null },
|
||||
disabledAt: null,
|
||||
privacyPolicyAcceptedAt: { not: null },
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
|
||||
@@ -93,7 +93,7 @@ export function NewsForm() {
|
||||
/>
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-[var(--ink-muted)]">
|
||||
<UploadCloud className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
PDF, JPG und PNG bis 10 MB pro Datei.
|
||||
PDF, JPG und PNG: maximal 5 Dateien, je 10 MB und insgesamt 25 MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { LayoutGrid, Mail, Search, Table, Users } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { resendParentInvite } from "../actions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -245,6 +247,7 @@ export function FamiliesListClient({
|
||||
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
|
||||
<span className="break-words [overflow-wrap:anywhere]">
|
||||
{parent.email}
|
||||
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -351,6 +354,7 @@ export function FamiliesListClient({
|
||||
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
|
||||
<span className="break-words [overflow-wrap:anywhere]">
|
||||
{parent.email}
|
||||
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -416,3 +420,11 @@ export function FamiliesListClient({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResendInviteButton({ userId }: { userId: string }) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
return <Button type="button" size="sm" variant="ghost" disabled={pending} onClick={() => startTransition(async () => {
|
||||
const result = await resendParentInvite(userId);
|
||||
if (result.error) toast.error(result.error); else toast.success("Einladung erneut gesendet.");
|
||||
})}>{pending ? "Wird gesendet…" : "Einladung erneut senden"}</Button>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import crypto from "crypto";
|
||||
import { hashToken, newToken } from "@/lib/tokens";
|
||||
import { createElement } from "react";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { Prisma, UserRole } from "@prisma/client";
|
||||
@@ -9,6 +9,7 @@ import { z } from "zod";
|
||||
import { InviteEmail } from "@/emails/InviteEmail";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const INVITE_TOKEN_TTL_DAYS = 7;
|
||||
@@ -115,7 +116,7 @@ function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
|
||||
}
|
||||
|
||||
function createInviteToken() {
|
||||
const token = crypto.randomUUID();
|
||||
const token = newToken();
|
||||
const expires = new Date(
|
||||
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000,
|
||||
);
|
||||
@@ -153,6 +154,8 @@ export async function addFamilyAction(
|
||||
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
|
||||
}
|
||||
|
||||
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { errors: { _form: ["Zu viele Einladungen. Bitte versuche es später erneut."] } };
|
||||
|
||||
const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData));
|
||||
if (!parsedFamily.success) {
|
||||
return { errors: parsedFamily.error.flatten().fieldErrors };
|
||||
@@ -245,7 +248,7 @@ export async function addFamilyAction(
|
||||
await tx.verificationToken.create({
|
||||
data: {
|
||||
identifier: createdParent.id,
|
||||
token: invite.token,
|
||||
token: hashToken(invite.token),
|
||||
expires: invite.expires,
|
||||
},
|
||||
});
|
||||
@@ -320,6 +323,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
|
||||
const removedChildIdSet = new Set(removedChildIds);
|
||||
const activeChildren = children.filter((child) => !removedChildIdSet.has(child.id));
|
||||
if (activeChildren.length + newChildren.length > 20) return { error: "Maximal 20 Kinder pro Familie." };
|
||||
if (activeChildren.length + newChildren.length === 0) {
|
||||
return { error: "Eine Familie benötigt mindestens ein Kind." };
|
||||
}
|
||||
@@ -347,7 +351,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
where: { id: familyId, kitaId },
|
||||
select: {
|
||||
id: true,
|
||||
users: { select: { id: true } },
|
||||
users: { select: { id: true, email: true } },
|
||||
children: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
@@ -356,6 +360,13 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
return { error: "Familie wurde nicht gefunden." };
|
||||
}
|
||||
|
||||
const needsEmail = newParents.length > 0 || parents.some(parent => existingFamily.users.find(user => user.id === parent.id)?.email !== parent.email);
|
||||
if (needsEmail) {
|
||||
const configError = getAppEmailConfigError();
|
||||
if (configError) return { error: configError };
|
||||
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { error: "Zu viele Einladungen. Bitte versuche es später erneut." };
|
||||
}
|
||||
|
||||
const existingParentIds = new Set(existingFamily.users.map((user) => user.id));
|
||||
const existingChildIds = new Set(existingFamily.children.map((child) => child.id));
|
||||
|
||||
@@ -382,14 +393,20 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
});
|
||||
|
||||
for (const parent of parents) {
|
||||
const current = await tx.user.findFirst({ where: { id: parent.id, kitaId, familyId }, select: { email: true } });
|
||||
if (!current) throw new Error("Parent assignment changed");
|
||||
const emailChanged = current.email !== parent.email;
|
||||
await tx.user.update({
|
||||
where: { id: parent.id },
|
||||
data: {
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
email: parent.email,
|
||||
},
|
||||
where: { id: parent.id, kitaId, familyId },
|
||||
data: { firstName: parent.firstName, lastName: parent.lastName, email: parent.email,
|
||||
...(emailChanged ? { passwordHash: "", emailVerifiedAt: null, sessionVersion: { increment: 1 } } : {}) },
|
||||
});
|
||||
if (emailChanged) {
|
||||
const invite = createInviteToken();
|
||||
await tx.verificationToken.deleteMany({ where: { identifier: parent.id } });
|
||||
await tx.verificationToken.create({ data: { identifier: parent.id, token: hashToken(invite.token), expires: invite.expires } });
|
||||
inviteJobs.push({ to: parent.email, parentName: `${parent.firstName} ${parent.lastName}`, inviteUrl: invite.inviteUrl });
|
||||
}
|
||||
}
|
||||
|
||||
for (const parent of newParents) {
|
||||
@@ -413,7 +430,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
await tx.verificationToken.create({
|
||||
data: {
|
||||
identifier: createdParent.id,
|
||||
token: invite.token,
|
||||
token: hashToken(invite.token),
|
||||
expires: invite.expires,
|
||||
},
|
||||
});
|
||||
@@ -421,7 +438,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
|
||||
for (const child of activeChildren) {
|
||||
await tx.child.update({
|
||||
where: { id: child.id },
|
||||
where: { id: child.id, kitaId, familyId },
|
||||
data: {
|
||||
firstName: child.firstName,
|
||||
lastName: child.lastName,
|
||||
@@ -430,7 +447,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
}
|
||||
|
||||
for (const childId of removedChildIds) {
|
||||
await tx.child.delete({ where: { id: childId } });
|
||||
await tx.child.delete({ where: { id: childId, kitaId, familyId } });
|
||||
}
|
||||
|
||||
for (const child of newChildren) {
|
||||
@@ -469,3 +486,28 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function resendParentInvite(userId: string) {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
if (typeof userId !== "string" || !userId || userId.length > 64) return { error: "Ungültige Eingabedaten." };
|
||||
if (!(await rateLimit(`family-invites:${session.user.kitaId}`, 30, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
|
||||
if (!(await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
|
||||
const user = await prisma.user.findFirst({ where: { id: userId, kitaId: session.user.kitaId, emailVerifiedAt: null, passwordHash: "", disabledAt: null },
|
||||
select: { id: true, email: true, firstName: true, lastName: true, kita: { select: { name: true } } } });
|
||||
if (!user?.kita) return { error: "Keine offene Einladung gefunden." };
|
||||
const configError = getAppEmailConfigError(); if (configError) return { error: configError };
|
||||
const invite = createInviteToken();
|
||||
const renewed = await prisma.$transaction(async tx => {
|
||||
// Lock and re-check the account before issuing a link for the selected mailbox.
|
||||
// A concurrent address change/activation must invalidate this resend.
|
||||
const eligible = await tx.user.updateMany({ where: { id: user.id, email: user.email, kitaId: session.user.kitaId,
|
||||
emailVerifiedAt: null, passwordHash: "", disabledAt: null }, data: { sessionVersion: { increment: 1 } } });
|
||||
if (eligible.count !== 1) return false;
|
||||
await tx.verificationToken.deleteMany({ where: { identifier: user.id } });
|
||||
await tx.verificationToken.create({ data: { identifier: user.id, token: hashToken(invite.token), expires: invite.expires } });
|
||||
return true;
|
||||
});
|
||||
if (!renewed) return { error: "Die Einladung hat sich geändert. Bitte lade die Seite neu." };
|
||||
const error = await sendInviteJobs(user.kita.name, [{ to: user.email, parentName: `${user.firstName} ${user.lastName}`, inviteUrl: invite.inviteUrl }]);
|
||||
return error ? { error } : { success: true };
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
const dutySchema = z.object({
|
||||
name: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein").max(50),
|
||||
description: z.string().optional(),
|
||||
description: z.string().trim().max(2000).optional(),
|
||||
});
|
||||
|
||||
export async function createDuty(rawPayload: unknown) {
|
||||
|
||||
@@ -154,7 +154,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Familie aktualisiert.");
|
||||
toast.success("Familie aktualisiert. Geänderte E-Mail-Adressen müssen über einen neuen Einladungslink bestätigt werden.");
|
||||
setRemovedChildIds([]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||
import { createElement } from "react";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
Prisma,
|
||||
NotdienstAlertStatus,
|
||||
NotdienstPlanStatus,
|
||||
NotdienstSwapStatus,
|
||||
@@ -17,6 +19,8 @@ import { z } from "zod";
|
||||
import { NotdienstSwapEmail } from "@/emails/NotdienstSwapEmail";
|
||||
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
||||
import { sendAppEmail } from "@/lib/mail";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// =====================================================================
|
||||
@@ -24,8 +28,8 @@ import { prisma } from "@/lib/prisma";
|
||||
// =====================================================================
|
||||
|
||||
const terminSchema = z.object({
|
||||
title: z.string().trim().min(2, "Titel muss mindestens 2 Zeichen lang sein"),
|
||||
description: z.string().trim().optional(),
|
||||
title: z.string().trim().min(2, "Titel muss mindestens 2 Zeichen lang sein").max(160),
|
||||
description: z.string().trim().max(20000).optional(),
|
||||
type: z.nativeEnum(TerminType),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
@@ -99,8 +103,9 @@ type SwapAssignmentWithFamily = {
|
||||
async function getSwapAssignment(
|
||||
assignmentId: string,
|
||||
kitaId: string,
|
||||
db: Prisma.TransactionClient = prisma,
|
||||
): Promise<SwapAssignmentWithFamily | null> {
|
||||
return prisma.notdienstAssignment.findFirst({
|
||||
return db.notdienstAssignment.findFirst({
|
||||
where: { id: assignmentId, kitaId },
|
||||
select: {
|
||||
id: true,
|
||||
@@ -114,7 +119,7 @@ async function getSwapAssignment(
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: {
|
||||
email: true,
|
||||
firstName: true,
|
||||
@@ -295,6 +300,7 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
|
||||
|
||||
export async function createNotdienstSwapRequest(rawPayload: unknown) {
|
||||
const session = await requireKitaSession();
|
||||
if (!(await rateLimit(`createNotdienstSwapRequest:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." };
|
||||
if (session.user.role !== UserRole.ELTERN) {
|
||||
return { error: "Nur Eltern können Notdienst-Tausche anfragen." };
|
||||
}
|
||||
@@ -311,54 +317,58 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
|
||||
const today = getTodayUtcDateOnly();
|
||||
|
||||
try {
|
||||
const [targetAssignment, offeredAssignment, kita, existingRequest] =
|
||||
await Promise.all([
|
||||
getSwapAssignment(targetAssignmentId, session.user.kitaId),
|
||||
getSwapAssignment(offeredAssignmentId, session.user.kitaId),
|
||||
prisma.kita.findUnique({
|
||||
where: { id: session.user.kitaId },
|
||||
select: { name: true },
|
||||
}),
|
||||
prisma.notdienstSwapRequest.findFirst({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
status: NotdienstSwapStatus.PENDING,
|
||||
OR: [
|
||||
{ targetAssignmentId },
|
||||
{ offeredAssignmentId },
|
||||
{ targetAssignmentId: offeredAssignmentId },
|
||||
{ offeredAssignmentId: targetAssignmentId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
const { targetAssignment, offeredAssignment, kita } = await serializableTransaction(async tx => {
|
||||
const [targetAssignment, offeredAssignment, kita, existingRequest] =
|
||||
await Promise.all([
|
||||
getSwapAssignment(targetAssignmentId, session.user.kitaId, tx),
|
||||
getSwapAssignment(offeredAssignmentId, session.user.kitaId, tx),
|
||||
tx.kita.findUnique({
|
||||
where: { id: session.user.kitaId },
|
||||
select: { name: true },
|
||||
}),
|
||||
tx.notdienstSwapRequest.findFirst({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
status: NotdienstSwapStatus.PENDING,
|
||||
OR: [
|
||||
{ targetAssignmentId },
|
||||
{ offeredAssignmentId },
|
||||
{ targetAssignmentId: offeredAssignmentId },
|
||||
{ offeredAssignmentId: targetAssignmentId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const validationError = validateSwapAssignmentPair({
|
||||
targetAssignment,
|
||||
offeredAssignment,
|
||||
requesterFamilyId: session.user.familyId,
|
||||
today,
|
||||
});
|
||||
if (validationError) {
|
||||
return { error: validationError };
|
||||
}
|
||||
if (!targetAssignment || !offeredAssignment || !kita) {
|
||||
return { error: "Notdienst nicht gefunden." };
|
||||
}
|
||||
if (existingRequest) {
|
||||
return { error: "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage." };
|
||||
}
|
||||
const validationError = validateSwapAssignmentPair({
|
||||
targetAssignment,
|
||||
offeredAssignment,
|
||||
requesterFamilyId: session.user.familyId!,
|
||||
today,
|
||||
});
|
||||
if (validationError) {
|
||||
throw new UserFacingError(validationError);
|
||||
}
|
||||
if (!targetAssignment || !offeredAssignment || !kita) {
|
||||
throw new UserFacingError("Notdienst nicht gefunden.");
|
||||
}
|
||||
if (existingRequest) {
|
||||
throw new UserFacingError("Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.");
|
||||
}
|
||||
|
||||
await prisma.notdienstSwapRequest.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId,
|
||||
requesterUserId: session.user.id,
|
||||
requesterFamilyId: session.user.familyId,
|
||||
targetAssignmentId,
|
||||
offeredAssignmentId,
|
||||
message: message || null,
|
||||
},
|
||||
await tx.notdienstSwapRequest.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId,
|
||||
requesterUserId: session.user.id,
|
||||
requesterFamilyId: session.user.familyId!,
|
||||
targetAssignmentId,
|
||||
offeredAssignmentId,
|
||||
message: message || null,
|
||||
},
|
||||
});
|
||||
|
||||
return { targetAssignment, offeredAssignment, kita };
|
||||
});
|
||||
|
||||
await notifyFamilyAboutSwap({
|
||||
@@ -375,7 +385,9 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
|
||||
revalidateCalendarAndNotdienst();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Erstellen der Notdienst-Tauschanfrage:", error);
|
||||
if (!(error instanceof Error && error.message === "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.")) {
|
||||
console.error("Fehler beim Erstellen der Notdienst-Tauschanfrage:", error);
|
||||
}
|
||||
return { error: "Tauschanfrage konnte nicht erstellt werden." };
|
||||
}
|
||||
}
|
||||
@@ -394,7 +406,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
const today = getTodayUtcDateOnly();
|
||||
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const result = await serializableTransaction(async (tx) => {
|
||||
const request = await tx.notdienstSwapRequest.findFirst({
|
||||
where: {
|
||||
id: parsed.data,
|
||||
@@ -419,7 +431,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -445,7 +457,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -462,11 +474,11 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new Error("Tauschanfrage nicht gefunden oder nicht mehr offen.");
|
||||
throw new UserFacingError("Tauschanfrage nicht gefunden oder nicht mehr offen.");
|
||||
}
|
||||
|
||||
if (request.targetAssignment.child.familyId !== session.user.familyId) {
|
||||
throw new Error("Nur der angefragte Haushalt kann diese Anfrage annehmen.");
|
||||
throw new UserFacingError("Nur der angefragte Haushalt kann diese Anfrage annehmen.");
|
||||
}
|
||||
|
||||
const validationError = validateSwapAssignmentPair({
|
||||
@@ -476,7 +488,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
today,
|
||||
});
|
||||
if (validationError) {
|
||||
throw new Error(validationError);
|
||||
throw new UserFacingError(validationError);
|
||||
}
|
||||
|
||||
await tx.notdienstAssignment.update({
|
||||
@@ -487,14 +499,15 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
where: { id: request.offeredAssignmentId },
|
||||
data: { childId: request.targetAssignment.childId },
|
||||
});
|
||||
await tx.notdienstSwapRequest.update({
|
||||
where: { id: request.id },
|
||||
const responded = await tx.notdienstSwapRequest.updateMany({
|
||||
where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING },
|
||||
data: {
|
||||
status: NotdienstSwapStatus.ACCEPTED,
|
||||
respondedById: session.user.id,
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (responded.count !== 1) throw new UserFacingError("Diese Anfrage ist nicht mehr offen.");
|
||||
await tx.notdienstSwapRequest.updateMany({
|
||||
where: {
|
||||
id: { not: request.id },
|
||||
@@ -550,7 +563,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : "Tauschanfrage konnte nicht angenommen werden.",
|
||||
error: publicActionError(error, "Tauschanfrage konnte nicht angenommen werden."),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -585,7 +598,7 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -603,7 +616,7 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -622,14 +635,15 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
return { error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen." };
|
||||
}
|
||||
|
||||
await prisma.notdienstSwapRequest.update({
|
||||
where: { id: request.id },
|
||||
const responded = await prisma.notdienstSwapRequest.updateMany({
|
||||
where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING },
|
||||
data: {
|
||||
status: NotdienstSwapStatus.DECLINED,
|
||||
respondedById: session.user.id,
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (responded.count !== 1) return { error: "Diese Anfrage ist nicht mehr offen." };
|
||||
|
||||
const kita = await prisma.kita.findUnique({
|
||||
where: { id: session.user.kitaId },
|
||||
@@ -694,14 +708,15 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." };
|
||||
}
|
||||
|
||||
await prisma.notdienstSwapRequest.update({
|
||||
where: { id: request.id },
|
||||
const responded = await prisma.notdienstSwapRequest.updateMany({
|
||||
where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING },
|
||||
data: {
|
||||
status: NotdienstSwapStatus.CANCELLED,
|
||||
respondedById: session.user.id,
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (responded.count !== 1) return { error: "Diese Anfrage ist nicht mehr offen." };
|
||||
|
||||
revalidateCalendarAndNotdienst();
|
||||
return { success: true };
|
||||
@@ -713,6 +728,7 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
|
||||
export async function createTerminRequest(rawPayload: unknown) {
|
||||
const session = await requireKitaSession();
|
||||
if (!(await rateLimit(`createTerminRequest:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." };
|
||||
|
||||
const parsed = terminSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
@@ -914,11 +930,12 @@ export async function toggleMitbringselList(terminId: string, enabled: boolean)
|
||||
|
||||
const mitbringselSchema = z.object({
|
||||
terminId: z.string(),
|
||||
content: z.string().min(1, "Eintrag darf nicht leer sein"),
|
||||
content: z.string().trim().min(1, "Eintrag darf nicht leer sein").max(500),
|
||||
});
|
||||
|
||||
export async function addMitbringsel(rawPayload: unknown) {
|
||||
const session = await requireKitaSession();
|
||||
if (!(await rateLimit(`addMitbringsel:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." };
|
||||
|
||||
const parsed = mitbringselSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -239,6 +239,8 @@ export default async function KalenderPage({
|
||||
const allUserTermine: CalendarListItemDto[] = [
|
||||
...confirmedTermineRows.map((termin) => ({
|
||||
...termin,
|
||||
participantInfo: isAdmin || termin.participations.some(p => p.status === EventParticipationStatus.ATTENDING)
|
||||
? termin.participantInfo : null,
|
||||
kind: "termin" as const,
|
||||
showParticipantInfo:
|
||||
isAdmin ||
|
||||
@@ -249,6 +251,8 @@ export default async function KalenderPage({
|
||||
})),
|
||||
...myPendingTermineRows.map((termin) => ({
|
||||
...termin,
|
||||
participantInfo: isAdmin || termin.participations.some(p => p.status === EventParticipationStatus.ATTENDING)
|
||||
? termin.participantInfo : null,
|
||||
kind: "termin" as const,
|
||||
showParticipantInfo:
|
||||
isAdmin ||
|
||||
|
||||
@@ -18,13 +18,13 @@ import { getTargetMonthData } from "@/lib/date-utils";
|
||||
const dateKeyPattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
const payloadSchema = z.object({
|
||||
childrenIds: z.array(z.string()).min(1),
|
||||
childrenIds: z.array(z.string().min(1).max(64)).min(1).max(20),
|
||||
dates: z.array(
|
||||
z.string().regex(dateKeyPattern).refine((value) => {
|
||||
const date = dateKeyToUtcDate(value);
|
||||
return value === date.toISOString().slice(0, 10);
|
||||
return Number.isFinite(date.getTime()) && value === date.toISOString().slice(0, 10);
|
||||
}),
|
||||
),
|
||||
).max(31),
|
||||
});
|
||||
|
||||
function dateKeyToUtcDate(dateKey: string) {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||
import { createElement } from "react";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { NotdienstPlanStatus, UserRole } from "@prisma/client";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
|
||||
import { dateOnlySchema, validPlanMonth } from "@/lib/date-schema";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
||||
@@ -32,6 +35,8 @@ export async function generatePlanAction(year?: number, month?: number) {
|
||||
targetMonth = data.targetMonth;
|
||||
}
|
||||
|
||||
if (!validPlanMonth(targetYear, targetMonth)) return { error: "Ungültiger Planungsmonat." };
|
||||
|
||||
// Prüfen, ob schon ein Plan existiert
|
||||
const existingPlan = await prisma.notdienstPlan.findUnique({
|
||||
where: {
|
||||
@@ -156,6 +161,7 @@ export async function updateAssignmentAction(
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
|
||||
if (!dateOnlySchema.safeParse(date).success) return { error: "Ungültiges Datum." };
|
||||
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
|
||||
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
|
||||
|
||||
@@ -166,21 +172,21 @@ export async function updateAssignmentAction(
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
throw new Error("Plan nicht gefunden.");
|
||||
throw new UserFacingError("Plan nicht gefunden.");
|
||||
}
|
||||
|
||||
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
|
||||
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
|
||||
|
||||
if (!isDraft && !isPublished) {
|
||||
throw new Error("Plan kann nicht bearbeitet werden.");
|
||||
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
|
||||
}
|
||||
|
||||
if (isPublished) {
|
||||
const today = new Date();
|
||||
const todayUtc = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
|
||||
if (parsedDate < todayUtc) {
|
||||
throw new Error("Vergangene Notdienste können nicht geändert werden.");
|
||||
throw new UserFacingError("Vergangene Notdienste können nicht geändert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +198,7 @@ export async function updateAssignmentAction(
|
||||
family: {
|
||||
include: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -222,7 +228,7 @@ export async function updateAssignmentAction(
|
||||
family: {
|
||||
include: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -231,7 +237,7 @@ export async function updateAssignmentAction(
|
||||
});
|
||||
|
||||
if (!newChild) {
|
||||
throw new Error("Kind gehört nicht zu dieser Kita.");
|
||||
throw new UserFacingError("Kind gehört nicht zu dieser Kita.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,7 +370,7 @@ export async function updateAssignmentAction(
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : "Update fehlgeschlagen.",
|
||||
error: publicActionError(error, "Update fehlgeschlagen."),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -379,6 +385,8 @@ export async function updateKitaNotdienstSettingsAction(
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 ||
|
||||
!Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) return { error: "Ungültige Einstellungen." };
|
||||
try {
|
||||
await prisma.kita.update({
|
||||
where: { id: kitaId },
|
||||
@@ -398,6 +406,8 @@ export async function updateKitaNotdienstSettingsAction(
|
||||
|
||||
export async function sendNotdienstRemindersAction(year: number, month: number) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
|
||||
if (!(await rateLimit(`notdienst-reminders:${session.user.kitaId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
@@ -426,7 +436,7 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
|
||||
},
|
||||
include: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -513,11 +523,11 @@ export async function resetPlanAction(planId: string) {
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
throw new Error("Plan nicht gefunden.");
|
||||
throw new UserFacingError("Plan nicht gefunden.");
|
||||
}
|
||||
|
||||
if (plan.status !== NotdienstPlanStatus.DRAFT) {
|
||||
throw new Error("Nur Pläne im Entwurfsstatus können zurückgesetzt werden.");
|
||||
throw new UserFacingError("Nur Pläne im Entwurfsstatus können zurückgesetzt werden.");
|
||||
}
|
||||
|
||||
// Delete all assignments
|
||||
@@ -535,6 +545,6 @@ export async function resetPlanAction(planId: string) {
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Zurücksetzen des Plans:", error);
|
||||
return { error: error instanceof Error ? error.message : "Fehler beim Zurücksetzen des Plans." };
|
||||
return { error: publicActionError(error, "Fehler beim Zurücksetzen des Plans.") };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getChildBirthdateRange,
|
||||
parseDateInput,
|
||||
} from "@/lib/child-birthdate";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche,
|
||||
@@ -118,14 +119,18 @@ export async function createMyChild(rawPayload: unknown) {
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.child.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId,
|
||||
familyId: session.user.familyId,
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
dateOfBirth: parseDateInput(parsed.data.dateOfBirth),
|
||||
},
|
||||
await serializableTransaction(async tx => {
|
||||
const count = await tx.child.count({ where: { kitaId: session.user.kitaId, familyId: session.user.familyId! } });
|
||||
if (count >= 20) throw new Error("Household child limit reached");
|
||||
await tx.child.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId,
|
||||
familyId: session.user.familyId!,
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
dateOfBirth: parseDateInput(parsed.data.dateOfBirth),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/profil");
|
||||
@@ -158,10 +163,11 @@ export async function createMyFamily(rawPayload: unknown) {
|
||||
},
|
||||
});
|
||||
|
||||
await tx.user.update({
|
||||
where: { id: session.user.id },
|
||||
const attached = await tx.user.updateMany({
|
||||
where: { id: session.user.id, kitaId: session.user.kitaId, familyId: null, disabledAt: null, sessionVersion: session.user.sessionVersion },
|
||||
data: { familyId: family.id },
|
||||
});
|
||||
if (attached.count !== 1) throw new Error("Account already assigned or revoked");
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/profil");
|
||||
@@ -170,7 +176,9 @@ export async function createMyFamily(rawPayload: unknown) {
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
|
||||
if (!(error instanceof Error && error.message === "Account already assigned or revoked")) {
|
||||
console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
|
||||
}
|
||||
return { error: "Der Haushalt konnte nicht angelegt werden." };
|
||||
}
|
||||
}
|
||||
@@ -239,7 +247,7 @@ export async function updateMyChild(childId: string, rawPayload: unknown) {
|
||||
}
|
||||
|
||||
await prisma.child.update({
|
||||
where: { id: childId },
|
||||
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! },
|
||||
data: {
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
@@ -271,7 +279,7 @@ export async function deleteMyChild(childId: string) {
|
||||
}
|
||||
|
||||
await prisma.child.delete({
|
||||
where: { id: childId },
|
||||
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! },
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/profil");
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { passwordSchema } from "@/lib/password";
|
||||
import { limitPublicAction } from "@/lib/rate-limit";
|
||||
import { hash } from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { signIn } from "@/auth";
|
||||
import { hashToken, isPublicToken } from "@/lib/tokens";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// =====================================================================
|
||||
@@ -18,8 +21,8 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
const inviteSchema = z
|
||||
.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(8, "Mindestens 8 Zeichen.").max(72, "Maximal 72 Zeichen."),
|
||||
token: z.string().refine(isPublicToken),
|
||||
password: passwordSchema,
|
||||
confirmPassword: z.string().min(1, "Passwort-Bestätigung fehlt."),
|
||||
acceptPrivacyPolicy: z.literal("on", {
|
||||
error: "Bitte die Datenschutzerklärung akzeptieren.",
|
||||
@@ -80,11 +83,12 @@ export async function acceptInviteAction(
|
||||
}
|
||||
|
||||
const { token, password, directoryOptIn } = parsed.data;
|
||||
if (!(await limitPublicAction("invite", hashToken(token), 10, 900))) return { attempt, values, errors: { _form: ["Bitte versuche es später erneut."] } };
|
||||
const now = new Date();
|
||||
|
||||
// ── 1. Token erneut DB-seitig validieren ──────────────────────────
|
||||
const verificationToken = await prisma.verificationToken.findUnique({
|
||||
where: { token },
|
||||
const verificationToken = await prisma.verificationToken.findFirst({
|
||||
where: { token: { in: [hashToken(token), ...(token.length === 36 ? [token] : [])] }, expires: { gt: new Date() } },
|
||||
});
|
||||
|
||||
if (!verificationToken) {
|
||||
@@ -111,10 +115,10 @@ export async function acceptInviteAction(
|
||||
// ── 2. User validieren (existiert und hat noch kein Passwort) ──────
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true, passwordHash: true },
|
||||
select: { id: true, email: true, passwordHash: true, disabledAt: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
if (!user || user.disabledAt) {
|
||||
return {
|
||||
attempt,
|
||||
values,
|
||||
@@ -138,22 +142,22 @@ export async function acceptInviteAction(
|
||||
const passwordHash = await hash(password, 12);
|
||||
|
||||
// ── 4. User updaten + Token löschen (atomar) ──────────────────────
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
passwordHash,
|
||||
privacyPolicyAcceptedAt: now,
|
||||
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
|
||||
directoryOptInAt: directoryOptIn === "on" ? now : null,
|
||||
emailVerifiedAt: now,
|
||||
lastLoginAt: now,
|
||||
},
|
||||
}),
|
||||
prisma.verificationToken.delete({
|
||||
where: { token },
|
||||
}),
|
||||
]);
|
||||
try {
|
||||
await prisma.$transaction(async tx => {
|
||||
const consumed = await tx.verificationToken.deleteMany({ where: { token: verificationToken.token, expires: { gt: new Date() } } });
|
||||
if (consumed.count !== 1) throw new Error("INVITE_USED");
|
||||
const activated = await tx.user.updateMany({
|
||||
where: { id: userId, passwordHash: "", disabledAt: null },
|
||||
data: { passwordHash, privacyPolicyAcceptedAt: now, privacyPolicyVersion: PRIVACY_POLICY_VERSION,
|
||||
directoryOptInAt: directoryOptIn === "on" ? now : null, emailVerifiedAt: now, lastLoginAt: now,
|
||||
sessionVersion: { increment: 1 } },
|
||||
});
|
||||
if (activated.count !== 1) throw new Error("INVITE_USED");
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "INVITE_USED") return { attempt, values, errors: { _form: ["Dieser Link kann nicht mehr verwendet werden."] } };
|
||||
throw error;
|
||||
}
|
||||
|
||||
// ── 5. Direkt einloggen → wirft NEXT_REDIRECT ─────────────────────
|
||||
await signIn("credentials", {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Baby } from "lucide-react";
|
||||
|
||||
import { hashToken, isPublicToken } from "@/lib/tokens";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
Card,
|
||||
@@ -12,7 +13,8 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { InviteForm } from "./invite-form";
|
||||
|
||||
export const metadata = { title: "Konto aktivieren · Kita-Planer" };
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "Konto aktivieren · Kita-Planer", robots: { index: false, follow: false } };
|
||||
|
||||
// =====================================================================
|
||||
// /invite/[token] · Öffentliche Seite (kein Auth erforderlich)
|
||||
@@ -30,9 +32,11 @@ export default async function InvitePage({
|
||||
}) {
|
||||
const { token } = await params;
|
||||
|
||||
if (!isPublicToken(token)) notFound();
|
||||
|
||||
// Token in DB nachschlagen
|
||||
const verificationToken = await prisma.verificationToken.findUnique({
|
||||
where: { token },
|
||||
const verificationToken = await prisma.verificationToken.findFirst({
|
||||
where: { token: { in: [hashToken(token), ...(token.length === 36 ? [token] : [])] }, expires: { gt: new Date() } },
|
||||
});
|
||||
|
||||
// Unbekannter Token → 404
|
||||
@@ -46,6 +50,7 @@ export default async function InvitePage({
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: verificationToken.identifier },
|
||||
select: {
|
||||
disabledAt: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
emailVerifiedAt: true,
|
||||
@@ -53,7 +58,7 @@ export default async function InvitePage({
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) notFound();
|
||||
if (!user || user.disabledAt) notFound();
|
||||
|
||||
const userName = `${user.firstName} ${user.lastName}`;
|
||||
const alreadyAccepted = !!user.emailVerifiedAt;
|
||||
|
||||
+6
-2
@@ -1,3 +1,5 @@
|
||||
import { getSession } from "@/lib/auth-utils";
|
||||
import { headers } from "next/headers";
|
||||
import type { Metadata } from "next";
|
||||
import { Manrope } from "next/font/google";
|
||||
import "./globals.css";
|
||||
@@ -44,11 +46,13 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const [requestHeaders, session] = await Promise.all([headers(), getSession()]);
|
||||
const nonce = requestHeaders.get("x-nonce") ?? undefined;
|
||||
return (
|
||||
<html
|
||||
lang="de"
|
||||
@@ -56,7 +60,7 @@ export default function RootLayout({
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
{children}
|
||||
<UmamiAnalytics />
|
||||
<UmamiAnalytics nonce={nonce} enabled={!session?.user?.id} />
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { AuthError } from "next-auth";
|
||||
import { z } from "zod";
|
||||
|
||||
import { safeCallbackUrl } from "@/lib/safe-redirect";
|
||||
import { signIn } from "@/auth";
|
||||
|
||||
// =====================================================================
|
||||
@@ -17,7 +18,7 @@ import { signIn } from "@/auth";
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase().trim(),
|
||||
password: z.string().min(1, "Passwort fehlt."),
|
||||
password: z.string().min(1, "Passwort fehlt.").max(72),
|
||||
callbackUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -29,12 +30,6 @@ export type LoginState = {
|
||||
};
|
||||
};
|
||||
|
||||
function safeCallbackUrl(value: string | undefined) {
|
||||
if (!value) return "/dashboard";
|
||||
if (!value.startsWith("/") || value.startsWith("//")) return "/dashboard";
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function loginAction(
|
||||
_prev: LoginState,
|
||||
formData: FormData,
|
||||
|
||||
+6
-13
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { safeCallbackUrl } from "@/lib/safe-redirect";
|
||||
import { auth } from "@/auth";
|
||||
import { getPostLoginRedirect } from "@/lib/post-login-redirect";
|
||||
import {
|
||||
@@ -16,25 +17,17 @@ import { LoginForm } from "./login-form";
|
||||
export const metadata = { title: "Anmelden · Kita-Planer" };
|
||||
|
||||
type LoginPageProps = {
|
||||
searchParams?: Promise<{ callbackUrl?: string | string[] }>;
|
||||
searchParams?: Promise<{ callbackUrl?: string | string[]; reauth?: string }>;
|
||||
};
|
||||
|
||||
function getSafeCallbackUrl(value: string | string[] | undefined) {
|
||||
const callbackUrl = Array.isArray(value) ? value[0] : value;
|
||||
if (!callbackUrl) return "/dashboard";
|
||||
if (!callbackUrl.startsWith("/") || callbackUrl.startsWith("//")) {
|
||||
return "/dashboard";
|
||||
}
|
||||
return callbackUrl;
|
||||
}
|
||||
|
||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const session = await auth();
|
||||
if (session?.user?.id) {
|
||||
if (session?.user?.id && resolvedSearchParams?.reauth !== "1") {
|
||||
redirect(getPostLoginRedirect(session.user));
|
||||
}
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const callbackUrl = getSafeCallbackUrl(resolvedSearchParams?.callbackUrl);
|
||||
const raw = resolvedSearchParams?.callbackUrl;
|
||||
const callbackUrl = safeCallbackUrl(Array.isArray(raw) ? raw[0] : raw);
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const devAdminEmail = isDev ? process.env.DEV_LOGIN_ADMIN_EMAIL : undefined;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
|
||||
import { Prisma, UserRole } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { requireSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// =====================================================================
|
||||
@@ -23,9 +23,9 @@ const onboardingSchema = z
|
||||
.trim()
|
||||
.min(2, "Bitte mindestens 2 Zeichen angeben.")
|
||||
.max(120, "Maximal 120 Zeichen."),
|
||||
notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
|
||||
terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
|
||||
adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
|
||||
notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()).default(false),
|
||||
terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()).default(false),
|
||||
adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()).default(false),
|
||||
notdienstMinPerChildPerMonth: z.coerce
|
||||
.number()
|
||||
.int("Bitte eine ganze Zahl angeben.")
|
||||
@@ -87,10 +87,10 @@ export async function completeOnboardingAction(
|
||||
formData: FormData,
|
||||
): Promise<OnboardingState> {
|
||||
const attempt = prev.attempt + 1;
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
}
|
||||
const session = await requireSession();
|
||||
if (session.user.role !== UserRole.ADMIN) redirect("/forbidden");
|
||||
const founder = await prisma.user.findUnique({ where: { id: session.user.id } });
|
||||
if (!founder?.emailVerifiedAt || !founder.privacyPolicyAcceptedAt) redirect("/register");
|
||||
if (session.user.kitaId) {
|
||||
// Onboarding bereits abgeschlossen — verhindert doppeltes Einrichten
|
||||
// (z.B. durch zurück-navigieren oder Replay des Formulars).
|
||||
@@ -124,14 +124,15 @@ export async function completeOnboardingAction(
|
||||
// tatsächlich noch keine Kita hat (verhindert Race-Conditions bei
|
||||
// parallelen Onboarding-Submits).
|
||||
const updated = await tx.user.updateMany({
|
||||
where: { id: session.user.id, kitaId: null },
|
||||
where: { id: session.user.id, kitaId: null, role: UserRole.ADMIN, disabledAt: null,
|
||||
sessionVersion: session.user.sessionVersion, emailVerifiedAt: { not: null }, privacyPolicyAcceptedAt: { not: null } },
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
role: UserRole.ADMIN,
|
||||
},
|
||||
});
|
||||
|
||||
if (updated.count === 0) {
|
||||
if (updated.count !== 1) {
|
||||
throw new Error("USER_ALREADY_HAS_KITA");
|
||||
}
|
||||
});
|
||||
|
||||
+34
-59
@@ -1,26 +1,23 @@
|
||||
"use server";
|
||||
|
||||
import { hash } from "bcryptjs";
|
||||
import { Prisma, UserRole } from "@prisma/client";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { signIn } from "@/auth";
|
||||
import { hashToken, newToken } from "@/lib/tokens";
|
||||
import { limitPublicAction } from "@/lib/rate-limit";
|
||||
import { sendAppEmail } from "@/lib/mail";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// =====================================================================
|
||||
// /register · Server Action
|
||||
// ---------------------------------------------------------------------
|
||||
// Legt einen "heimatlosen" Admin-Account an (kitaId === null) und loggt
|
||||
// ihn direkt ein. Die Tenant-Erstellung folgt im Onboarding-Wizard.
|
||||
// Speichert eine kurzlebige Registrierung und versendet einen Bestätigungslink.
|
||||
// Konto und Passwort entstehen erst nach dem Nachweis des Postfachbesitzes.
|
||||
// =====================================================================
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase().trim(),
|
||||
// bcrypt akzeptiert max. 72 Bytes — wir capen davor zur Sicherheit
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Mindestens 8 Zeichen.")
|
||||
.max(72, "Maximal 72 Zeichen."),
|
||||
email: z.string().trim().max(254).email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase(),
|
||||
firstName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
|
||||
lastName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
|
||||
// HTML-Checkboxen senden "on" wenn aktiviert, sonst nichts
|
||||
@@ -29,12 +26,12 @@ const registerSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const PRIVACY_POLICY_VERSION = "2026-05-01";
|
||||
|
||||
export type RegisterState = {
|
||||
// Bumped after every action call so the client can key uncontrolled inputs
|
||||
// and force a remount with the echoed values from a failed submit.
|
||||
attempt: number;
|
||||
message?: string;
|
||||
// Echoed inputs (sans password) so failed submits don't wipe the form.
|
||||
values?: {
|
||||
firstName?: string;
|
||||
@@ -54,9 +51,9 @@ export type RegisterState = {
|
||||
|
||||
function echoValues(formData: FormData): RegisterState["values"] {
|
||||
return {
|
||||
firstName: String(formData.get("firstName") ?? ""),
|
||||
lastName: String(formData.get("lastName") ?? ""),
|
||||
email: String(formData.get("email") ?? ""),
|
||||
firstName: String(formData.get("firstName") ?? "").slice(0, 100),
|
||||
lastName: String(formData.get("lastName") ?? "").slice(0, 100),
|
||||
email: String(formData.get("email") ?? "").slice(0, 254),
|
||||
acceptPrivacyPolicy: formData.get("acceptPrivacyPolicy") === "on",
|
||||
};
|
||||
}
|
||||
@@ -74,50 +71,28 @@ export async function registerAction(
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
const { email, password, firstName, lastName } = parsed.data;
|
||||
|
||||
const passwordHash = await hash(password, 12);
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
firstName,
|
||||
lastName,
|
||||
// Gründer wird "heimatloser" Admin — beim Onboarding-Abschluss
|
||||
// wird die Rolle bestätigt und der User mit einer Kita verknüpft.
|
||||
role: UserRole.ADMIN,
|
||||
kitaId: null,
|
||||
privacyPolicyAcceptedAt: now,
|
||||
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
|
||||
emailVerifiedAt: now,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
return {
|
||||
attempt,
|
||||
values: echoValues(formData),
|
||||
errors: {
|
||||
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."],
|
||||
},
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
const { email, firstName, lastName } = parsed.data;
|
||||
const message = "Falls die Adresse registriert werden kann, erhältst du eine E-Mail. Öffne den Link, um dein Passwort zu wählen und deine Kita einzurichten. Der Link ist 30 Minuten gültig.";
|
||||
if (!(await limitPublicAction("register", email))) {
|
||||
return { attempt, errors: { _form: ["Zu viele Anfragen. Bitte versuche es später erneut."] } };
|
||||
}
|
||||
|
||||
// Direkt einloggen → wirft NEXT_REDIRECT, das wir nicht abfangen.
|
||||
await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirectTo: "/onboarding",
|
||||
const existing = await prisma.user.findUnique({ where: { email }, select: { role: true, kitaId: true, emailVerifiedAt: true, disabledAt: true } });
|
||||
if (existing && (existing.role !== "ADMIN" || existing.kitaId || existing.emailVerifiedAt || existing.disabledAt)) return { attempt, message };
|
||||
const token = newToken();
|
||||
await prisma.pendingRegistration.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
||||
await prisma.pendingRegistration.upsert({
|
||||
where: { email },
|
||||
create: { email, firstName, lastName, tokenHash: hashToken(token), expiresAt: new Date(Date.now() + 30 * 60_000) },
|
||||
update: { firstName, lastName, tokenHash: hashToken(token), expiresAt: new Date(Date.now() + 30 * 60_000) },
|
||||
});
|
||||
|
||||
// Unreachable – signIn redirected.
|
||||
return { attempt };
|
||||
const base = process.env.AUTH_URL ?? process.env.NEXTAUTH_URL ?? "http://localhost:3000";
|
||||
const result = await sendAppEmail({
|
||||
to: email,
|
||||
subject: "E-Mail bestätigen · Kita-Planer",
|
||||
react: createElement("div", null,
|
||||
createElement("p", null, "Bestätige deine E-Mail-Adresse, um dein Konto einzurichten. Falls du dich nicht registriert hast, kannst du diese Nachricht ignorieren."),
|
||||
createElement("a", { href: `${base}/register/verify/${token}` }, "Registrierung abschließen")),
|
||||
});
|
||||
if (!result.success) return { attempt, errors: { _form: ["Die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte versuche es später erneut."] } };
|
||||
return { attempt, message };
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export default async function RegisterPage() {
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle>Kita kostenlos registrieren</CardTitle>
|
||||
<CardDescription>
|
||||
Lege deinen Account an. Im nächsten Schritt richtest du deine Kita ein.
|
||||
Bestätige deine E-Mail-Adresse. Danach wählst du dein Passwort und richtest deine Kita ein.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
@@ -22,6 +22,8 @@ export function RegisterForm() {
|
||||
// semantics would wipe them after a failed submit.
|
||||
const attemptKey = state.attempt;
|
||||
|
||||
if (state.message) return <p role="status" className="text-sm">{state.message}</p>;
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
@@ -59,18 +61,6 @@ export function RegisterForm() {
|
||||
error={state.errors?.email?.[0]}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
key={`password-${attemptKey}`}
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
label="Passwort"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
helperText="Mindestens 8 Zeichen."
|
||||
error={state.errors?.password?.[0]}
|
||||
/>
|
||||
|
||||
<div className="flex items-start gap-3 pt-2">
|
||||
<Checkbox
|
||||
key={`acceptPrivacyPolicy-${attemptKey}`}
|
||||
@@ -122,7 +112,7 @@ export function RegisterForm() {
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={pending}>
|
||||
{pending ? "Wird erstellt…" : "Account erstellen"}
|
||||
{pending ? "Wird gesendet…" : "Bestätigungs-E-Mail anfordern"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use server";
|
||||
import { hash } from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { Prisma, UserRole } from "@prisma/client";
|
||||
import { signIn } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { passwordSchema } from "@/lib/password";
|
||||
import { hashToken, isPublicToken } from "@/lib/tokens";
|
||||
import { limitPublicAction } from "@/lib/rate-limit";
|
||||
|
||||
const schema = z.object({ token: z.string().refine(isPublicToken), password: passwordSchema,
|
||||
confirmPassword: z.string().max(72), acceptPrivacyPolicy: z.literal("on") })
|
||||
.refine(d => d.password === d.confirmPassword, { message: "Passwörter stimmen nicht überein.", path: ["confirmPassword"] });
|
||||
export type VerificationState = { error?: string };
|
||||
export async function verifyRegistrationAction(_state: VerificationState, form: FormData): Promise<VerificationState> {
|
||||
const parsed = schema.safeParse(Object.fromEntries(form));
|
||||
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Bitte Eingaben prüfen." };
|
||||
const { token, password } = parsed.data;
|
||||
if (!(await limitPublicAction("verify-registration", hashToken(token), 10, 900))) return { error: "Bitte versuche es später erneut." };
|
||||
const draft = await prisma.pendingRegistration.findUnique({ where: { tokenHash: hashToken(token) } });
|
||||
if (!draft || draft.expiresAt <= new Date()) return { error: "Der Link ist ungültig oder abgelaufen. Bitte fordere eine neue E-Mail an." };
|
||||
const passwordHash = await hash(password, 12);
|
||||
try {
|
||||
await prisma.$transaction(async tx => {
|
||||
const used = await tx.pendingRegistration.deleteMany({ where: { tokenHash: hashToken(token), expiresAt: { gt: new Date() } } });
|
||||
if (used.count !== 1) throw new Error("TOKEN_USED");
|
||||
const existing = await tx.user.findUnique({ where: { email: draft.email }, select: { id: true } });
|
||||
const data = { firstName: draft.firstName, lastName: draft.lastName, passwordHash,
|
||||
emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date(), privacyPolicyVersion: "2026-05-01" };
|
||||
if (existing) {
|
||||
const changed = await tx.user.updateMany({ where: { id: existing.id, role: UserRole.ADMIN, kitaId: null,
|
||||
emailVerifiedAt: null, disabledAt: null }, data: { ...data, sessionVersion: { increment: 1 } } });
|
||||
if (changed.count !== 1) throw new Error("TOKEN_USED");
|
||||
} else {
|
||||
await tx.user.create({ data: { ...data, email: draft.email, role: UserRole.ADMIN } });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") ||
|
||||
(error instanceof Error && error.message === "TOKEN_USED")) return { error: "Der Link wurde bereits verwendet. Bitte melde dich an." };
|
||||
throw error;
|
||||
}
|
||||
await signIn("credentials", { email: draft.email, password, redirectTo: "/onboarding" });
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { hashToken, isPublicToken } from "@/lib/tokens";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { VerifyForm } from "./verify-form";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "E-Mail bestätigen · Kita-Planer", robots: { index: false, follow: false } };
|
||||
export default async function VerifyPage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = await params;
|
||||
if (!isPublicToken(token)) notFound();
|
||||
const draft = await prisma.pendingRegistration.findUnique({ where: { tokenHash: hashToken(token) }, select: { expiresAt: true } });
|
||||
if (!draft || draft.expiresAt <= new Date()) return <main className="p-8">Dieser Link ist ungültig oder abgelaufen. <a href="/register" className="underline">Neue Bestätigungs-E-Mail anfordern</a></main>;
|
||||
return <main className="flex min-h-screen items-center justify-center p-4"><Card className="w-full max-w-md">
|
||||
<CardHeader><CardTitle>Registrierung abschließen</CardTitle></CardHeader>
|
||||
<CardContent><VerifyForm token={token} /></CardContent>
|
||||
</Card></main>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
import { useActionState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { verifyRegistrationAction, type VerificationState } from "./actions";
|
||||
export function VerifyForm({ token }: { token: string }) {
|
||||
const [state, action, pending] = useActionState(verifyRegistrationAction, {} as VerificationState);
|
||||
return <form action={action} className="space-y-4">
|
||||
<input type="hidden" name="token" value={token} />
|
||||
<div className="space-y-2"><Label htmlFor="password">Passwort</Label><Input id="password" name="password" type="password" autoComplete="new-password" minLength={8} required /></div>
|
||||
<div className="space-y-2"><Label htmlFor="confirmPassword">Passwort wiederholen</Label><Input id="confirmPassword" name="confirmPassword" type="password" autoComplete="new-password" required /></div>
|
||||
<label className="flex gap-2 text-sm"><input type="checkbox" name="acceptPrivacyPolicy" required />
|
||||
<span>Ich akzeptiere die <a className="underline" href="/datenschutz" target="_blank" rel="noreferrer">Datenschutzerklärung</a>.</span></label>
|
||||
{state.error && <p role="alert" className="text-sm text-destructive">{state.error}</p>}
|
||||
<Button type="submit" disabled={pending}>{pending ? "Wird eingerichtet…" : "E-Mail bestätigen und Konto erstellen"}</Button>
|
||||
</form>;
|
||||
}
|
||||
+45
-42
@@ -1,6 +1,7 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { compare } from "bcryptjs";
|
||||
import { limitPublicAction } from "@/lib/rate-limit";
|
||||
import { z } from "zod";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -14,12 +15,12 @@ import { prisma } from "@/lib/prisma";
|
||||
// =====================================================================
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
email: z.string().email().toLowerCase().trim(),
|
||||
password: z.string().min(1),
|
||||
email: z.string().trim().max(254).email().toLowerCase(),
|
||||
password: z.string().min(1).refine(value => Buffer.byteLength(value, "utf8") <= 72),
|
||||
});
|
||||
|
||||
// Brute-Force-Schutz auf Account-Ebene (planung.md §5.5).
|
||||
// Globales Rate-Limiting ergänzen wir später per Middleware.
|
||||
// Zusätzlich greifen atomare, instanzübergreifende DB-Limits vor bcrypt.
|
||||
const MAX_FAILED_ATTEMPTS = 10;
|
||||
const LOCKOUT_MINUTES = 15;
|
||||
|
||||
@@ -40,8 +41,14 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
if (!parsed.success) return null;
|
||||
const { email, password } = parsed.data;
|
||||
|
||||
if (!(await limitPublicAction("login", email, 20, 900))) return null;
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) return null;
|
||||
if (!user) {
|
||||
// Match the cost for nonexistent accounts without exposing account existence.
|
||||
await compare(password, "$2b$12$KbQiLuL6QbybTw/WNx4Ige2yCZcjVccoruuDx3vmkJt8YYUpphdIu");
|
||||
return null;
|
||||
}
|
||||
if (user.disabledAt || !user.emailVerifiedAt) return null;
|
||||
|
||||
// Account temporär gesperrt → Login pauschal ablehnen.
|
||||
if (user.lockedUntil && user.lockedUntil > new Date()) {
|
||||
@@ -50,23 +57,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
|
||||
const passwordOk = await compare(password, user.passwordHash);
|
||||
if (!passwordOk) {
|
||||
const nextAttempts = user.failedLoginAttempts + 1;
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedLoginAttempts: nextAttempts,
|
||||
lockedUntil:
|
||||
nextAttempts >= MAX_FAILED_ATTEMPTS
|
||||
? new Date(Date.now() + LOCKOUT_MINUTES * 60_000)
|
||||
: user.lockedUntil,
|
||||
},
|
||||
});
|
||||
await prisma.$executeRaw`
|
||||
UPDATE users SET "failedLoginAttempts" = "failedLoginAttempts" + 1,
|
||||
"lockedUntil" = CASE WHEN "failedLoginAttempts" + 1 >= ${MAX_FAILED_ATTEMPTS}
|
||||
THEN NOW() + make_interval(mins => ${LOCKOUT_MINUTES}::int) ELSE "lockedUntil" END
|
||||
WHERE id = ${user.id}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Erfolgreicher Login → Counter zurücksetzen, lastLogin aktualisieren.
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
const admitted = await prisma.user.updateMany({
|
||||
where: { id: user.id, disabledAt: null, sessionVersion: user.sessionVersion, passwordHash: user.passwordHash, emailVerifiedAt: { not: null },
|
||||
OR: [{ lockedUntil: null }, { lockedUntil: { lte: new Date() } }] },
|
||||
data: {
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
@@ -74,7 +76,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
},
|
||||
});
|
||||
|
||||
if (admitted.count !== 1) return null;
|
||||
return {
|
||||
sessionVersion: user.sessionVersion,
|
||||
authenticatedAt: Date.now(),
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: `${user.firstName} ${user.lastName}`.trim(),
|
||||
@@ -86,6 +91,13 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async redirect({ url, baseUrl }) {
|
||||
if (/[\\\x00-\x20]/.test(url)) return baseUrl;
|
||||
try {
|
||||
const destination = new URL(url, baseUrl);
|
||||
return destination.origin === new URL(baseUrl).origin ? destination.href : baseUrl;
|
||||
} catch { return baseUrl; }
|
||||
},
|
||||
/**
|
||||
* Wird beim Login (mit `user`) und bei jedem Token-Refresh (ohne `user`)
|
||||
* aufgerufen. Beim Login schreiben wir die mandantenrelevanten Felder
|
||||
@@ -96,31 +108,20 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.role = user.role;
|
||||
token.kitaId = user.kitaId;
|
||||
token.familyId = user.familyId;
|
||||
return token;
|
||||
token.sessionVersion = user.sessionVersion;
|
||||
token.authenticatedAt = user.authenticatedAt;
|
||||
}
|
||||
|
||||
const tokenUserId = token.id ?? token.sub;
|
||||
if (tokenUserId) {
|
||||
const fresh = await prisma.user.findUnique({
|
||||
where: { id: tokenUserId },
|
||||
select: { role: true, kitaId: true, familyId: true },
|
||||
});
|
||||
if (!fresh) {
|
||||
// User wurde gelöscht → Token entwerten.
|
||||
// (Auth.js erkennt den fehlenden `sub`/`id` und meldet ab.)
|
||||
delete (token as Partial<typeof token>).id;
|
||||
delete (token as Partial<typeof token>).sub;
|
||||
return token;
|
||||
}
|
||||
token.id = tokenUserId;
|
||||
token.role = fresh.role;
|
||||
token.kitaId = fresh.kitaId;
|
||||
token.familyId = fresh.familyId;
|
||||
}
|
||||
|
||||
const id = token.id ?? token.sub;
|
||||
if (typeof id !== "string" || !id || id.length > 64 || !Number.isSafeInteger(token.sessionVersion) || !Number.isFinite(token.authenticatedAt) || token.authenticatedAt > Date.now() + 60_000) return null;
|
||||
const fresh = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { role: true, kitaId: true, familyId: true, disabledAt: true, sessionVersion: true, emailVerifiedAt: true },
|
||||
});
|
||||
if (!fresh || fresh.disabledAt || !fresh.emailVerifiedAt || fresh.sessionVersion !== token.sessionVersion) return null;
|
||||
token.id = id;
|
||||
token.role = fresh.role;
|
||||
token.kitaId = fresh.kitaId;
|
||||
token.familyId = fresh.familyId;
|
||||
return token;
|
||||
},
|
||||
|
||||
@@ -131,7 +132,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
*/
|
||||
async session({ session, token }) {
|
||||
if (token && session.user) {
|
||||
session.user.id = token.id ?? token.sub;
|
||||
session.user.id = token.id;
|
||||
session.user.sessionVersion = token.sessionVersion;
|
||||
session.user.authenticatedAt = token.authenticatedAt;
|
||||
session.user.role = token.role;
|
||||
session.user.kitaId = token.kitaId;
|
||||
session.user.familyId = token.familyId;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Script from "next/script";
|
||||
|
||||
import { marketingFeatureSlugs } from "@/lib/marketing/features";
|
||||
@@ -6,30 +10,55 @@ const publicAnalyticsPaths = new Set([
|
||||
"/",
|
||||
"/impressum",
|
||||
"/datenschutz",
|
||||
"/login",
|
||||
"/register",
|
||||
...marketingFeatureSlugs.map((slug) => `/features/${slug}`),
|
||||
]);
|
||||
|
||||
export function UmamiAnalytics() {
|
||||
declare global { interface Window { kitaPlanerAnalyticsLoaded?: boolean } }
|
||||
|
||||
export function UmamiAnalytics({ nonce, enabled }: { nonce?: string; enabled: boolean }) {
|
||||
const pathname = usePathname();
|
||||
useEffect(() => {
|
||||
// A full document change discards an SDK already loaded on a public page.
|
||||
if ((!enabled || !publicAnalyticsPaths.has(pathname)) && window.kitaPlanerAnalyticsLoaded) {
|
||||
window.location.replace(window.location.href);
|
||||
return;
|
||||
}
|
||||
if (!enabled || !publicAnalyticsPaths.has(pathname)) return;
|
||||
// Auth and token pages get a fresh document before any private data is fetched.
|
||||
const navigate = (event: MouseEvent) => {
|
||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
const link = event.target instanceof Element ? event.target.closest("a[href]") : null;
|
||||
if (!(link instanceof HTMLAnchorElement)) return;
|
||||
const destination = new URL(link.href, window.location.origin);
|
||||
if (destination.origin === window.location.origin && !publicAnalyticsPaths.has(destination.pathname)) {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
window.location.assign(destination.href);
|
||||
}
|
||||
};
|
||||
document.addEventListener("click", navigate, true);
|
||||
return () => document.removeEventListener("click", navigate, true);
|
||||
}, [pathname, enabled]);
|
||||
const scriptUrl = process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL;
|
||||
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
|
||||
const domains = process.env.NEXT_PUBLIC_UMAMI_DOMAINS;
|
||||
|
||||
if (!scriptUrl || !websiteId) {
|
||||
if (!enabled || !scriptUrl || !websiteId || !publicAnalyticsPaths.has(pathname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script id="umami-before-send" strategy="afterInteractive">
|
||||
<Script nonce={nonce} id="umami-before-send" strategy="afterInteractive">
|
||||
{`
|
||||
window.kitaPlanerAnalyticsLoaded = true;
|
||||
window.kitaPlanerUmamiBeforeSend = function (_type, payload) {
|
||||
try {
|
||||
var url = new URL(payload && payload.url ? payload.url : window.location.href, window.location.origin);
|
||||
var path = url.pathname.length > 1 && url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname;
|
||||
var allowedPaths = ${JSON.stringify(Array.from(publicAnalyticsPaths))};
|
||||
return allowedPaths.indexOf(path) === -1 ? false : payload;
|
||||
if (allowedPaths.indexOf(path) === -1) return false;
|
||||
var clean = Object.assign({}, payload, { url: url.origin + path, referrer: "" });
|
||||
return clean;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
@@ -37,6 +66,7 @@ export function UmamiAnalytics() {
|
||||
`}
|
||||
</Script>
|
||||
<Script
|
||||
nonce={nonce}
|
||||
src={scriptUrl}
|
||||
strategy="afterInteractive"
|
||||
data-website-id={websiteId}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export class UserFacingError extends Error {}
|
||||
export function publicActionError(error: unknown, fallback: string): string {
|
||||
return error instanceof UserFacingError ? error.message : fallback;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NotdienstAlertStatus } from "@prisma/client";
|
||||
import { hashToken } from "@/lib/tokens";
|
||||
export function activeAlertTokenWhere(token: string) {
|
||||
const today = new Date(); today.setUTCHours(0, 0, 0, 0);
|
||||
return {
|
||||
confirmationToken: { in: [hashToken(token), ...(token.length === 36 ? [token] : [])] },
|
||||
status: NotdienstAlertStatus.PENDING,
|
||||
triggeredAt: { gt: new Date(Date.now() - 24 * 60 * 60_000) },
|
||||
assignment: { date: { gte: today } },
|
||||
parentUser: { disabledAt: null },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
|
||||
export function attachmentDirectory(): string {
|
||||
// Preserve the existing storage layout, including explicitly configured roots.
|
||||
return path.resolve(process.env.UPLOAD_DIR ?? "uploads", "announcements");
|
||||
}
|
||||
export function attachmentPath(id: string): string {
|
||||
if (!/^(?:[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}|c[a-z0-9]{24,32})$/i.test(id)) throw new Error("Invalid attachment ID");
|
||||
return path.join(attachmentDirectory(), id);
|
||||
}
|
||||
export function detectAttachmentType(bytes: Buffer): string | null {
|
||||
if (bytes.length < 12 || bytes.length > MAX_ATTACHMENT_SIZE) return null;
|
||||
if (bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10])) &&
|
||||
bytes.subarray(-12).equals(Buffer.from([0,0,0,0,73,69,78,68,174,66,96,130]))) return "image/png";
|
||||
if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255 && bytes.subarray(-2).equals(Buffer.from([255,217]))) return "image/jpeg";
|
||||
if (bytes.subarray(0, 5).toString("ascii") === "%PDF-" && bytes.subarray(-1024).toString("ascii").trimEnd().endsWith("%%EOF")) return "application/pdf";
|
||||
return null;
|
||||
}
|
||||
export function attachmentExtensionMatches(name: string, type: string): boolean {
|
||||
const extension = path.extname(name).toLowerCase();
|
||||
return type === "image/jpeg" ? [".jpg", ".jpeg"].includes(extension) :
|
||||
type === "image/png" ? extension === ".png" : type === "application/pdf" && extension === ".pdf";
|
||||
}
|
||||
export function attachmentDisposition(name: string): string {
|
||||
const safe = name.replace(/[\r\n]/g, "").slice(0, 160);
|
||||
const encoded = encodeURIComponent(safe).replace(/['()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||
return `attachment; filename="${safe.replace(/[^\w.\-]/g, "_")}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
|
||||
export async function removeAttachmentFiles(ids: string[]): Promise<void> {
|
||||
await Promise.all(ids.map(async id => {
|
||||
try { await rm(attachmentPath(id), { force: true }); }
|
||||
catch (error) { console.error("Attachment cleanup failed; run the orphan cleanup command", id, error); }
|
||||
}));
|
||||
}
|
||||
+17
-23
@@ -1,3 +1,4 @@
|
||||
import { cache } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Session } from "next-auth";
|
||||
import { UserRole } from "@prisma/client";
|
||||
@@ -28,12 +29,14 @@ export type KitaSession = AuthenticatedSession & {
|
||||
user: AuthenticatedSession["user"] & { kitaId: string };
|
||||
};
|
||||
|
||||
const readRequestSession = cache(() => auth());
|
||||
|
||||
export async function getSession(): Promise<Session | null> {
|
||||
return auth();
|
||||
return readRequestSession();
|
||||
}
|
||||
|
||||
export async function requireSession(): Promise<AuthenticatedSession> {
|
||||
const session = await auth();
|
||||
const session = await getSession();
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login");
|
||||
}
|
||||
@@ -46,17 +49,10 @@ export async function requireSession(): Promise<AuthenticatedSession> {
|
||||
* für alle Tenant-gebundenen Server Actions / Page Components.
|
||||
*/
|
||||
export async function requireKitaSession(): Promise<KitaSession> {
|
||||
if (process.env.TEST_MODE === "true") {
|
||||
return {
|
||||
user: {
|
||||
id: process.env.TEST_USER_ID || "test-user-id",
|
||||
role: (process.env.TEST_USER_ROLE as UserRole) || UserRole.ELTERN,
|
||||
kitaId: process.env.TEST_KITA_ID || "test-kita-id",
|
||||
familyId: process.env.TEST_FAMILY_ID || "test-family-id",
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
const session = await requireSession();
|
||||
return enforceKitaSession(await requireSession());
|
||||
}
|
||||
|
||||
async function enforceKitaSession(session: AuthenticatedSession): Promise<KitaSession> {
|
||||
|
||||
if (!session.user.kitaId) {
|
||||
// Superadmins haben keine Kita → eigene Oberfläche.
|
||||
@@ -80,20 +76,11 @@ export async function requireKitaSession(): Promise<KitaSession> {
|
||||
export async function requireRole(
|
||||
allowed: UserRole[],
|
||||
): Promise<AuthenticatedSession> {
|
||||
if (process.env.TEST_MODE === "true") {
|
||||
return {
|
||||
user: {
|
||||
id: process.env.TEST_USER_ID || "test-user-id",
|
||||
role: (process.env.TEST_USER_ROLE as UserRole) || UserRole.ADMIN,
|
||||
kitaId: process.env.TEST_KITA_ID || "test-kita-id",
|
||||
familyId: process.env.TEST_FAMILY_ID || "test-family-id",
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
const session = await requireSession();
|
||||
if (!allowed.includes(session.user.role)) {
|
||||
redirect("/forbidden");
|
||||
}
|
||||
if (session.user.role !== UserRole.SUPERADMIN) return enforceKitaSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -113,3 +100,10 @@ async function consentCheck(userId: string | undefined): Promise<boolean> {
|
||||
});
|
||||
return !!user?.privacyPolicyAcceptedAt;
|
||||
}
|
||||
|
||||
// Route handlers use this non-redirecting counterpart of the same consent gate.
|
||||
export async function getKitaSession(): Promise<KitaSession | null> {
|
||||
const session = await getSession();
|
||||
if (!session?.user?.id || !session.user.kitaId || !(await consentCheck(session.user.id))) return null;
|
||||
return session as KitaSession;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
export const dateOnlySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(value => {
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value;
|
||||
});
|
||||
export function validPlanMonth(year: unknown, month: unknown): boolean {
|
||||
return typeof year === "number" && Number.isInteger(year) && year >= 2020 && year <= 2100 &&
|
||||
typeof month === "number" && Number.isInteger(month) && month >= 1 && month <= 12;
|
||||
}
|
||||
+15
-9
@@ -20,11 +20,11 @@ export function getAppEmailConfigError() {
|
||||
return null;
|
||||
}
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
return "RESEND_API_KEY ist nicht konfiguriert.";
|
||||
return "E-Mail-Versand ist derzeit nicht verfügbar.";
|
||||
}
|
||||
|
||||
if (!process.env.EMAIL_FROM) {
|
||||
return "EMAIL_FROM ist nicht konfiguriert.";
|
||||
return "E-Mail-Versand ist derzeit nicht verfügbar.";
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -37,6 +37,15 @@ export async function sendAppEmail({
|
||||
from = process.env.EMAIL_FROM,
|
||||
replyTo,
|
||||
}: SendAppEmailInput): Promise<SendAppEmailResult> {
|
||||
if (Array.isArray(to)) {
|
||||
let id: string | undefined;
|
||||
for (const recipient of new Set(to)) {
|
||||
const result = await sendAppEmail({ to: recipient, subject, react, from, replyTo });
|
||||
if (!result.success) return result;
|
||||
id = result.id;
|
||||
}
|
||||
return { success: true, id };
|
||||
}
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.log("\n✉️ [DEV EMAIL SIMULATION]");
|
||||
@@ -50,14 +59,14 @@ export async function sendAppEmail({
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: "RESEND_API_KEY ist nicht konfiguriert.",
|
||||
error: "E-Mail-Versand ist derzeit nicht verfügbar.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!from) {
|
||||
return {
|
||||
success: false,
|
||||
error: "EMAIL_FROM ist nicht konfiguriert.",
|
||||
error: "E-Mail-Versand ist derzeit nicht verfügbar.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,7 +84,7 @@ export async function sendAppEmail({
|
||||
console.error("Resend konnte die E-Mail nicht versenden:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message ?? "E-Mail-Versand fehlgeschlagen.",
|
||||
error: "E-Mail-Versand fehlgeschlagen.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,10 +93,7 @@ export async function sendAppEmail({
|
||||
console.error("Unerwarteter Fehler beim E-Mail-Versand:", error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unerwarteter Fehler beim E-Mail-Versand.",
|
||||
error: "E-Mail-Versand fehlgeschlagen.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const passwordSchema = z.string().min(8, "Mindestens 8 Zeichen.")
|
||||
.refine(value => Buffer.byteLength(value, "utf8") <= 72,
|
||||
"Das Passwort darf höchstens 72 UTF-8-Bytes umfassen.");
|
||||
@@ -1,3 +1,6 @@
|
||||
if (process.env.NODE_ENV === "production" && process.env.TEST_MODE === "true") {
|
||||
throw new Error("TEST_MODE is forbidden in production");
|
||||
}
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
// Singleton-Pattern: Im Dev-Modus erzeugt Next.js bei jedem Hot-Reload
|
||||
|
||||
+35
-33
@@ -1,39 +1,41 @@
|
||||
type Bucket = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
const buckets = new Map<string, Bucket>();
|
||||
import { createHash } from "node:crypto";
|
||||
import { isIP } from "node:net";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export type RateLimitResult =
|
||||
| { allowed: true; remaining: number; resetAt: number }
|
||||
| { allowed: false; retryAfterSeconds: number };
|
||||
|
||||
export function rateLimit(
|
||||
key: string,
|
||||
limit: number,
|
||||
windowSeconds: number,
|
||||
): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const windowMs = windowSeconds * 1000;
|
||||
const existing = buckets.get(key);
|
||||
|
||||
if (!existing || existing.resetAt <= now) {
|
||||
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||
return { allowed: true, remaining: limit - 1, resetAt: now + windowMs };
|
||||
}
|
||||
|
||||
if (existing.count >= limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
retryAfterSeconds: Math.ceil((existing.resetAt - now) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
existing.count += 1;
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: limit - existing.count,
|
||||
resetAt: existing.resetAt,
|
||||
};
|
||||
// PostgreSQL performs the increment atomically across all application instances.
|
||||
export async function rateLimit(key: string, limit: number, windowSeconds: number): Promise<RateLimitResult> {
|
||||
const bucket = createHash("sha256").update(key).digest("hex");
|
||||
const [row] = await prisma.$queryRaw<{ count: number; resetAt: Date }[]>`
|
||||
INSERT INTO rate_limit_buckets (key, count, "resetAt")
|
||||
VALUES (${bucket}, 1, NOW() + make_interval(secs => ${windowSeconds}))
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
count = CASE WHEN rate_limit_buckets."resetAt" <= NOW() THEN 1
|
||||
ELSE LEAST(rate_limit_buckets.count + 1, ${limit + 1}) END,
|
||||
"resetAt" = CASE WHEN rate_limit_buckets."resetAt" <= NOW()
|
||||
THEN NOW() + make_interval(secs => ${windowSeconds}) ELSE rate_limit_buckets."resetAt" END
|
||||
RETURNING count, "resetAt"`;
|
||||
// Indexed, bounded cleanup avoids an unbounded memory map or growing stale buckets.
|
||||
await prisma.$executeRaw`DELETE FROM rate_limit_buckets WHERE key IN
|
||||
(SELECT key FROM rate_limit_buckets WHERE "resetAt" < NOW() LIMIT 100)`;
|
||||
return row.count <= limit
|
||||
? { allowed: true, remaining: limit - row.count, resetAt: row.resetAt.getTime() }
|
||||
: { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil((row.resetAt.getTime() - Date.now()) / 1000)) };
|
||||
}
|
||||
|
||||
export async function limitPublicAction(scope: string, identity: string, limit = 5, seconds = 3600): Promise<boolean> {
|
||||
if (!(await rateLimit(`${scope}:burst`, 100, 60)).allowed) return false;
|
||||
if (!(await rateLimit(`${scope}:global`, 1000, seconds)).allowed) return false;
|
||||
if (!(await rateLimit(`${scope}:identity:${identity}`, limit, seconds)).allowed) return false;
|
||||
// Enable only behind a reverse proxy that REPLACES this header. Never trust arbitrary forwarded lists.
|
||||
const header = process.env.TRUSTED_CLIENT_IP_HEADER;
|
||||
if (header && ["x-real-ip", "cf-connecting-ip"].includes(header)) {
|
||||
const ip = (await headers()).get(header)?.trim();
|
||||
if (ip && isIP(ip) && !(await rateLimit(`${scope}:ip:${ip}`, limit * 3, seconds)).allowed) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function safeCallbackUrl(value: string | undefined): string {
|
||||
if (!value || !value.startsWith("/") || value.startsWith("//") || /[\\\x00-\x20]/.test(value)) return "/dashboard";
|
||||
try {
|
||||
const url = new URL(value, "https://kita.invalid");
|
||||
return url.origin === "https://kita.invalid" ? `${url.pathname}${url.search}${url.hash}` : "/dashboard";
|
||||
} catch { return "/dashboard"; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
export function newToken(): string { return randomBytes(32).toString("hex"); }
|
||||
export function isPublicToken(token: unknown): token is string {
|
||||
return typeof token === "string" && /^(?:[a-f0-9]{64}|[a-f0-9-]{36})$/i.test(token);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function serializableTransaction<T>(operation: (tx: Prisma.TransactionClient) => Promise<T>): Promise<T> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try { return await prisma.$transaction(operation, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); }
|
||||
catch (error) {
|
||||
if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== "P2034" || attempt >= 2) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-7
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
@@ -5,7 +6,7 @@ import { auth } from "@/auth";
|
||||
// =====================================================================
|
||||
// Kita-Planer · Proxy (ehem. Middleware, seit Next.js 16 umbenannt)
|
||||
// ---------------------------------------------------------------------
|
||||
// Optimistischer Guard — prüft den JWT-Cookie direkt, ohne DB-Call.
|
||||
// Routing-Guard; auth() validiert das Konto und die Sitzungsfassung gegen die DB.
|
||||
// Der sichere DB-seitige Check (Consent, Rolle) findet in den
|
||||
// jeweiligen Server Components via `requireKitaSession()` statt.
|
||||
//
|
||||
@@ -30,18 +31,18 @@ function getPostLoginPath(user: { role?: string; kitaId?: string | null }) {
|
||||
return user.kitaId ? "/dashboard" : ONBOARDING_ROUTE;
|
||||
}
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
async function routeRequest(request: NextRequest, requestHeaders: Headers) {
|
||||
const { pathname } = request.nextUrl;
|
||||
const isPublicRoute =
|
||||
PUBLIC_PATHS.has(pathname) ||
|
||||
PUBLIC_PREFIX.some((prefix) => pathname.startsWith(prefix));
|
||||
|
||||
if (isPublicRoute) {
|
||||
return NextResponse.next();
|
||||
return NextResponse.next({ request: { headers: requestHeaders } });
|
||||
}
|
||||
|
||||
// Auth.js liest den Session-JWT aus dem Cookie —
|
||||
// kein DB-Call, pure Token-Verifikation (Edge-sicher).
|
||||
// Die Token-Callbacks prüfen auch Sperren und gelöschte Konten.
|
||||
const session = await auth();
|
||||
const user = session?.user;
|
||||
const hasValidUser = !!user?.id;
|
||||
@@ -62,7 +63,7 @@ export async function proxy(request: NextRequest) {
|
||||
if (hasValidUser && user) {
|
||||
// 2a. Eingeloggter User auf Login/Register-Seite → direkt zum Ziel,
|
||||
// damit die öffentliche Startseite stabil indexierbar bleibt.
|
||||
if (pathname === "/login" || pathname === "/register") {
|
||||
if ((pathname === "/login" && request.nextUrl.searchParams.get("reauth") !== "1") || pathname === "/register") {
|
||||
return NextResponse.redirect(
|
||||
new URL(getPostLoginPath(user), request.nextUrl),
|
||||
);
|
||||
@@ -84,12 +85,12 @@ export async function proxy(request: NextRequest) {
|
||||
}
|
||||
|
||||
// 2c. Hat kitaId und will /onboarding aufrufen → direkt ins Dashboard.
|
||||
if (user.kitaId && pathname.startsWith(ONBOARDING_ROUTE)) {
|
||||
if (user.kitaId && pathname === ONBOARDING_ROUTE) {
|
||||
return NextResponse.redirect(new URL("/dashboard", request.nextUrl));
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
return NextResponse.next({ request: { headers: requestHeaders } });
|
||||
}
|
||||
|
||||
// Proxy auf alle sinnvollen Routen anwenden; statische Dateien,
|
||||
@@ -99,3 +100,36 @@ export const config = {
|
||||
"/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
|
||||
],
|
||||
};
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
if (request.method === "POST") {
|
||||
const expectedOrigin = new URL(process.env.AUTH_URL ?? process.env.NEXTAUTH_URL ?? request.nextUrl.origin).origin;
|
||||
if (request.headers.get("origin") !== expectedOrigin) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
const nonce = randomBytes(16).toString("base64");
|
||||
const dev = process.env.NODE_ENV === "development";
|
||||
let analyticsOrigin = "";
|
||||
try { analyticsOrigin = new URL(process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL ?? "").origin; } catch { /* analytics disabled */ }
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${dev ? " 'unsafe-eval'" : ""}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https://images.unsplash.com",
|
||||
"font-src 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'",
|
||||
`connect-src 'self' ${analyticsOrigin}${dev ? " ws: wss:" : ""}`,
|
||||
...(!dev ? ["upgrade-insecure-requests"] : []),
|
||||
].join("; ");
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set("x-nonce", nonce);
|
||||
requestHeaders.set("Content-Security-Policy", csp);
|
||||
const response = await routeRequest(request, requestHeaders);
|
||||
response.headers.set("Content-Security-Policy", csp);
|
||||
response.headers.set("Referrer-Policy", "no-referrer");
|
||||
if (request.nextUrl.pathname.startsWith("/alert/") || request.nextUrl.pathname.startsWith("/invite/") || request.nextUrl.pathname.startsWith("/register/verify/") || PROTECTED_PREFIX.some(p => request.nextUrl.pathname.startsWith(p))) {
|
||||
response.headers.set("Cache-Control", "private, no-store");
|
||||
response.headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
Vendored
+6
@@ -15,6 +15,8 @@ import type { UserRole } from "@prisma/client";
|
||||
declare module "next-auth" {
|
||||
interface User extends DefaultUser {
|
||||
id: string;
|
||||
sessionVersion: number;
|
||||
authenticatedAt: number;
|
||||
role: UserRole;
|
||||
kitaId: string | null;
|
||||
familyId: string | null;
|
||||
@@ -23,6 +25,8 @@ declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
sessionVersion: number;
|
||||
authenticatedAt: number;
|
||||
role: UserRole;
|
||||
kitaId: string | null;
|
||||
familyId: string | null;
|
||||
@@ -36,6 +40,8 @@ declare module "next-auth" {
|
||||
declare module "@auth/core/jwt" {
|
||||
interface JWT {
|
||||
id: string;
|
||||
sessionVersion: number;
|
||||
authenticatedAt: number;
|
||||
role: UserRole;
|
||||
kitaId: string | null;
|
||||
familyId: string | null;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports -- Standalone Node integration test. */
|
||||
const assert = require('node:assert/strict');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { hash } = require('bcryptjs');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const origin = 'http://localhost:3057';
|
||||
const testUrl = process.env.SECURITY_TEST_DATABASE_URL;
|
||||
if (!testUrl || !['127.0.0.1','localhost'].includes(new URL(testUrl).hostname) || !new URL(testUrl).pathname.endsWith('/kita_security_test')) throw new Error('Use only the dedicated local test database.');
|
||||
const prisma = new PrismaClient({ datasourceUrl: testUrl });
|
||||
const prefix = `http-security-${randomUUID()}`;
|
||||
let attachmentId;
|
||||
const upload = path.resolve(process.env.UPLOAD_DIR, 'announcements');
|
||||
let checks = 0;
|
||||
function checked() { checks++; }
|
||||
async function login(email, password) {
|
||||
const jar = new Map();
|
||||
function cookies(response) {
|
||||
for (const cookie of response.headers.getSetCookie()) {
|
||||
const item = cookie.split(';')[0]; const index = item.indexOf('=');
|
||||
jar.set(item.slice(0,index), item.slice(index + 1));
|
||||
}
|
||||
}
|
||||
const csrf = await fetch(origin + '/api/auth/csrf'); cookies(csrf);
|
||||
const { csrfToken } = await csrf.json();
|
||||
const response = await fetch(origin + '/api/auth/callback/credentials', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: origin,
|
||||
Cookie: [...jar].map(([key,value]) => `${key}=${value}`).join('; '), 'X-Auth-Return-Redirect': '1' },
|
||||
body: new URLSearchParams({ csrfToken, email, password, callbackUrl: origin + '/dashboard' }), redirect: 'manual',
|
||||
});
|
||||
cookies(response);
|
||||
return [...jar].map(([key,value]) => `${key}=${value}`).join('; ');
|
||||
}
|
||||
async function main() {
|
||||
const password = 'Local-http-test-password';
|
||||
const passwordHash = await hash(password, 12);
|
||||
const kitaA = await prisma.kita.create({ data: { name: prefix + '-A', slug: prefix + '-a' } });
|
||||
const kitaB = await prisma.kita.create({ data: { name: prefix + '-B', slug: prefix + '-b' } });
|
||||
const makeUser = (kita,label) => prisma.user.create({ data: { email: `${label}.${prefix}@example.invalid`, firstName: label, lastName: 'HTTP', kitaId: kita.id,
|
||||
role: 'ADMIN', passwordHash, emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } });
|
||||
const userA = await makeUser(kitaA,'a'); const userB = await makeUser(kitaB,'b');
|
||||
const page = await fetch(origin + '/login');
|
||||
const html = await page.text();
|
||||
assert.equal(page.status, 200); checked();
|
||||
assert.equal(page.headers.get('x-content-type-options'), 'nosniff'); checked();
|
||||
assert.equal(page.headers.get('x-frame-options'), 'DENY'); checked();
|
||||
assert.equal(page.headers.get('referrer-policy'), 'no-referrer'); checked();
|
||||
const csp = page.headers.get('content-security-policy');
|
||||
assert.ok(csp.includes("'strict-dynamic'") && !csp.includes("'unsafe-eval'")); checked();
|
||||
const nonce = csp.match(/'nonce-([^']+)'/)[1];
|
||||
assert.ok(html.includes(`nonce="${nonce}"`)); checked();
|
||||
assert.ok(!html.includes('umami-before-send')); checked();
|
||||
for (const headers of [ {}, { Origin: 'https://evil.invalid' }, { Origin: 'https://evil.invalid', 'x-forwarded-host': 'evil.invalid' } ]) {
|
||||
const blocked = await fetch(origin + '/register', { method: 'POST', headers: { ...headers, 'Next-Action': 'invalid-test-action' }, body: '[]', redirect: 'manual' });
|
||||
assert.equal(blocked.status,403); checked();
|
||||
}
|
||||
for (const url of ['/api/announcements/attachments/invalid', 'https://images.unsplash.com/unknown-image']) {
|
||||
const optimized = await fetch(origin + '/_next/image?' + new URLSearchParams({ url, w: '640', q: '75' }));
|
||||
assert.equal(optimized.status,400); checked();
|
||||
}
|
||||
const cookieA = await login(userA.email,password);
|
||||
const cookieB = await login(userB.email,password);
|
||||
const getSession = async cookie => (await fetch(origin + '/api/auth/session', { headers: { Cookie: cookie } })).json();
|
||||
assert.equal((await getSession(cookieA))?.user.id, userA.id); checked();
|
||||
assert.equal((await fetch(origin + '/admin', { headers: { Cookie: cookieA }, redirect: 'manual' })).status, 307); checked();
|
||||
assert.equal((await fetch(origin + '/dashboard', { redirect: 'manual' })).status, 307); checked();
|
||||
const privatePage = await fetch(origin + '/dashboard', { headers: { Cookie: cookieA }, redirect: 'manual' });
|
||||
assert.equal(privatePage.status, 200); checked();
|
||||
assert.match(privatePage.headers.get('cache-control'), /no-store/); checked();
|
||||
attachmentId = randomUUID();
|
||||
await prisma.announcement.create({ data: { kitaId: kitaB.id, authorId: userB.id, title: 'Private attachment', content: 'Private',
|
||||
attachments: { create: { id: attachmentId, fileName: 'bad.html', fileType: 'text/html', fileUrl: '/api/' + attachmentId } } } });
|
||||
await fs.mkdir(upload, { recursive: true }); await fs.writeFile(path.join(upload,attachmentId), '<script>bad()</script>');
|
||||
const attachment = cookie => fetch(origin + '/api/announcements/attachments/' + attachmentId, { headers: { Cookie: cookie } });
|
||||
assert.equal((await attachment(cookieA)).status, 404); checked();
|
||||
const own = await attachment(cookieB); assert.equal(own.status,200); checked();
|
||||
assert.equal(own.headers.get('content-type'),'application/octet-stream'); checked();
|
||||
assert.match(own.headers.get('content-disposition'),/^attachment;/); checked();
|
||||
assert.equal(own.headers.get('content-security-policy'),"default-src 'none'; sandbox"); checked();
|
||||
await prisma.user.update({ where: { id: userB.id }, data: { privacyPolicyAcceptedAt: null } });
|
||||
assert.equal((await attachment(cookieB)).status,401); checked();
|
||||
await prisma.user.update({ where: { id: userA.id }, data: { disabledAt: new Date(), sessionVersion: { increment: 1 } } });
|
||||
assert.equal((await getSession(cookieA))?.user,undefined); checked();
|
||||
await prisma.user.update({ where: { id: userA.id }, data: { disabledAt: null } });
|
||||
assert.equal((await getSession(cookieA))?.user,undefined); checked();
|
||||
const newCookie = await login(userA.email,password);
|
||||
assert.equal((await getSession(newCookie))?.user.id,userA.id); checked();
|
||||
await prisma.user.delete({ where: { id: userA.id } });
|
||||
assert.equal((await getSession(newCookie))?.user,undefined); checked();
|
||||
// Auth.js must reject trusted-but-obsolete token payloads rather than constructing a ghost session.
|
||||
const { encode } = await import('@auth/core/jwt');
|
||||
for (const payload of [{ role: 'ADMIN', kitaId: kitaA.id }, { id: userB.id, role: 'ADMIN', kitaId: kitaB.id },
|
||||
{ id: 123, sessionVersion: 0, authenticatedAt: Date.now() },
|
||||
{ id: 'deleted-' + prefix, role: 'ADMIN', kitaId: kitaA.id, sessionVersion: 0, authenticatedAt: Date.now() }]) {
|
||||
const jwt = await encode({ token: payload, secret: 'security-test-only-not-a-production-secret', salt: 'authjs.session-token' });
|
||||
assert.equal((await getSession('authjs.session-token=' + jwt))?.user,undefined); checked();
|
||||
}
|
||||
console.log(`${checks} HTTP security checks passed against the production build.`);
|
||||
}
|
||||
main().finally(async () => {
|
||||
if (attachmentId) await fs.rm(path.join(upload,attachmentId),{ force: true });
|
||||
await prisma.kita.deleteMany({ where: { slug: { startsWith: prefix } } });
|
||||
await prisma.$disconnect();
|
||||
}).catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,378 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports -- CommonJS is required by the isolated TypeScript module loader. */
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const { PrismaClient, UserRole } = require('@prisma/client');
|
||||
const { hash, compare } = require('bcryptjs');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
|
||||
// Execute the actual application code. Only the Next request/UI boundary and
|
||||
// outgoing email transport are replaced. Database queries and bcrypt are real.
|
||||
function loader(overrides = {}) {
|
||||
const cache = new Map();
|
||||
function load(file) {
|
||||
file = path.resolve(file);
|
||||
if (cache.has(file)) return cache.get(file).exports;
|
||||
const mod = { exports: {} }; cache.set(file, mod);
|
||||
const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true },
|
||||
fileName: file,
|
||||
}).outputText;
|
||||
const req = spec => {
|
||||
if (Object.hasOwn(overrides, spec)) return overrides[spec];
|
||||
if (spec === '@/lib/prisma') return { prisma };
|
||||
if (spec === 'next/headers') return { headers: async () => new Headers() };
|
||||
if (spec === 'next/cache') return { revalidatePath() {} };
|
||||
if (spec === 'next/navigation') return { redirect(url) { throw new Error(`REDIRECT:${url}`); }, notFound() { throw new Error('NOT_FOUND'); } };
|
||||
if (spec.startsWith('@/') || spec.startsWith('.')) {
|
||||
const target = spec.startsWith('@/') ? path.resolve('src', spec.slice(2)) : path.resolve(path.dirname(file), spec);
|
||||
for (const suffix of ['.ts', '.tsx', '/index.ts']) if (fs.existsSync(target + suffix)) return load(target + suffix);
|
||||
}
|
||||
return require(spec);
|
||||
};
|
||||
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, { filename: file })(req, mod, mod.exports);
|
||||
return mod.exports;
|
||||
}
|
||||
return load;
|
||||
}
|
||||
let prisma, kitaA, kitaB, familyA, familyB, parentA, parentB, adminA, founder, superadmin, childA, childB, passHash;
|
||||
const prefix = `security-${randomUUID()}`;
|
||||
const emails = [];
|
||||
const session = user => ({ user: { id: user.id, kitaId: user.kitaId, familyId: user.familyId, role: user.role, email: user.email, sessionVersion: user.sessionVersion, authenticatedAt: Date.now() } });
|
||||
const fields = data => { const form = new FormData(); for (const [key, value] of Object.entries(data)) form.set(key, value); return form; };
|
||||
const emailMock = { getAppEmailConfigError: () => null, sendAppEmail: async input => { emails.push(input); return { success: true }; } };
|
||||
const noAuth = { auth: async () => null, signIn: async () => {} };
|
||||
let authConfig;
|
||||
before(async () => {
|
||||
const url = process.env.SECURITY_TEST_DATABASE_URL;
|
||||
if (!url || !['127.0.0.1','localhost'].includes(new URL(url).hostname) || !new URL(url).pathname.endsWith('/kita_security_test')) {
|
||||
throw new Error('Set SECURITY_TEST_DATABASE_URL to the dedicated local kita_security_test database; never use application data.');
|
||||
}
|
||||
prisma = new PrismaClient({ datasourceUrl: url });
|
||||
passHash = await hash('Safe-test-password', 12);
|
||||
kitaA = await prisma.kita.create({ data: { name: prefix + '-A', slug: prefix + '-a' } });
|
||||
kitaB = await prisma.kita.create({ data: { name: prefix + '-B', slug: prefix + '-b' } });
|
||||
familyA = await prisma.family.create({ data: { kitaId: kitaA.id, name: 'A' } });
|
||||
familyB = await prisma.family.create({ data: { kitaId: kitaB.id, name: 'B' } });
|
||||
const makeUser = (label, role, kitaId, familyId) => prisma.user.create({ data: { email: `${label}.${prefix}@example.invalid`, firstName: label, lastName: 'Test', role, kitaId, familyId, passwordHash: passHash, emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } });
|
||||
parentA = await makeUser('parent-a', UserRole.ELTERN, kitaA.id, familyA.id);
|
||||
parentB = await makeUser('parent-b', UserRole.ELTERN, kitaB.id, familyB.id);
|
||||
adminA = await makeUser('admin-a', UserRole.ADMIN, kitaA.id, null);
|
||||
founder = await makeUser('founder', UserRole.ADMIN, null, null);
|
||||
superadmin = await makeUser('superadmin', UserRole.SUPERADMIN, null, null);
|
||||
childA = await prisma.child.create({ data: { kitaId: kitaA.id, familyId: familyA.id, firstName: 'A', lastName: 'Test' } });
|
||||
childB = await prisma.child.create({ data: { kitaId: kitaB.id, familyId: familyB.id, firstName: 'B', lastName: 'Test' } });
|
||||
loader({ 'next-auth': config => { authConfig = config; return {}; }, 'next-auth/providers/credentials': options => options })('src/auth.ts');
|
||||
});
|
||||
after(async () => {
|
||||
if (!prisma) return;
|
||||
await prisma.verificationToken.deleteMany({ where: { identifier: { in: [parentA?.id, parentB?.id].filter(Boolean) } } });
|
||||
await prisma.pendingRegistration.deleteMany({ where: { email: { contains: prefix } } });
|
||||
await prisma.kita.deleteMany({ where: { slug: { startsWith: prefix } } });
|
||||
await prisma.user.deleteMany({ where: { email: { contains: prefix } } });
|
||||
await prisma.auditLog.deleteMany({ where: { actorEmail: { contains: prefix } } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
test('bcrypt limit counts UTF-8 bytes, preventing equivalent long passwords', async () => {
|
||||
const { passwordSchema } = loader()('src/lib/password.ts');
|
||||
assert.equal(passwordSchema.safeParse('😀'.repeat(18)).success, true);
|
||||
assert.equal(passwordSchema.safeParse('😀'.repeat(18) + 'A').success, false);
|
||||
assert.equal(passwordSchema.safeParse('a'.repeat(73)).success, false);
|
||||
assert.equal(await compare('😀'.repeat(18) + 'B', await hash('😀'.repeat(18) + 'A', 4)), true);
|
||||
});
|
||||
test('redirects reject external, backslash and control-character destinations', () => {
|
||||
const { safeCallbackUrl } = loader()('src/lib/safe-redirect.ts');
|
||||
for (const bad of ['//evil.invalid','/\\evil.invalid','https://evil.invalid','/\nevil']) assert.equal(safeCallbackUrl(bad), '/dashboard');
|
||||
assert.equal(safeCallbackUrl('/admin?view=1'), '/admin?view=1');
|
||||
});
|
||||
test('invalid calendar dates and planning months are rejected', () => {
|
||||
const { dateOnlySchema, validPlanMonth } = loader()('src/lib/date-schema.ts');
|
||||
for (const bad of ['2026-02-31','2026-13-01','9999-99-99','garbage']) assert.equal(dateOnlySchema.safeParse(bad).success, false);
|
||||
assert.equal(dateOnlySchema.safeParse('2028-02-29').success, true);
|
||||
assert.equal(validPlanMonth(2026, 13), false); assert.equal(validPlanMonth(2026.5, 1), false);
|
||||
});
|
||||
test('upload checks reject HTML disguised as JPG and path traversal', () => {
|
||||
const files = loader()('src/lib/attachments.ts');
|
||||
assert.equal(files.detectAttachmentType(Buffer.from('<html><script>alert(1)</script></html>')), null);
|
||||
assert.throws(() => files.attachmentPath('../../etc/passwd'));
|
||||
assert.equal(files.attachmentExtensionMatches('attack.jpg', 'application/pdf'), false);
|
||||
assert.match(files.attachmentDisposition('a\r\nX-Injected: yes.pdf'), /^attachment;/);
|
||||
assert.doesNotMatch(files.attachmentDisposition('a\r\nX-Injected: yes.pdf'), /[\r\n]/);
|
||||
});
|
||||
test('rate limiting is atomic under concurrent requests and expires correctly', async () => {
|
||||
const { rateLimit } = loader()('src/lib/rate-limit.ts');
|
||||
const results = await Promise.all(Array.from({ length: 20 }, () => rateLimit(prefix + ':parallel', 5, 60)));
|
||||
assert.equal(results.filter(result => result.allowed).length, 5);
|
||||
assert.equal((await rateLimit(prefix + ':parallel', 5, 60)).allowed, false);
|
||||
const { createHash } = require('node:crypto');
|
||||
await prisma.rateLimitBucket.update({ where: { key: createHash('sha256').update(prefix + ':parallel').digest('hex') }, data: { resetAt: new Date(0) } });
|
||||
assert.equal((await rateLimit(prefix + ':parallel', 5, 60)).allowed, true);
|
||||
});
|
||||
test('JWT callback rejects deleted, malformed and old-format sessions', async () => {
|
||||
assert.equal(await authConfig.callbacks.jwt({ token: {} }), null);
|
||||
assert.equal(await authConfig.callbacks.jwt({ token: { id: parentA.id } }), null);
|
||||
assert.equal(await authConfig.callbacks.jwt({ token: { id: 'missing-' + prefix, sessionVersion: 0, authenticatedAt: Date.now() } }), null);
|
||||
});
|
||||
test('administrative lock and unlock permanently revoke existing sessions', async () => {
|
||||
const admitted = await authConfig.providers[0].authorize({ email: parentA.email, password: 'Safe-test-password' });
|
||||
assert.equal(admitted.id, parentA.id);
|
||||
const token = await authConfig.callbacks.jwt({ token: {}, user: admitted });
|
||||
const admin = loader({ '@/auth': { auth: async () => session(superadmin) } })('src/app/admin/actions.ts');
|
||||
assert.equal((await admin.lockUser(parentA.id)).ok, true);
|
||||
assert.equal(await authConfig.callbacks.jwt({ token: { ...token } }), null);
|
||||
assert.equal(await authConfig.providers[0].authorize({ email: parentA.email, password: 'Safe-test-password' }), null);
|
||||
assert.equal((await admin.unlockUser(parentA.id)).ok, true);
|
||||
assert.equal(await authConfig.callbacks.jwt({ token: { ...token } }), null);
|
||||
const relogin = await authConfig.providers[0].authorize({ email: parentA.email, password: 'Safe-test-password' });
|
||||
assert.equal(relogin.sessionVersion, token.sessionVersion + 1);
|
||||
parentA = await prisma.user.findUnique({ where: { id: parentA.id } });
|
||||
});
|
||||
test('parallel incorrect passwords cannot lose failed-login increments', async () => {
|
||||
await Promise.all(Array.from({ length: 8 }, () => authConfig.providers[0].authorize({ email: parentB.email, password: 'wrong' })));
|
||||
const user = await prisma.user.findUnique({ where: { id: parentB.id } });
|
||||
assert.equal(user.failedLoginAttempts, 8);
|
||||
await prisma.user.update({ where: { id: parentB.id }, data: { failedLoginAttempts: 0 } });
|
||||
});
|
||||
test('normal roles require consent and tenant; TEST_MODE never creates a fake identity', async () => {
|
||||
await prisma.user.update({ where: { id: adminA.id }, data: { privacyPolicyAcceptedAt: null } });
|
||||
const util = loader({ '@/auth': { auth: async () => session(adminA) } })('src/lib/auth-utils.ts');
|
||||
await assert.rejects(util.requireRole([UserRole.ADMIN]), /REDIRECT:\/onboarding\/consent/);
|
||||
assert.equal(await util.getKitaSession(), null);
|
||||
await prisma.user.update({ where: { id: adminA.id }, data: { privacyPolicyAcceptedAt: new Date() } });
|
||||
const old = process.env.TEST_MODE; process.env.TEST_MODE = 'true';
|
||||
try { await assert.rejects(loader({ '@/auth': noAuth })('src/lib/auth-utils.ts').requireRole([UserRole.SUPERADMIN]), /REDIRECT:\/login/); }
|
||||
finally { if (old === undefined) delete process.env.TEST_MODE; else process.env.TEST_MODE = old; }
|
||||
});
|
||||
test('onboarding denies missing IDs, global admins and unverified founders', async () => {
|
||||
const payload = fields({ kitaName: 'Test', notdienstMinPerChildPerMonth: '1' });
|
||||
for (const bad of [null, { user: { role: UserRole.ADMIN } }, session(superadmin)]) {
|
||||
const action = loader({ '@/auth': { auth: async () => bad } })('src/app/onboarding/actions.ts').completeOnboardingAction;
|
||||
await assert.rejects(action({ attempt: 0 }, payload), /REDIRECT:/);
|
||||
}
|
||||
await prisma.user.update({ where: { id: founder.id }, data: { emailVerifiedAt: null } });
|
||||
await assert.rejects(loader({ '@/auth': { auth: async () => session(founder) } })('src/app/onboarding/actions.ts').completeOnboardingAction({ attempt: 0 }, payload), /REDIRECT:\/register/);
|
||||
await prisma.user.update({ where: { id: founder.id }, data: { emailVerifiedAt: new Date() } });
|
||||
});
|
||||
test('database blocks cross-tenant child, user, RSVP and announcement links', async () => {
|
||||
await assert.rejects(prisma.child.create({ data: { firstName: 'Wrong', lastName: 'Tenant', kitaId: kitaA.id, familyId: familyB.id } }), error => error.code === 'P2003');
|
||||
await assert.rejects(prisma.user.update({ where: { id: parentA.id }, data: { familyId: familyB.id } }), error => error.code === 'P2003');
|
||||
await assert.rejects(prisma.announcement.create({ data: { kitaId: kitaA.id, authorId: parentB.id, title: 'Wrong', content: 'Wrong' } }), error => error.code === 'P2003');
|
||||
const event = await prisma.termin.create({ data: { kitaId: kitaA.id, title: 'Event', type: 'SONSTIGES', status: 'CONFIRMED', startDate: new Date(), endDate: new Date() } });
|
||||
await assert.rejects(prisma.eventParticipation.create({ data: { kitaId: kitaA.id, eventId: event.id, childId: childB.id, status: 'ATTENDING' } }), error => error.code === 'P2003');
|
||||
});
|
||||
test('download route rejects foreign attachments and requires consent', async () => {
|
||||
const { writeFile, mkdir } = require('node:fs/promises');
|
||||
const files = loader()('src/lib/attachments.ts');
|
||||
const id = randomUUID();
|
||||
const announcement = await prisma.announcement.create({ data: { kitaId: kitaB.id, authorId: parentB.id, title: 'Private', content: 'Private', attachments: { create: { id, fileName: 'unsafe.html', fileType: 'text/html', fileUrl: '/api/' + id } } } });
|
||||
await mkdir(files.attachmentDirectory(), { recursive: true });
|
||||
await writeFile(files.attachmentPath(id), '<script>secret()</script>');
|
||||
try {
|
||||
const load = user => loader({ '@/auth': { auth: async () => session(user) } })('src/app/api/announcements/attachments/[id]/route.ts').GET(new Request('http://localhost'), { params: Promise.resolve({ id }) });
|
||||
assert.equal((await load(parentA)).status, 404);
|
||||
const response = await load(parentB);
|
||||
assert.equal(response.status, 200); assert.equal(response.headers.get('content-type'), 'application/octet-stream');
|
||||
assert.match(response.headers.get('content-disposition'), /^attachment;/);
|
||||
assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); assert.equal(response.headers.get('cache-control'), 'private, no-store');
|
||||
await prisma.user.update({ where: { id: parentB.id }, data: { privacyPolicyAcceptedAt: null } });
|
||||
assert.equal((await load(parentB)).status, 401);
|
||||
} finally {
|
||||
await files.removeAttachmentFiles([id]);
|
||||
await prisma.user.update({ where: { id: parentB.id }, data: { privacyPolicyAcceptedAt: new Date() } });
|
||||
await prisma.announcement.delete({ where: { id: announcement.id } });
|
||||
}
|
||||
});
|
||||
test('registration has no user before mail verification and token can be consumed once', async () => {
|
||||
const email = `registration.${prefix}@example.invalid`;
|
||||
const load = loader({ '@/auth': noAuth, '@/lib/mail': emailMock });
|
||||
const registration = load('src/app/register/actions.ts');
|
||||
const result = await registration.registerAction({ attempt: 0 }, fields({ email, firstName: 'Founder', lastName: 'Test', acceptPrivacyPolicy: 'on' }));
|
||||
assert.ok(result.message); assert.equal(await prisma.user.findUnique({ where: { email } }), null);
|
||||
const message = emails.at(-1); const link = message.react.props.children[1].props.href; const token = link.split('/').at(-1);
|
||||
const draft = await prisma.pendingRegistration.findUnique({ where: { email } });
|
||||
assert.notEqual(draft.tokenHash, token);
|
||||
const verify = load('src/app/register/verify/[token]/actions.ts').verifyRegistrationAction;
|
||||
const form = () => fields({ token, password: 'Owner-chosen-password', confirmPassword: 'Owner-chosen-password', acceptPrivacyPolicy: 'on' });
|
||||
const results = await Promise.all([verify({}, form()), verify({}, form())]);
|
||||
assert.equal(results.filter(result => !result.error).length, 1);
|
||||
const user = await prisma.user.findUnique({ where: { email } }); assert.ok(user.emailVerifiedAt); assert.equal(user.kitaId, null);
|
||||
assert.equal(await compare('Owner-chosen-password', user.passwordHash), true);
|
||||
assert.ok((await verify({}, form())).error);
|
||||
});
|
||||
test('invite activation uses a hashed expiring token and rejects concurrent reuse', async () => {
|
||||
const load = loader({ '@/auth': noAuth }); const { newToken, hashToken } = load('src/lib/tokens.ts');
|
||||
const token = newToken(); const email = `invite.${prefix}@example.invalid`;
|
||||
const user = await prisma.user.create({ data: { email, firstName: 'Invited', lastName: 'Test', kitaId: kitaA.id, familyId: familyA.id, passwordHash: '' } });
|
||||
await prisma.verificationToken.create({ data: { token: hashToken(token), identifier: user.id, expires: new Date(Date.now() + 60_000) } });
|
||||
const accept = load('src/app/invite/[token]/actions.ts').acceptInviteAction;
|
||||
const form = () => fields({ token, password: 'Invite-test-password', confirmPassword: 'Invite-test-password', acceptPrivacyPolicy: 'on' });
|
||||
const results = await Promise.all([accept({ attempt: 0 }, form()), accept({ attempt: 0 }, form())]);
|
||||
assert.equal(results.filter(result => !result.errors).length, 1);
|
||||
assert.equal(await prisma.verificationToken.findUnique({ where: { token: hashToken(token) } }), null);
|
||||
});
|
||||
test('household creation is atomic for parallel submissions', async () => {
|
||||
const action = loader({ '@/auth': { auth: async () => session(adminA) } })('src/app/dashboard/profil/actions.ts').createMyFamily;
|
||||
const before = await prisma.family.count({ where: { kitaId: kitaA.id } });
|
||||
const results = await Promise.all([action({ familyName: 'Test household' }), action({ familyName: 'Test household' })]);
|
||||
assert.equal(results.filter(result => result.success).length, 1);
|
||||
assert.equal(await prisma.family.count({ where: { kitaId: kitaA.id } }), before + 1);
|
||||
});
|
||||
test('bulk email transport never exposes another recipient in To', async () => {
|
||||
const sent = [];
|
||||
const oldKey = process.env.RESEND_API_KEY; const oldFrom = process.env.EMAIL_FROM;
|
||||
process.env.RESEND_API_KEY = 'test-only'; process.env.EMAIL_FROM = 'test@example.invalid';
|
||||
try {
|
||||
const mail = loader({ resend: { Resend: class { emails = { send: async input => { sent.push(input); return { data: { id: 'test' } }; } }; } } })('src/lib/mail.ts');
|
||||
assert.equal((await mail.sendAppEmail({ to: ['a@example.invalid','b@example.invalid','a@example.invalid'], subject: 'Test', react: null })).success, true);
|
||||
assert.deepEqual(sent.map(input => input.to), ['a@example.invalid','b@example.invalid']);
|
||||
} finally {
|
||||
if (oldKey === undefined) delete process.env.RESEND_API_KEY; else process.env.RESEND_API_KEY = oldKey;
|
||||
if (oldFrom === undefined) delete process.env.EMAIL_FROM; else process.env.EMAIL_FROM = oldFrom;
|
||||
}
|
||||
});
|
||||
|
||||
test('read receipts cannot link users and announcements across tenants', async () => {
|
||||
const announcement = await prisma.announcement.create({ data: { kitaId: kitaA.id, authorId: parentA.id, title: 'Tenant A', content: 'Private' } });
|
||||
await assert.rejects(prisma.announcementRead.create({ data: { kitaId: kitaA.id, userId: parentB.id, announcementId: announcement.id } }), error => error.code === 'P2003');
|
||||
await assert.rejects(prisma.user.update({ where: { id: parentA.id }, data: { kitaId: null } }), error => error.message.includes('users_family_requires_tenant'));
|
||||
});
|
||||
test('participant secrets are removed from props delivered to unauthorized browsers', async () => {
|
||||
const secret = 'CONFIDENTIAL-PARTICIPANT-' + prefix;
|
||||
const event = await prisma.termin.create({ data: { kitaId: kitaA.id, title: 'Private details', type: 'SONSTIGES', status: 'CONFIRMED', requiresRsvp: true,
|
||||
participantInfo: secret, startDate: new Date(Date.now() + 86400000), endDate: new Date(Date.now() + 86400000 + 3600000) } });
|
||||
const mockComponents = { './_components/admin-termin-modal': { AdminTerminModal: () => null },
|
||||
'./_components/pending-anfragen': { PendingAnfragen: () => null },
|
||||
'./_components/termin-request-modal': { TerminRequestModal: () => null },
|
||||
'./_components/calendar-overview-client': { CalendarOverviewClient: () => null } };
|
||||
const page = user => loader({ ...mockComponents, '@/auth': { auth: async () => session(user) } })('src/app/dashboard/kalender/page.tsx').default({ searchParams: Promise.resolve({}) });
|
||||
assert.equal(JSON.stringify(await page(parentA)).includes(secret), false);
|
||||
await prisma.eventParticipation.create({ data: { kitaId: kitaA.id, childId: childA.id, eventId: event.id, status: 'ATTENDING' } });
|
||||
assert.equal(JSON.stringify(await page(parentA)).includes(secret), true);
|
||||
assert.equal(JSON.stringify(await page(parentB)).includes(secret), false);
|
||||
});
|
||||
test('alarm links expire and concurrent confirmations consume the token only once', async () => {
|
||||
const load = loader(); const { newToken, hashToken } = load('src/lib/tokens.ts');
|
||||
const plan = await prisma.notdienstPlan.create({ data: { kitaId: kitaA.id, year: 2029, month: 1, status: 'PUBLISHED' } });
|
||||
const assignment = await prisma.notdienstAssignment.create({ data: { kitaId: kitaA.id, planId: plan.id, childId: childA.id, date: new Date(Date.now() + 86400000) } });
|
||||
const token = newToken();
|
||||
const alert = await prisma.notdienstAlert.create({ data: { kitaId: kitaA.id, assignmentId: assignment.id, parentUserId: parentA.id, confirmationToken: hashToken(token), triggeredAt: new Date(Date.now() - 48 * 3600000) } });
|
||||
const confirm = load('src/app/alert/[token]/actions.ts').confirmAlertAction;
|
||||
assert.ok((await confirm(token)).error);
|
||||
await prisma.notdienstAlert.update({ where: { id: alert.id }, data: { triggeredAt: new Date() } });
|
||||
const results = await Promise.all([confirm(token), confirm(token)]);
|
||||
assert.equal(results.filter(result => result.success).length, 1);
|
||||
assert.ok((await confirm(token)).error);
|
||||
});
|
||||
test('parallel swap creation and acceptance cannot duplicate or undo a swap', async () => {
|
||||
const otherFamily = await prisma.family.create({ data: { kitaId: kitaA.id, name: 'Swap partner' } });
|
||||
const partner = await prisma.user.create({ data: { email: `partner.${prefix}@example.invalid`, firstName: 'Partner', lastName: 'Test', kitaId: kitaA.id,
|
||||
familyId: otherFamily.id, role: 'ELTERN', passwordHash: passHash, emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } });
|
||||
const partnerChild = await prisma.child.create({ data: { kitaId: kitaA.id, familyId: otherFamily.id, firstName: 'Partner', lastName: 'Test' } });
|
||||
const plan = await prisma.notdienstPlan.create({ data: { kitaId: kitaA.id, year: 2029, month: 2, status: 'PUBLISHED' } });
|
||||
const offered = await prisma.notdienstAssignment.create({ data: { kitaId: kitaA.id, planId: plan.id, childId: childA.id, date: new Date(Date.now() + 2 * 86400000) } });
|
||||
const target = await prisma.notdienstAssignment.create({ data: { kitaId: kitaA.id, planId: plan.id, childId: partnerChild.id, date: new Date(Date.now() + 3 * 86400000) } });
|
||||
const actions = user => loader({ '@/lib/mail': emailMock, '@/auth': { auth: async () => session(user) } })('src/app/dashboard/kalender/actions.ts');
|
||||
const requester = actions(parentA); const recipient = actions(partner);
|
||||
const payload = { targetAssignmentId: target.id, offeredAssignmentId: offered.id };
|
||||
const created = await Promise.all([requester.createNotdienstSwapRequest(payload), requester.createNotdienstSwapRequest(payload)]);
|
||||
assert.equal(created.filter(result => result.success).length, 1);
|
||||
const request = await prisma.notdienstSwapRequest.findFirst({ where: { kitaId: kitaA.id, targetAssignmentId: target.id } });
|
||||
assert.ok((await actions(parentB).acceptNotdienstSwapRequest(request.id)).error);
|
||||
const accepted = await Promise.all([recipient.acceptNotdienstSwapRequest(request.id), recipient.acceptNotdienstSwapRequest(request.id)]);
|
||||
assert.equal(accepted.filter(result => result.success).length, 1);
|
||||
assert.equal((await prisma.notdienstAssignment.findUnique({ where: { id: target.id } })).childId, childA.id);
|
||||
assert.equal((await prisma.notdienstAssignment.findUnique({ where: { id: offered.id } })).childId, partnerChild.id);
|
||||
assert.ok((await requester.cancelNotdienstSwapRequest(request.id)).error);
|
||||
});
|
||||
|
||||
test('changing a parent email revokes sessions and requires fresh mailbox activation', async () => {
|
||||
const newEmail = `changed.${prefix}@example.invalid`;
|
||||
const load = loader({ '@/auth': { auth: async () => session(adminA), signIn: async () => {} }, '@/lib/mail': emailMock });
|
||||
const actions = load('src/app/dashboard/families/actions.ts');
|
||||
const oldToken = await authConfig.callbacks.jwt({ token: { id: parentA.id, sessionVersion: parentA.sessionVersion, authenticatedAt: Date.now() } });
|
||||
assert.equal((await actions.updateFamilyAction({ familyId: familyA.id, familyName: 'Family A',
|
||||
parents: [{ id: parentA.id, firstName: 'Parent', lastName: 'Test', email: newEmail }],
|
||||
children: [{ id: childA.id, firstName: 'A', lastName: 'Test' }] })).success, true);
|
||||
const changed = await prisma.user.findUnique({ where: { id: parentA.id } });
|
||||
assert.equal(changed.emailVerifiedAt,null); assert.equal(changed.passwordHash,'');
|
||||
assert.equal(await authConfig.callbacks.jwt({ token: oldToken }),null);
|
||||
assert.equal(await authConfig.providers[0].authorize({ email: newEmail, password: 'Safe-test-password' }),null);
|
||||
const initialInvite = emails.at(-1).react.props.inviteLink.split('/').at(-1);
|
||||
assert.equal((await actions.resendParentInvite(parentA.id)).success,true);
|
||||
const renewedInvite = emails.at(-1).react.props.inviteLink.split('/').at(-1);
|
||||
assert.notEqual(initialInvite,renewedInvite);
|
||||
const accept = load('src/app/invite/[token]/actions.ts').acceptInviteAction;
|
||||
const form = token => fields({ token, password: 'New-owner-password', confirmPassword: 'New-owner-password', acceptPrivacyPolicy: 'on' });
|
||||
assert.ok((await accept({ attempt: 0 }, form(initialInvite))).errors);
|
||||
assert.equal((await accept({ attempt: 0 }, form(renewedInvite))).errors,undefined);
|
||||
assert.ok((await prisma.user.findUnique({ where: { id: parentA.id } })).emailVerifiedAt);
|
||||
});
|
||||
test('resending an invite cannot issue an active link to an obsolete mailbox', async () => {
|
||||
const user = await prisma.user.create({ data: { email: `resend-race.${prefix}@example.invalid`, firstName: 'Race', lastName: 'Test',
|
||||
role: 'ELTERN', kitaId: kitaA.id, familyId: familyA.id, passwordHash: '' } });
|
||||
// Interleave a committed address change after the resend's initial lookup.
|
||||
const guardedPrisma = new Proxy(prisma, { get(target, property) {
|
||||
if (property === '$transaction') return async operation => {
|
||||
await prisma.user.update({ where: { id: user.id }, data: { email: `new-resend-race.${prefix}@example.invalid` } });
|
||||
return prisma.$transaction(operation);
|
||||
};
|
||||
const value = target[property]; return typeof value === 'function' ? value.bind(target) : value;
|
||||
} });
|
||||
const load = loader({ '@/auth': { auth: async () => session(adminA) }, '@/lib/mail': emailMock, '@/lib/prisma': { prisma: guardedPrisma } });
|
||||
const before = emails.length;
|
||||
assert.ok((await load('src/app/dashboard/families/actions.ts').resendParentInvite(user.id)).error);
|
||||
assert.equal(emails.length,before);
|
||||
assert.equal(await prisma.verificationToken.count({ where: { identifier: user.id } }),0);
|
||||
});
|
||||
test('upload quota holds under concurrent writes and accepted MIME comes from file bytes', async () => {
|
||||
const bytes = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aXZQAAAAASUVORK5CYII=', 'base64');
|
||||
const oldQuota = process.env.UPLOAD_TENANT_QUOTA_BYTES;
|
||||
process.env.UPLOAD_TENANT_QUOTA_BYTES = String(bytes.length * 2);
|
||||
const load = loader({ '@/auth': { auth: async () => session(adminA) }, '@/lib/mail': emailMock });
|
||||
const create = load('src/actions/announcements.ts').createAnnouncement;
|
||||
const form = () => { const result = fields({ title: 'Quota test', content: 'Safe contents', audience: 'ALLE' }); result.append('attachments',new File([bytes],'test.png',{ type: 'image/png' })); return result; };
|
||||
try {
|
||||
const results = await Promise.all([create(form()), create(form()), create(form())]);
|
||||
assert.equal(results.filter(result => result.success).length,2);
|
||||
const used = await prisma.attachment.aggregate({ where: { announcement: { kitaId: kitaA.id } }, _sum: { sizeBytes: true } });
|
||||
assert.equal(used._sum.sizeBytes,bytes.length * 2);
|
||||
const bad = fields({ title: 'Quota bad', content: 'Unsafe contents' });
|
||||
bad.append('attachments',new File(['<html>bad</html>'],'bad.jpg',{ type: 'text/html' }));
|
||||
assert.ok((await create(bad)).error);
|
||||
} finally {
|
||||
const files = await prisma.attachment.findMany({ where: { announcement: { kitaId: kitaA.id, title: 'Quota test' } }, select: { id: true } });
|
||||
await load('src/lib/attachments.ts').removeAttachmentFiles(files.map(file => file.id));
|
||||
await prisma.announcement.deleteMany({ where: { kitaId: kitaA.id, title: 'Quota test' } });
|
||||
if (oldQuota === undefined) delete process.env.UPLOAD_TENANT_QUOTA_BYTES; else process.env.UPLOAD_TENANT_QUOTA_BYTES = oldQuota;
|
||||
}
|
||||
});
|
||||
|
||||
test('all supplemental database tenant constraints are present', async () => {
|
||||
const [row] = await prisma.$queryRaw`SELECT count(*)::int AS count FROM pg_constraint WHERE conname LIKE '%_tenant_fkey'`;
|
||||
assert.equal(row.count,31);
|
||||
});
|
||||
test('role guard authenticates once and keeps role validation on that session', async () => {
|
||||
let calls = 0;
|
||||
const util = loader({ '@/auth': { auth: async () => { calls++; return session(calls === 1 ? adminA : parentB); } } })('src/lib/auth-utils.ts');
|
||||
assert.equal((await util.requireRole([UserRole.ADMIN])).user.id,adminA.id);
|
||||
assert.equal(calls,1);
|
||||
});
|
||||
test('parallel alarm triggers send one set of messages and skip unverified recipients', async () => {
|
||||
const unverified = await prisma.user.create({ data: { email: `unverified.${prefix}@example.invalid`, firstName: 'Unverified', lastName: 'Test', role: 'ELTERN',
|
||||
kitaId: kitaA.id, familyId: familyA.id, passwordHash: '', emailVerifiedAt: null } });
|
||||
const plan = await prisma.notdienstPlan.create({ data: { kitaId: kitaA.id, year: 2029, month: 3, status: 'PUBLISHED' } });
|
||||
const assignment = await prisma.notdienstAssignment.create({ data: { kitaId: kitaA.id, planId: plan.id, childId: childA.id, date: new Date(Date.now() + 86400000) } });
|
||||
const trigger = loader({ '@/auth': { auth: async () => session(adminA) }, '@/lib/mail': emailMock })('src/app/dashboard/actions.ts').triggerAlertAction;
|
||||
const before = emails.length;
|
||||
const results = await Promise.all([trigger(assignment.id),trigger(assignment.id)]);
|
||||
assert.equal(results.filter(result => result.success).length,1);
|
||||
const alerts = await prisma.notdienstAlert.findMany({ where: { assignmentId: assignment.id } });
|
||||
assert.ok(alerts.length > 0);
|
||||
assert.equal(alerts.some(alert => alert.parentUserId === unverified.id),false);
|
||||
assert.equal(emails.slice(before).some(email => email.to === unverified.email),false);
|
||||
assert.equal(emails.length - before,alerts.length);
|
||||
});
|
||||
Reference in New Issue
Block a user