Harden authentication, tenant isolation and security boundaries

This commit is contained in:
t.indorf
2026-10-09 22:49:55 +02:00
parent ec3f4f933d
commit 656ada80bd
71 changed files with 3107 additions and 1200 deletions
+12 -7
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine AS base FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS base
WORKDIR /app WORKDIR /app
@@ -28,6 +28,10 @@ COPY . .
RUN npm run build:prod 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 FROM base AS runner
ENV NODE_ENV=production ENV NODE_ENV=production
@@ -35,10 +39,11 @@ ENV PORT=3000
ENV HOSTNAME=0.0.0.0 ENV HOSTNAME=0.0.0.0
ENV UPLOAD_DIR=/app/uploads/announcements ENV UPLOAD_DIR=/app/uploads/announcements
COPY --from=builder /app ./ RUN addgroup -S -g 1001 app && adduser -S -u 1001 -G app app
COPY --from=builder --chown=app:app /app/.next/standalone ./
RUN mkdir -p /app/uploads/announcements && chmod +x /app/scripts/start-production.sh 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 EXPOSE 3000
CMD ["node", "server.js"]
CMD ["./scripts/start-production.sh"]
+52 -39
View File
@@ -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. 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.
- `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.
## 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. 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.
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.
Beim Container-Start fuehrt `scripts/start-production.sh` automatisch aus: ## Bestehende Installation aktualisieren
```bash 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.
npx prisma db push 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 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.
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.
## Welche Coolify-Postgres-URL? ## Auswirkungen auf bestehende Konten
Wenn die App und die Postgres-DB im selben Coolify-Projekt/Netzwerk laufen, nimm 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.
die interne Datenbank-URL aus Coolify, nicht die oeffentliche externe URL. Das ist
stabiler und vermeidet unnoetigen Traffic ueber den Host.
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 ## Dateien und Wartung
postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
`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. 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.
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.
+30 -53
View File
@@ -1,60 +1,37 @@
# Production Checklist # Production-Checkliste
## 1. Domain & Basis-URLs ## Vor dem Rollout
- [ ] Produktionsdomain festlegen: `https://[deine-domain.de]` - [ ] Datenbank und Uploads gesichert; Wiederherstellung geprüft.
- [ ] `NEXT_PUBLIC_SITE_URL` auf die Produktionsdomain setzen - [ ] Alte App gestoppt; Wartungsfenster und erneute Anmeldung angekündigt.
- [ ] `AUTH_URL` und `NEXTAUTH_URL` auf die Produktionsdomain setzen - [ ] Bestehendes Schema mit Baseline verglichen; Tenant-Preflight ohne Befunde.
- [ ] Nach Deployment die WhatsApp-/LinkedIn-Vorschau mit der finalen URL testen - [ ] 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 - [ ] Drei öffentliche Basis-URLs auf dieselbe kanonische HTTPS-Origin gesetzt.
- [ ] `DATABASE_URL` in der Coolify-App eintragen - [ ] Starkes `AUTH_SECRET`; echte Secrets nur in Hosting-Konfiguration.
- [ ] Beim ersten App-Start wird das Schema automatisch mit `npx prisma db push --skip-generate` angewendet - [ ] TLS und Proxy-Header korrekt; kein öffentlicher DB-/App-/pgAdmin-Port.
- [ ] Fuer spaetere Releases idealerweise Prisma-Migrationen einfuehren - [ ] 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` - [ ] Anmeldung und Registrierung mit Mailbox-Bestätigung getestet.
- [ ] Keine `.env`-Dateien mit echten Secrets committen - [ ] Einladungen, Adressänderung und erneutes Senden getestet.
- [ ] Dashboard ist per Metadata auf `noindex,nofollow` gesetzt - [ ] Administrative Sperre entwertet bestehende Sitzung; Entsperrung verlangt neue Anmeldung.
- [ ] Produktionsdomain auf HTTPS betreiben - [ ] 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 Details und Befehle stehen in [README.md](README.md). Lokale Tests ersetzen nicht die Kontrolle der produktiven TLS-, Proxy-, Backup-, Mail- und Container-Konfiguration.
- [ ] 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
+16 -2
View File
@@ -11,6 +11,11 @@ POSTGRES_DB="kita_planer"
POSTGRES_USER="kita_planer" POSTGRES_USER="kita_planer"
POSTGRES_PASSWORD="[GENERATE_ME]" 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 im Coolify-Compose-Stack
PGADMIN_DEFAULT_EMAIL="[admin@deine-domain.de]" PGADMIN_DEFAULT_EMAIL="[admin@deine-domain.de]"
PGADMIN_DEFAULT_PASSWORD="[GENERATE_ME]" 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. # Muss auf dem Server dauerhaft erhalten bleiben und darf nicht im Repo liegen.
UPLOAD_DIR="/var/lib/kita-planer/uploads/announcements" UPLOAD_DIR="/var/lib/kita-planer/uploads/announcements"
# Optional: Nur bewusst fuer initiales Demo-Seeding in Produktion setzen. # Standard: 256 MiB je Kita, 10 GiB insgesamt. Compose verwendet diese Werte.
# ALLOW_DATABASE_SEED="true" 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"
+43 -7
View File
@@ -1,6 +1,6 @@
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-kita_planer} POSTGRES_DB: ${POSTGRES_DB:-kita_planer}
POSTGRES_USER: ${POSTGRES_USER:-kita_planer} POSTGRES_USER: ${POSTGRES_USER:-kita_planer}
@@ -27,7 +27,7 @@ services:
NODE_ENV: production NODE_ENV: production
PORT: 3000 PORT: 3000
HOSTNAME: 0.0.0.0 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_SECRET: ${AUTH_SECRET:?}
AUTH_URL: ${AUTH_URL:?} AUTH_URL: ${AUTH_URL:?}
NEXTAUTH_URL: ${NEXTAUTH_URL:?} NEXTAUTH_URL: ${NEXTAUTH_URL:?}
@@ -39,17 +39,52 @@ services:
EMAIL_FROM: ${EMAIL_FROM:?} EMAIL_FROM: ${EMAIL_FROM:?}
ADMIN_EMAIL: ${ADMIN_EMAIL:?} ADMIN_EMAIL: ${ADMIN_EMAIL:?}
UPLOAD_DIR: /app/uploads/announcements 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: volumes:
- kita_planer_uploads:/app/uploads - kita_planer_uploads:/app/uploads
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
ports: restart: "no"
- "3000"
restart: unless-stopped
pgadmin: pgadmin:
image: dpage/pgadmin4:latest profiles: ["maintenance"]
image: dpage/pgadmin4@sha256:c332c5f6dfba995d9ebc4af261d93506d6876085d712eaaa3defc8dd1a3f26de
environment: environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:?} PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:?}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:?} PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:?}
@@ -61,10 +96,11 @@ services:
postgres: postgres:
condition: service_healthy condition: service_healthy
ports: ports:
- "80" - "127.0.0.1:5050:80"
restart: unless-stopped restart: unless-stopped
volumes: volumes:
kita_planer_postgres_data: kita_planer_postgres_data:
kita_planer_uploads: kita_planer_uploads:
kita_planer_next_cache:
kita_planer_pgadmin_data: kita_planer_pgadmin_data:
+2 -2
View File
@@ -2,14 +2,14 @@ version: '3.8'
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
container_name: kita_planer_db container_name: kita_planer_db
environment: environment:
POSTGRES_USER: kita POSTGRES_USER: kita
POSTGRES_PASSWORD: kita POSTGRES_PASSWORD: kita
POSTGRES_DB: kita_planer POSTGRES_DB: kita_planer
ports: ports:
- "5433:5432" - "127.0.0.1:5433:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
restart: unless-stopped restart: unless-stopped
+22 -1
View File
@@ -1,12 +1,33 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { 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: { 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: [ remotePatterns: [
{ {
protocol: "https", protocol: "https",
hostname: "images.unsplash.com", hostname: "images.unsplash.com",
pathname: "/**", port: "",
pathname: "/photo-1503454537195-1dcabb73ffb9",
search: "?auto=format&fit=crop&w=2400&q=86",
}, },
], ],
}, },
+572 -520
View File
File diff suppressed because it is too large Load Diff
+23 -10
View File
@@ -3,23 +3,28 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "NODE_ENV=development prisma db push && NODE_ENV=development prisma db seed && next dev", "dev": "next dev",
"build": "next build", "build": "next build && node scripts/prepare-standalone.mjs",
"build:prod": "prisma generate && next build", "build:prod": "prisma generate && next build && node scripts/prepare-standalone.mjs",
"start": "next start", "start": "next start",
"start:prod": "next start", "start:prod": "node .next/standalone/server.js",
"lint": "eslint", "lint": "eslint",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio", "prisma:studio": "prisma studio",
"db:seed": "NODE_ENV=development prisma db seed", "db:seed": "prisma db seed",
"db:seed:reset": "NODE_ENV=development prisma db seed -- --reset" "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": { "prisma": {
"seed": "tsx prisma/seed.ts" "seed": "tsx prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.2",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@prisma/client": "^6.19.3", "@prisma/client": "^6.19.3",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -36,8 +41,8 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"next": "16.2.4", "next": "^16.4.0",
"next-auth": "^5.0.0-beta.31", "next-auth": "^5.0.0-beta.32",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
@@ -57,10 +62,18 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.4", "eslint-config-next": "^16.4.0",
"prisma": "^6.19.3", "prisma": "^6.19.3",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5" "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;
+1
View File
@@ -0,0 +1 @@
provider = "postgresql"
+33
View File
@@ -170,6 +170,7 @@ model Family {
@@index([kitaId]) @@index([kitaId])
@@map("families") @@map("families")
@@unique([id, kitaId])
} }
// ===================================================================== // =====================================================================
@@ -217,6 +218,8 @@ model User {
lastLoginAt DateTime? lastLoginAt DateTime?
failedLoginAttempts Int @default(0) failedLoginAttempts Int @default(0)
lockedUntil DateTime? lockedUntil DateTime?
disabledAt DateTime?
sessionVersion Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -244,6 +247,7 @@ model User {
@@index([familyId]) @@index([familyId])
@@index([kitaId, role]) @@index([kitaId, role])
@@map("users") @@map("users")
@@unique([id, kitaId])
} }
// ===================================================================== // =====================================================================
@@ -276,6 +280,7 @@ model Child {
@@index([kitaId]) @@index([kitaId])
@@index([familyId]) @@index([familyId])
@@map("children") @@map("children")
@@unique([id, kitaId])
} }
// ===================================================================== // =====================================================================
@@ -298,6 +303,7 @@ model Educator {
@@index([kitaId]) @@index([kitaId])
@@map("educators") @@map("educators")
@@unique([id, kitaId])
} }
// ===================================================================== // =====================================================================
@@ -321,6 +327,7 @@ model ParentDuty {
@@unique([kitaId, name]) @@unique([kitaId, name])
@@index([kitaId]) @@index([kitaId])
@@map("parent_duties") @@map("parent_duties")
@@unique([id, kitaId])
} }
model ParentDutyAssignment { model ParentDutyAssignment {
@@ -360,6 +367,7 @@ model DutyType {
@@unique([kitaId, name]) @@unique([kitaId, name])
@@index([kitaId]) @@index([kitaId])
@@map("duty_types") @@map("duty_types")
@@unique([id, kitaId])
} }
model DutyAssignment { model DutyAssignment {
@@ -435,6 +443,7 @@ model Announcement {
@@index([kitaId, createdAt]) @@index([kitaId, createdAt])
@@index([authorId]) @@index([authorId])
@@unique([id, kitaId])
@@map("announcements") @@map("announcements")
} }
@@ -446,12 +455,14 @@ model Attachment {
fileName String fileName String
fileUrl String fileUrl String
fileType String fileType String
sizeBytes Int?
@@index([announcementId]) @@index([announcementId])
@@map("attachments") @@map("attachments")
} }
model AnnouncementRead { model AnnouncementRead {
kitaId String
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -462,6 +473,7 @@ model AnnouncementRead {
@@unique([userId, announcementId]) @@unique([userId, announcementId])
@@index([announcementId]) @@index([announcementId])
@@index([kitaId])
@@map("announcement_reads") @@map("announcement_reads")
} }
@@ -536,6 +548,7 @@ model Termin {
@@index([kitaId, startDate]) @@index([kitaId, startDate])
@@index([kitaId, status]) @@index([kitaId, status])
@@map("termine") @@map("termine")
@@unique([id, kitaId])
} }
model EventParticipation { model EventParticipation {
@@ -612,6 +625,7 @@ model NotdienstPlan {
@@unique([kitaId, year, month]) @@unique([kitaId, year, month])
@@index([kitaId]) @@index([kitaId])
@@map("notdienst_plans") @@map("notdienst_plans")
@@unique([id, kitaId])
} }
/// Verfügbarkeits-Eintrag eines Elternteils für ein Kind an einem Tag. /// Verfügbarkeits-Eintrag eines Elternteils für ein Kind an einem Tag.
@@ -664,6 +678,7 @@ model NotdienstAssignment {
@@unique([planId, date]) @@unique([planId, date])
@@index([kitaId, date]) @@index([kitaId, date])
@@map("notdienst_assignments") @@map("notdienst_assignments")
@@unique([id, kitaId])
} }
/// Tauschanfrage zwischen Eltern fuer zwei bereits veroeffentlichte /// Tauschanfrage zwischen Eltern fuer zwei bereits veroeffentlichte
@@ -786,3 +801,21 @@ model VerificationToken {
@@unique([identifier, token]) @@unique([identifier, token])
@@map("verification_tokens") @@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
View File
@@ -19,7 +19,7 @@ import { hash } from "bcryptjs";
// noch keine Kita und keine User enthaelt. // noch keine Kita und keine User enthaelt.
// //
// Reset: `npm run db:seed:reset` loescht gezielt die Demo-Daten und baut // 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(); const prisma = new PrismaClient();
@@ -101,16 +101,8 @@ function getWorkingDays(year: number, month: number, limit?: number) {
} }
function isSeedAllowed() { function isSeedAllowed() {
if ( if (process.env.NODE_ENV !== "production" && process.env.ALLOW_DATABASE_SEED === "true") return true;
process.env.NODE_ENV === "development" || console.log("Seed deaktiviert. Nur außerhalb der Produktion mit ALLOW_DATABASE_SEED=true erlaubt.");
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.",
);
return false; return false;
} }
@@ -688,10 +680,10 @@ async function createAnnouncements(context: SeedContext) {
await prisma.announcementRead.createMany({ await prisma.announcementRead.createMany({
data: [ data: [
{ userId: koordinator.id, announcementId: sommerfest.id }, { kitaId: kita.id, userId: koordinator.id, announcementId: sommerfest.id },
{ userId: elternSchmidt.id, announcementId: sommerfest.id }, { kitaId: kita.id, userId: elternSchmidt.id, announcementId: sommerfest.id },
{ userId: elternYilmaz.id, announcementId: sommerfest.id }, { kitaId: kita.id, userId: elternYilmaz.id, announcementId: sommerfest.id },
{ userId: elternFischer.id, announcementId: garderobe.id }, { kitaId: kita.id, userId: elternFischer.id, announcementId: garderobe.id },
], ],
}); });
} }
+23
View File
@@ -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; });
+62
View File
@@ -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;
+21
View File
@@ -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; });
+3
View File
@@ -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 });
+34
View File
@@ -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;
});
+2 -6
View File
@@ -1,8 +1,4 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
# Apply reviewed migrations separately before starting the runtime.
echo "Applying Prisma schema..." exec node server.js
npx prisma db push --skip-generate
echo "Starting Kita-Planer..."
exec npm run start:prod
+4 -3
View File
@@ -2,6 +2,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { AbsenceReason, UserRole } from "@prisma/client"; import { AbsenceReason, UserRole } from "@prisma/client";
import { dateOnlySchema } from "@/lib/date-schema";
import { z } from "zod"; import { z } from "zod";
import { requireKitaSession, requireRole } from "@/lib/auth-utils"; import { requireKitaSession, requireRole } from "@/lib/auth-utils";
@@ -9,14 +10,14 @@ import { prisma } from "@/lib/prisma";
const reportAbsenceSchema = z.object({ const reportAbsenceSchema = z.object({
childId: z.string().min(1), childId: z.string().min(1),
startDate: z.string().min(1), startDate: dateOnlySchema,
endDate: z.string().min(1), endDate: dateOnlySchema,
reason: z.nativeEnum(AbsenceReason), reason: z.nativeEnum(AbsenceReason),
note: z.string().trim().max(500).optional(), note: z.string().trim().max(500).optional(),
}); });
const dailyAbsenceSchema = z.object({ const dailyAbsenceSchema = z.object({
date: z.string().min(1), date: dateOnlySchema,
}); });
export type DailyAbsenceDto = { export type DailyAbsenceDto = {
+62 -69
View File
@@ -2,7 +2,9 @@
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { mkdir, rm, writeFile } from "fs/promises"; 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 { createElement } from "react";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { Prisma, UserRole } from "@prisma/client"; import { Prisma, UserRole } from "@prisma/client";
@@ -13,15 +15,6 @@ import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail"; import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma"; 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 audienceSchema = z.enum(["ELTERN", "ERZIEHER", "ALLE"]);
const announcementSchema = z.object({ const announcementSchema = z.object({
@@ -31,30 +24,10 @@ const announcementSchema = z.object({
audience: audienceSchema, audience: audienceSchema,
}); });
function getUploadDir() {
return path.resolve(process.env.UPLOAD_DIR ?? "uploads", "announcements");
}
function getSafeFileName(name: string) { function getSafeFileName(name: string) {
return name.replace(/[^\w.\- äöüÄÖÜß]/g, "_").slice(0, 160); 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>) { async function getRecipients(kitaId: string, audience: z.infer<typeof audienceSchema>) {
const where: Prisma.UserWhereInput = const where: Prisma.UserWhereInput =
audience === "ELTERN" audience === "ELTERN"
@@ -76,7 +49,7 @@ async function getRecipients(kitaId: string, audience: z.infer<typeof audienceSc
}; };
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
where, where: { ...where, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true }, select: { email: true },
}); });
@@ -106,56 +79,75 @@ export async function createAnnouncement(formData: FormData) {
.getAll("attachments") .getAll("attachments")
.filter((value): value is File => value instanceof File && value.size > 0); .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) { for (const file of attachmentFiles) {
const error = validateAttachment(file); const bytes = Buffer.from(await file.arrayBuffer());
if (error) { const fileType = detectAttachmentType(bytes);
return { error }; 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 announcement = await serializableTransaction(async tx => {
const id = randomUUID(); if (attachmentInputs.length) {
return { const pending = await tx.attachment.count({ where: { sizeBytes: null } });
id, if (pending) return null;
file, const [tenantUsage, globalUsage] = await Promise.all([
fileName: getSafeFileName(file.name), tx.attachment.aggregate({ where: { announcement: { kitaId } }, _sum: { sizeBytes: true } }),
fileType: file.type || "application/octet-stream", tx.attachment.aggregate({ _sum: { sizeBytes: true } }),
fileUrl: `/api/announcements/attachments/${id}`, ]);
}; 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;
const announcement = await prisma.announcement.create({ };
data: { if ((tenantUsage._sum.sizeBytes ?? 0) + bytes > quota(process.env.UPLOAD_TENANT_QUOTA_BYTES, 256 * 1024 ** 2) ||
kitaId, (globalUsage._sum.sizeBytes ?? 0) + bytes > quota(process.env.UPLOAD_GLOBAL_QUOTA_BYTES, 10 * 1024 ** 3)) return null;
title: parsed.data.title, }
content: parsed.data.content, return tx.announcement.create({
authorId: session.user.id, data: {
attachments: { kitaId,
create: attachmentInputs.map((attachment) => ({ title: parsed.data.title,
id: attachment.id, content: parsed.data.content,
fileName: attachment.fileName, authorId: session.user.id,
fileType: attachment.fileType, attachments: {
fileUrl: attachment.fileUrl, create: attachmentInputs.map((attachment) => ({
})), id: attachment.id,
fileName: attachment.fileName,
fileType: attachment.fileType,
sizeBytes: attachment.bytes.length,
fileUrl: attachment.fileUrl,
})),
},
}, },
}, select: {
select: { id: true,
id: true, title: true,
title: true, content: 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) { if (attachmentInputs.length > 0) {
const uploadDir = getUploadDir(); const uploadDir = attachmentDirectory();
try { try {
await mkdir(uploadDir, { recursive: true }); await mkdir(uploadDir, { recursive: true });
await Promise.all( await Promise.all(
attachmentInputs.map(async (attachment) => { attachmentInputs.map(async (attachment) => {
const arrayBuffer = await attachment.file.arrayBuffer();
await writeFile( await writeFile(
path.join(uploadDir, attachment.id), attachmentPath(attachment.id),
Buffer.from(arrayBuffer), 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 prisma.announcement.delete({ where: { id: announcement.id } });
await Promise.all( await Promise.all(
attachmentInputs.map((attachment) => 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); console.error("Ankündigungs-Anhang konnte nicht gespeichert werden:", error);
@@ -232,6 +224,7 @@ export async function markAnnouncementRead(announcementId: string) {
}, },
}, },
create: { create: {
kitaId: session.user.kitaId,
userId: session.user.id, userId: session.user.id,
announcementId: announcement.id, announcementId: announcement.id,
}, },
+5 -27
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { createElement } from "react"; import { createElement } from "react";
import { headers } from "next/headers";
import { ContactConfirmationEmail } from "@/emails/ContactConfirmationEmail"; import { ContactConfirmationEmail } from "@/emails/ContactConfirmationEmail";
import { ContactRequestEmail } from "@/emails/ContactRequestEmail"; import { ContactRequestEmail } from "@/emails/ContactRequestEmail";
@@ -11,39 +11,17 @@ import {
type ContactSubmissionPayload, type ContactSubmissionPayload,
} from "@/lib/contact-schema"; } from "@/lib/contact-schema";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail"; 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 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) { export async function submitContactRequest(data: ContactSubmissionPayload) {
// Honeypot: silently absorb bot submissions without informing them. // 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 }; return { success: true };
} }
const ip = await resolveClientIp(); if (!(await limitPublicAction("contact", String(data.email ?? "").trim().toLowerCase()))) {
const limit = rateLimit( return { success: false, error: "Zu viele Anfragen. Bitte versuche es später erneut." };
`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.`,
};
} }
const parsed = contactRequestSchema.safeParse(data); const parsed = contactRequestSchema.safeParse(data);
+15 -3
View File
@@ -1,5 +1,7 @@
"use server"; "use server";
import { removeAttachmentFiles } from "@/lib/attachments";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { AuditTargetType, UserRole } from "@prisma/client"; import { AuditTargetType, UserRole } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
@@ -26,6 +28,7 @@ type ActionResult = {
async function requireSuperadminActor() { async function requireSuperadminActor() {
const session = await requireRole([UserRole.SUPERADMIN]); const session = await requireRole([UserRole.SUPERADMIN]);
if (Date.now() - session.user.authenticatedAt > 15 * 60_000) redirect("/login?reauth=1&callbackUrl=/admin");
return { return {
id: session.user.id, id: session.user.id,
email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin", email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
@@ -92,7 +95,8 @@ export async function deleteKita(
const counts = await getKitaDeleteCounts(kita.id); 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({ await tx.auditLog.create({
data: { data: {
actorUserId: actor.id, actorUserId: actor.id,
@@ -107,8 +111,10 @@ export async function deleteKita(
}); });
await tx.kita.delete({ where: { id: kita.id } }); await tx.kita.delete({ where: { id: kita.id } });
return attachments.map(item => item.id);
}); });
await removeAttachmentFiles(attachmentIds);
revalidatePath("/admin"); revalidatePath("/admin");
revalidatePath("/admin/audit"); revalidatePath("/admin/audit");
return { ok: true, redirectTo: "/admin" }; 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({ await tx.verificationToken.deleteMany({
where: { identifier: user.id }, where: { identifier: user.id },
}); });
@@ -206,8 +213,10 @@ export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
}, },
}); });
await tx.user.delete({ where: { id: user.id } }); await tx.user.delete({ where: { id: user.id } });
return attachments.map(item => item.id);
}); });
await removeAttachmentFiles(attachmentIds);
revalidatePath("/admin"); revalidatePath("/admin");
revalidatePath(`/admin/kitas/${user.kitaId}`); revalidatePath(`/admin/kitas/${user.kitaId}`);
revalidatePath("/admin/audit"); revalidatePath("/admin/audit");
@@ -219,6 +228,7 @@ async function updateUserSupportState(
action: "USER_LOCK" | "USER_UNLOCK" | "USER_FAILED_LOGIN_RESET", action: "USER_LOCK" | "USER_UNLOCK" | "USER_FAILED_LOGIN_RESET",
data: { data: {
lockedUntil?: Date | null; lockedUntil?: Date | null;
disabledAt?: Date | null;
failedLoginAttempts?: number; failedLoginAttempts?: number;
}, },
): Promise<ActionResult> { ): Promise<ActionResult> {
@@ -238,7 +248,7 @@ async function updateUserSupportState(
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.user.update({ await tx.user.update({
where: { id: user.id }, where: { id: user.id },
data, data: { ...data, ...(action === "USER_LOCK" ? { sessionVersion: { increment: 1 } } : {}) },
}); });
await tx.auditLog.create({ await tx.auditLog.create({
data: { data: {
@@ -266,12 +276,14 @@ async function updateUserSupportState(
export async function lockUser(userId: string): Promise<ActionResult> { export async function lockUser(userId: string): Promise<ActionResult> {
return updateUserSupportState(userId, "USER_LOCK", { return updateUserSupportState(userId, "USER_LOCK", {
disabledAt: new Date(),
lockedUntil: new Date("2099-12-31T23:59:59.000Z"), lockedUntil: new Date("2099-12-31T23:59:59.000Z"),
}); });
} }
export async function unlockUser(userId: string): Promise<ActionResult> { export async function unlockUser(userId: string): Promise<ActionResult> {
return updateUserSupportState(userId, "USER_UNLOCK", { return updateUserSupportState(userId, "USER_UNLOCK", {
disabledAt: null,
lockedUntil: null, lockedUntil: null,
failedLoginAttempts: 0, failedLoginAttempts: 0,
}); });
+12 -28
View File
@@ -1,34 +1,18 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { NotdienstAlertStatus } from "@prisma/client"; import { NotdienstAlertStatus } from "@prisma/client";
import { prisma } from "@/lib/prisma"; 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) { export async function confirmAlertAction(token: string) {
try { if (!isPublicToken(token)) return { error: "Ungültiger Bestätigungs-Link." };
const alert = await prisma.notdienstAlert.findUnique({ if (!(await limitPublicAction("alert-confirm", hashToken(token), 10, 900))) return { error: "Bitte versuche es später erneut." };
where: { confirmationToken: token }, const updated = await prisma.notdienstAlert.updateMany({
}); where: activeAlertTokenWhere(token),
data: { status: NotdienstAlertStatus.CONFIRMED, confirmedAt: new Date() },
if (!alert) { });
return { error: "Ungültiger Bestätigungs-Link." }; if (updated.count !== 1) return { error: "Dieser Link ist abgelaufen oder wurde bereits verwendet." };
} revalidatePath("/dashboard");
return { success: true };
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." };
}
} }
+7 -3
View File
@@ -4,6 +4,8 @@ import { de } from "date-fns/locale";
import { BellRing, ShieldCheck, XCircle } from "lucide-react"; import { BellRing, ShieldCheck, XCircle } from "lucide-react";
import { NotdienstAlertStatus } from "@prisma/client"; import { NotdienstAlertStatus } from "@prisma/client";
import { activeAlertTokenWhere } from "@/lib/alert-tokens";
import { isPublicToken } from "@/lib/tokens";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { import {
Card, Card,
@@ -14,7 +16,8 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { AlertConfirmForm } from "./alert-confirm-form"; 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({ export default async function AlertPage({
params, params,
@@ -23,8 +26,9 @@ export default async function AlertPage({
}) { }) {
const { token } = await params; const { token } = await params;
const alert = await prisma.notdienstAlert.findUnique({ if (!isPublicToken(token)) notFound();
where: { confirmationToken: token }, const alert = await prisma.notdienstAlert.findFirst({
where: activeAlertTokenWhere(token),
select: { select: {
status: true, status: true,
parentUser: { parentUser: {
@@ -1,30 +1,18 @@
import { readFile } from "fs/promises"; import { open } from "node:fs/promises";
import path from "path"; import { constants } from "node:fs";
import { attachmentPath, attachmentDisposition, detectAttachmentType, MAX_ATTACHMENT_SIZE } from "@/lib/attachments";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/auth"; import { getKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const runtime = "nodejs"; 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( export async function GET(
_request: Request, _request: Request,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
const session = await auth(); const session = await getKitaSession();
const user = session?.user; const user = session?.user;
if (!user?.id || !user.kitaId) { if (!user?.id || !user.kitaId) {
@@ -32,6 +20,7 @@ export async function GET(
} }
const { id } = await params; const { id } = await params;
try { attachmentPath(id); } catch { return NextResponse.json({ error: "Not found" }, { status: 404 }); }
const attachment = await prisma.attachment.findFirst({ const attachment = await prisma.attachment.findFirst({
where: { where: {
id, id,
@@ -51,12 +40,22 @@ export async function GET(
} }
try { 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), { return new Response(new Uint8Array(file), {
headers: { headers: {
"Content-Type": attachment.fileType, "Content-Type": detectAttachmentType(file) ?? "application/octet-stream",
"Content-Disposition": contentDisposition(attachment.fileName), "Content-Disposition": attachmentDisposition(attachment.fileName),
"Cache-Control": "private, max-age=300", "Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
"Referrer-Policy": "no-referrer",
}, },
}); });
} catch (error) { } catch (error) {
+25 -22
View File
@@ -1,6 +1,9 @@
"use server"; "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 { createElement } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; 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 session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!; 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 { try {
const [existing, assignment] = await Promise.all([ const [existing, assignment] = await Promise.all([
// Prüfen ob es schon einen aktiven Alert für dieses Assignment gibt // 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 }, where: { assignmentId, kitaId },
}), }),
prisma.notdienstAssignment.findFirst({ prisma.notdienstAssignment.findFirst({
where: { id: assignmentId, kitaId }, where: { id: assignmentId, kitaId, plan: { status: "PUBLISHED" } },
include: { include: {
child: { child: {
select: { select: {
id: true, id: true,
familyId: true,
firstName: true, firstName: true,
lastName: true, lastName: true,
family: { family: {
select: { select: {
users: { users: {
where: { role: UserRole.ELTERN }, where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { select: {
id: true, id: true,
email: true, email: true,
@@ -75,24 +80,22 @@ export async function triggerAlertAction(assignmentId: string) {
locale: de, locale: de,
}); });
const childName = `${assignment.child.firstName} ${assignment.child.lastName}`; const childName = `${assignment.child.firstName} ${assignment.child.lastName}`;
const alertJobs: { email: string; token: string }[] = []; const alertJobs = parents.map(parent => ({ email: parent.email, token: newToken(), parentId: parent.id }));
await serializableTransaction(async tx => {
await prisma.$transaction( if (await tx.notdienstAlert.findFirst({ where: { assignmentId, kitaId }, select: { id: true } })) {
parents.map((parent) => { throw new UserFacingError("Für diese Einteilung wurde bereits ein Alarm ausgelöst.");
const token = crypto.randomUUID(); }
alertJobs.push({ email: parent.email, token }); const current = await tx.notdienstAssignment.findFirst({ where: { id: assignmentId, kitaId, plan: { status: "PUBLISHED" } }, select: { childId: true, date: true } });
return prisma.notdienstAlert.create({ const today = new Date(); today.setUTCHours(0, 0, 0, 0);
data: { 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.");
kitaId, for (const job of alertJobs) {
assignmentId, const parent = await tx.user.findFirst({ where: { id: job.parentId, kitaId, familyId: assignment.child.familyId,
parentUserId: parent.id, email: job.email, disabledAt: null, emailVerifiedAt: { not: null }, privacyPolicyAcceptedAt: { not: null } }, select: { id: true } });
triggeredById: session.user.id, if (!parent) throw new UserFacingError("Die Empfänger haben sich geändert. Bitte lade die Seite neu.");
status: NotdienstAlertStatus.PENDING, await tx.notdienstAlert.create({ data: { kitaId, assignmentId, parentUserId: parent.id,
confirmationToken: token, triggeredById: session.user.id, status: NotdienstAlertStatus.PENDING, confirmationToken: hashToken(job.token) } });
}, }
}); });
}),
);
for (const job of alertJobs) { for (const job of alertJobs) {
const emailResult = await sendAppEmail({ const emailResult = await sendAppEmail({
@@ -118,6 +121,6 @@ export async function triggerAlertAction(assignmentId: string) {
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return { error: "Fehler beim Auslösen des Alarms." }; return { error: publicActionError(error, "Fehler beim Auslösen des Alarms.") };
} }
} }
+7 -2
View File
@@ -5,11 +5,13 @@ import { revalidatePath } from "next/cache";
import { DutyAssignmentStatus, UserRole } from "@prisma/client"; import { DutyAssignmentStatus, UserRole } from "@prisma/client";
import { endOfWeek, format, startOfDay, startOfWeek } from "date-fns"; import { endOfWeek, format, startOfDay, startOfWeek } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { dateOnlySchema } from "@/lib/date-schema";
import { z } from "zod"; import { z } from "zod";
import { DutyReminderEmail } from "@/emails/DutyReminderEmail"; import { DutyReminderEmail } from "@/emails/DutyReminderEmail";
import { requireRole } from "@/lib/auth-utils"; import { requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail"; import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { rateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
const dutyTypeSchema = z.object({ const dutyTypeSchema = z.object({
@@ -20,8 +22,8 @@ const dutyTypeSchema = z.object({
const assignmentSchema = z.object({ const assignmentSchema = z.object({
dutyTypeId: z.string().min(1), dutyTypeId: z.string().min(1),
familyId: z.string().nullable(), familyId: z.string().nullable(),
startDate: z.string().min(1), startDate: dateOnlySchema,
endDate: z.string().min(1), endDate: dateOnlySchema,
}); });
async function requireDutyAdmin() { async function requireDutyAdmin() {
@@ -160,6 +162,7 @@ export async function upsertDutyAssignment(rawPayload: unknown) {
export async function checkAndSendDutyReminders() { export async function checkAndSendDutyReminders() {
const { kitaId } = await requireDutyAdmin(); const { kitaId } = await requireDutyAdmin();
if (!(await rateLimit(`duty-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
const configError = getAppEmailConfigError(); const configError = getAppEmailConfigError();
if (configError) { if (configError) {
@@ -195,6 +198,8 @@ export async function checkAndSendDutyReminders() {
users: { users: {
where: { where: {
emailVerifiedAt: { not: null }, emailVerifiedAt: { not: null },
disabledAt: null,
privacyPolicyAcceptedAt: { not: null },
}, },
select: { select: {
email: true, email: true,
+1 -1
View File
@@ -93,7 +93,7 @@ export function NewsForm() {
/> />
<p className="mt-2 flex items-center gap-1 text-xs text-[var(--ink-muted)]"> <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]" /> <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> </p>
</div> </div>
</div> </div>
@@ -1,7 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useTransition } from "react";
import { LayoutGrid, Mail, Search, Table, Users } from "lucide-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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 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]" /> <Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]"> <span className="break-words [overflow-wrap:anywhere]">
{parent.email} {parent.email}
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
</span> </span>
</div> </div>
</div> </div>
@@ -351,6 +354,7 @@ export function FamiliesListClient({
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" /> <Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]"> <span className="break-words [overflow-wrap:anywhere]">
{parent.email} {parent.email}
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
</span> </span>
</div> </div>
</div> </div>
@@ -416,3 +420,11 @@ export function FamiliesListClient({
</div> </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>;
}
+55 -13
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import crypto from "crypto"; import { hashToken, newToken } from "@/lib/tokens";
import { createElement } from "react"; import { createElement } from "react";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { Prisma, UserRole } from "@prisma/client"; import { Prisma, UserRole } from "@prisma/client";
@@ -9,6 +9,7 @@ import { z } from "zod";
import { InviteEmail } from "@/emails/InviteEmail"; import { InviteEmail } from "@/emails/InviteEmail";
import { requireRole } from "@/lib/auth-utils"; import { requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail"; import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { rateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
const INVITE_TOKEN_TTL_DAYS = 7; const INVITE_TOKEN_TTL_DAYS = 7;
@@ -115,7 +116,7 @@ function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
} }
function createInviteToken() { function createInviteToken() {
const token = crypto.randomUUID(); const token = newToken();
const expires = new Date( const expires = new Date(
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000, Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000,
); );
@@ -153,6 +154,8 @@ export async function addFamilyAction(
return { errors: { _form: ["Kein Mandant zugeordnet."] } }; 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)); const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData));
if (!parsedFamily.success) { if (!parsedFamily.success) {
return { errors: parsedFamily.error.flatten().fieldErrors }; return { errors: parsedFamily.error.flatten().fieldErrors };
@@ -245,7 +248,7 @@ export async function addFamilyAction(
await tx.verificationToken.create({ await tx.verificationToken.create({
data: { data: {
identifier: createdParent.id, identifier: createdParent.id,
token: invite.token, token: hashToken(invite.token),
expires: invite.expires, expires: invite.expires,
}, },
}); });
@@ -320,6 +323,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
const removedChildIdSet = new Set(removedChildIds); const removedChildIdSet = new Set(removedChildIds);
const activeChildren = children.filter((child) => !removedChildIdSet.has(child.id)); 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) { if (activeChildren.length + newChildren.length === 0) {
return { error: "Eine Familie benötigt mindestens ein Kind." }; return { error: "Eine Familie benötigt mindestens ein Kind." };
} }
@@ -347,7 +351,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
where: { id: familyId, kitaId }, where: { id: familyId, kitaId },
select: { select: {
id: true, id: true,
users: { select: { id: true } }, users: { select: { id: true, email: true } },
children: { select: { id: true } }, children: { select: { id: true } },
}, },
}); });
@@ -356,6 +360,13 @@ export async function updateFamilyAction(rawPayload: unknown) {
return { error: "Familie wurde nicht gefunden." }; 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 existingParentIds = new Set(existingFamily.users.map((user) => user.id));
const existingChildIds = new Set(existingFamily.children.map((child) => child.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) { 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({ await tx.user.update({
where: { id: parent.id }, where: { id: parent.id, kitaId, familyId },
data: { data: { firstName: parent.firstName, lastName: parent.lastName, email: parent.email,
firstName: parent.firstName, ...(emailChanged ? { passwordHash: "", emailVerifiedAt: null, sessionVersion: { increment: 1 } } : {}) },
lastName: parent.lastName,
email: parent.email,
},
}); });
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) { for (const parent of newParents) {
@@ -413,7 +430,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
await tx.verificationToken.create({ await tx.verificationToken.create({
data: { data: {
identifier: createdParent.id, identifier: createdParent.id,
token: invite.token, token: hashToken(invite.token),
expires: invite.expires, expires: invite.expires,
}, },
}); });
@@ -421,7 +438,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
for (const child of activeChildren) { for (const child of activeChildren) {
await tx.child.update({ await tx.child.update({
where: { id: child.id }, where: { id: child.id, kitaId, familyId },
data: { data: {
firstName: child.firstName, firstName: child.firstName,
lastName: child.lastName, lastName: child.lastName,
@@ -430,7 +447,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
} }
for (const childId of removedChildIds) { 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) { for (const child of newChildren) {
@@ -469,3 +486,28 @@ export async function updateFamilyAction(rawPayload: unknown) {
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { success: true }; 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 };
}
+1 -1
View File
@@ -9,7 +9,7 @@ import { prisma } from "@/lib/prisma";
const dutySchema = z.object({ const dutySchema = z.object({
name: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein").max(50), 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) { export async function createDuty(rawPayload: unknown) {
@@ -154,7 +154,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
return; return;
} }
toast.success("Familie aktualisiert."); toast.success("Familie aktualisiert. Geänderte E-Mail-Adressen müssen über einen neuen Einladungslink bestätigt werden.");
setRemovedChildIds([]); setRemovedChildIds([]);
}); });
} }
+84 -67
View File
@@ -1,8 +1,10 @@
"use server"; "use server";
import { UserFacingError, publicActionError } from "@/lib/action-error";
import { createElement } from "react"; import { createElement } from "react";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { import {
Prisma,
NotdienstAlertStatus, NotdienstAlertStatus,
NotdienstPlanStatus, NotdienstPlanStatus,
NotdienstSwapStatus, NotdienstSwapStatus,
@@ -17,6 +19,8 @@ import { z } from "zod";
import { NotdienstSwapEmail } from "@/emails/NotdienstSwapEmail"; import { NotdienstSwapEmail } from "@/emails/NotdienstSwapEmail";
import { requireKitaSession, requireRole } from "@/lib/auth-utils"; import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { sendAppEmail } from "@/lib/mail"; import { sendAppEmail } from "@/lib/mail";
import { serializableTransaction } from "@/lib/transaction";
import { rateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// ===================================================================== // =====================================================================
@@ -24,8 +28,8 @@ import { prisma } from "@/lib/prisma";
// ===================================================================== // =====================================================================
const terminSchema = z.object({ const terminSchema = z.object({
title: z.string().trim().min(2, "Titel muss mindestens 2 Zeichen lang sein"), title: z.string().trim().min(2, "Titel muss mindestens 2 Zeichen lang sein").max(160),
description: z.string().trim().optional(), description: z.string().trim().max(20000).optional(),
type: z.nativeEnum(TerminType), type: z.nativeEnum(TerminType),
startDate: z.string().datetime(), startDate: z.string().datetime(),
endDate: z.string().datetime(), endDate: z.string().datetime(),
@@ -99,8 +103,9 @@ type SwapAssignmentWithFamily = {
async function getSwapAssignment( async function getSwapAssignment(
assignmentId: string, assignmentId: string,
kitaId: string, kitaId: string,
db: Prisma.TransactionClient = prisma,
): Promise<SwapAssignmentWithFamily | null> { ): Promise<SwapAssignmentWithFamily | null> {
return prisma.notdienstAssignment.findFirst({ return db.notdienstAssignment.findFirst({
where: { id: assignmentId, kitaId }, where: { id: assignmentId, kitaId },
select: { select: {
id: true, id: true,
@@ -114,7 +119,7 @@ async function getSwapAssignment(
select: { select: {
name: true, name: true,
users: { users: {
where: { role: UserRole.ELTERN }, where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { select: {
email: true, email: true,
firstName: true, firstName: true,
@@ -295,6 +300,7 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
export async function createNotdienstSwapRequest(rawPayload: unknown) { export async function createNotdienstSwapRequest(rawPayload: unknown) {
const session = await requireKitaSession(); 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) { if (session.user.role !== UserRole.ELTERN) {
return { error: "Nur Eltern können Notdienst-Tausche anfragen." }; return { error: "Nur Eltern können Notdienst-Tausche anfragen." };
} }
@@ -311,54 +317,58 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
const today = getTodayUtcDateOnly(); const today = getTodayUtcDateOnly();
try { try {
const [targetAssignment, offeredAssignment, kita, existingRequest] = const { targetAssignment, offeredAssignment, kita } = await serializableTransaction(async tx => {
await Promise.all([ const [targetAssignment, offeredAssignment, kita, existingRequest] =
getSwapAssignment(targetAssignmentId, session.user.kitaId), await Promise.all([
getSwapAssignment(offeredAssignmentId, session.user.kitaId), getSwapAssignment(targetAssignmentId, session.user.kitaId, tx),
prisma.kita.findUnique({ getSwapAssignment(offeredAssignmentId, session.user.kitaId, tx),
where: { id: session.user.kitaId }, tx.kita.findUnique({
select: { name: true }, where: { id: session.user.kitaId },
}), select: { name: true },
prisma.notdienstSwapRequest.findFirst({ }),
where: { tx.notdienstSwapRequest.findFirst({
kitaId: session.user.kitaId, where: {
status: NotdienstSwapStatus.PENDING, kitaId: session.user.kitaId,
OR: [ status: NotdienstSwapStatus.PENDING,
{ targetAssignmentId }, OR: [
{ offeredAssignmentId }, { targetAssignmentId },
{ targetAssignmentId: offeredAssignmentId }, { offeredAssignmentId },
{ offeredAssignmentId: targetAssignmentId }, { targetAssignmentId: offeredAssignmentId },
], { offeredAssignmentId: targetAssignmentId },
}, ],
select: { id: true }, },
}), select: { id: true },
]); }),
]);
const validationError = validateSwapAssignmentPair({ const validationError = validateSwapAssignmentPair({
targetAssignment, targetAssignment,
offeredAssignment, offeredAssignment,
requesterFamilyId: session.user.familyId, requesterFamilyId: session.user.familyId!,
today, today,
}); });
if (validationError) { if (validationError) {
return { error: validationError }; throw new UserFacingError(validationError);
} }
if (!targetAssignment || !offeredAssignment || !kita) { if (!targetAssignment || !offeredAssignment || !kita) {
return { error: "Notdienst nicht gefunden." }; throw new UserFacingError("Notdienst nicht gefunden.");
} }
if (existingRequest) { if (existingRequest) {
return { error: "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage." }; throw new UserFacingError("Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.");
} }
await prisma.notdienstSwapRequest.create({ await tx.notdienstSwapRequest.create({
data: { data: {
kitaId: session.user.kitaId, kitaId: session.user.kitaId,
requesterUserId: session.user.id, requesterUserId: session.user.id,
requesterFamilyId: session.user.familyId, requesterFamilyId: session.user.familyId!,
targetAssignmentId, targetAssignmentId,
offeredAssignmentId, offeredAssignmentId,
message: message || null, message: message || null,
}, },
});
return { targetAssignment, offeredAssignment, kita };
}); });
await notifyFamilyAboutSwap({ await notifyFamilyAboutSwap({
@@ -375,7 +385,9 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
revalidateCalendarAndNotdienst(); revalidateCalendarAndNotdienst();
return { success: true }; return { success: true };
} catch (error) { } 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." }; return { error: "Tauschanfrage konnte nicht erstellt werden." };
} }
} }
@@ -394,7 +406,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
const today = getTodayUtcDateOnly(); const today = getTodayUtcDateOnly();
try { try {
const result = await prisma.$transaction(async (tx) => { const result = await serializableTransaction(async (tx) => {
const request = await tx.notdienstSwapRequest.findFirst({ const request = await tx.notdienstSwapRequest.findFirst({
where: { where: {
id: parsed.data, id: parsed.data,
@@ -419,7 +431,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
select: { select: {
name: true, name: true,
users: { users: {
where: { role: UserRole.ELTERN }, where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true }, select: { email: true, firstName: true, lastName: true },
}, },
}, },
@@ -445,7 +457,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
select: { select: {
name: true, name: true,
users: { users: {
where: { role: UserRole.ELTERN }, where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true }, select: { email: true, firstName: true, lastName: true },
}, },
}, },
@@ -462,11 +474,11 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
}); });
if (!request) { 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) { 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({ const validationError = validateSwapAssignmentPair({
@@ -476,7 +488,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
today, today,
}); });
if (validationError) { if (validationError) {
throw new Error(validationError); throw new UserFacingError(validationError);
} }
await tx.notdienstAssignment.update({ await tx.notdienstAssignment.update({
@@ -487,14 +499,15 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
where: { id: request.offeredAssignmentId }, where: { id: request.offeredAssignmentId },
data: { childId: request.targetAssignment.childId }, data: { childId: request.targetAssignment.childId },
}); });
await tx.notdienstSwapRequest.update({ const responded = await tx.notdienstSwapRequest.updateMany({
where: { id: request.id }, where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING },
data: { data: {
status: NotdienstSwapStatus.ACCEPTED, status: NotdienstSwapStatus.ACCEPTED,
respondedById: session.user.id, respondedById: session.user.id,
respondedAt: new Date(), respondedAt: new Date(),
}, },
}); });
if (responded.count !== 1) throw new UserFacingError("Diese Anfrage ist nicht mehr offen.");
await tx.notdienstSwapRequest.updateMany({ await tx.notdienstSwapRequest.updateMany({
where: { where: {
id: { not: request.id }, id: { not: request.id },
@@ -550,7 +563,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
return { 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: { select: {
name: true, name: true,
users: { users: {
where: { role: UserRole.ELTERN }, where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true }, select: { email: true, firstName: true, lastName: true },
}, },
}, },
@@ -603,7 +616,7 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
select: { select: {
name: true, name: true,
users: { users: {
where: { role: UserRole.ELTERN }, where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true }, 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." }; return { error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen." };
} }
await prisma.notdienstSwapRequest.update({ const responded = await prisma.notdienstSwapRequest.updateMany({
where: { id: request.id }, where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING },
data: { data: {
status: NotdienstSwapStatus.DECLINED, status: NotdienstSwapStatus.DECLINED,
respondedById: session.user.id, respondedById: session.user.id,
respondedAt: new Date(), respondedAt: new Date(),
}, },
}); });
if (responded.count !== 1) return { error: "Diese Anfrage ist nicht mehr offen." };
const kita = await prisma.kita.findUnique({ const kita = await prisma.kita.findUnique({
where: { id: session.user.kitaId }, where: { id: session.user.kitaId },
@@ -694,14 +708,15 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) {
return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." }; return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." };
} }
await prisma.notdienstSwapRequest.update({ const responded = await prisma.notdienstSwapRequest.updateMany({
where: { id: request.id }, where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING },
data: { data: {
status: NotdienstSwapStatus.CANCELLED, status: NotdienstSwapStatus.CANCELLED,
respondedById: session.user.id, respondedById: session.user.id,
respondedAt: new Date(), respondedAt: new Date(),
}, },
}); });
if (responded.count !== 1) return { error: "Diese Anfrage ist nicht mehr offen." };
revalidateCalendarAndNotdienst(); revalidateCalendarAndNotdienst();
return { success: true }; return { success: true };
@@ -713,6 +728,7 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) {
export async function createTerminRequest(rawPayload: unknown) { export async function createTerminRequest(rawPayload: unknown) {
const session = await requireKitaSession(); 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); const parsed = terminSchema.safeParse(rawPayload);
if (!parsed.success) { if (!parsed.success) {
@@ -914,11 +930,12 @@ export async function toggleMitbringselList(terminId: string, enabled: boolean)
const mitbringselSchema = z.object({ const mitbringselSchema = z.object({
terminId: z.string(), 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) { export async function addMitbringsel(rawPayload: unknown) {
const session = await requireKitaSession(); 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); const parsed = mitbringselSchema.safeParse(rawPayload);
if (!parsed.success) { if (!parsed.success) {
+4
View File
@@ -239,6 +239,8 @@ export default async function KalenderPage({
const allUserTermine: CalendarListItemDto[] = [ const allUserTermine: CalendarListItemDto[] = [
...confirmedTermineRows.map((termin) => ({ ...confirmedTermineRows.map((termin) => ({
...termin, ...termin,
participantInfo: isAdmin || termin.participations.some(p => p.status === EventParticipationStatus.ATTENDING)
? termin.participantInfo : null,
kind: "termin" as const, kind: "termin" as const,
showParticipantInfo: showParticipantInfo:
isAdmin || isAdmin ||
@@ -249,6 +251,8 @@ export default async function KalenderPage({
})), })),
...myPendingTermineRows.map((termin) => ({ ...myPendingTermineRows.map((termin) => ({
...termin, ...termin,
participantInfo: isAdmin || termin.participations.some(p => p.status === EventParticipationStatus.ATTENDING)
? termin.participantInfo : null,
kind: "termin" as const, kind: "termin" as const,
showParticipantInfo: showParticipantInfo:
isAdmin || isAdmin ||
+3 -3
View File
@@ -18,13 +18,13 @@ import { getTargetMonthData } from "@/lib/date-utils";
const dateKeyPattern = /^\d{4}-\d{2}-\d{2}$/; const dateKeyPattern = /^\d{4}-\d{2}-\d{2}$/;
const payloadSchema = z.object({ 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( dates: z.array(
z.string().regex(dateKeyPattern).refine((value) => { z.string().regex(dateKeyPattern).refine((value) => {
const date = dateKeyToUtcDate(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) { function dateKeyToUtcDate(dateKey: string) {
+21 -11
View File
@@ -1,11 +1,14 @@
"use server"; "use server";
import { UserFacingError, publicActionError } from "@/lib/action-error";
import { createElement } from "react"; import { createElement } from "react";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { NotdienstPlanStatus, UserRole } from "@prisma/client"; import { NotdienstPlanStatus, UserRole } from "@prisma/client";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; 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 { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils"; import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
@@ -32,6 +35,8 @@ export async function generatePlanAction(year?: number, month?: number) {
targetMonth = data.targetMonth; targetMonth = data.targetMonth;
} }
if (!validPlanMonth(targetYear, targetMonth)) return { error: "Ungültiger Planungsmonat." };
// Prüfen, ob schon ein Plan existiert // Prüfen, ob schon ein Plan existiert
const existingPlan = await prisma.notdienstPlan.findUnique({ const existingPlan = await prisma.notdienstPlan.findUnique({
where: { where: {
@@ -156,6 +161,7 @@ export async function updateAssignmentAction(
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!; 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 [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy)); const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
@@ -166,21 +172,21 @@ export async function updateAssignmentAction(
}); });
if (!plan) { if (!plan) {
throw new Error("Plan nicht gefunden."); throw new UserFacingError("Plan nicht gefunden.");
} }
const isDraft = plan.status === NotdienstPlanStatus.DRAFT; const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED; const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
if (!isDraft && !isPublished) { if (!isDraft && !isPublished) {
throw new Error("Plan kann nicht bearbeitet werden."); throw new UserFacingError("Plan kann nicht bearbeitet werden.");
} }
if (isPublished) { if (isPublished) {
const today = new Date(); const today = new Date();
const todayUtc = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate())); const todayUtc = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
if (parsedDate < todayUtc) { 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: { family: {
include: { include: {
users: { users: {
where: { role: UserRole.ELTERN }, where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true }, select: { email: true, firstName: true, lastName: true },
}, },
}, },
@@ -222,7 +228,7 @@ export async function updateAssignmentAction(
family: { family: {
include: { include: {
users: { users: {
where: { role: UserRole.ELTERN }, where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true }, select: { email: true, firstName: true, lastName: true },
}, },
}, },
@@ -231,7 +237,7 @@ export async function updateAssignmentAction(
}); });
if (!newChild) { 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 }; return { success: true };
} catch (error) { } catch (error) {
return { 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." }; 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 { try {
await prisma.kita.update({ await prisma.kita.update({
where: { id: kitaId }, where: { id: kitaId },
@@ -398,6 +406,8 @@ export async function updateKitaNotdienstSettingsAction(
export async function sendNotdienstRemindersAction(year: number, month: number) { export async function sendNotdienstRemindersAction(year: number, month: number) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); 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; const kitaId = session.user.kitaId;
if (!kitaId) { if (!kitaId) {
return { error: "Kein Mandant zugeordnet." }; return { error: "Kein Mandant zugeordnet." };
@@ -426,7 +436,7 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
}, },
include: { include: {
users: { 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) { if (!plan) {
throw new Error("Plan nicht gefunden."); throw new UserFacingError("Plan nicht gefunden.");
} }
if (plan.status !== NotdienstPlanStatus.DRAFT) { 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 // Delete all assignments
@@ -535,6 +545,6 @@ export async function resetPlanAction(planId: string) {
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Fehler beim Zurücksetzen des Plans:", 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.") };
} }
} }
+21 -13
View File
@@ -10,6 +10,7 @@ import {
getChildBirthdateRange, getChildBirthdateRange,
parseDateInput, parseDateInput,
} from "@/lib/child-birthdate"; } from "@/lib/child-birthdate";
import { serializableTransaction } from "@/lib/transaction";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche, // Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche,
@@ -118,14 +119,18 @@ export async function createMyChild(rawPayload: unknown) {
} }
try { try {
await prisma.child.create({ await serializableTransaction(async tx => {
data: { const count = await tx.child.count({ where: { kitaId: session.user.kitaId, familyId: session.user.familyId! } });
kitaId: session.user.kitaId, if (count >= 20) throw new Error("Household child limit reached");
familyId: session.user.familyId, await tx.child.create({
firstName: parsed.data.firstName, data: {
lastName: parsed.data.lastName, kitaId: session.user.kitaId,
dateOfBirth: parseDateInput(parsed.data.dateOfBirth), familyId: session.user.familyId!,
}, firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
dateOfBirth: parseDateInput(parsed.data.dateOfBirth),
},
});
}); });
revalidatePath("/dashboard/profil"); revalidatePath("/dashboard/profil");
@@ -158,10 +163,11 @@ export async function createMyFamily(rawPayload: unknown) {
}, },
}); });
await tx.user.update({ const attached = await tx.user.updateMany({
where: { id: session.user.id }, where: { id: session.user.id, kitaId: session.user.kitaId, familyId: null, disabledAt: null, sessionVersion: session.user.sessionVersion },
data: { familyId: family.id }, data: { familyId: family.id },
}); });
if (attached.count !== 1) throw new Error("Account already assigned or revoked");
}); });
revalidatePath("/dashboard/profil"); revalidatePath("/dashboard/profil");
@@ -170,7 +176,9 @@ export async function createMyFamily(rawPayload: unknown) {
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { success: true }; return { success: true };
} catch (error) { } 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." }; return { error: "Der Haushalt konnte nicht angelegt werden." };
} }
} }
@@ -239,7 +247,7 @@ export async function updateMyChild(childId: string, rawPayload: unknown) {
} }
await prisma.child.update({ await prisma.child.update({
where: { id: childId }, where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! },
data: { data: {
firstName: parsed.data.firstName, firstName: parsed.data.firstName,
lastName: parsed.data.lastName, lastName: parsed.data.lastName,
@@ -271,7 +279,7 @@ export async function deleteMyChild(childId: string) {
} }
await prisma.child.delete({ await prisma.child.delete({
where: { id: childId }, where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! },
}); });
revalidatePath("/dashboard/profil"); revalidatePath("/dashboard/profil");
+26 -22
View File
@@ -1,9 +1,12 @@
"use server"; "use server";
import { passwordSchema } from "@/lib/password";
import { limitPublicAction } from "@/lib/rate-limit";
import { hash } from "bcryptjs"; import { hash } from "bcryptjs";
import { z } from "zod"; import { z } from "zod";
import { signIn } from "@/auth"; import { signIn } from "@/auth";
import { hashToken, isPublicToken } from "@/lib/tokens";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// ===================================================================== // =====================================================================
@@ -18,8 +21,8 @@ import { prisma } from "@/lib/prisma";
const inviteSchema = z const inviteSchema = z
.object({ .object({
token: z.string().min(1), token: z.string().refine(isPublicToken),
password: z.string().min(8, "Mindestens 8 Zeichen.").max(72, "Maximal 72 Zeichen."), password: passwordSchema,
confirmPassword: z.string().min(1, "Passwort-Bestätigung fehlt."), confirmPassword: z.string().min(1, "Passwort-Bestätigung fehlt."),
acceptPrivacyPolicy: z.literal("on", { acceptPrivacyPolicy: z.literal("on", {
error: "Bitte die Datenschutzerklärung akzeptieren.", error: "Bitte die Datenschutzerklärung akzeptieren.",
@@ -80,11 +83,12 @@ export async function acceptInviteAction(
} }
const { token, password, directoryOptIn } = parsed.data; 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(); const now = new Date();
// ── 1. Token erneut DB-seitig validieren ────────────────────────── // ── 1. Token erneut DB-seitig validieren ──────────────────────────
const verificationToken = await prisma.verificationToken.findUnique({ const verificationToken = await prisma.verificationToken.findFirst({
where: { token }, where: { token: { in: [hashToken(token), ...(token.length === 36 ? [token] : [])] }, expires: { gt: new Date() } },
}); });
if (!verificationToken) { if (!verificationToken) {
@@ -111,10 +115,10 @@ export async function acceptInviteAction(
// ── 2. User validieren (existiert und hat noch kein Passwort) ────── // ── 2. User validieren (existiert und hat noch kein Passwort) ──────
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: userId }, 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 { return {
attempt, attempt,
values, values,
@@ -138,22 +142,22 @@ export async function acceptInviteAction(
const passwordHash = await hash(password, 12); const passwordHash = await hash(password, 12);
// ── 4. User updaten + Token löschen (atomar) ────────────────────── // ── 4. User updaten + Token löschen (atomar) ──────────────────────
await prisma.$transaction([ try {
prisma.user.update({ await prisma.$transaction(async tx => {
where: { id: userId }, const consumed = await tx.verificationToken.deleteMany({ where: { token: verificationToken.token, expires: { gt: new Date() } } });
data: { if (consumed.count !== 1) throw new Error("INVITE_USED");
passwordHash, const activated = await tx.user.updateMany({
privacyPolicyAcceptedAt: now, where: { id: userId, passwordHash: "", disabledAt: null },
privacyPolicyVersion: PRIVACY_POLICY_VERSION, data: { passwordHash, privacyPolicyAcceptedAt: now, privacyPolicyVersion: PRIVACY_POLICY_VERSION,
directoryOptInAt: directoryOptIn === "on" ? now : null, directoryOptInAt: directoryOptIn === "on" ? now : null, emailVerifiedAt: now, lastLoginAt: now,
emailVerifiedAt: now, sessionVersion: { increment: 1 } },
lastLoginAt: now, });
}, if (activated.count !== 1) throw new Error("INVITE_USED");
}), });
prisma.verificationToken.delete({ } catch (error) {
where: { token }, 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 ───────────────────── // ── 5. Direkt einloggen → wirft NEXT_REDIRECT ─────────────────────
await signIn("credentials", { await signIn("credentials", {
+9 -4
View File
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Baby } from "lucide-react"; import { Baby } from "lucide-react";
import { hashToken, isPublicToken } from "@/lib/tokens";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { import {
Card, Card,
@@ -12,7 +13,8 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { InviteForm } from "./invite-form"; 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) // /invite/[token] · Öffentliche Seite (kein Auth erforderlich)
@@ -30,9 +32,11 @@ export default async function InvitePage({
}) { }) {
const { token } = await params; const { token } = await params;
if (!isPublicToken(token)) notFound();
// Token in DB nachschlagen // Token in DB nachschlagen
const verificationToken = await prisma.verificationToken.findUnique({ const verificationToken = await prisma.verificationToken.findFirst({
where: { token }, where: { token: { in: [hashToken(token), ...(token.length === 36 ? [token] : [])] }, expires: { gt: new Date() } },
}); });
// Unbekannter Token → 404 // Unbekannter Token → 404
@@ -46,6 +50,7 @@ export default async function InvitePage({
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: verificationToken.identifier }, where: { id: verificationToken.identifier },
select: { select: {
disabledAt: true,
firstName: true, firstName: true,
lastName: true, lastName: true,
emailVerifiedAt: 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 userName = `${user.firstName} ${user.lastName}`;
const alreadyAccepted = !!user.emailVerifiedAt; const alreadyAccepted = !!user.emailVerifiedAt;
+6 -2
View File
@@ -1,3 +1,5 @@
import { getSession } from "@/lib/auth-utils";
import { headers } from "next/headers";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Manrope } from "next/font/google"; import { Manrope } from "next/font/google";
import "./globals.css"; import "./globals.css";
@@ -44,11 +46,13 @@ export const metadata: Metadata = {
}, },
}; };
export default function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const [requestHeaders, session] = await Promise.all([headers(), getSession()]);
const nonce = requestHeaders.get("x-nonce") ?? undefined;
return ( return (
<html <html
lang="de" lang="de"
@@ -56,7 +60,7 @@ export default function RootLayout({
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
{children} {children}
<UmamiAnalytics /> <UmamiAnalytics nonce={nonce} enabled={!session?.user?.id} />
<Toaster /> <Toaster />
</body> </body>
</html> </html>
+2 -7
View File
@@ -3,6 +3,7 @@
import { AuthError } from "next-auth"; import { AuthError } from "next-auth";
import { z } from "zod"; import { z } from "zod";
import { safeCallbackUrl } from "@/lib/safe-redirect";
import { signIn } from "@/auth"; import { signIn } from "@/auth";
// ===================================================================== // =====================================================================
@@ -17,7 +18,7 @@ import { signIn } from "@/auth";
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase().trim(), 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(), 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( export async function loginAction(
_prev: LoginState, _prev: LoginState,
formData: FormData, formData: FormData,
+6 -13
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { safeCallbackUrl } from "@/lib/safe-redirect";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getPostLoginRedirect } from "@/lib/post-login-redirect"; import { getPostLoginRedirect } from "@/lib/post-login-redirect";
import { import {
@@ -16,25 +17,17 @@ import { LoginForm } from "./login-form";
export const metadata = { title: "Anmelden · Kita-Planer" }; export const metadata = { title: "Anmelden · Kita-Planer" };
type LoginPageProps = { 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) { export default async function LoginPage({ searchParams }: LoginPageProps) {
const resolvedSearchParams = await searchParams;
const session = await auth(); const session = await auth();
if (session?.user?.id) { if (session?.user?.id && resolvedSearchParams?.reauth !== "1") {
redirect(getPostLoginRedirect(session.user)); redirect(getPostLoginRedirect(session.user));
} }
const resolvedSearchParams = await searchParams; const raw = resolvedSearchParams?.callbackUrl;
const callbackUrl = getSafeCallbackUrl(resolvedSearchParams?.callbackUrl); const callbackUrl = safeCallbackUrl(Array.isArray(raw) ? raw[0] : raw);
const isDev = process.env.NODE_ENV === "development"; const isDev = process.env.NODE_ENV === "development";
const devAdminEmail = isDev ? process.env.DEV_LOGIN_ADMIN_EMAIL : undefined; const devAdminEmail = isDev ? process.env.DEV_LOGIN_ADMIN_EMAIL : undefined;
+11 -10
View File
@@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
import { Prisma, UserRole } from "@prisma/client"; import { Prisma, UserRole } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/auth"; import { requireSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// ===================================================================== // =====================================================================
@@ -23,9 +23,9 @@ const onboardingSchema = z
.trim() .trim()
.min(2, "Bitte mindestens 2 Zeichen angeben.") .min(2, "Bitte mindestens 2 Zeichen angeben.")
.max(120, "Maximal 120 Zeichen."), .max(120, "Maximal 120 Zeichen."),
notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()).default(false),
terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()).default(false),
adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()).default(false),
notdienstMinPerChildPerMonth: z.coerce notdienstMinPerChildPerMonth: z.coerce
.number() .number()
.int("Bitte eine ganze Zahl angeben.") .int("Bitte eine ganze Zahl angeben.")
@@ -87,10 +87,10 @@ export async function completeOnboardingAction(
formData: FormData, formData: FormData,
): Promise<OnboardingState> { ): Promise<OnboardingState> {
const attempt = prev.attempt + 1; const attempt = prev.attempt + 1;
const session = await auth(); const session = await requireSession();
if (!session?.user) { if (session.user.role !== UserRole.ADMIN) redirect("/forbidden");
redirect("/login"); const founder = await prisma.user.findUnique({ where: { id: session.user.id } });
} if (!founder?.emailVerifiedAt || !founder.privacyPolicyAcceptedAt) redirect("/register");
if (session.user.kitaId) { if (session.user.kitaId) {
// Onboarding bereits abgeschlossen — verhindert doppeltes Einrichten // Onboarding bereits abgeschlossen — verhindert doppeltes Einrichten
// (z.B. durch zurück-navigieren oder Replay des Formulars). // (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 // tatsächlich noch keine Kita hat (verhindert Race-Conditions bei
// parallelen Onboarding-Submits). // parallelen Onboarding-Submits).
const updated = await tx.user.updateMany({ 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: { data: {
kitaId: kita.id, kitaId: kita.id,
role: UserRole.ADMIN, role: UserRole.ADMIN,
}, },
}); });
if (updated.count === 0) { if (updated.count !== 1) {
throw new Error("USER_ALREADY_HAS_KITA"); throw new Error("USER_ALREADY_HAS_KITA");
} }
}); });
+34 -59
View File
@@ -1,26 +1,23 @@
"use server"; "use server";
import { hash } from "bcryptjs"; import { createElement } from "react";
import { Prisma, UserRole } from "@prisma/client";
import { z } from "zod"; 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"; import { prisma } from "@/lib/prisma";
// ===================================================================== // =====================================================================
// /register · Server Action // /register · Server Action
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Legt einen "heimatlosen" Admin-Account an (kitaId === null) und loggt // Speichert eine kurzlebige Registrierung und versendet einen Bestätigungslink.
// ihn direkt ein. Die Tenant-Erstellung folgt im Onboarding-Wizard. // Konto und Passwort entstehen erst nach dem Nachweis des Postfachbesitzes.
// ===================================================================== // =====================================================================
const registerSchema = z.object({ const registerSchema = z.object({
email: z.string().email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase().trim(), email: z.string().trim().max(254).email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase(),
// bcrypt akzeptiert max. 72 Bytes — wir capen davor zur Sicherheit
password: z
.string()
.min(8, "Mindestens 8 Zeichen.")
.max(72, "Maximal 72 Zeichen."),
firstName: z.string().min(1, "Pflichtfeld.").max(100).trim(), firstName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
lastName: 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 // 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 = { export type RegisterState = {
// Bumped after every action call so the client can key uncontrolled inputs // Bumped after every action call so the client can key uncontrolled inputs
// and force a remount with the echoed values from a failed submit. // and force a remount with the echoed values from a failed submit.
attempt: number; attempt: number;
message?: string;
// Echoed inputs (sans password) so failed submits don't wipe the form. // Echoed inputs (sans password) so failed submits don't wipe the form.
values?: { values?: {
firstName?: string; firstName?: string;
@@ -54,9 +51,9 @@ export type RegisterState = {
function echoValues(formData: FormData): RegisterState["values"] { function echoValues(formData: FormData): RegisterState["values"] {
return { return {
firstName: String(formData.get("firstName") ?? ""), firstName: String(formData.get("firstName") ?? "").slice(0, 100),
lastName: String(formData.get("lastName") ?? ""), lastName: String(formData.get("lastName") ?? "").slice(0, 100),
email: String(formData.get("email") ?? ""), email: String(formData.get("email") ?? "").slice(0, 254),
acceptPrivacyPolicy: formData.get("acceptPrivacyPolicy") === "on", acceptPrivacyPolicy: formData.get("acceptPrivacyPolicy") === "on",
}; };
} }
@@ -74,50 +71,28 @@ export async function registerAction(
errors: parsed.error.flatten().fieldErrors, errors: parsed.error.flatten().fieldErrors,
}; };
} }
const { email, password, firstName, lastName } = parsed.data; 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.";
const passwordHash = await hash(password, 12); if (!(await limitPublicAction("register", email))) {
const now = new Date(); return { attempt, errors: { _form: ["Zu viele Anfragen. Bitte versuche es später erneut."] } };
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 existing = await prisma.user.findUnique({ where: { email }, select: { role: true, kitaId: true, emailVerifiedAt: true, disabledAt: true } });
// Direkt einloggen → wirft NEXT_REDIRECT, das wir nicht abfangen. if (existing && (existing.role !== "ADMIN" || existing.kitaId || existing.emailVerifiedAt || existing.disabledAt)) return { attempt, message };
await signIn("credentials", { const token = newToken();
email, await prisma.pendingRegistration.deleteMany({ where: { expiresAt: { lt: new Date() } } });
password, await prisma.pendingRegistration.upsert({
redirectTo: "/onboarding", 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) },
}); });
const base = process.env.AUTH_URL ?? process.env.NEXTAUTH_URL ?? "http://localhost:3000";
// Unreachable – signIn redirected. const result = await sendAppEmail({
return { attempt }; 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 };
} }
+1 -1
View File
@@ -27,7 +27,7 @@ export default async function RegisterPage() {
<CardHeader className="space-y-2"> <CardHeader className="space-y-2">
<CardTitle>Kita kostenlos registrieren</CardTitle> <CardTitle>Kita kostenlos registrieren</CardTitle>
<CardDescription> <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> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
+3 -13
View File
@@ -22,6 +22,8 @@ export function RegisterForm() {
// semantics would wipe them after a failed submit. // semantics would wipe them after a failed submit.
const attemptKey = state.attempt; const attemptKey = state.attempt;
if (state.message) return <p role="status" className="text-sm">{state.message}</p>;
return ( return (
<form action={formAction} className="space-y-4"> <form action={formAction} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
@@ -59,18 +61,6 @@ export function RegisterForm() {
error={state.errors?.email?.[0]} 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"> <div className="flex items-start gap-3 pt-2">
<Checkbox <Checkbox
key={`acceptPrivacyPolicy-${attemptKey}`} key={`acceptPrivacyPolicy-${attemptKey}`}
@@ -122,7 +112,7 @@ export function RegisterForm() {
)} )}
<Button type="submit" className="w-full" disabled={pending}> <Button type="submit" className="w-full" disabled={pending}>
{pending ? "Wird erstellt…" : "Account erstellen"} {pending ? "Wird gesendet…" : "Bestätigungs-E-Mail anfordern"}
</Button> </Button>
</form> </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 {};
}
+17
View File
@@ -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
View File
@@ -1,6 +1,7 @@
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials"; import Credentials from "next-auth/providers/credentials";
import { compare } from "bcryptjs"; import { compare } from "bcryptjs";
import { limitPublicAction } from "@/lib/rate-limit";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -14,12 +15,12 @@ import { prisma } from "@/lib/prisma";
// ===================================================================== // =====================================================================
const credentialsSchema = z.object({ const credentialsSchema = z.object({
email: z.string().email().toLowerCase().trim(), email: z.string().trim().max(254).email().toLowerCase(),
password: z.string().min(1), password: z.string().min(1).refine(value => Buffer.byteLength(value, "utf8") <= 72),
}); });
// Brute-Force-Schutz auf Account-Ebene (planung.md §5.5). // 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 MAX_FAILED_ATTEMPTS = 10;
const LOCKOUT_MINUTES = 15; const LOCKOUT_MINUTES = 15;
@@ -40,8 +41,14 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (!parsed.success) return null; if (!parsed.success) return null;
const { email, password } = parsed.data; const { email, password } = parsed.data;
if (!(await limitPublicAction("login", email, 20, 900))) return null;
const user = await prisma.user.findUnique({ where: { email } }); 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. // Account temporär gesperrt → Login pauschal ablehnen.
if (user.lockedUntil && user.lockedUntil > new Date()) { 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); const passwordOk = await compare(password, user.passwordHash);
if (!passwordOk) { if (!passwordOk) {
const nextAttempts = user.failedLoginAttempts + 1; await prisma.$executeRaw`
await prisma.user.update({ UPDATE users SET "failedLoginAttempts" = "failedLoginAttempts" + 1,
where: { id: user.id }, "lockedUntil" = CASE WHEN "failedLoginAttempts" + 1 >= ${MAX_FAILED_ATTEMPTS}
data: { THEN NOW() + make_interval(mins => ${LOCKOUT_MINUTES}::int) ELSE "lockedUntil" END
failedLoginAttempts: nextAttempts, WHERE id = ${user.id}`;
lockedUntil:
nextAttempts >= MAX_FAILED_ATTEMPTS
? new Date(Date.now() + LOCKOUT_MINUTES * 60_000)
: user.lockedUntil,
},
});
return null; return null;
} }
// Erfolgreicher Login → Counter zurücksetzen, lastLogin aktualisieren. // Erfolgreicher Login → Counter zurücksetzen, lastLogin aktualisieren.
await prisma.user.update({ const admitted = await prisma.user.updateMany({
where: { id: user.id }, where: { id: user.id, disabledAt: null, sessionVersion: user.sessionVersion, passwordHash: user.passwordHash, emailVerifiedAt: { not: null },
OR: [{ lockedUntil: null }, { lockedUntil: { lte: new Date() } }] },
data: { data: {
failedLoginAttempts: 0, failedLoginAttempts: 0,
lockedUntil: null, lockedUntil: null,
@@ -74,7 +76,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
}, },
}); });
if (admitted.count !== 1) return null;
return { return {
sessionVersion: user.sessionVersion,
authenticatedAt: Date.now(),
id: user.id, id: user.id,
email: user.email, email: user.email,
name: `${user.firstName} ${user.lastName}`.trim(), name: `${user.firstName} ${user.lastName}`.trim(),
@@ -86,6 +91,13 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
}), }),
], ],
callbacks: { 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`) * Wird beim Login (mit `user`) und bei jedem Token-Refresh (ohne `user`)
* aufgerufen. Beim Login schreiben wir die mandantenrelevanten Felder * aufgerufen. Beim Login schreiben wir die mandantenrelevanten Felder
@@ -96,31 +108,20 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
async jwt({ token, user }) { async jwt({ token, user }) {
if (user) { if (user) {
token.id = user.id; token.id = user.id;
token.role = user.role; token.sessionVersion = user.sessionVersion;
token.kitaId = user.kitaId; token.authenticatedAt = user.authenticatedAt;
token.familyId = user.familyId;
return token;
} }
const id = token.id ?? token.sub;
const tokenUserId = 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;
if (tokenUserId) { const fresh = await prisma.user.findUnique({
const fresh = await prisma.user.findUnique({ where: { id },
where: { id: tokenUserId }, select: { role: true, kitaId: true, familyId: true, disabledAt: true, sessionVersion: true, emailVerifiedAt: true },
select: { role: true, kitaId: true, familyId: true }, });
}); if (!fresh || fresh.disabledAt || !fresh.emailVerifiedAt || fresh.sessionVersion !== token.sessionVersion) return null;
if (!fresh) { token.id = id;
// User wurde gelöscht → Token entwerten. token.role = fresh.role;
// (Auth.js erkennt den fehlenden `sub`/`id` und meldet ab.) token.kitaId = fresh.kitaId;
delete (token as Partial<typeof token>).id; token.familyId = fresh.familyId;
delete (token as Partial<typeof token>).sub;
return token;
}
token.id = tokenUserId;
token.role = fresh.role;
token.kitaId = fresh.kitaId;
token.familyId = fresh.familyId;
}
return token; return token;
}, },
@@ -131,7 +132,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
*/ */
async session({ session, token }) { async session({ session, token }) {
if (token && session.user) { 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.role = token.role;
session.user.kitaId = token.kitaId; session.user.kitaId = token.kitaId;
session.user.familyId = token.familyId; session.user.familyId = token.familyId;
+36 -6
View File
@@ -1,3 +1,7 @@
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
import Script from "next/script"; import Script from "next/script";
import { marketingFeatureSlugs } from "@/lib/marketing/features"; import { marketingFeatureSlugs } from "@/lib/marketing/features";
@@ -6,30 +10,55 @@ const publicAnalyticsPaths = new Set([
"/", "/",
"/impressum", "/impressum",
"/datenschutz", "/datenschutz",
"/login",
"/register",
...marketingFeatureSlugs.map((slug) => `/features/${slug}`), ...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 scriptUrl = process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL;
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID; const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
const domains = process.env.NEXT_PUBLIC_UMAMI_DOMAINS; const domains = process.env.NEXT_PUBLIC_UMAMI_DOMAINS;
if (!scriptUrl || !websiteId) { if (!enabled || !scriptUrl || !websiteId || !publicAnalyticsPaths.has(pathname)) {
return null; return null;
} }
return ( 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) { window.kitaPlanerUmamiBeforeSend = function (_type, payload) {
try { try {
var url = new URL(payload && payload.url ? payload.url : window.location.href, window.location.origin); 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 path = url.pathname.length > 1 && url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname;
var allowedPaths = ${JSON.stringify(Array.from(publicAnalyticsPaths))}; 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) { } catch (_error) {
return false; return false;
} }
@@ -37,6 +66,7 @@ export function UmamiAnalytics() {
`} `}
</Script> </Script>
<Script <Script
nonce={nonce}
src={scriptUrl} src={scriptUrl}
strategy="afterInteractive" strategy="afterInteractive"
data-website-id={websiteId} data-website-id={websiteId}
+4
View File
@@ -0,0 +1,4 @@
export class UserFacingError extends Error {}
export function publicActionError(error: unknown, fallback: string): string {
return error instanceof UserFacingError ? error.message : fallback;
}
+12
View File
@@ -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 },
};
}
+37
View File
@@ -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
View File
@@ -1,3 +1,4 @@
import { cache } from "react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import { UserRole } from "@prisma/client"; import { UserRole } from "@prisma/client";
@@ -28,12 +29,14 @@ export type KitaSession = AuthenticatedSession & {
user: AuthenticatedSession["user"] & { kitaId: string }; user: AuthenticatedSession["user"] & { kitaId: string };
}; };
const readRequestSession = cache(() => auth());
export async function getSession(): Promise<Session | null> { export async function getSession(): Promise<Session | null> {
return auth(); return readRequestSession();
} }
export async function requireSession(): Promise<AuthenticatedSession> { export async function requireSession(): Promise<AuthenticatedSession> {
const session = await auth(); const session = await getSession();
if (!session?.user?.id) { if (!session?.user?.id) {
redirect("/login"); redirect("/login");
} }
@@ -46,17 +49,10 @@ export async function requireSession(): Promise<AuthenticatedSession> {
* für alle Tenant-gebundenen Server Actions / Page Components. * für alle Tenant-gebundenen Server Actions / Page Components.
*/ */
export async function requireKitaSession(): Promise<KitaSession> { export async function requireKitaSession(): Promise<KitaSession> {
if (process.env.TEST_MODE === "true") { return enforceKitaSession(await requireSession());
return { }
user: {
id: process.env.TEST_USER_ID || "test-user-id", async function enforceKitaSession(session: AuthenticatedSession): Promise<KitaSession> {
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();
if (!session.user.kitaId) { if (!session.user.kitaId) {
// Superadmins haben keine Kita → eigene Oberfläche. // Superadmins haben keine Kita → eigene Oberfläche.
@@ -80,20 +76,11 @@ export async function requireKitaSession(): Promise<KitaSession> {
export async function requireRole( export async function requireRole(
allowed: UserRole[], allowed: UserRole[],
): Promise<AuthenticatedSession> { ): 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(); const session = await requireSession();
if (!allowed.includes(session.user.role)) { if (!allowed.includes(session.user.role)) {
redirect("/forbidden"); redirect("/forbidden");
} }
if (session.user.role !== UserRole.SUPERADMIN) return enforceKitaSession(session);
return session; return session;
} }
@@ -113,3 +100,10 @@ async function consentCheck(userId: string | undefined): Promise<boolean> {
}); });
return !!user?.privacyPolicyAcceptedAt; 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;
}
+9
View File
@@ -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
View File
@@ -20,11 +20,11 @@ export function getAppEmailConfigError() {
return null; return null;
} }
if (!process.env.RESEND_API_KEY) { 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) { if (!process.env.EMAIL_FROM) {
return "EMAIL_FROM ist nicht konfiguriert."; return "E-Mail-Versand ist derzeit nicht verfügbar.";
} }
return null; return null;
@@ -37,6 +37,15 @@ export async function sendAppEmail({
from = process.env.EMAIL_FROM, from = process.env.EMAIL_FROM,
replyTo, replyTo,
}: SendAppEmailInput): Promise<SendAppEmailResult> { }: 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.RESEND_API_KEY) {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
console.log("\n✉️ [DEV EMAIL SIMULATION]"); console.log("\n✉️ [DEV EMAIL SIMULATION]");
@@ -50,14 +59,14 @@ export async function sendAppEmail({
} }
return { return {
success: false, success: false,
error: "RESEND_API_KEY ist nicht konfiguriert.", error: "E-Mail-Versand ist derzeit nicht verfügbar.",
}; };
} }
if (!from) { if (!from) {
return { return {
success: false, 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); console.error("Resend konnte die E-Mail nicht versenden:", error);
return { return {
success: false, 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); console.error("Unerwarteter Fehler beim E-Mail-Versand:", error);
return { return {
success: false, success: false,
error: error: "E-Mail-Versand fehlgeschlagen.",
error instanceof Error
? error.message
: "Unerwarteter Fehler beim E-Mail-Versand.",
}; };
} }
} }
+5
View File
@@ -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.");
+3
View File
@@ -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"; import { PrismaClient } from "@prisma/client";
// Singleton-Pattern: Im Dev-Modus erzeugt Next.js bei jedem Hot-Reload // Singleton-Pattern: Im Dev-Modus erzeugt Next.js bei jedem Hot-Reload
+33 -31
View File
@@ -1,39 +1,41 @@
type Bucket = { import { createHash } from "node:crypto";
count: number; import { isIP } from "node:net";
resetAt: number; import { headers } from "next/headers";
}; import { prisma } from "@/lib/prisma";
const buckets = new Map<string, Bucket>();
export type RateLimitResult = export type RateLimitResult =
| { allowed: true; remaining: number; resetAt: number } | { allowed: true; remaining: number; resetAt: number }
| { allowed: false; retryAfterSeconds: number }; | { allowed: false; retryAfterSeconds: number };
export function rateLimit( // PostgreSQL performs the increment atomically across all application instances.
key: string, export async function rateLimit(key: string, limit: number, windowSeconds: number): Promise<RateLimitResult> {
limit: number, const bucket = createHash("sha256").update(key).digest("hex");
windowSeconds: number, const [row] = await prisma.$queryRaw<{ count: number; resetAt: Date }[]>`
): RateLimitResult { INSERT INTO rate_limit_buckets (key, count, "resetAt")
const now = Date.now(); VALUES (${bucket}, 1, NOW() + make_interval(secs => ${windowSeconds}))
const windowMs = windowSeconds * 1000; ON CONFLICT (key) DO UPDATE SET
const existing = buckets.get(key); 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)) };
}
if (!existing || existing.resetAt <= now) { export async function limitPublicAction(scope: string, identity: string, limit = 5, seconds = 3600): Promise<boolean> {
buckets.set(key, { count: 1, resetAt: now + windowMs }); if (!(await rateLimit(`${scope}:burst`, 100, 60)).allowed) return false;
return { allowed: true, remaining: limit - 1, resetAt: now + windowMs }; 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;
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,
};
} }
+7
View File
@@ -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"; }
}
+9
View File
@@ -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);
}
+11
View File
@@ -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
View File
@@ -1,3 +1,4 @@
import { randomBytes } from "node:crypto";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { auth } from "@/auth"; import { auth } from "@/auth";
@@ -5,7 +6,7 @@ import { auth } from "@/auth";
// ===================================================================== // =====================================================================
// Kita-Planer · Proxy (ehem. Middleware, seit Next.js 16 umbenannt) // 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 // Der sichere DB-seitige Check (Consent, Rolle) findet in den
// jeweiligen Server Components via `requireKitaSession()` statt. // jeweiligen Server Components via `requireKitaSession()` statt.
// //
@@ -30,18 +31,18 @@ function getPostLoginPath(user: { role?: string; kitaId?: string | null }) {
return user.kitaId ? "/dashboard" : ONBOARDING_ROUTE; return user.kitaId ? "/dashboard" : ONBOARDING_ROUTE;
} }
export async function proxy(request: NextRequest) { async function routeRequest(request: NextRequest, requestHeaders: Headers) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
const isPublicRoute = const isPublicRoute =
PUBLIC_PATHS.has(pathname) || PUBLIC_PATHS.has(pathname) ||
PUBLIC_PREFIX.some((prefix) => pathname.startsWith(prefix)); PUBLIC_PREFIX.some((prefix) => pathname.startsWith(prefix));
if (isPublicRoute) { if (isPublicRoute) {
return NextResponse.next(); return NextResponse.next({ request: { headers: requestHeaders } });
} }
// Auth.js liest den Session-JWT aus dem Cookie — // 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 session = await auth();
const user = session?.user; const user = session?.user;
const hasValidUser = !!user?.id; const hasValidUser = !!user?.id;
@@ -62,7 +63,7 @@ export async function proxy(request: NextRequest) {
if (hasValidUser && user) { if (hasValidUser && user) {
// 2a. Eingeloggter User auf Login/Register-Seite → direkt zum Ziel, // 2a. Eingeloggter User auf Login/Register-Seite → direkt zum Ziel,
// damit die öffentliche Startseite stabil indexierbar bleibt. // 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( return NextResponse.redirect(
new URL(getPostLoginPath(user), request.nextUrl), 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. // 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.redirect(new URL("/dashboard", request.nextUrl));
} }
} }
return NextResponse.next(); return NextResponse.next({ request: { headers: requestHeaders } });
} }
// Proxy auf alle sinnvollen Routen anwenden; statische Dateien, // 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).*)", "/((?!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;
}
+6
View File
@@ -15,6 +15,8 @@ import type { UserRole } from "@prisma/client";
declare module "next-auth" { declare module "next-auth" {
interface User extends DefaultUser { interface User extends DefaultUser {
id: string; id: string;
sessionVersion: number;
authenticatedAt: number;
role: UserRole; role: UserRole;
kitaId: string | null; kitaId: string | null;
familyId: string | null; familyId: string | null;
@@ -23,6 +25,8 @@ declare module "next-auth" {
interface Session { interface Session {
user: { user: {
id: string; id: string;
sessionVersion: number;
authenticatedAt: number;
role: UserRole; role: UserRole;
kitaId: string | null; kitaId: string | null;
familyId: string | null; familyId: string | null;
@@ -36,6 +40,8 @@ declare module "next-auth" {
declare module "@auth/core/jwt" { declare module "@auth/core/jwt" {
interface JWT { interface JWT {
id: string; id: string;
sessionVersion: number;
authenticatedAt: number;
role: UserRole; role: UserRole;
kitaId: string | null; kitaId: string | null;
familyId: string | null; familyId: string | null;
+105
View File
@@ -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; });
+378
View File
@@ -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);
});