Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c99662f6e | ||
|
|
a9f57acf70 | ||
|
|
656ada80bd |
+12
-7
@@ -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 ["sh", "scripts/start-production.sh"]
|
||||||
CMD ["./scripts/start-production.sh"]
|
|
||||||
|
|||||||
@@ -1,176 +1,7 @@
|
|||||||
# Deployment und Mailversand – Anleitung für den Kita-Planer
|
# Deployment und Mailversand
|
||||||
|
|
||||||
Stand: 09.10.2026. Grundlage sind die aktuelle Docker-/Coolify-Konfiguration und die umgesetzten Funktionen für Personal und Notdienstalarm.
|
Die aktuelle Anleitung steht in [README.md](README.md), die Konfigurationsvorlage in [production.env.example](production.env.example).
|
||||||
|
|
||||||
## 1. Vor dem Deployment festlegen
|
Der Compose-Dienst `migrate` initialisiert das Schema über versionierte Migrationen und stellt den separaten Runtime-Zugang bereit. Die App startet erst nach erfolgreicher Initialisierung. `prisma db push`, Demo-Seeding und historische SQL-Upgrades sind im Produktionsstart nicht vorgesehen.
|
||||||
|
|
||||||
- **App-Adresse:** eine feste HTTPS-Adresse, zum Beispiel `https://app.example.org`.
|
Für Resend müssen Versanddomain, `RESEND_API_KEY` und `EMAIL_FROM` gültig konfiguriert sein. `ADMIN_EMAIL` ist das Ziel für das Kontaktformular. Die drei öffentlichen Basis-URLs müssen übereinstimmen, damit Einladungs- und Bestätigungslinks auf die richtige Anwendung zeigen. Der Notdienst-Worker startet im App-Container mit `NOTDIENST_JOB_SECRET`; kein zusätzlicher Cron-Dienst ist erforderlich.
|
||||||
- **Datenbank:** entweder PostgreSQL aus dem mitgelieferten Compose-Stack oder eine bereits bestehende Datenbank.
|
|
||||||
- **Versanddomain:** eine eigene bei Resend verifizierte Domain, zum Beispiel `notify.example.org`.
|
|
||||||
- **Absender:** zum Beispiel `Kita-Planer <noreply@notify.example.org>`.
|
|
||||||
- **Kontaktpostfach:** eine tatsächlich erreichbare Adresse für `ADMIN_EMAIL`.
|
|
||||||
- **Sicherung:** Datenbank und Upload-Dateien vor einem Update sichern; die Wiederherstellung sollte bekannt sein.
|
|
||||||
|
|
||||||
Die Beispieladressen und Platzhalter in dieser Anleitung müssen durch deine Werte ersetzt werden. Zugangsdaten werden in der Hosting-Konfiguration hinterlegt und nicht ins Repository geschrieben.
|
|
||||||
|
|
||||||
## 2. Coolify und Datenbank richtig verbinden
|
|
||||||
|
|
||||||
In Coolify den gewünschten Release-Branch aus Gitea auswählen, **Docker Compose** als Build Pack und `docker-compose.coolify.yml` als Compose-Datei verwenden. Die aktuelle Implementierung des Notdienstalarms liegt auf `codex/notdienstalarm`; der ausgewählte Deployment-Stand muss diese Änderungen enthalten. Die öffentliche Domain dem Dienst **app**, internem Port **3000**, zuordnen und HTTPS aktivieren.
|
|
||||||
|
|
||||||
### Variante A: Datenbank des mitgelieferten Compose-Stacks
|
|
||||||
|
|
||||||
Die vorhandene Compose-Datei startet `postgres`, `app` und `pgadmin`. Für die Datenbank werden `POSTGRES_DB`, `POSTGRES_USER` und `POSTGRES_PASSWORD` gesetzt. Der App-Dienst erhält daraus automatisch:
|
|
||||||
|
|
||||||
```text
|
|
||||||
postgresql://POSTGRES_USER:POSTGRES_PASSWORD@postgres:5432/POSTGRES_DB?schema=public
|
|
||||||
```
|
|
||||||
|
|
||||||
`postgres` ist hier der interne Dienstname. Eine zusätzlich in Coolify eingetragene Variable `DATABASE_URL` wird von der aktuellen Compose-Datei **nicht** verwendet, weil die URL dort direkt zusammengesetzt wird. Bei Sonderzeichen im Datenbankpasswort muss die URL korrekt kodiert sein; alternativ die Compose-Konfiguration auf eine ausdrücklich bereitgestellte, korrekt kodierte `DATABASE_URL` umstellen.
|
|
||||||
|
|
||||||
### Variante B: Bereits vorhandene Coolify-PostgreSQL-Datenbank
|
|
||||||
|
|
||||||
Die Compose-Datei muss vor dem Deployment angepasst werden: Der App-Dienst soll die hinterlegte `DATABASE_URL` übernehmen, beispielsweise über `DATABASE_URL: ${DATABASE_URL:?}`. Die Abhängigkeit von dem bisherigen `postgres`-Dienst ist entsprechend anzupassen; einen unbenötigten separaten Datenbankdienst vermeiden. Falls pgAdmin weiter genutzt wird, dessen Konfiguration und Abhängigkeit ebenfalls prüfen.
|
|
||||||
|
|
||||||
Die App muss die bestehende Datenbank über das interne Coolify-Netzwerk erreichen können. `localhost` in der App-URL würde auf den App-Container zeigen. Vor dem Start verifizieren, dass die URL wirklich auf die gewünschte Datenbank zeigt; der Start wendet das Anwendungsschema auf genau diese Datenbank an.
|
|
||||||
|
|
||||||
## 3. Umgebungsvariablen
|
|
||||||
|
|
||||||
Die ausfüllbare Vorlage ist [production.env.example](production.env.example). In Coolify werden Werte normalerweise ohne die Anführungszeichen einer `.env`-Datei eingetragen.
|
|
||||||
|
|
||||||
| Variable | Was du eintragen bzw. prüfen musst |
|
|
||||||
| --- | --- |
|
|
||||||
| `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` | Für den mitgelieferten Datenbankdienst. Bestehende Zugangsdaten bei Updates beibehalten; Werte mit der tatsächlichen Datenbank abgleichen. |
|
|
||||||
| `DATABASE_URL` | Bei einer angepassten Compose-Datei bzw. anderem Hosting: interne PostgreSQL-URL einschließlich Zugangsdaten und Schema. |
|
|
||||||
| `AUTH_SECRET` | Einmalig zufällig erzeugen und dauerhaft speichern; nicht bei jedem Deployment ersetzen. Ein Wechsel beendet bestehende Sessions. |
|
|
||||||
| `AUTH_URL` | Die öffentliche HTTPS-App-Adresse. |
|
|
||||||
| `NEXTAUTH_URL` | Dieselbe öffentliche Adresse; bestehende Einladungsabläufe verwenden auch diesen Namen. |
|
|
||||||
| `NEXT_PUBLIC_SITE_URL` | Dieselbe öffentliche Adresse, ohne abschließenden Slash und ohne Unterpfad. Für Mail-Links und öffentliche URLs erforderlich. |
|
|
||||||
| `RESEND_API_KEY` | Gültiger Resend-Schlüssel mit Versandberechtigung für die gewählte Domain. |
|
|
||||||
| `EMAIL_FROM` | Anzeigename und Adresse der verifizierten Versanddomain, beispielsweise `Kita-Planer <noreply@notify.example.org>`. |
|
|
||||||
| `ADMIN_EMAIL` | Empfänger der Kontaktformular-Anfragen. Diese Variable ersetzt nicht die Kita-Admin-Konten für Personal- oder Notdiensthinweise. |
|
|
||||||
| `NOTDIENST_JOB_SECRET` | Eigenes zufälliges Secret mit mindestens 32 Zeichen für die automatische Notdienst-Fristprüfung. |
|
|
||||||
| `UPLOAD_DIR` | Dauerhaft gespeichertes Verzeichnis für Anhänge; im aktuellen Compose-Stack `/app/uploads/announcements`. |
|
|
||||||
| `PGADMIN_DEFAULT_EMAIL`, `PGADMIN_DEFAULT_PASSWORD` | Nur erforderlich, wenn der mitgelieferte pgAdmin-Dienst genutzt wird. |
|
|
||||||
| `NEXT_PUBLIC_UMAMI_*` | Optional. Ohne Analytics leer lassen. |
|
|
||||||
|
|
||||||
Zwei getrennte Secrets lassen sich lokal erzeugen:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# AUTH_SECRET
|
|
||||||
openssl rand -base64 32
|
|
||||||
|
|
||||||
# NOTDIENST_JOB_SECRET – einen anderen Wert verwenden
|
|
||||||
openssl rand -hex 32
|
|
||||||
```
|
|
||||||
|
|
||||||
Die ausgegebenen Werte sicher in Coolify hinterlegen. `NEXT_PUBLIC_SITE_URL` muss im aktuellen Docker-Ablauf sowohl beim **Build** als auch zur **Laufzeit** vorhanden sein. Nach einer Domainänderung neu bauen und deployen. Nach Änderungen an Mail-Schlüssel oder Absender die App neu starten, damit die neue Konfiguration verwendet wird.
|
|
||||||
|
|
||||||
Für Produktion gilt `NODE_ENV=production`. `TEST_MODE` und `ALLOW_DATABASE_SEED` bleiben ungesetzt. Der vorhandene Befehl `npm run dev` führt einen Schemaabgleich und Demo-Seeding aus und ist deshalb kein Produktionsstart.
|
|
||||||
|
|
||||||
## 4. Schema, Start und dauerhafte Dateien
|
|
||||||
|
|
||||||
Der Docker-Build installiert Abhängigkeiten, generiert den Prisma-Client und erstellt den Produktionsbuild. Beim Container-Start führt `scripts/start-production.sh` aus:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npx prisma db push --skip-generate
|
|
||||||
```
|
|
||||||
|
|
||||||
Danach startet es den Notdienst-Worker und die App. Das Projekt besitzt weiterhin keine vollständige versionierte Prisma-Migrationshistorie. Vor Updates Schemaänderungen und Startlogs prüfen; bei einem fehlgeschlagenen Schemaabgleich die Ursache klären, bevor das Deployment fortgesetzt wird.
|
|
||||||
|
|
||||||
Für kontrollierte, einmalige SQL-Upgrades gibt es diese Alternativen:
|
|
||||||
|
|
||||||
| Ausgangsstand | Passendes SQL-Upgrade |
|
|
||||||
| --- | --- |
|
|
||||||
| Personalbereich noch nicht vorhanden, Stand passend zur Einführung | [personalbereich.sql](sql/personalbereich.sql) führt den vollständigen Personalbereich **einschließlich** Mail-Erweiterung ein. |
|
|
||||||
| Personalbereich bereits auf `043fed2`, Mail-Erweiterung noch nicht vorhanden | Nur [personalbereich-email.sql](sql/personalbereich-email.sql). |
|
|
||||||
| Personalbereich einschließlich Mail-Erweiterung vorhanden, Notdienstalarm noch nicht vorhanden | [notdienstalarm.sql](sql/notdienstalarm.sql). |
|
|
||||||
|
|
||||||
`personalbereich.sql` und `personalbereich-email.sql` sind **keine** aufeinanderfolgende Skriptfolge: Das erste enthält die Mail-Erweiterung bereits. Die SQL-Skripte sind nicht für wiederholte Ausführung gedacht. Hat `db push` die betreffende Erweiterung schon angewandt, das entsprechende SQL nicht zusätzlich ausführen.
|
|
||||||
|
|
||||||
Im Compose-Stack sichern die Volumes `kita_planer_postgres_data` und `kita_planer_uploads` Datenbank und Anhänge über Containerwechsel hinweg. Bei Nutzung von pgAdmin kommt dessen eigenes Volume hinzu. Die Anwendung hängt `announcements` an `UPLOAD_DIR` an; im aktuellen Compose-Setup liegen Dateien deshalb unter `/app/uploads/announcements/announcements`. Dieser Pfad ist vom Volume `/app/uploads` abgedeckt. Bei einem Wechsel des Speicherpfads bestehende Dateien und Sicherungen berücksichtigen.
|
|
||||||
|
|
||||||
Bei anderem Hosting sind Build, Schemaabgleich, App-Start und Worker separat sicherzustellen. `npm run start` bzw. `npm run start:prod` allein starten nur die App, wenden kein Schema an und starten keinen Notdienst-Worker.
|
|
||||||
|
|
||||||
## 5. Mailversand mit Resend einrichten
|
|
||||||
|
|
||||||
Die Anwendung versendet über die **Resend-API**. SMTP-Host, SMTP-Port oder ein normales Postfachpasswort werden dafür nicht verwendet.
|
|
||||||
|
|
||||||
1. Im Resend-Dashboard eine eigene Versanddomain hinzufügen. Eine Subdomain wie `notify.example.org` hält den Transaktionsversand übersichtlich getrennt; auch Resend empfiehlt Subdomains für den Versand.
|
|
||||||
2. Die von Resend angezeigten DNS-Einträge für SPF/DKIM einschließlich der dort verlangten TXT-, MX- oder CNAME-Einträge exakt beim DNS-Anbieter übernehmen. Resend-CNAMEs als DNS-Einträge ohne Proxy konfigurieren. Keine generischen DNS-Werte aus dieser Anleitung einsetzen.
|
|
||||||
3. Im Resend-Dashboard die erfolgreiche Verifizierung abwarten.
|
|
||||||
4. `EMAIL_FROM` auf eine Adresse genau dieser Versanddomain setzen. Bei Verifizierung von `notify.example.org` beispielsweise `noreply@notify.example.org` verwenden.
|
|
||||||
|
|
||||||
Die aktuellen Schritte beschreibt die offizielle [Resend-Anleitung zur Domain-Verifizierung](https://resend.com/docs/add-a-domain).
|
|
||||||
|
|
||||||
5. DMARC passend zum bestehenden Mailbetrieb konfigurieren. Die [Resend-DMARC-Anleitung](https://resend.com/docs/dashboard/domains/dmarc) erklärt die Einrichtung; bestehende Postfach- und DNS-Konfiguration berücksichtigen.
|
|
||||||
6. Einen Versand-API-Schlüssel anlegen, `RESEND_API_KEY` in Coolify setzen und die App neu starten.
|
|
||||||
7. Für Einladungs- und Rückmeldelinks Open-/Click-Tracking ausgeschaltet lassen. Das ist unsere Betriebsempfehlung für diese Transaktionsmails. Tracking ist bei Resend standardmäßig deaktiviert und kann Domains separat zugeordnet werden. Siehe [Resend: Open and Click Tracking](https://resend.com/docs/dashboard/domains/tracking).
|
|
||||||
|
|
||||||
`onboarding@resend.dev` ist für den regulären Versand an Familien ungeeignet: Mit der Testdomain darf nur an die zum Resend-Konto gehörende Adresse gesendet werden. Für andere Empfänger braucht es die eigene verifizierte Domain. Siehe [Resend: Testdomain und 403-Fehler](https://resend.com/docs/knowledge-base/403-error-resend-dev-domain).
|
|
||||||
|
|
||||||
`ADMIN_EMAIL` sollte ein betreutes Postfach sein. Für andere Absenderadressen separat entscheiden, ob Antworten empfangen werden sollen; die Versandkonfiguration allein richtet in der Anwendung kein betreutes Postfach ein. Im Kontaktformular setzt die App Reply-To für Anfrage und Eingangsbestätigung. Die aktuellen Personal- und Notdienstmails setzen kein eigenes Reply-To und führen die Empfänger über die Links in die App.
|
|
||||||
|
|
||||||
In Entwicklung ohne API-Schlüssel simuliert der Mailtransport den Versand und meldet Erfolg. Ein funktionierender lokaler Ablauf belegt daher noch keine echte Zustellung. In Produktion bleibt fehlgeschlagener Versand als Fehler bzw. ausstehender Auftrag sichtbar. Mit einem vorhandenen echten API-Schlüssel kann auch die Entwicklungsumgebung echte E-Mails verschicken.
|
|
||||||
|
|
||||||
## 6. Wer erhält welche E-Mails?
|
|
||||||
|
|
||||||
| Anlass | Empfänger / Verhalten |
|
|
||||||
| --- | --- |
|
|
||||||
| Kontaktformular | `ADMIN_EMAIL` erhält die Anfrage; die anfragende Person erhält eine Eingangsbestätigung. |
|
|
||||||
| Eltern-Einladung | Hinterlegte Elternadresse mit persönlichem Einladungslink. |
|
|
||||||
| Fachkraft aktivieren | Hinterlegte Fachkraftadresse; der Aktivierungslink gilt sieben Tage. Ein neuer Link ersetzt den alten. |
|
|
||||||
| Personal-Antrag / eingereichte Arbeitswoche | Alle zuständigen Kita-Admins derselben Kita. |
|
|
||||||
| Personal-Entscheidung | Betroffene Fachkraft, sofern Entscheidungsmails im Profil eingeschaltet sind. Aktivierungsmails bleiben davon unabhängig. |
|
|
||||||
| Notdienstalarm auslösen | Elternkonten der eingeteilten Familie; jedes Konto erhält einen eigenen Link. |
|
|
||||||
| Notdienst-Rückmeldung / Absage / Fristablauf | Kita-Admins und Notdienst-Koordination derselben Kita. |
|
|
||||||
| Notdienst-Stornierung / dokumentierte Statusänderung | Betroffener Haushalt; bei dokumentierter Rückmeldung erhalten auch Admins und Koordination einen Hinweis. |
|
|
||||||
|
|
||||||
Private Personalnotizen, Kontostände und interne Begründungen sowie interne Notdienst-Fachkraftdaten werden nicht in diese Personal-/Notdienstmails übernommen. Die Auswahl der Kita-Empfänger erfolgt anhand der Konten und Rollen in der Datenbank; `ADMIN_EMAIL` ist hierfür kein Sammelverteiler.
|
|
||||||
|
|
||||||
## 7. Automatische Notdienst-Fristprüfung und Wiederholungen
|
|
||||||
|
|
||||||
Mit gültigem `NOTDIENST_JOB_SECRET` startet der Docker-Produktionsstart `scripts/notdienst-alarm-worker.mjs`. Dieser ruft nach fünf Sekunden und danach ungefähr jede Minute die lokale Route `POST /api/jobs/notdienstalarme` auf. Die voreingestellte Antwortfrist beträgt 30 Minuten; die Meldung erfolgt beim nächsten erfolgreichen Joblauf nach Fristende, nicht sekundengenau.
|
|
||||||
|
|
||||||
Bei anderem Hosting den Worker dauerhaft und mit automatischem Neustart betreiben oder einen Scheduler mindestens einmal pro Minute die Jobroute per POST mit `Authorization: Bearer <NOTDIENST_JOB_SECRET>` aufrufen lassen. Der Prozess darf nicht davon abhängen, dass ein Browser geöffnet ist. Ein gesetztes Secret allein beweist keinen laufenden Worker.
|
|
||||||
|
|
||||||
| Bereich | Was passiert nach einem Versandfehler? |
|
|
||||||
| --- | --- |
|
|
||||||
| Notdienstalarm | Auftrag bleibt erhalten; der Job versucht den Versand erneut. In der Alarmansicht ist zusätzlich ein manueller erneuter Versuch möglich. Die Antwortfrist startet dabei nicht neu. |
|
|
||||||
| Personalbereich | Auftrag bleibt erhalten; über **Versand erneut versuchen** in der Oberfläche wiederholen. Aktuell gibt es hierfür keinen eigenen Hintergrundjob; der Notdienst-Worker bearbeitet keine Personalmails. |
|
|
||||||
| Andere bestehende Mailabläufe | Nicht alle besitzen eine dauerhafte Versandwarteschlange. Den konkreten Fehler und den Stand von Einladung/Aktion prüfen, bevor der Ablauf erneut ausgelöst wird. |
|
|
||||||
|
|
||||||
Ein Alarm-Job verarbeitet höchstens 100 Fristfälle und 100 ausstehende E-Mail-Aufträge pro Lauf. Bei größerem Rückstand sind weitere Läufe nötig. Die gespeicherte Aktion bleibt bei einem Mailfehler erhalten. Resend-Annahme bedeutet nicht, dass die Nachricht bereits im Postfach angekommen ist; eine automatische Auswertung von Zustell-/Bounce-Webhooks ist im geprüften Anwendungscode nicht angebunden.
|
|
||||||
|
|
||||||
## 8. Nach dem Deployment prüfen
|
|
||||||
|
|
||||||
Die Mailprüfung mit eigens dafür eingerichteten Konten und Postfächern durchführen. Bei einer Notdienstprüfung eine Test-Kita und eine Test-Einteilung verwenden, damit keine regulären Familien versehentlich alarmiert werden.
|
|
||||||
|
|
||||||
- [ ] HTTPS, Startseite, Anmeldung und Dashboard erreichbar.
|
|
||||||
- [ ] Build- und Startlogs ohne Schemafehler; App verwendet die richtige Datenbank.
|
|
||||||
- [ ] Anhänge nach einem Containerneustart weiterhin verfügbar.
|
|
||||||
- [ ] Kontaktformular: Anfrage kommt bei `ADMIN_EMAIL` an und Eingangsbestätigung im Testpostfach.
|
|
||||||
- [ ] Eltern- und Fachkrafteinladung: korrekte öffentliche Domain im Link; Aktivierung funktioniert.
|
|
||||||
- [ ] Personal-Antrag: Kita-Admins erhalten den Hinweis; Entscheidung erreicht die Fachkraft. Abschalteinstellung im Profil geprüft.
|
|
||||||
- [ ] Test-Notdienstalarm: nur die eingeteilte Testfamilie erhält die Anfrage; eine Haushaltsantwort aktualisiert den gemeinsamen Status.
|
|
||||||
- [ ] Test-Fristablauf bei geschlossenem Browser: genau ein Hinweis pro zuständigem Admin/Koordinator, beim nächsten Joblauf nach Fristende.
|
|
||||||
- [ ] Stornierung informiert den Testhaushalt; alter Link kann keine Antwort mehr speichern.
|
|
||||||
- [ ] Resend-Dashboard und echte Postfächer einschließlich Spamordner geprüft; keine ausstehenden fehlgeschlagenen Aufträge übersehen.
|
|
||||||
- [ ] Resend-Kontingente, API-Fehler und Bounces im laufenden Betrieb überwacht.
|
|
||||||
|
|
||||||
## 9. Häufige Fehler
|
|
||||||
|
|
||||||
| Beobachtung | Zuerst prüfen |
|
|
||||||
| --- | --- |
|
|
||||||
| Mail fehlt trotz lokaler Erfolgsmeldung | War es Entwicklungsmodus ohne API-Schlüssel? Dann wurde nur simuliert. |
|
|
||||||
| Link zeigt auf localhost oder die falsche Domain | Alle drei App-URLs angleichen, `NEXT_PUBLIC_SITE_URL` auch beim Build setzen und neu deployen. Schon versendete Links ändern sich dadurch nicht rückwirkend. |
|
|
||||||
| Resend lehnt Versand ab | API-Schlüssel, verifizierte Domain, `EMAIL_FROM`, Versandberechtigung und Kontingente prüfen. Bei 403 auch die Testdomain prüfen. |
|
|
||||||
| Resend hat angenommen, Postfach bleibt leer | Resend-Zustellstatus, Bounce/Suppression, Empfängeradresse und Spamordner prüfen. |
|
|
||||||
| Personalaktion gespeichert, Mail ausstehend | Konfiguration beheben, dann im Personalbereich den Versand erneut versuchen. |
|
|
||||||
| Notdienst-Frist ist abgelaufen, kein Hinweis kommt | Worker/Scheduler, Logs und `NOTDIENST_JOB_SECRET` prüfen; Browseranzeige und tatsächliche Jobausführung sind getrennt. |
|
|
||||||
| Jobroute liefert 401 | Bearer-Header fehlt oder Secret stimmt nicht. |
|
|
||||||
| Jobroute liefert 503 | Secret fehlt oder ist kürzer als 32 Zeichen. |
|
|
||||||
| Jobroute liefert 500 | Joblauf fehlgeschlagen; Serverlogs und Datenbank prüfen. |
|
|
||||||
| Jobroute liefert 200, dennoch fehlen Mails | Das Ergebnis kann fehlgeschlagene Versandaufträge enthalten. Versandübersicht und Resend-Konfiguration prüfen. |
|
|
||||||
| Deployment startet nicht | Required-Variablen der Compose-Datei, Datenbankzugriff, Schemaabgleich und Secret-Länge prüfen. |
|
|
||||||
|
|
||||||
Ergänzende Details: [Notdienstalarm](../docs/NOTDIENSTALARM.md), [Personalbereich](../docs/PERSONALBEREICH.md), [Produktionscheckliste](production-checklist.md). Die produktive Hosting- und Resend-Konfiguration wurde für diese Anleitung nicht verändert; sie dokumentiert die noch auszuführenden Einrichtungsschritte.
|
|
||||||
|
|||||||
+51
-42
@@ -1,61 +1,70 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
Die zentrale Schritt-für-Schritt-Anleitung steht in [DEPLOYMENT_UND_MAILVERSAND.md](DEPLOYMENT_UND_MAILVERSAND.md). Sie erklärt die Datenbankvarianten, alle benötigten Variablen, Resend und DNS, Fristprüfung, Versandwiederholungen und die Prüfung nach dem Deployment.
|
## Konfiguration
|
||||||
|
|
||||||
## Dateien
|
Werte aus `production.env.example` als Hosting-Secrets hinterlegen. Im Compose-Stack erzeugt der Dienst `migrate` automatisch ein separates Runtime-Passwort im privaten Secret-Volume, wendet die Migrationen an, vergibt die eingeschränkten DB-Rechte und bereitet die Upload-/Cache-Volumes vor. Danach startet `app` mit UID/GID 1001. Das Eigentümerpasswort steht nur PostgreSQL und dem Initialisierungsdienst zur Verfügung. Für manuelle CLI-Befehle außerhalb von Compose bleibt `DATABASE_URL` die jeweilige administrative oder eingeschränkte URL. Keine echten Secrets im Repository speichern.
|
||||||
|
|
||||||
- `production.env.example`: Vorlage fuer alle benoetigten Environment Variables.
|
`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.
|
||||||
- `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
|
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.
|
||||||
|
|
||||||
1. Repository in deine Gitea-Instanz pushen.
|
## Neustart mit leerer Datenbank
|
||||||
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. Datenbankvariante wählen: Die aktuelle Compose-Datei bringt PostgreSQL mit und setzt daraus die App-URL zusammen. Für eine bestehende Coolify-Datenbank zuerst die Compose-Konfiguration auf deren `DATABASE_URL` und Netzwerkverbindung anpassen; siehe zentrale Anleitung.
|
|
||||||
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:
|
Wenn vorhandene Daten ausdrücklich löschbare Testdaten sind: ausschließlich die zu dieser Installation gehörenden Datenbank- und Upload-Volumes zurücksetzen. Danach genügt ein normales Deployment mit `docker compose -f docker-compose.coolify.yml up -d --build app`. Es sind weder eine Baseline-Markierung noch manuelle Schema-Upgrades oder Demo-Seeding erforderlich. Der Initialisierungsdienst läuft vor der App; ein Fehler verhindert deren Start. Bestehende produktive Daten erfordern dagegen die unten beschriebene Vorbereitung.
|
||||||
|
|
||||||
```bash
|
Im Normalbetrieb bleiben Datenbank, Uploads, Cache und Runtime-Secret-Volume bei Deployments bestehen. Das Runtime-Passwort wird bei einem normalen Neustart wiederverwendet. Niemals die Datenbank als Startnebenwirkung löschen.
|
||||||
npx prisma db push --skip-generate
|
|
||||||
|
## Bestehende Installation aktualisieren
|
||||||
|
|
||||||
|
1. Datenbank **und** Upload-Volume sichern und Wiederherstellung überprüfen. Wartungsfenster ankündigen und alte App stoppen: das neue Schema und der neue Code gehören zusammen.
|
||||||
|
2. Den Zustand der bestehenden Datenbank mit `prisma/migrations/20261009000000_baseline/migration.sql` vergleichen. Die Baseline bildet das bisherige Schema ab. Bei Abweichungen zuerst die Unterschiede gezielt klären; eine Baseline-Markierung prüft das Schema nicht automatisch.
|
||||||
|
3. `scripts/check-tenant-integrity.sql` mit `psql "$MIGRATION_DATABASE_URL" -v ON_ERROR_STOP=1 -f scripts/check-tenant-integrity.sql` ausführen. **Keine Ergebniszeilen** bedeutet, dass keine der geprüften widersprüchlichen Tenant-Verknüpfungen vorliegt. Ergebnisse vor der Migration fachlich korrigieren; keine Daten automatisch einem anderen Mandanten zuweisen.
|
||||||
|
4. **Nur für eine bestehende, zur Baseline passende Datenbank** einmal die Baseline als bereits angewendet markieren. Bei einer leeren neuen Datenbank diesen Schritt auslassen.
|
||||||
|
5. Alle weiteren Migrationen anwenden, Runtime-Rolle bereitstellen und Größenmetadaten vorhandener Anhänge nachtragen.
|
||||||
|
6. Eigentümer bestehender Upload-/Cache-Volumes auf UID/GID 1001 vorbereiten, dann die neue App starten. Stichproben mit zwei verschiedenen Kitas und den echten E-Mail-Flows prüfen.
|
||||||
|
|
||||||
|
Compose-Beispiele (Environment-Secrets vorher bereitstellen):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose -f docker-compose.coolify.yml build app migrate
|
||||||
|
# NUR bei bestehender, zuvor geprüfter Baseline-Datenbank:
|
||||||
|
docker compose -f docker-compose.coolify.yml run --rm migrate npx prisma migrate resolve --applied 20261009000000_baseline
|
||||||
|
# Initialisierung läuft automatisch vor der App:
|
||||||
|
docker compose -f docker-compose.coolify.yml up -d app
|
||||||
```
|
```
|
||||||
|
|
||||||
Coolify erkennt Variablen aus `docker-compose.coolify.yml` und zeigt required Variablen
|
Die Baseline-Markierung benötigt bei einem manuellen Aufruf eine administrative `DATABASE_URL`; für eine leere Datenbank entfällt sie vollständig. Vorhandene Uploads müssen vor dem Start kontrolliert und ihre Größen unter administrativem Zugang nachgetragen werden. Für bestehende root-eigene Unterverzeichnisse ist eine gezielte Eigentümerkorrektur nötig; die Initialisierung bereitet die Basisverzeichnisse vor.
|
||||||
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.
|
|
||||||
|
|
||||||
## Notdienstalarm
|
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.
|
||||||
|
|
||||||
Die Compose-Vorlage benötigt zusätzlich `NOTDIENST_JOB_SECRET` (zufällig, mindestens 32 Zeichen). Der Produktionsstart führt die Fristprüfung über einen lokalen Hintergrundprozess ungefähr jede Minute aus. Ein alleiniger Start mit `npm run start` benötigt einen separat gestarteten Worker oder einen Scheduler. Details, Versandprüfung und das additive SQL-Upgrade stehen in [docs/NOTDIENSTALARM.md](../docs/NOTDIENSTALARM.md).
|
## Auswirkungen auf bestehende Konten
|
||||||
|
|
||||||
## Welche Coolify-Postgres-URL?
|
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.
|
||||||
|
|
||||||
Wenn die App und die Postgres-DB im selben Coolify-Projekt/Netzwerk laufen, nimm
|
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-Rückmeldelinks bis zum Ende des jeweiligen Einsatzes; eine gemeinsame Haushaltsantwort kann nur einmal gespeichert werden. Sperren entwerten vorhandene Rückmeldelinks dauerhaft. Die öffentlichen Token-Antworten zeigen nur die benötigten Einsatzangaben. Bestehende UUID-Links bleiben innerhalb ihrer Laufzeit kompatibel. Zwei-Faktor-Anmeldung ist auf ausdrücklichen Wunsch vorerst nicht eingeführt.
|
||||||
die interne Datenbank-URL aus Coolify, nicht die oeffentliche externe URL. Das ist
|
|
||||||
stabiler und vermeidet unnoetigen Traffic ueber den Host.
|
|
||||||
|
|
||||||
Das Format bleibt:
|
## Dateien und Wartung
|
||||||
|
|
||||||
```txt
|
`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.
|
||||||
postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
|
|
||||||
|
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 **41 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.
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Use with docker-compose.coolify.yml for a local production-like installation.
|
||||||
|
# Both services are reachable only from this computer.
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:3000"
|
||||||
|
postgres:
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5433:5432"
|
||||||
@@ -1,69 +1,39 @@
|
|||||||
# Production Checklist
|
# Production-Checkliste
|
||||||
|
|
||||||
## 1. Domain & Basis-URLs
|
## Vor dem Rollout mit bestehenden Daten
|
||||||
|
|
||||||
- [ ] Produktionsdomain festlegen: `https://[deine-domain.de]`
|
Bei einem ausdrücklich genehmigten Reset löschbarer Testdaten entfallen Backup-Übernahme, Baseline-Markierung und Upload-Backfill. Nach dem gezielten Reset initialisiert das normale Deployment den leeren Stand.
|
||||||
- [ ] `NEXT_PUBLIC_SITE_URL` auf die Produktionsdomain setzen
|
|
||||||
- [ ] `AUTH_URL` und `NEXTAUTH_URL` auf die Produktionsdomain setzen
|
|
||||||
- [ ] Nach Deployment die WhatsApp-/LinkedIn-Vorschau mit der finalen URL testen
|
|
||||||
|
|
||||||
## 2. Datenbank
|
- [ ] Datenbank und Uploads gesichert; Wiederherstellung geprüft.
|
||||||
|
- [ ] Alte App gestoppt; Wartungsfenster und erneute Anmeldung angekündigt.
|
||||||
|
- [ ] Bestehendes Schema mit Baseline verglichen; Tenant-Preflight ohne Befunde.
|
||||||
|
- [ ] Baseline nur bei bestehender passender DB markiert; anschließend alle Migrationen angewendet.
|
||||||
|
- [ ] Initialisierungsdienst erfolgreich; App verwendet automatisch die eingeschränkte Runtime-Rolle.
|
||||||
|
- [ ] Upload-Größen nachgetragen; gemeldete fehlende Dateien geprüft.
|
||||||
|
- [ ] Upload- und Cache-Volumes für UID/GID 1001 beschreibbar; Upload-Pfad unverändert.
|
||||||
|
|
||||||
- [ ] Datenbankvariante wählen: mitgelieferter Compose-Dienst oder vorhandene Datenbank
|
## Hosting und Konfiguration
|
||||||
- [ ] Bei vorhandener Datenbank Compose-Konfiguration und Netzwerkverbindung auf deren interne `DATABASE_URL` anpassen; die unveränderte Vorlage verwendet den eigenen `postgres`-Dienst
|
|
||||||
- [ ] Beim ersten App-Start wird das Schema automatisch mit `npx prisma db push --skip-generate` angewendet
|
|
||||||
- [ ] Fuer spaetere Releases idealerweise Prisma-Migrationen einfuehren
|
|
||||||
|
|
||||||
## 3. Auth & Sicherheit
|
- [ ] Drei öffentliche Basis-URLs auf dieselbe kanonische HTTPS-Origin gesetzt.
|
||||||
|
- [ ] Starkes `AUTH_SECRET`; echte Secrets nur in Hosting-Konfiguration.
|
||||||
|
- [ ] TLS und Proxy-Header korrekt; kein öffentlicher DB-/App-/pgAdmin-Port.
|
||||||
|
- [ ] Request-Limits, Timeouts und Upload-Body-Limit am Proxy eingerichtet.
|
||||||
|
- [ ] Trusted-IP-Header nur bei garantierter Überschreibung aktiviert.
|
||||||
|
- [ ] Maildomain bei Resend verifiziert; Produktionsabsender gesetzt.
|
||||||
|
- [ ] Optionale Analytics nur auf öffentlichen Marketingseiten; keine Token-/Login-/Dashboard-Daten.
|
||||||
|
- [ ] Upload-Speicher und Backups überwacht; Löschfristen festgelegt.
|
||||||
|
- [ ] Impressum und Datenschutztexte an tatsächliche Betreiber/Anbieter angepasst.
|
||||||
|
|
||||||
- [ ] `AUTH_SECRET` neu generieren: `openssl rand -base64 32`
|
## Nach dem Rollout
|
||||||
- [ ] Keine `.env`-Dateien mit echten Secrets committen
|
|
||||||
- [ ] Dashboard ist per Metadata auf `noindex,nofollow` gesetzt
|
|
||||||
- [ ] Produktionsdomain auf HTTPS betreiben
|
|
||||||
|
|
||||||
## 4. Mail
|
- [ ] Anmeldung und Registrierung mit Mailbox-Bestätigung getestet.
|
||||||
|
- [ ] Einladungen, Adressänderung und erneutes Senden getestet.
|
||||||
|
- [ ] Administrative Sperre entwertet bestehende Sitzung; Entsperrung verlangt neue Anmeldung.
|
||||||
|
- [ ] Zwei getrennte Kitas: Anhänge, Kalender und Mutationen bleiben isoliert.
|
||||||
|
- [ ] Anhänge heruntergeladen; Quotas und persistentes Volume geprüft.
|
||||||
|
- [ ] Privates Dashboard ohne Analytics und ohne Cache; CSP ohne Browserfehler.
|
||||||
|
- [ ] Kontakt-/Notdienst-Mailfluss und Fehlerbehandlung geprüft.
|
||||||
|
- [ ] Build, Lint, Typprüfung, Sicherheitstests und Runtime-Audit erfolgreich.
|
||||||
|
- [ ] Offenes Entwicklungs-Advisory und fixierte Image-Digests weiter beobachten.
|
||||||
|
|
||||||
- [ ] Eigene Versanddomain bei Resend verifizieren
|
Details und Befehle stehen in [README.md](README.md). Lokale Tests ersetzen nicht die Kontrolle der produktiven TLS-, Proxy-, Backup-, Mail- und Container-Konfiguration.
|
||||||
- [ ] `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`
|
|
||||||
- [ ] Produktionsstart mit Schemaabgleich und Notdienst-Worker verwenden: `scripts/start-production.sh`; bei anderem Hosting diese Schritte separat sicherstellen
|
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
## Notdienstalarm – ergänzende Betriebsprüfung
|
|
||||||
|
|
||||||
- [ ] `NOTDIENST_JOB_SECRET` zufällig erzeugt, mindestens 32 Zeichen, nur als Runtime-Secret gespeichert.
|
|
||||||
- [ ] Schema-Upgrade gesichert und angewandt; `db push` oder einmaliges SQL-Upgrade, nicht beides hintereinander.
|
|
||||||
- [ ] Worker bzw. Scheduler startet nach Neustart automatisch und ruft die geschützte Jobroute jede Minute auf.
|
|
||||||
- [ ] Fristablauf mit geschlossenem Browser geprüft: einmaliger Hinweis an Admins und Koordination.
|
|
||||||
- [ ] Versanddienst und öffentliche App-Adresse geprüft; Rückmeldelinks erreichen die tatsächliche Domain.
|
|
||||||
- [ ] Versandübersicht auf Fehler geprüft; Annahme durch den Maildienst und Postfachzustellung getrennt geprüft.
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ POSTGRES_DB="kita_planer"
|
|||||||
POSTGRES_USER="kita_planer"
|
POSTGRES_USER="kita_planer"
|
||||||
POSTGRES_PASSWORD="[GENERATE_ME]"
|
POSTGRES_PASSWORD="[GENERATE_ME]"
|
||||||
|
|
||||||
|
# Der Initialisierungsdienst erzeugt automatisch einen separaten Runtime-Zugang.
|
||||||
|
# Keine zusätzlichen APP_DATABASE_URL-/Passwort-Secrets nötig.
|
||||||
|
# Passwort wird nur im privaten kita_planer_runtime_secrets-Volume gespeichert.
|
||||||
|
|
||||||
# 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]"
|
||||||
@@ -44,5 +48,14 @@ NOTDIENST_JOB_SECRET="[GENERATE_ME]"
|
|||||||
# 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"
|
||||||
|
|||||||
@@ -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,8 @@ 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
|
APP_DATABASE_PASSWORD_FILE: /app/runtime-secrets/database-password
|
||||||
|
APP_DATABASE_NAME: ${POSTGRES_DB:-kita_planer}
|
||||||
AUTH_SECRET: ${AUTH_SECRET:?}
|
AUTH_SECRET: ${AUTH_SECRET:?}
|
||||||
AUTH_URL: ${AUTH_URL:?}
|
AUTH_URL: ${AUTH_URL:?}
|
||||||
NEXTAUTH_URL: ${NEXTAUTH_URL:?}
|
NEXTAUTH_URL: ${NEXTAUTH_URL:?}
|
||||||
@@ -40,17 +41,59 @@ services:
|
|||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:?}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:?}
|
||||||
NOTDIENST_JOB_SECRET: ${NOTDIENST_JOB_SECRET:?}
|
NOTDIENST_JOB_SECRET: ${NOTDIENST_JOB_SECRET:?}
|
||||||
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:
|
volumes:
|
||||||
- kita_planer_uploads:/app/uploads
|
- kita_planer_uploads:/app/uploads
|
||||||
|
- kita_planer_next_cache:/app/.next/cache
|
||||||
|
- kita_planer_runtime_secrets:/app/runtime-secrets:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
expose:
|
||||||
- "3000"
|
- "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
|
restart: unless-stopped
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: migrator
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:?}
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-kita_planer}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-kita_planer}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
|
||||||
|
APP_DATABASE_PASSWORD_FILE: /app/runtime-secrets/database-password
|
||||||
|
UPLOAD_DIR: /app/uploads/announcements
|
||||||
|
command: ["node", "scripts/initialize-production.mjs"]
|
||||||
|
volumes:
|
||||||
|
- kita_planer_uploads:/app/uploads
|
||||||
|
- kita_planer_next_cache:/app/runtime-cache
|
||||||
|
- kita_planer_runtime_secrets:/app/runtime-secrets
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
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:?}
|
||||||
@@ -62,10 +105,12 @@ 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_runtime_secrets:
|
||||||
kita_planer_pgadmin_data:
|
kita_planer_pgadmin_data:
|
||||||
|
|||||||
+2
-2
@@ -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
@@ -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",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+572
-520
File diff suppressed because it is too large
Load Diff
+26
-13
@@ -3,20 +3,26 @@
|
|||||||
"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": "sh .next/standalone/scripts/start-production.sh",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"test:staff": "tsx --test tests/staff-calculations.test.ts",
|
|
||||||
"test:staff:integration": "tsx --test tests/staff-service.test.ts",
|
|
||||||
"test:alarms": "tsx --test tests/notdienst-alarm.test.ts",
|
|
||||||
"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",
|
||||||
|
"test:staff": "tsx --test tests/staff-calculations.test.ts",
|
||||||
|
"test:staff:integration": "tsx --test tests/staff-service.test.ts",
|
||||||
|
"test:alarms": "tsx --experimental-test-module-mocks --test tests/notdienst-alarm.test.ts",
|
||||||
"test:parent-flows": "tsx --test tests/parent-flows.test.ts",
|
"test:parent-flows": "tsx --test tests/parent-flows.test.ts",
|
||||||
"test:parent-flows:integration": "tsx --experimental-test-module-mocks --test tests/parent-flows.integration.test.ts"
|
"test:parent-flows:integration": "tsx --experimental-test-module-mocks --test tests/parent-flows.integration.test.ts"
|
||||||
},
|
},
|
||||||
@@ -24,7 +30,6 @@
|
|||||||
"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",
|
||||||
@@ -41,8 +46,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",
|
||||||
@@ -62,10 +67,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;
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
BEGIN;
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RsvpMode" AS ENUM ('CHILD', 'FAMILY');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AnnouncementAudience" AS ENUM ('ELTERN', 'ERZIEHER', 'ALLE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "NotdienstAlarmStatus" AS ENUM ('PENDING', 'CONFIRMED', 'DECLINED', 'LATE_CONFIRMATION', 'REVIEW_REQUIRED', 'CANCELLED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "StaffRequestStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'CANCEL_REQUESTED', 'CANCELLED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "StaffLeaveKind" AS ENUM ('VACATION', 'TIME_OFF', 'SICKNESS');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "StaffWeekStatus" AS ENUM ('DRAFT', 'SUBMITTED', 'APPROVED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "staffDecisionEmails" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "announcements" ADD COLUMN "audience" "AnnouncementAudience" NOT NULL DEFAULT 'ALLE';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "termine" ADD COLUMN "rsvpMode" "RsvpMode" NOT NULL DEFAULT 'CHILD';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "notdienst_alerts" ADD COLUMN "alarmId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "staff_profiles" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kitaId" TEXT NOT NULL,
|
||||||
|
"educatorId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT,
|
||||||
|
"employmentStart" DATE NOT NULL,
|
||||||
|
"employmentEnd" DATE,
|
||||||
|
"trackingStart" DATE NOT NULL,
|
||||||
|
"openingMinutes" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "staff_profiles_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "staff_emails" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"profileId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"toAdmins" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"subject" TEXT NOT NULL,
|
||||||
|
"message" TEXT NOT NULL,
|
||||||
|
"path" TEXT NOT NULL,
|
||||||
|
"inviteToken" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"sentAt" TIMESTAMP(3),
|
||||||
|
"skippedAt" TIMESTAMP(3),
|
||||||
|
"leaseUntil" TIMESTAMP(3),
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"failed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
CONSTRAINT "staff_emails_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "staff_work_patterns" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"profileId" TEXT NOT NULL,
|
||||||
|
"effectiveFrom" DATE NOT NULL,
|
||||||
|
"dailyMinutes" INTEGER[],
|
||||||
|
|
||||||
|
CONSTRAINT "staff_work_patterns_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "staff_day_overrides" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"profileId" TEXT NOT NULL,
|
||||||
|
"date" DATE NOT NULL,
|
||||||
|
"minutes" INTEGER NOT NULL,
|
||||||
|
"reason" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "staff_day_overrides_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "staff_holidays" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kitaId" TEXT NOT NULL,
|
||||||
|
"date" DATE NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "staff_holidays_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "staff_leave_accounts" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"profileId" TEXT NOT NULL,
|
||||||
|
"year" INTEGER NOT NULL,
|
||||||
|
"entitlementUnits" INTEGER NOT NULL,
|
||||||
|
"carryUnits" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "staff_leave_accounts_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "staff_leaves" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"profileId" TEXT NOT NULL,
|
||||||
|
"kind" "StaffLeaveKind" NOT NULL,
|
||||||
|
"status" "StaffRequestStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"startDate" DATE NOT NULL,
|
||||||
|
"endDate" DATE NOT NULL,
|
||||||
|
"halfDay" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"minutes" INTEGER,
|
||||||
|
"note" TEXT,
|
||||||
|
"decisionNote" TEXT,
|
||||||
|
"requestedById" TEXT NOT NULL,
|
||||||
|
"reviewedById" TEXT,
|
||||||
|
"reviewedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "staff_leaves_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "staff_leave_days" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"leaveId" TEXT NOT NULL,
|
||||||
|
"date" DATE NOT NULL,
|
||||||
|
"vacationUnits" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"creditMinutes" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "staff_leave_days_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "staff_time_entries" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"profileId" TEXT NOT NULL,
|
||||||
|
"date" DATE NOT NULL,
|
||||||
|
"startMinute" INTEGER NOT NULL,
|
||||||
|
"endMinute" INTEGER NOT NULL,
|
||||||
|
"breakMinutes" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"note" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "staff_time_entries_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "staff_weeks" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"profileId" TEXT NOT NULL,
|
||||||
|
"weekStart" DATE NOT NULL,
|
||||||
|
"status" "StaffWeekStatus" NOT NULL DEFAULT 'DRAFT',
|
||||||
|
"submittedAt" TIMESTAMP(3),
|
||||||
|
"reviewedAt" TIMESTAMP(3),
|
||||||
|
"reviewedById" TEXT,
|
||||||
|
"decisionNote" TEXT,
|
||||||
|
"dailySnapshot" JSONB,
|
||||||
|
|
||||||
|
CONSTRAINT "staff_weeks_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "staff_adjustments" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"profileId" TEXT NOT NULL,
|
||||||
|
"date" DATE NOT NULL,
|
||||||
|
"minutes" INTEGER NOT NULL,
|
||||||
|
"reason" TEXT NOT NULL,
|
||||||
|
"actorId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "staff_adjustments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "staff_history" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"profileId" TEXT NOT NULL,
|
||||||
|
"actorId" TEXT NOT NULL,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"details" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "staff_history_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "staff_notifications" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"profileId" TEXT NOT NULL,
|
||||||
|
"message" TEXT NOT NULL,
|
||||||
|
"readAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "staff_notifications_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "family_event_participations" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kitaId" TEXT NOT NULL,
|
||||||
|
"eventId" TEXT NOT NULL,
|
||||||
|
"familyId" TEXT NOT NULL,
|
||||||
|
"status" "EventParticipationStatus" NOT NULL,
|
||||||
|
"adultCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "family_event_participations_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "notdienst_alarms" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kitaId" TEXT NOT NULL,
|
||||||
|
"assignmentId" TEXT,
|
||||||
|
"activeAssignmentId" TEXT,
|
||||||
|
"familyId" TEXT NOT NULL,
|
||||||
|
"familyName" TEXT NOT NULL,
|
||||||
|
"childName" TEXT NOT NULL,
|
||||||
|
"date" DATE NOT NULL,
|
||||||
|
"wholeDay" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"startMinute" INTEGER,
|
||||||
|
"endMinute" INTEGER,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"deadline" TIMESTAMP(3) NOT NULL,
|
||||||
|
"status" "NotdienstAlarmStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"triggeredById" TEXT,
|
||||||
|
"triggeredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"respondedById" TEXT,
|
||||||
|
"respondedAt" TIMESTAMP(3),
|
||||||
|
"responseSource" TEXT,
|
||||||
|
"escalatedAt" TIMESTAMP(3),
|
||||||
|
"educatorId" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"cancelledAt" TIMESTAMP(3),
|
||||||
|
"cancelReason" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "notdienst_alarms_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "notdienst_alarm_recipients" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"alarmId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"sessionVersion" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "notdienst_alarm_recipients_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "notdienst_alarm_notifications" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"alarmId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"eventKey" TEXT NOT NULL,
|
||||||
|
"message" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"readAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "notdienst_alarm_notifications_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "notdienst_alarm_emails" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"alarmId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"eventKey" TEXT NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"subject" TEXT NOT NULL,
|
||||||
|
"message" TEXT NOT NULL,
|
||||||
|
"path" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"sentAt" TIMESTAMP(3),
|
||||||
|
"skippedAt" TIMESTAMP(3),
|
||||||
|
"leaseUntil" TIMESTAMP(3),
|
||||||
|
"failed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "notdienst_alarm_emails_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "notdienst_alarm_history" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"alarmId" TEXT NOT NULL,
|
||||||
|
"actorId" TEXT,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"details" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "notdienst_alarm_history_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "staff_profiles_educatorId_key" ON "staff_profiles"("educatorId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "staff_profiles_userId_key" ON "staff_profiles"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "staff_profiles_kitaId_idx" ON "staff_profiles"("kitaId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "staff_emails_profileId_sentAt_skippedAt_idx" ON "staff_emails"("profileId", "sentAt", "skippedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "staff_work_patterns_profileId_effectiveFrom_key" ON "staff_work_patterns"("profileId", "effectiveFrom");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "staff_day_overrides_profileId_date_key" ON "staff_day_overrides"("profileId", "date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "staff_holidays_kitaId_date_key" ON "staff_holidays"("kitaId", "date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "staff_leave_accounts_profileId_year_key" ON "staff_leave_accounts"("profileId", "year");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "staff_leaves_profileId_status_startDate_idx" ON "staff_leaves"("profileId", "status", "startDate");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "staff_leave_days_leaveId_date_key" ON "staff_leave_days"("leaveId", "date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "staff_time_entries_profileId_date_idx" ON "staff_time_entries"("profileId", "date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "staff_weeks_profileId_weekStart_key" ON "staff_weeks"("profileId", "weekStart");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "staff_adjustments_profileId_date_idx" ON "staff_adjustments"("profileId", "date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "staff_history_profileId_createdAt_idx" ON "staff_history"("profileId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "staff_notifications_userId_readAt_createdAt_idx" ON "staff_notifications"("userId", "readAt", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "family_event_participations_kitaId_idx" ON "family_event_participations"("kitaId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "family_event_participations_familyId_idx" ON "family_event_participations"("familyId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "family_event_participations_eventId_familyId_key" ON "family_event_participations"("eventId", "familyId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "notdienst_alarms_activeAssignmentId_key" ON "notdienst_alarms"("activeAssignmentId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "notdienst_alarms_kitaId_date_idx" ON "notdienst_alarms"("kitaId", "date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "notdienst_alarms_status_deadline_idx" ON "notdienst_alarms"("status", "deadline");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "notdienst_alarm_recipients_token_key" ON "notdienst_alarm_recipients"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "notdienst_alarm_recipients_alarmId_userId_key" ON "notdienst_alarm_recipients"("alarmId", "userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "notdienst_alarm_notifications_userId_readAt_idx" ON "notdienst_alarm_notifications"("userId", "readAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "notdienst_alarm_notifications_alarmId_userId_eventKey_key" ON "notdienst_alarm_notifications"("alarmId", "userId", "eventKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "notdienst_alarm_emails_sentAt_skippedAt_createdAt_idx" ON "notdienst_alarm_emails"("sentAt", "skippedAt", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "notdienst_alarm_emails_alarmId_userId_eventKey_key" ON "notdienst_alarm_emails"("alarmId", "userId", "eventKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "notdienst_alarm_history_alarmId_createdAt_idx" ON "notdienst_alarm_history"("alarmId", "createdAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_educatorId_fkey" FOREIGN KEY ("educatorId") REFERENCES "educators"("id") ON DELETE NO ACTION ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "staff_emails" ADD CONSTRAINT "staff_emails_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "staff_emails" ADD CONSTRAINT "staff_emails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "staff_work_patterns" ADD CONSTRAINT "staff_work_patterns_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "staff_day_overrides" ADD CONSTRAINT "staff_day_overrides_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "staff_holidays" ADD CONSTRAINT "staff_holidays_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "staff_leave_accounts" ADD CONSTRAINT "staff_leave_accounts_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "staff_leaves" ADD CONSTRAINT "staff_leaves_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "staff_leave_days" ADD CONSTRAINT "staff_leave_days_leaveId_fkey" FOREIGN KEY ("leaveId") REFERENCES "staff_leaves"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "staff_time_entries" ADD CONSTRAINT "staff_time_entries_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "staff_weeks" ADD CONSTRAINT "staff_weeks_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "staff_adjustments" ADD CONSTRAINT "staff_adjustments_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "staff_history" ADD CONSTRAINT "staff_history_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "staff_notifications" ADD CONSTRAINT "staff_notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "staff_notifications" ADD CONSTRAINT "staff_notifications_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "family_event_participations" ADD CONSTRAINT "family_event_participations_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "family_event_participations" ADD CONSTRAINT "family_event_participations_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "termine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "family_event_participations" ADD CONSTRAINT "family_event_participations_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "notdienst_assignments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "notdienst_alarm_recipients" ADD CONSTRAINT "notdienst_alarm_recipients_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "notdienst_alarm_recipients" ADD CONSTRAINT "notdienst_alarm_recipients_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "notdienst_alarm_notifications" ADD CONSTRAINT "notdienst_alarm_notifications_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "notdienst_alarm_notifications" ADD CONSTRAINT "notdienst_alarm_notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "notdienst_alarm_emails" ADD CONSTRAINT "notdienst_alarm_emails_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "notdienst_alarm_emails" ADD CONSTRAINT "notdienst_alarm_emails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "notdienst_alarm_history" ADD CONSTRAINT "notdienst_alarm_history_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
BEGIN;
|
||||||
|
-- Additional tenant invariants intentionally supplement Prisma relations.
|
||||||
|
ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_educatorId_tenant_fkey" FOREIGN KEY ("educatorId", "kitaId") REFERENCES "educators"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_userId_tenant_fkey" FOREIGN KEY ("userId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
ALTER TABLE "family_event_participations" ADD CONSTRAINT "family_event_participations_eventId_tenant_fkey" FOREIGN KEY ("eventId", "kitaId") REFERENCES "termine"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
ALTER TABLE "family_event_participations" ADD CONSTRAINT "family_event_participations_familyId_tenant_fkey" FOREIGN KEY ("familyId", "kitaId") REFERENCES "families"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_familyId_tenant_fkey" FOREIGN KEY ("familyId", "kitaId") REFERENCES "families"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_assignmentId_tenant_fkey" FOREIGN KEY ("assignmentId", "kitaId") REFERENCES "notdienst_assignments"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_activeAssignmentId_tenant_fkey" FOREIGN KEY ("activeAssignmentId", "kitaId") REFERENCES "notdienst_assignments"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_triggeredById_tenant_fkey" FOREIGN KEY ("triggeredById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_respondedById_tenant_fkey" FOREIGN KEY ("respondedById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_educatorId_tenant_fkey" FOREIGN KEY ("educatorId", "kitaId") REFERENCES "educators"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
provider = "postgresql"
|
||||||
@@ -196,6 +196,7 @@ model Family {
|
|||||||
|
|
||||||
@@index([kitaId])
|
@@index([kitaId])
|
||||||
@@map("families")
|
@@map("families")
|
||||||
|
@@unique([id, kitaId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -244,6 +245,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
|
||||||
@@ -277,6 +280,7 @@ model User {
|
|||||||
@@index([familyId])
|
@@index([familyId])
|
||||||
@@index([kitaId, role])
|
@@index([kitaId, role])
|
||||||
@@map("users")
|
@@map("users")
|
||||||
|
@@unique([id, kitaId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -309,6 +313,7 @@ model Child {
|
|||||||
@@index([kitaId])
|
@@index([kitaId])
|
||||||
@@index([familyId])
|
@@index([familyId])
|
||||||
@@map("children")
|
@@map("children")
|
||||||
|
@@unique([id, kitaId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -332,6 +337,7 @@ model Educator {
|
|||||||
|
|
||||||
@@index([kitaId])
|
@@index([kitaId])
|
||||||
@@map("educators")
|
@@map("educators")
|
||||||
|
@@unique([id, kitaId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Personalbereich: Tage als UTC-Datum, Zeiten als Minuten, Anspruch in halben Tagen.
|
// Personalbereich: Tage als UTC-Datum, Zeiten als Minuten, Anspruch in halben Tagen.
|
||||||
@@ -580,6 +586,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 {
|
||||||
@@ -619,6 +626,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 {
|
||||||
@@ -695,6 +703,7 @@ model Announcement {
|
|||||||
|
|
||||||
@@index([kitaId, createdAt])
|
@@index([kitaId, createdAt])
|
||||||
@@index([authorId])
|
@@index([authorId])
|
||||||
|
@@unique([id, kitaId])
|
||||||
@@map("announcements")
|
@@map("announcements")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,12 +715,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)
|
||||||
@@ -722,6 +733,7 @@ model AnnouncementRead {
|
|||||||
|
|
||||||
@@unique([userId, announcementId])
|
@@unique([userId, announcementId])
|
||||||
@@index([announcementId])
|
@@index([announcementId])
|
||||||
|
@@index([kitaId])
|
||||||
@@map("announcement_reads")
|
@@map("announcement_reads")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -798,6 +810,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 {
|
||||||
@@ -893,6 +906,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.
|
||||||
@@ -946,6 +960,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
|
||||||
@@ -1033,6 +1048,7 @@ model NotdienstAlarmRecipient {
|
|||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
token String @unique
|
token String @unique
|
||||||
|
sessionVersion Int @default(0)
|
||||||
|
|
||||||
@@unique([alarmId, userId])
|
@@unique([alarmId, userId])
|
||||||
@@map("notdienst_alarm_recipients")
|
@@map("notdienst_alarm_recipients")
|
||||||
@@ -1174,3 +1190,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
@@ -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 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { stat } from "node:fs/promises";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { attachmentPath } from "../src/lib/attachments";
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
async function main() {
|
||||||
|
let count = 0, missing = 0;
|
||||||
|
while (true) {
|
||||||
|
const batch = await prisma.attachment.findMany({ where: { sizeBytes: null }, select: { id: true }, take: 100 });
|
||||||
|
if (!batch.length) break;
|
||||||
|
for (const item of batch) {
|
||||||
|
let sizeBytes = 0;
|
||||||
|
try { sizeBytes = (await stat(attachmentPath(item.id))).size; }
|
||||||
|
catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||||
|
missing++;
|
||||||
|
}
|
||||||
|
await prisma.attachment.update({ where: { id: item.id }, data: { sizeBytes } });
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Attachment sizes recorded: ${count}; missing files: ${missing}.`);
|
||||||
|
}
|
||||||
|
main().finally(() => prisma.$disconnect()).catch(() => { console.error("Attachment size backfill failed."); process.exitCode = 1; });
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
-- Read-only preflight for both legacy and upgraded schemas. Empty result means no violations.
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT 'users.familyId' AS relation, count(*) AS invalid_rows FROM "users" c JOIN "families" p ON c."familyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'children.familyId' AS relation, count(*) AS invalid_rows FROM "children" c JOIN "families" p ON c."familyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'parent_duty_assignments.dutyId' AS relation, count(*) AS invalid_rows FROM "parent_duty_assignments" c JOIN "parent_duties" p ON c."dutyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'parent_duty_assignments.userId' AS relation, count(*) AS invalid_rows FROM "parent_duty_assignments" c JOIN "users" p ON c."userId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'duty_assignments.familyId' AS relation, count(*) AS invalid_rows FROM "duty_assignments" c JOIN "families" p ON c."familyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'duty_assignments.dutyTypeId' AS relation, count(*) AS invalid_rows FROM "duty_assignments" c JOIN "duty_types" p ON c."dutyTypeId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'absences.childId' AS relation, count(*) AS invalid_rows FROM "absences" c JOIN "children" p ON c."childId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'announcements.authorId' AS relation, count(*) AS invalid_rows FROM "announcements" c JOIN "users" p ON c."authorId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'invitations.invitedById' AS relation, count(*) AS invalid_rows FROM "invitations" c JOIN "users" p ON c."invitedById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'termine.createdById' AS relation, count(*) AS invalid_rows FROM "termine" c JOIN "users" p ON c."createdById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'termine.approvedById' AS relation, count(*) AS invalid_rows FROM "termine" c JOIN "users" p ON c."approvedById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'event_participations.eventId' AS relation, count(*) AS invalid_rows FROM "event_participations" c JOIN "termine" p ON c."eventId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'event_participations.childId' AS relation, count(*) AS invalid_rows FROM "event_participations" c JOIN "children" p ON c."childId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'mitbringsel_items.terminId' AS relation, count(*) AS invalid_rows FROM "mitbringsel_items" c JOIN "termine" p ON c."terminId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'mitbringsel_items.userId' AS relation, count(*) AS invalid_rows FROM "mitbringsel_items" c JOIN "users" p ON c."userId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'notdienst_plans.createdById' AS relation, count(*) AS invalid_rows FROM "notdienst_plans" c JOIN "users" p ON c."createdById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'notdienst_availabilities.childId' AS relation, count(*) AS invalid_rows FROM "notdienst_availabilities" c JOIN "children" p ON c."childId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'notdienst_availabilities.userId' AS relation, count(*) AS invalid_rows FROM "notdienst_availabilities" c JOIN "users" p ON c."userId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'notdienst_assignments.planId' AS relation, count(*) AS invalid_rows FROM "notdienst_assignments" c JOIN "notdienst_plans" p ON c."planId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'notdienst_assignments.childId' AS relation, count(*) AS invalid_rows FROM "notdienst_assignments" c JOIN "children" p ON c."childId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'notdienst_swap_requests.requesterUserId' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "users" p ON c."requesterUserId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'notdienst_swap_requests.requesterFamilyId' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "families" p ON c."requesterFamilyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'notdienst_swap_requests.targetAssignmentId' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "notdienst_assignments" p ON c."targetAssignmentId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'notdienst_swap_requests.offeredAssignmentId' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "notdienst_assignments" p ON c."offeredAssignmentId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'notdienst_swap_requests.respondedById' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "users" p ON c."respondedById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'notdienst_alerts.assignmentId' AS relation, count(*) AS invalid_rows FROM "notdienst_alerts" c JOIN "notdienst_assignments" p ON c."assignmentId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'notdienst_alerts.parentUserId' AS relation, count(*) AS invalid_rows FROM "notdienst_alerts" c JOIN "users" p ON c."parentUserId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'notdienst_alerts.triggeredById' AS relation, count(*) AS invalid_rows FROM "notdienst_alerts" c JOIN "users" p ON c."triggeredById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'notdienst_alerts.educatorId' AS relation, count(*) AS invalid_rows FROM "notdienst_alerts" c JOIN "educators" p ON c."educatorId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'announcement_reads.userId' AS relation, count(*) AS invalid_rows FROM announcement_reads r JOIN announcements a ON r."announcementId" = a.id JOIN users u ON r."userId" = u.id WHERE u."kitaId" IS DISTINCT FROM a."kitaId"
|
||||||
|
) violations WHERE invalid_rows > 0;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { readdir, stat, rm } from "node:fs/promises";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { attachmentDirectory, attachmentPath } from "../src/lib/attachments";
|
||||||
|
|
||||||
|
// Dry run by default. A 24-hour grace period protects concurrent uploads.
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
async function main() {
|
||||||
|
const remove = process.argv.includes("--delete");
|
||||||
|
let candidates = 0;
|
||||||
|
for (const entry of await readdir(attachmentDirectory(), { withFileTypes: true })) {
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
let file: string;
|
||||||
|
try { file = attachmentPath(entry.name); } catch { continue; }
|
||||||
|
if ((await stat(file)).mtimeMs > Date.now() - 24 * 60 * 60_000) continue;
|
||||||
|
if (await prisma.attachment.findUnique({ where: { id: entry.name }, select: { id: true } })) continue;
|
||||||
|
candidates++;
|
||||||
|
if (remove) await rm(file, { force: true });
|
||||||
|
}
|
||||||
|
console.log(`${candidates} orphan attachments ${remove ? "removed" : "found (dry run)"}`);
|
||||||
|
}
|
||||||
|
main().finally(() => prisma.$disconnect()).catch(error => { console.error(error); process.exitCode = 1; });
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, chownSync } from 'node:fs';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
const passwordFile = process.env.APP_DATABASE_PASSWORD_FILE;
|
||||||
|
if (!passwordFile) throw new Error('Runtime password file is required.');
|
||||||
|
const directory = passwordFile.slice(0, passwordFile.lastIndexOf('/'));
|
||||||
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||||
|
const password = existsSync(passwordFile) ? readFileSync(passwordFile, 'utf8').trim() : randomBytes(32).toString('hex');
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(password)) throw new Error('Invalid runtime password file.');
|
||||||
|
if (!existsSync(passwordFile)) writeFileSync(passwordFile, password + '\n', { flag: 'wx', mode: 0o400 });
|
||||||
|
chmodSync(passwordFile, 0o400);
|
||||||
|
chownSync(passwordFile, 1001, 1001);
|
||||||
|
chownSync(directory, 1001, 1001);
|
||||||
|
const ownerUrl = new URL('postgresql://postgres:5432/' + encodeURIComponent(process.env.POSTGRES_DB || 'kita_planer'));
|
||||||
|
ownerUrl.username = process.env.POSTGRES_USER || 'kita_planer';
|
||||||
|
ownerUrl.password = process.env.POSTGRES_PASSWORD || '';
|
||||||
|
if (!ownerUrl.password) throw new Error('Database owner password is required.');
|
||||||
|
const environment = { ...process.env, DATABASE_URL: ownerUrl.toString(), APP_DATABASE_PASSWORD: password };
|
||||||
|
for (const args of [['node_modules/prisma/build/index.js', 'migrate', 'deploy'], ['scripts/provision-runtime-role.mjs']]) {
|
||||||
|
const result = spawnSync(process.execPath, args, { env: environment, stdio: 'inherit' });
|
||||||
|
if (result.status !== 0) process.exit(result.status || 1);
|
||||||
|
}
|
||||||
|
for (const path of ['/app/uploads', '/app/uploads/announcements', '/app/uploads/announcements/announcements', '/app/runtime-cache']) {
|
||||||
|
mkdirSync(path, { recursive: true, mode: 0o700 });
|
||||||
|
chownSync(path, 1001, 1001);
|
||||||
|
}
|
||||||
|
console.log('Database initialized; restricted application access and volumes are ready.');
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { cpSync, mkdirSync } from 'node:fs';
|
||||||
|
cpSync('.next/static', '.next/standalone/.next/static', { recursive: true });
|
||||||
|
cpSync('public', '.next/standalone/public', { recursive: true });
|
||||||
|
mkdirSync('.next/standalone/scripts', { recursive: true });
|
||||||
|
cpSync('scripts/start-production.sh', '.next/standalone/scripts/start-production.sh');
|
||||||
|
cpSync('scripts/notdienst-alarm-worker.mjs', '.next/standalone/scripts/notdienst-alarm-worker.mjs');
|
||||||
|
cpSync('scripts/runtime-database-url.mjs', '.next/standalone/scripts/runtime-database-url.mjs');
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
const file = process.env.APP_DATABASE_PASSWORD_FILE;
|
||||||
|
if (!file) throw new Error('Runtime password file is required.');
|
||||||
|
const password = readFileSync(file, 'utf8').trim();
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(password)) throw new Error('Invalid runtime password file.');
|
||||||
|
const url = new URL('postgresql://kita_runtime@postgres:5432/' + encodeURIComponent(process.env.APP_DATABASE_NAME || 'kita_planer'));
|
||||||
|
url.password = password;
|
||||||
|
process.stdout.write(url.toString());
|
||||||
@@ -1,17 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
echo "Applying Prisma schema..."
|
if [ -n "${APP_DATABASE_PASSWORD_FILE:-}" ]; then
|
||||||
npx prisma db push --skip-generate
|
DATABASE_URL="$(node scripts/runtime-database-url.mjs)"
|
||||||
|
export DATABASE_URL
|
||||||
echo "Starting Kita-Planer..."
|
fi
|
||||||
|
# Schema changes are applied by the maintenance image before runtime startup.
|
||||||
if [ -n "${NOTDIENST_JOB_SECRET:-}" ]; then
|
if [ -n "${NOTDIENST_JOB_SECRET:-}" ]; then
|
||||||
if [ "${#NOTDIENST_JOB_SECRET}" -lt 32 ]; then
|
|
||||||
echo "NOTDIENST_JOB_SECRET muss mindestens 32 Zeichen haben." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
node scripts/notdienst-alarm-worker.mjs &
|
node scripts/notdienst-alarm-worker.mjs &
|
||||||
else
|
|
||||||
echo "Notdienst-Fristprüfung deaktiviert: NOTDIENST_JOB_SECRET fehlt."
|
|
||||||
fi
|
fi
|
||||||
exec npm run start:prod
|
exec node server.js
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
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 { AnnouncementAudience, UserRole } from "@prisma/client";
|
import { AnnouncementAudience, UserRole, type Prisma } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { NewsEmail } from "@/emails/NewsEmail";
|
import { NewsEmail } from "@/emails/NewsEmail";
|
||||||
@@ -17,15 +19,6 @@ import {
|
|||||||
visibleAnnouncementAudiences,
|
visibleAnnouncementAudiences,
|
||||||
} from "@/lib/announcement-access";
|
} from "@/lib/announcement-access";
|
||||||
|
|
||||||
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({
|
||||||
@@ -35,42 +28,15 @@ 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) {
|
async function getRecipients(kitaId: string, audience: z.infer<typeof audienceSchema>) {
|
||||||
const extension = path.extname(file.name).toLowerCase();
|
const where: Prisma.UserWhereInput = { kitaId, emailVerifiedAt: { not: null }, ...announcementRecipientWhere(audience as AnnouncementAudience) };
|
||||||
const typeAllowed = ALLOWED_TYPES.has(file.type);
|
|
||||||
const extensionAllowed = ALLOWED_EXTENSIONS.has(extension);
|
|
||||||
|
|
||||||
if (!typeAllowed && !extensionAllowed) {
|
|
||||||
return "Nur PDF, JPG und PNG sind erlaubt.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > MAX_ATTACHMENT_SIZE) {
|
|
||||||
return "Anhänge dürfen maximal 10 MB groß sein.";
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getRecipients(
|
|
||||||
kitaId: string,
|
|
||||||
audience: z.infer<typeof audienceSchema>,
|
|
||||||
) {
|
|
||||||
const where = {
|
|
||||||
kitaId,
|
|
||||||
emailVerifiedAt: { not: null },
|
|
||||||
...announcementRecipientWhere(audience as AnnouncementAudience),
|
|
||||||
};
|
|
||||||
|
|
||||||
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 },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,25 +66,41 @@ 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 attachmentInputs = attachmentFiles.map((file) => {
|
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
return {
|
attachmentInputs.push({ id, bytes, fileName: getSafeFileName(file.name), fileType,
|
||||||
id,
|
fileUrl: `/api/announcements/attachments/${id}` });
|
||||||
file,
|
}
|
||||||
fileName: getSafeFileName(file.name),
|
|
||||||
fileType: file.type || "application/octet-stream",
|
|
||||||
fileUrl: `/api/announcements/attachments/${id}`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const announcement = await prisma.announcement.create({
|
const announcement = await serializableTransaction(async tx => {
|
||||||
|
if (attachmentInputs.length) {
|
||||||
|
const pending = await tx.attachment.count({ where: { sizeBytes: null } });
|
||||||
|
if (pending) return null;
|
||||||
|
const [tenantUsage, globalUsage] = await Promise.all([
|
||||||
|
tx.attachment.aggregate({ where: { announcement: { kitaId } }, _sum: { sizeBytes: true } }),
|
||||||
|
tx.attachment.aggregate({ _sum: { sizeBytes: true } }),
|
||||||
|
]);
|
||||||
|
const bytes = attachmentInputs.reduce((total, item) => total + item.bytes.length, 0);
|
||||||
|
const quota = (value: string | undefined, fallback: number) => {
|
||||||
|
const parsed = Number(value); return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
};
|
||||||
|
if ((tenantUsage._sum.sizeBytes ?? 0) + bytes > quota(process.env.UPLOAD_TENANT_QUOTA_BYTES, 256 * 1024 ** 2) ||
|
||||||
|
(globalUsage._sum.sizeBytes ?? 0) + bytes > quota(process.env.UPLOAD_GLOBAL_QUOTA_BYTES, 10 * 1024 ** 3)) return null;
|
||||||
|
}
|
||||||
|
return tx.announcement.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId,
|
kitaId,
|
||||||
title: parsed.data.title,
|
title: parsed.data.title,
|
||||||
@@ -130,6 +112,7 @@ export async function createAnnouncement(formData: FormData) {
|
|||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
fileName: attachment.fileName,
|
fileName: attachment.fileName,
|
||||||
fileType: attachment.fileType,
|
fileType: attachment.fileType,
|
||||||
|
sizeBytes: attachment.bytes.length,
|
||||||
fileUrl: attachment.fileUrl,
|
fileUrl: attachment.fileUrl,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@@ -140,17 +123,19 @@ export async function createAnnouncement(formData: FormData) {
|
|||||||
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 },
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -158,7 +143,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(
|
console.error(
|
||||||
@@ -232,6 +217,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
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
import { publicActionError } from "@/lib/action-error";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { respondToAlarm } from "@/lib/alarms/service";
|
import { respondToAlarm } from "@/lib/alarms/service";
|
||||||
import { deliverAlarmEmails } from "@/lib/alarms/email";
|
import { deliverAlarmEmails } from "@/lib/alarms/email";
|
||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { limitPublicAction } from "@/lib/rate-limit";
|
||||||
|
import { hashToken, isPublicToken } from "@/lib/tokens";
|
||||||
|
|
||||||
export async function respondAlertAction(token: string, approve: boolean) {
|
export async function respondAlertAction(token: string, approve: boolean) {
|
||||||
if (typeof token !== "string" || token.length > 128)
|
if (!isPublicToken(token) || typeof approve !== "boolean")
|
||||||
return { error: "Ungültiger Rückmeldelink." };
|
return { error: "Ungültiger Rückmeldelink." };
|
||||||
if (!rateLimit(`alarm-response:${token}`, 12, 60).allowed)
|
if (!(await limitPublicAction("alarm-response", hashToken(token), 12, 60)))
|
||||||
return { error: "Bitte kurz warten und erneut versuchen." };
|
return { error: "Bitte kurz warten und erneut versuchen." };
|
||||||
try {
|
try {
|
||||||
const result = await respondToAlarm(token, approve);
|
const result = await respondToAlarm(token, approve);
|
||||||
@@ -22,12 +24,8 @@ export async function respondAlertAction(token: string, approve: boolean) {
|
|||||||
revalidatePath("/dashboard/notdienst/alarm");
|
revalidatePath("/dashboard/notdienst/alarm");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
const message = publicActionError(error, "");
|
||||||
error instanceof Error &&
|
if (message) return { error: message };
|
||||||
!error.message.includes("prisma") &&
|
|
||||||
!error.message.includes("Invalid `")
|
|
||||||
)
|
|
||||||
return { error: error.message };
|
|
||||||
return {
|
return {
|
||||||
error:
|
error:
|
||||||
"Die Rückmeldung konnte nicht gespeichert werden. Bitte den aktuellen Stand prüfen.",
|
"Die Rückmeldung konnte nicht gespeichert werden. Bitte den aktuellen Stand prüfen.",
|
||||||
|
|||||||
@@ -1,31 +1,19 @@
|
|||||||
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";
|
||||||
import { visibleAnnouncementAudiences } from "@/lib/announcement-access";
|
import { visibleAnnouncementAudiences } from "@/lib/announcement-access";
|
||||||
|
|
||||||
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) {
|
||||||
@@ -33,6 +21,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,
|
||||||
@@ -53,12 +42,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, no-store",
|
"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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -95,7 +95,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>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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([]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -18,6 +20,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";
|
||||||
import { lockEvent } from "@/lib/event-lock";
|
import { lockEvent } from "@/lib/event-lock";
|
||||||
|
|
||||||
@@ -26,8 +30,8 @@ import { lockEvent } from "@/lib/event-lock";
|
|||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
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(),
|
||||||
@@ -107,8 +111,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,
|
||||||
@@ -122,7 +127,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,
|
||||||
@@ -329,6 +334,7 @@ function buildTerminData(
|
|||||||
|
|
||||||
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." };
|
||||||
}
|
}
|
||||||
@@ -345,15 +351,16 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
|
|||||||
const today = getTodayUtcDateOnly();
|
const today = getTodayUtcDateOnly();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const { targetAssignment, offeredAssignment, kita } = await serializableTransaction(async tx => {
|
||||||
const [targetAssignment, offeredAssignment, kita, existingRequest] =
|
const [targetAssignment, offeredAssignment, kita, existingRequest] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
getSwapAssignment(targetAssignmentId, session.user.kitaId),
|
getSwapAssignment(targetAssignmentId, session.user.kitaId, tx),
|
||||||
getSwapAssignment(offeredAssignmentId, session.user.kitaId),
|
getSwapAssignment(offeredAssignmentId, session.user.kitaId, tx),
|
||||||
prisma.kita.findUnique({
|
tx.kita.findUnique({
|
||||||
where: { id: session.user.kitaId },
|
where: { id: session.user.kitaId },
|
||||||
select: { name: true },
|
select: { name: true },
|
||||||
}),
|
}),
|
||||||
prisma.notdienstSwapRequest.findFirst({
|
tx.notdienstSwapRequest.findFirst({
|
||||||
where: {
|
where: {
|
||||||
kitaId: session.user.kitaId,
|
kitaId: session.user.kitaId,
|
||||||
status: NotdienstSwapStatus.PENDING,
|
status: NotdienstSwapStatus.PENDING,
|
||||||
@@ -371,33 +378,35 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
|
|||||||
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 {
|
throw new UserFacingError("Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.");
|
||||||
error:
|
|
||||||
"Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.notdienstSwapRequest.create({
|
if (await tx.notdienstAlarm.count({ where: { kitaId: session.user.kitaId, activeAssignmentId: { in: [targetAssignment.id, offeredAssignment.id] } } })) throw new UserFacingError("Notdienste mit aktiver Alarmierung können nicht getauscht werden.");
|
||||||
|
|
||||||
|
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({
|
||||||
family: targetAssignment.child.family,
|
family: targetAssignment.child.family,
|
||||||
subject: `Notdienst-Tauschanfrage für ${formatNotdienstDate(targetAssignment.date)}`,
|
subject: `Notdienst-Tauschanfrage für ${formatNotdienstDate(targetAssignment.date)}`,
|
||||||
@@ -412,8 +421,10 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
|
|||||||
revalidateCalendarAndNotdienst();
|
revalidateCalendarAndNotdienst();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (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);
|
console.error("Fehler beim Erstellen der Notdienst-Tauschanfrage:", error);
|
||||||
return { error: "Tauschanfrage konnte nicht erstellt werden." };
|
}
|
||||||
|
return { error: publicActionError(error, "Tauschanfrage konnte nicht erstellt werden.") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,8 +442,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
|||||||
const today = getTodayUtcDateOnly();
|
const today = getTodayUtcDateOnly();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await prisma.$transaction(
|
const result = await serializableTransaction(async (tx) => {
|
||||||
async (tx) => {
|
|
||||||
const request = await tx.notdienstSwapRequest.findFirst({
|
const request = await tx.notdienstSwapRequest.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: parsed.data,
|
id: parsed.data,
|
||||||
@@ -457,22 +467,15 @@ 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: {
|
select: { email: true, firstName: true, lastName: true },
|
||||||
email: true,
|
|
||||||
firstName: true,
|
|
||||||
lastName: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
alerts: {
|
alerts: {
|
||||||
where: {
|
where: { status: { in: activeNotdienstAlertStatuses } },
|
||||||
alarmId: null,
|
|
||||||
status: { in: activeNotdienstAlertStatuses },
|
|
||||||
},
|
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -490,22 +493,15 @@ 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: {
|
select: { email: true, firstName: true, lastName: true },
|
||||||
email: true,
|
|
||||||
firstName: true,
|
|
||||||
lastName: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
alerts: {
|
alerts: {
|
||||||
where: {
|
where: { status: { in: activeNotdienstAlertStatuses } },
|
||||||
alarmId: null,
|
|
||||||
status: { in: activeNotdienstAlertStatuses },
|
|
||||||
},
|
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -514,28 +510,11 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!request) {
|
if (!request) {
|
||||||
throw new Error(
|
throw new UserFacingError("Tauschanfrage nicht gefunden oder nicht mehr offen.");
|
||||||
"Tauschanfrage nicht gefunden oder nicht mehr offen.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
await tx.notdienstAlarm.count({
|
|
||||||
where: {
|
|
||||||
activeAssignmentId: {
|
|
||||||
in: [request.targetAssignmentId, request.offeredAssignmentId],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
throw new Error(
|
|
||||||
"Notdienste mit aktiver Alarmierung können nicht getauscht werden.",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (request.targetAssignment.child.familyId !== session.user.familyId) {
|
if (request.targetAssignment.child.familyId !== session.user.familyId) {
|
||||||
throw new Error(
|
throw new UserFacingError("Nur der angefragte Haushalt kann diese Anfrage annehmen.");
|
||||||
"Nur der angefragte Haushalt kann diese Anfrage annehmen.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const validationError = validateSwapAssignmentPair({
|
const validationError = validateSwapAssignmentPair({
|
||||||
@@ -545,9 +524,11 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
|||||||
today,
|
today,
|
||||||
});
|
});
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
throw new Error(validationError);
|
throw new UserFacingError(validationError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await tx.notdienstAlarm.count({ where: { kitaId: session.user.kitaId, activeAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } } })) throw new UserFacingError("Notdienste mit aktiver Alarmierung können nicht getauscht werden.");
|
||||||
|
|
||||||
await tx.notdienstAssignment.update({
|
await tx.notdienstAssignment.update({
|
||||||
where: { id: request.targetAssignmentId },
|
where: { id: request.targetAssignmentId },
|
||||||
data: { childId: request.offeredAssignment.childId },
|
data: { childId: request.offeredAssignment.childId },
|
||||||
@@ -556,30 +537,23 @@ 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 },
|
||||||
kitaId: session.user.kitaId,
|
kitaId: session.user.kitaId,
|
||||||
status: NotdienstSwapStatus.PENDING,
|
status: NotdienstSwapStatus.PENDING,
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{ targetAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } },
|
||||||
targetAssignmentId: {
|
{ offeredAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } },
|
||||||
in: [request.targetAssignmentId, request.offeredAssignmentId],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offeredAssignmentId: {
|
|
||||||
in: [request.targetAssignmentId, request.offeredAssignmentId],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
@@ -592,9 +566,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
|||||||
targetAssignment: request.targetAssignment,
|
targetAssignment: request.targetAssignment,
|
||||||
offeredAssignment: request.offeredAssignment,
|
offeredAssignment: request.offeredAssignment,
|
||||||
};
|
};
|
||||||
},
|
});
|
||||||
{ isolationLevel: "Serializable" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const kita = await prisma.kita.findUnique({
|
const kita = await prisma.kita.findUnique({
|
||||||
where: { id: session.user.kitaId },
|
where: { id: session.user.kitaId },
|
||||||
@@ -629,10 +601,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
error:
|
error: publicActionError(error, "Tauschanfrage konnte nicht angenommen werden."),
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Tauschanfrage konnte nicht angenommen werden.",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -667,7 +636,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 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -685,7 +654,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 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -706,14 +675,15 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 },
|
||||||
@@ -778,14 +748,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 };
|
||||||
@@ -800,6 +771,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) {
|
||||||
@@ -1042,11 +1014,12 @@ export async function toggleMitbringselList(
|
|||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
import { publicActionError } from "@/lib/action-error";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||||
@@ -48,12 +49,8 @@ export async function alarmAction(
|
|||||||
return {
|
return {
|
||||||
error: error.issues[0]?.message ?? "Bitte die Eingaben prüfen.",
|
error: error.issues[0]?.message ?? "Bitte die Eingaben prüfen.",
|
||||||
};
|
};
|
||||||
if (
|
const message = publicActionError(error, "");
|
||||||
error instanceof Error &&
|
if (message) return { error: message };
|
||||||
!error.message.includes("prisma") &&
|
|
||||||
!error.message.includes("Invalid `")
|
|
||||||
)
|
|
||||||
return { error: error.message };
|
|
||||||
return { error: "Die Alarmaktion konnte nicht gespeichert werden." };
|
return { error: "Die Alarmaktion konnte nicht gespeichert werden." };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||||
|
import { dateOnlySchema, validPlanMonth } from "@/lib/date-schema";
|
||||||
|
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 { NotdienstPlanStatus, UserRole } from "@prisma/client";
|
import { NotdienstPlanStatus, UserRole } from "@prisma/client";
|
||||||
@@ -37,6 +40,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: {
|
||||||
@@ -161,6 +166,7 @@ export async function updateAssignmentAction(
|
|||||||
const actor = await requireAlarmActor();
|
const actor = await requireAlarmActor();
|
||||||
const kitaId = actor.kitaId;
|
const kitaId = actor.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));
|
||||||
|
|
||||||
@@ -173,14 +179,14 @@ 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) {
|
||||||
@@ -189,7 +195,7 @@ export async function updateAssignmentAction(
|
|||||||
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
|
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||||
);
|
);
|
||||||
if (parsedDate < todayUtc) {
|
if (parsedDate < todayUtc) {
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Vergangene Notdienste können nicht geändert werden.",
|
"Vergangene Notdienste können nicht geändert werden.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -203,7 +209,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(
|
|||||||
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 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -240,7 +246,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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,7 +397,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."),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -406,6 +412,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 },
|
||||||
@@ -433,6 +441,8 @@ export async function sendNotdienstRemindersAction(
|
|||||||
return { error: "Kein Mandant zugeordnet." };
|
return { error: "Kein Mandant zugeordnet." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
|
||||||
|
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
|
||||||
const mailConfigError = getAppEmailConfigError();
|
const mailConfigError = getAppEmailConfigError();
|
||||||
if (mailConfigError) {
|
if (mailConfigError) {
|
||||||
return { error: mailConfigError };
|
return { error: mailConfigError };
|
||||||
@@ -456,7 +466,7 @@ export async function sendNotdienstRemindersAction(
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
users: {
|
users: {
|
||||||
where: { role: UserRole.ELTERN },
|
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -551,11 +561,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(
|
throw new UserFacingError(
|
||||||
"Nur Pläne im Entwurfsstatus können zurückgesetzt werden.",
|
"Nur Pläne im Entwurfsstatus können zurückgesetzt werden.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -576,10 +586,7 @@ export async function resetPlanAction(planId: string) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Fehler beim Zurücksetzen des Plans:", error);
|
console.error("Fehler beim Zurücksetzen des Plans:", error);
|
||||||
return {
|
return {
|
||||||
error:
|
error: publicActionError(error, "Fehler beim Zurücksetzen des Plans."),
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Fehler beim Zurücksetzen des Plans.",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
import { publicActionError } from "@/lib/action-error";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -63,12 +64,8 @@ export async function staffAction(
|
|||||||
return {
|
return {
|
||||||
error: error.issues[0]?.message ?? "Bitte die Eingaben prüfen.",
|
error: error.issues[0]?.message ?? "Bitte die Eingaben prüfen.",
|
||||||
};
|
};
|
||||||
if (
|
const message = publicActionError(error, "");
|
||||||
error instanceof Error &&
|
if (message) return { error: message };
|
||||||
!error.message.includes("prisma") &&
|
|
||||||
!error.message.includes("Invalid `")
|
|
||||||
)
|
|
||||||
return { error: error.message };
|
|
||||||
return { error: "Speichern fehlgeschlagen. Bitte erneut versuchen." };
|
return { error: "Speichern fehlgeschlagen. Bitte erneut versuchen." };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,15 +119,19 @@ export async function createMyChild(rawPayload: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.child.create({
|
await serializableTransaction(async tx => {
|
||||||
|
const count = await tx.child.count({ where: { kitaId: session.user.kitaId, familyId: session.user.familyId! } });
|
||||||
|
if (count >= 20) throw new Error("Household child limit reached");
|
||||||
|
await tx.child.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId: session.user.kitaId,
|
kitaId: session.user.kitaId,
|
||||||
familyId: session.user.familyId,
|
familyId: session.user.familyId!,
|
||||||
firstName: parsed.data.firstName,
|
firstName: parsed.data.firstName,
|
||||||
lastName: parsed.data.lastName,
|
lastName: parsed.data.lastName,
|
||||||
dateOfBirth: parseDateInput(parsed.data.dateOfBirth),
|
dateOfBirth: parseDateInput(parsed.data.dateOfBirth),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
revalidatePath("/dashboard/profil");
|
revalidatePath("/dashboard/profil");
|
||||||
revalidatePath("/dashboard/families");
|
revalidatePath("/dashboard/families");
|
||||||
@@ -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) {
|
||||||
|
if (!(error instanceof Error && error.message === "Account already assigned or revoked")) {
|
||||||
console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
|
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");
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
const existing = await prisma.user.findUnique({ where: { email }, select: { role: true, kitaId: true, emailVerifiedAt: true, disabledAt: true } });
|
||||||
await prisma.user.create({
|
if (existing && (existing.role !== "ADMIN" || existing.kitaId || existing.emailVerifiedAt || existing.disabledAt)) return { attempt, message };
|
||||||
data: {
|
const token = newToken();
|
||||||
email,
|
await prisma.pendingRegistration.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
||||||
passwordHash,
|
await prisma.pendingRegistration.upsert({
|
||||||
firstName,
|
where: { email },
|
||||||
lastName,
|
create: { email, firstName, lastName, tokenHash: hashToken(token), expiresAt: new Date(Date.now() + 30 * 60_000) },
|
||||||
// Gründer wird "heimatloser" Admin — beim Onboarding-Abschluss
|
update: { firstName, lastName, tokenHash: hashToken(token), expiresAt: new Date(Date.now() + 30 * 60_000) },
|
||||||
// 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) {
|
const base = process.env.AUTH_URL ?? process.env.NEXTAUTH_URL ?? "http://localhost:3000";
|
||||||
if (
|
const result = await sendAppEmail({
|
||||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
to: email,
|
||||||
err.code === "P2002"
|
subject: "E-Mail bestätigen · Kita-Planer",
|
||||||
) {
|
react: createElement("div", null,
|
||||||
return {
|
createElement("p", null, "Bestätige deine E-Mail-Adresse, um dein Konto einzurichten. Falls du dich nicht registriert hast, kannst du diese Nachricht ignorieren."),
|
||||||
attempt,
|
createElement("a", { href: `${base}/register/verify/${token}` }, "Registrierung abschließen")),
|
||||||
values: echoValues(formData),
|
|
||||||
errors: {
|
|
||||||
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direkt einloggen → wirft NEXT_REDIRECT, das wir nicht abfangen.
|
|
||||||
await signIn("credentials", {
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
redirectTo: "/onboarding",
|
|
||||||
});
|
});
|
||||||
|
if (!result.success) return { attempt, errors: { _form: ["Die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte versuche es später erneut."] } };
|
||||||
// Unreachable – signIn redirected.
|
return { attempt, message };
|
||||||
return { attempt };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
+40
-37
@@ -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: tokenUserId },
|
where: { id },
|
||||||
select: { role: true, kitaId: true, familyId: true },
|
select: { role: true, kitaId: true, familyId: true, disabledAt: true, sessionVersion: true, emailVerifiedAt: true },
|
||||||
});
|
});
|
||||||
if (!fresh) {
|
if (!fresh || fresh.disabledAt || !fresh.emailVerifiedAt || fresh.sessionVersion !== token.sessionVersion) return null;
|
||||||
// User wurde gelöscht → Token entwerten.
|
token.id = id;
|
||||||
// (Auth.js erkennt den fehlenden `sub`/`id` und meldet ab.)
|
|
||||||
delete (token as Partial<typeof token>).id;
|
|
||||||
delete (token as Partial<typeof token>).sub;
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
token.id = tokenUserId;
|
|
||||||
token.role = fresh.role;
|
token.role = fresh.role;
|
||||||
token.kitaId = fresh.kitaId;
|
token.kitaId = fresh.kitaId;
|
||||||
token.familyId = fresh.familyId;
|
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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export class UserFacingError extends Error {}
|
||||||
|
export function publicActionError(error: unknown, fallback: string): string {
|
||||||
|
return error instanceof UserFacingError ? error.message : fallback;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { hashToken } from "@/lib/tokens";
|
||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { sendAppEmail } from "@/lib/mail";
|
import { sendAppEmail } from "@/lib/mail";
|
||||||
@@ -38,14 +39,14 @@ export async function deliverAlarmEmails(
|
|||||||
include: { user: true, alarm: { include: { kita: true } } },
|
include: { user: true, alarm: { include: { kita: true } } },
|
||||||
});
|
});
|
||||||
const { user, alarm } = job;
|
const { user, alarm } = job;
|
||||||
let eligible = user.kitaId === alarm.kitaId;
|
let eligible = user.kitaId === alarm.kitaId && !user.disabledAt && !!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt && (!user.lockedUntil || user.lockedUntil <= now);
|
||||||
if (job.kind === "MANAGER") eligible &&= managerRoles.includes(user.role);
|
if (job.kind === "MANAGER") eligible &&= managerRoles.includes(user.role);
|
||||||
else
|
else
|
||||||
eligible &&= user.role === "ELTERN" && user.familyId === alarm.familyId;
|
eligible &&= user.role === "ELTERN" && user.familyId === alarm.familyId;
|
||||||
if (job.kind === "REQUEST") {
|
if (job.kind === "REQUEST") {
|
||||||
const token = job.path.split("/").pop();
|
const token = job.path.split("/").pop();
|
||||||
const recipient = await prisma.notdienstAlarmRecipient.findFirst({
|
const recipient = await prisma.notdienstAlarmRecipient.findFirst({
|
||||||
where: { alarmId: alarm.id, userId: user.id, token },
|
where: { alarmId: alarm.id, userId: user.id, sessionVersion: user.sessionVersion, OR: [{ token: hashToken(token ?? "") }, ...(token?.length === 36 ? [{ token }] : [])] },
|
||||||
});
|
});
|
||||||
eligible &&=
|
eligible &&=
|
||||||
!!recipient && alarm.status === "PENDING" && alarm.expiresAt > now;
|
!!recipient && alarm.status === "PENDING" && alarm.expiresAt > now;
|
||||||
|
|||||||
+43
-33
@@ -1,4 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { hashToken, newToken } from "@/lib/tokens";
|
||||||
|
import { UserFacingError } from "@/lib/action-error";
|
||||||
import { Prisma, type NotdienstAlarm, type UserRole } from "@prisma/client";
|
import { Prisma, type NotdienstAlarm, type UserRole } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -49,17 +51,19 @@ export async function validateAlarmActor(
|
|||||||
? !managerRoles.includes(actor.role)
|
? !managerRoles.includes(actor.role)
|
||||||
: ![...managerRoles, "ELTERN"].includes(actor.role)
|
: ![...managerRoles, "ELTERN"].includes(actor.role)
|
||||||
)
|
)
|
||||||
throw new Error("Kein Zugriff auf die Notdienstalarmierung.");
|
throw new UserFacingError("Kein Zugriff auf die Notdienstalarmierung.");
|
||||||
const user = await tx.user.findFirst({
|
const user = await tx.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: actor.id,
|
id: actor.id,
|
||||||
kitaId: actor.kitaId,
|
kitaId: actor.kitaId,
|
||||||
role: actor.role,
|
role: actor.role,
|
||||||
privacyPolicyAcceptedAt: { not: null },
|
privacyPolicyAcceptedAt: { not: null },
|
||||||
|
emailVerifiedAt: { not: null },
|
||||||
|
disabledAt: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!user || (user.lockedUntil && user.lockedUntil > new Date()))
|
if (!user || (user.lockedUntil && user.lockedUntil > new Date()))
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Dein Konto hat keinen Zugriff auf die Notdienstalarmierung.",
|
"Dein Konto hat keinen Zugriff auf die Notdienstalarmierung.",
|
||||||
);
|
);
|
||||||
return user;
|
return user;
|
||||||
@@ -82,7 +86,7 @@ export async function assertAssignmentNotAlarmed(
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Für diese Einteilung besteht ein aktiver Alarm. Bitte den Alarm zuerst stornieren.",
|
"Für diese Einteilung besteht ein aktiver Alarm. Bitte den Alarm zuerst stornieren.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -105,28 +109,27 @@ async function queue(
|
|||||||
kind: string,
|
kind: string,
|
||||||
eventKey: string,
|
eventKey: string,
|
||||||
message: string,
|
message: string,
|
||||||
|
requestTokens?: Map<string, string>,
|
||||||
) {
|
) {
|
||||||
const users =
|
const users =
|
||||||
audience === "managers"
|
audience === "managers"
|
||||||
? await tx.user.findMany({
|
? await tx.user.findMany({
|
||||||
where: { kitaId: alarm.kitaId, role: { in: managerRoles } },
|
where: { kitaId: alarm.kitaId, role: { in: managerRoles }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||||
})
|
})
|
||||||
: await tx.user.findMany({
|
: await tx.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
kitaId: alarm.kitaId,
|
kitaId: alarm.kitaId,
|
||||||
familyId: alarm.familyId,
|
familyId: alarm.familyId,
|
||||||
role: "ELTERN",
|
role: "ELTERN",
|
||||||
|
emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null },
|
||||||
alarmRecipients: { some: { alarmId: alarm.id } },
|
alarmRecipients: { some: { alarmId: alarm.id } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const jobs: string[] = [];
|
const jobs: string[] = [];
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
const recipient =
|
const recipient =
|
||||||
kind === "REQUEST"
|
kind === "REQUEST" ? requestTokens?.get(user.id) : null;
|
||||||
? await tx.notdienstAlarmRecipient.findUnique({
|
if (kind === "REQUEST" && !recipient) throw new Error("Request token missing");
|
||||||
where: { alarmId_userId: { alarmId: alarm.id, userId: user.id } },
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
await tx.notdienstAlarmNotification.create({
|
await tx.notdienstAlarmNotification.create({
|
||||||
data: { alarmId: alarm.id, userId: user.id, eventKey, message },
|
data: { alarmId: alarm.id, userId: user.id, eventKey, message },
|
||||||
});
|
});
|
||||||
@@ -139,7 +142,7 @@ async function queue(
|
|||||||
subject: `Notdienst ${dateKey(alarm.date)} · ${kind === "REQUEST" ? "Bitte Rückmeldung" : kind === "CANCEL" ? "Einsatz storniert" : "Neue Rückmeldung"}`,
|
subject: `Notdienst ${dateKey(alarm.date)} · ${kind === "REQUEST" ? "Bitte Rückmeldung" : kind === "CANCEL" ? "Einsatz storniert" : "Neue Rückmeldung"}`,
|
||||||
message,
|
message,
|
||||||
path: recipient
|
path: recipient
|
||||||
? `/alert/${recipient.token}`
|
? `/alert/${recipient}`
|
||||||
: `/dashboard/notdienst/alarm?alarm=${alarm.id}`,
|
: `/dashboard/notdienst/alarm?alarm=${alarm.id}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -189,7 +192,7 @@ export async function executeAlarmCommand(
|
|||||||
input.endMinute === undefined ||
|
input.endMinute === undefined ||
|
||||||
input.endMinute <= input.startMinute)
|
input.endMinute <= input.startMinute)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Bitte gültige Einsatzzeiten mit Ende nach Beginn angeben.",
|
"Bitte gültige Einsatzzeiten mit Ende nach Beginn angeben.",
|
||||||
);
|
);
|
||||||
const assignment = await tx.notdienstAssignment.findFirst({
|
const assignment = await tx.notdienstAssignment.findFirst({
|
||||||
@@ -201,7 +204,7 @@ export async function executeAlarmCommand(
|
|||||||
include: {
|
include: {
|
||||||
family: {
|
family: {
|
||||||
include: {
|
include: {
|
||||||
users: { where: { role: "ELTERN", kitaId: actor.kitaId } },
|
users: { where: { role: "ELTERN", kitaId: actor.kitaId, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -214,17 +217,17 @@ export async function executeAlarmCommand(
|
|||||||
assignment.child.family.kitaId !== actor.kitaId ||
|
assignment.child.family.kitaId !== actor.kitaId ||
|
||||||
assignment.plan.kitaId !== actor.kitaId
|
assignment.plan.kitaId !== actor.kitaId
|
||||||
)
|
)
|
||||||
throw new Error("Einteilung nicht gefunden.");
|
throw new UserFacingError("Einteilung nicht gefunden.");
|
||||||
if (!assignment.kita.notdienstModuleEnabled)
|
if (!assignment.kita.notdienstModuleEnabled)
|
||||||
throw new Error("Notdienst ist für diese Kita deaktiviert.");
|
throw new UserFacingError("Notdienst ist für diese Kita deaktiviert.");
|
||||||
if (assignment.plan.status !== "PUBLISHED")
|
if (assignment.plan.status !== "PUBLISHED")
|
||||||
throw new Error("Bitte den Notdienstplan zuerst veröffentlichen.");
|
throw new UserFacingError("Bitte den Notdienstplan zuerst veröffentlichen.");
|
||||||
const date = dateKey(assignment.date);
|
const date = dateKey(assignment.date);
|
||||||
if (date < todayKey(now))
|
if (date < todayKey(now))
|
||||||
throw new Error("Vergangene Notdienste können nicht alarmiert werden.");
|
throw new UserFacingError("Vergangene Notdienste können nicht alarmiert werden.");
|
||||||
const expiresAt = endOfAlarm(date, input.wholeDay, input.endMinute);
|
const expiresAt = endOfAlarm(date, input.wholeDay, input.endMinute);
|
||||||
if (expiresAt <= now)
|
if (expiresAt <= now)
|
||||||
throw new Error("Dieser Einsatz ist bereits beendet.");
|
throw new UserFacingError("Dieser Einsatz ist bereits beendet.");
|
||||||
if (!input.wholeDay) berlinInstant(date, input.startMinute!);
|
if (!input.wholeDay) berlinInstant(date, input.startMinute!);
|
||||||
if (
|
if (
|
||||||
(await tx.notdienstAlarm.count({
|
(await tx.notdienstAlarm.count({
|
||||||
@@ -238,12 +241,13 @@ export async function executeAlarmCommand(
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Für diese Einteilung besteht bereits ein aktiver Alarm.",
|
"Für diese Einteilung besteht bereits ein aktiver Alarm.",
|
||||||
);
|
);
|
||||||
const parents = assignment.child.family.users;
|
const parents = assignment.child.family.users;
|
||||||
|
const requestTokens = new Map(parents.map(parent => [parent.id, newToken()]));
|
||||||
if (!parents.length)
|
if (!parents.length)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Für diesen Haushalt sind keine Elternkonten hinterlegt.",
|
"Für diesen Haushalt sind keine Elternkonten hinterlegt.",
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
@@ -252,7 +256,7 @@ export async function executeAlarmCommand(
|
|||||||
where: { id: input.educatorId, kitaId: actor.kitaId, active: true },
|
where: { id: input.educatorId, kitaId: actor.kitaId, active: true },
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
throw new Error("Fachkraft nicht gefunden.");
|
throw new UserFacingError("Fachkraft nicht gefunden.");
|
||||||
const alarm = await tx.notdienstAlarm.create({
|
const alarm = await tx.notdienstAlarm.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId: actor.kitaId,
|
kitaId: actor.kitaId,
|
||||||
@@ -279,7 +283,8 @@ export async function executeAlarmCommand(
|
|||||||
recipients: {
|
recipients: {
|
||||||
create: parents.map((parent) => ({
|
create: parents.map((parent) => ({
|
||||||
userId: parent.id,
|
userId: parent.id,
|
||||||
token: randomUUID(),
|
token: hashToken(requestTokens.get(parent.id)!),
|
||||||
|
sessionVersion: parent.sessionVersion,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -298,13 +303,14 @@ export async function executeAlarmCommand(
|
|||||||
"REQUEST",
|
"REQUEST",
|
||||||
"request",
|
"request",
|
||||||
`${alarmDetails(alarm)}. Bitte gebt bis ${instantLabel(alarm.deadline)} Uhr für euren Haushalt Bescheid, ob ihr den Einsatz übernehmt.`,
|
`${alarmDetails(alarm)}. Bitte gebt bis ${instantLabel(alarm.deadline)} Uhr für euren Haushalt Bescheid, ob ihr den Einsatz übernehmt.`,
|
||||||
|
requestTokens,
|
||||||
);
|
);
|
||||||
return { alarmId: alarm.id, emailIds };
|
return { alarmId: alarm.id, emailIds };
|
||||||
}
|
}
|
||||||
const alarm = await tx.notdienstAlarm.findFirst({
|
const alarm = await tx.notdienstAlarm.findFirst({
|
||||||
where: { id: id.parse(data.alarmId), kitaId: actor.kitaId },
|
where: { id: id.parse(data.alarmId), kitaId: actor.kitaId },
|
||||||
});
|
});
|
||||||
if (!alarm) throw new Error("Alarm nicht gefunden.");
|
if (!alarm) throw new UserFacingError("Alarm nicht gefunden.");
|
||||||
if (command === "retry") {
|
if (command === "retry") {
|
||||||
const jobs = await tx.notdienstAlarmEmail.findMany({
|
const jobs = await tx.notdienstAlarmEmail.findMany({
|
||||||
where: { alarmId: alarm.id, sentAt: null, skippedAt: null },
|
where: { alarmId: alarm.id, sentAt: null, skippedAt: null },
|
||||||
@@ -317,7 +323,7 @@ export async function executeAlarmCommand(
|
|||||||
if (command === "cancel") {
|
if (command === "cancel") {
|
||||||
const reason = note.parse(data.reason);
|
const reason = note.parse(data.reason);
|
||||||
if (alarm.status === "CANCELLED")
|
if (alarm.status === "CANCELLED")
|
||||||
throw new Error("Der Alarm ist bereits storniert.");
|
throw new UserFacingError("Der Alarm ist bereits storniert.");
|
||||||
const updated = await tx.notdienstAlarm.update({
|
const updated = await tx.notdienstAlarm.update({
|
||||||
where: { id: alarm.id },
|
where: { id: alarm.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -342,12 +348,12 @@ export async function executeAlarmCommand(
|
|||||||
}
|
}
|
||||||
if (command === "recordResponse" || command === "approveLate") {
|
if (command === "recordResponse" || command === "approveLate") {
|
||||||
if (alarm.status === "CANCELLED" || alarm.expiresAt <= now)
|
if (alarm.status === "CANCELLED" || alarm.expiresAt <= now)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Dieser Alarm ist storniert oder der Einsatz bereits beendet.",
|
"Dieser Alarm ist storniert oder der Einsatz bereits beendet.",
|
||||||
);
|
);
|
||||||
const reason = note.parse(data.reason);
|
const reason = note.parse(data.reason);
|
||||||
if (command === "approveLate" && alarm.status !== "LATE_CONFIRMATION")
|
if (command === "approveLate" && alarm.status !== "LATE_CONFIRMATION")
|
||||||
throw new Error("Es liegt keine verspätete Zusage zur Freigabe vor.");
|
throw new UserFacingError("Es liegt keine verspätete Zusage zur Freigabe vor.");
|
||||||
const approve =
|
const approve =
|
||||||
command === "approveLate" || z.boolean().parse(data.approve);
|
command === "approveLate" || z.boolean().parse(data.approve);
|
||||||
const updated = await tx.notdienstAlarm.update({
|
const updated = await tx.notdienstAlarm.update({
|
||||||
@@ -389,13 +395,13 @@ export async function executeAlarmCommand(
|
|||||||
);
|
);
|
||||||
return { alarmId: alarm.id, emailIds: emails };
|
return { alarmId: alarm.id, emailIds: emails };
|
||||||
}
|
}
|
||||||
throw new Error("Unbekannte Alarmaktion.");
|
throw new UserFacingError("Unbekannte Alarmaktion.");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recipientForToken(tx: Tx, token: string) {
|
async function recipientForToken(tx: Tx, token: string) {
|
||||||
const recipient = await tx.notdienstAlarmRecipient.findUnique({
|
const recipient = await tx.notdienstAlarmRecipient.findFirst({
|
||||||
where: { token },
|
where: { OR: [{ token: hashToken(token) }, ...(token.length === 36 ? [{ token }] : [])] },
|
||||||
include: {
|
include: {
|
||||||
user: true,
|
user: true,
|
||||||
alarm: { include: { kita: { select: { name: true } } } },
|
alarm: { include: { kita: { select: { name: true } } } },
|
||||||
@@ -404,6 +410,9 @@ async function recipientForToken(tx: Tx, token: string) {
|
|||||||
if (
|
if (
|
||||||
!recipient ||
|
!recipient ||
|
||||||
recipient.user.role !== "ELTERN" ||
|
recipient.user.role !== "ELTERN" ||
|
||||||
|
recipient.sessionVersion !== recipient.user.sessionVersion ||
|
||||||
|
!!recipient.user.disabledAt || !recipient.user.emailVerifiedAt || !recipient.user.privacyPolicyAcceptedAt ||
|
||||||
|
recipient.alarm.expiresAt <= new Date() ||
|
||||||
(recipient.user.lockedUntil && recipient.user.lockedUntil > new Date()) ||
|
(recipient.user.lockedUntil && recipient.user.lockedUntil > new Date()) ||
|
||||||
recipient.user.kitaId !== recipient.alarm.kitaId ||
|
recipient.user.kitaId !== recipient.alarm.kitaId ||
|
||||||
recipient.user.familyId !== recipient.alarm.familyId
|
recipient.user.familyId !== recipient.alarm.familyId
|
||||||
@@ -413,8 +422,8 @@ async function recipientForToken(tx: Tx, token: string) {
|
|||||||
}
|
}
|
||||||
export async function getPublicAlarm(token: string) {
|
export async function getPublicAlarm(token: string) {
|
||||||
if (!tokenSchema.safeParse(token).success) return null;
|
if (!tokenSchema.safeParse(token).success) return null;
|
||||||
const legacy = await prisma.notdienstAlert.findUnique({
|
const legacy = await prisma.notdienstAlert.findFirst({
|
||||||
where: { confirmationToken: token },
|
where: { OR: [{ confirmationToken: hashToken(token) }, ...(token.length === 36 ? [{ confirmationToken: token }] : [])] },
|
||||||
select: { alarmId: true, assignmentId: true },
|
select: { alarmId: true, assignmentId: true },
|
||||||
});
|
});
|
||||||
if (legacy && !legacy.alarmId)
|
if (legacy && !legacy.alarmId)
|
||||||
@@ -447,14 +456,14 @@ export async function respondToAlarm(
|
|||||||
await getPublicAlarm(token);
|
await getPublicAlarm(token);
|
||||||
return transaction(async (tx) => {
|
return transaction(async (tx) => {
|
||||||
const recipient = await recipientForToken(tx, token);
|
const recipient = await recipientForToken(tx, token);
|
||||||
if (!recipient) throw new Error("Ungültiger Rückmeldelink.");
|
if (!recipient) throw new UserFacingError("Ungültiger Rückmeldelink.");
|
||||||
const alarm = recipient.alarm;
|
const alarm = recipient.alarm;
|
||||||
if (alarm.status !== "PENDING")
|
if (alarm.status !== "PENDING")
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Für diesen Haushalt liegt bereits eine Rückmeldung vor oder der Alarm wurde storniert.",
|
"Für diesen Haushalt liegt bereits eine Rückmeldung vor oder der Alarm wurde storniert.",
|
||||||
);
|
);
|
||||||
if (alarm.expiresAt <= now)
|
if (alarm.expiresAt <= now)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Der Einsatz ist beendet. Dieser Rückmeldelink ist abgelaufen.",
|
"Der Einsatz ist beendet. Dieser Rückmeldelink ist abgelaufen.",
|
||||||
);
|
);
|
||||||
const updated = await tx.notdienstAlarm.update({
|
const updated = await tx.notdienstAlarm.update({
|
||||||
@@ -636,6 +645,7 @@ export async function importLegacyAlarms(
|
|||||||
create: uniqueRecipients.map((a) => ({
|
create: uniqueRecipients.map((a) => ({
|
||||||
userId: a.parentUserId,
|
userId: a.parentUserId,
|
||||||
token: a.confirmationToken,
|
token: a.confirmationToken,
|
||||||
|
sessionVersion: a.parentUser.sessionVersion,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { NotdienstAlertStatus } from "@prisma/client";
|
||||||
|
import { hashToken } from "@/lib/tokens";
|
||||||
|
export function activeAlertTokenWhere(token: string) {
|
||||||
|
const today = new Date(); today.setUTCHours(0, 0, 0, 0);
|
||||||
|
return {
|
||||||
|
confirmationToken: { in: [hashToken(token), ...(token.length === 36 ? [token] : [])] },
|
||||||
|
status: NotdienstAlertStatus.PENDING,
|
||||||
|
triggeredAt: { gt: new Date(Date.now() - 24 * 60 * 60_000) },
|
||||||
|
assignment: { date: { gte: today } },
|
||||||
|
parentUser: { disabledAt: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
|
||||||
|
export function attachmentDirectory(): string {
|
||||||
|
// Preserve the existing storage layout, including explicitly configured roots.
|
||||||
|
return path.resolve(process.env.UPLOAD_DIR ?? "uploads", "announcements");
|
||||||
|
}
|
||||||
|
export function attachmentPath(id: string): string {
|
||||||
|
if (!/^(?:[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}|c[a-z0-9]{24,32})$/i.test(id)) throw new Error("Invalid attachment ID");
|
||||||
|
return path.join(attachmentDirectory(), id);
|
||||||
|
}
|
||||||
|
export function detectAttachmentType(bytes: Buffer): string | null {
|
||||||
|
if (bytes.length < 12 || bytes.length > MAX_ATTACHMENT_SIZE) return null;
|
||||||
|
if (bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10])) &&
|
||||||
|
bytes.subarray(-12).equals(Buffer.from([0,0,0,0,73,69,78,68,174,66,96,130]))) return "image/png";
|
||||||
|
if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255 && bytes.subarray(-2).equals(Buffer.from([255,217]))) return "image/jpeg";
|
||||||
|
if (bytes.subarray(0, 5).toString("ascii") === "%PDF-" && bytes.subarray(-1024).toString("ascii").trimEnd().endsWith("%%EOF")) return "application/pdf";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
export function attachmentExtensionMatches(name: string, type: string): boolean {
|
||||||
|
const extension = path.extname(name).toLowerCase();
|
||||||
|
return type === "image/jpeg" ? [".jpg", ".jpeg"].includes(extension) :
|
||||||
|
type === "image/png" ? extension === ".png" : type === "application/pdf" && extension === ".pdf";
|
||||||
|
}
|
||||||
|
export function attachmentDisposition(name: string): string {
|
||||||
|
const safe = name.replace(/[\r\n]/g, "").slice(0, 160);
|
||||||
|
const encoded = encodeURIComponent(safe).replace(/['()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||||
|
return `attachment; filename="${safe.replace(/[^\w.\-]/g, "_")}"; filename*=UTF-8''${encoded}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeAttachmentFiles(ids: string[]): Promise<void> {
|
||||||
|
await Promise.all(ids.map(async id => {
|
||||||
|
try { await rm(attachmentPath(id), { force: true }); }
|
||||||
|
catch (error) { console.error("Attachment cleanup failed; run the orphan cleanup command", id, error); }
|
||||||
|
}));
|
||||||
|
}
|
||||||
+16
-22
@@ -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",
|
|
||||||
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();
|
|
||||||
|
async function enforceKitaSession(session: AuthenticatedSession): Promise<KitaSession> {
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -21,11 +21,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;
|
||||||
@@ -39,6 +39,15 @@ export async function sendAppEmail({
|
|||||||
replyTo,
|
replyTo,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
}: 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]");
|
||||||
@@ -52,14 +61,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.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +89,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.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,10 +98,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.",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const passwordSchema = z.string().min(8, "Mindestens 8 Zeichen.")
|
||||||
|
.refine(value => Buffer.byteLength(value, "utf8") <= 72,
|
||||||
|
"Das Passwort darf höchstens 72 UTF-8-Bytes umfassen.");
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
if (process.env.NODE_ENV === "production" && process.env.TEST_MODE === "true") {
|
||||||
|
throw new Error("TEST_MODE is forbidden in production");
|
||||||
|
}
|
||||||
import { PrismaClient } from "@prisma/client";
|
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
|
||||||
|
|||||||
+32
-30
@@ -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,
|
||||||
if (!existing || existing.resetAt <= now) {
|
"resetAt" = CASE WHEN rate_limit_buckets."resetAt" <= NOW()
|
||||||
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
THEN NOW() + make_interval(secs => ${windowSeconds}) ELSE rate_limit_buckets."resetAt" END
|
||||||
return { allowed: true, remaining: limit - 1, resetAt: now + windowMs };
|
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.count >= limit) {
|
export async function limitPublicAction(scope: string, identity: string, limit = 5, seconds = 3600): Promise<boolean> {
|
||||||
return {
|
if (!(await rateLimit(`${scope}:burst`, 100, 60)).allowed) return false;
|
||||||
allowed: false,
|
if (!(await rateLimit(`${scope}:global`, 1000, seconds)).allowed) return false;
|
||||||
retryAfterSeconds: Math.ceil((existing.resetAt - now) / 1000),
|
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;
|
||||||
existing.count += 1;
|
|
||||||
return {
|
|
||||||
allowed: true,
|
|
||||||
remaining: limit - existing.count,
|
|
||||||
resetAt: existing.resetAt,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"; }
|
||||||
|
}
|
||||||
@@ -13,12 +13,15 @@ export async function requireStaffActor(): Promise<StaffActor> {
|
|||||||
kitaId: true,
|
kitaId: true,
|
||||||
role: true,
|
role: true,
|
||||||
lockedUntil: true,
|
lockedUntil: true,
|
||||||
|
disabledAt: true,
|
||||||
|
emailVerifiedAt: true,
|
||||||
privacyPolicyAcceptedAt: true,
|
privacyPolicyAcceptedAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
!current?.kitaId ||
|
!current?.kitaId ||
|
||||||
!current.privacyPolicyAcceptedAt ||
|
!current.privacyPolicyAcceptedAt ||
|
||||||
|
!!current.disabledAt || !current.emailVerifiedAt ||
|
||||||
current.role !== user.role ||
|
current.role !== user.role ||
|
||||||
(current.role !== UserRole.ADMIN && current.role !== UserRole.ERZIEHER) ||
|
(current.role !== UserRole.ADMIN && current.role !== UserRole.ERZIEHER) ||
|
||||||
(current.lockedUntil && current.lockedUntil > new Date())
|
(current.lockedUntil && current.lockedUntil > new Date())
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
|
import { hashToken } from "@/lib/tokens";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { sendAppEmail } from "@/lib/mail";
|
import { sendAppEmail } from "@/lib/mail";
|
||||||
import { InviteEmail } from "@/emails/InviteEmail";
|
import { InviteEmail } from "@/emails/InviteEmail";
|
||||||
@@ -39,10 +40,10 @@ export async function deliverStaffEmails(ids: string[], send = sendAppEmail) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { user, profile } = job;
|
const { user, profile } = job;
|
||||||
let eligible = user.kitaId === profile.kitaId;
|
let eligible = user.kitaId === profile.kitaId && !user.disabledAt && (!user.lockedUntil || user.lockedUntil <= now);
|
||||||
if (job.inviteToken) {
|
if (job.inviteToken) {
|
||||||
const token = await prisma.verificationToken.findUnique({
|
const token = await prisma.verificationToken.findUnique({
|
||||||
where: { token: job.inviteToken },
|
where: { token: hashToken(job.inviteToken) },
|
||||||
});
|
});
|
||||||
eligible &&=
|
eligible &&=
|
||||||
profile.userId === user.id &&
|
profile.userId === user.id &&
|
||||||
@@ -53,7 +54,7 @@ export async function deliverStaffEmails(ids: string[], send = sendAppEmail) {
|
|||||||
token.expires > now;
|
token.expires > now;
|
||||||
} else {
|
} else {
|
||||||
eligible &&=
|
eligible &&=
|
||||||
!!user.privacyPolicyAcceptedAt &&
|
!!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt &&
|
||||||
(!user.lockedUntil || user.lockedUntil <= now) &&
|
(!user.lockedUntil || user.lockedUntil <= now) &&
|
||||||
(job.toAdmins
|
(job.toAdmins
|
||||||
? user.role === "ADMIN"
|
? user.role === "ADMIN"
|
||||||
|
|||||||
+66
-62
@@ -1,4 +1,6 @@
|
|||||||
|
import { UserFacingError } from "@/lib/action-error";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { hashToken, newToken } from "@/lib/tokens";
|
||||||
import {
|
import {
|
||||||
Prisma,
|
Prisma,
|
||||||
UserRole,
|
UserRole,
|
||||||
@@ -72,7 +74,7 @@ async function inviteStaff(
|
|||||||
profile.userId &&
|
profile.userId &&
|
||||||
(profile.user?.email !== email || profile.user.emailVerifiedAt)
|
(profile.user?.email !== email || profile.user.emailVerifiedAt)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Dieses Profil besitzt bereits einen Account. Offene Aktivierungslinks können nur für dieselbe Adresse erneuert werden.",
|
"Dieses Profil besitzt bereits einen Account. Offene Aktivierungslinks können nur für dieselbe Adresse erneuert werden.",
|
||||||
);
|
);
|
||||||
const existingUser = await tx.user.findUnique({ where: { email } });
|
const existingUser = await tx.user.findUnique({ where: { email } });
|
||||||
@@ -84,7 +86,7 @@ async function inviteStaff(
|
|||||||
where: { userId: existingUser.id, id: { not: profile.id } },
|
where: { userId: existingUser.id, id: { not: profile.id } },
|
||||||
})))
|
})))
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Diese Adresse ist bereits einem anderen Konto oder Profil zugeordnet.",
|
"Diese Adresse ist bereits einem anderen Konto oder Profil zugeordnet.",
|
||||||
);
|
);
|
||||||
const user =
|
const user =
|
||||||
@@ -112,12 +114,12 @@ async function inviteStaff(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (user.emailVerifiedAt) return {};
|
if (user.emailVerifiedAt) return {};
|
||||||
const token = randomUUID();
|
const token = newToken();
|
||||||
await tx.verificationToken.deleteMany({ where: { identifier: user.id } });
|
await tx.verificationToken.deleteMany({ where: { identifier: user.id } });
|
||||||
await tx.verificationToken.create({
|
await tx.verificationToken.create({
|
||||||
data: {
|
data: {
|
||||||
identifier: user.id,
|
identifier: user.id,
|
||||||
token,
|
token: hashToken(token),
|
||||||
expires: new Date(Date.now() + 7 * 86400000),
|
expires: new Date(Date.now() + 7 * 86400000),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -187,7 +189,7 @@ export type StaffProfileData = Prisma.StaffProfileGetPayload<{
|
|||||||
|
|
||||||
function requireAdmin(actor: StaffActor) {
|
function requireAdmin(actor: StaffActor) {
|
||||||
if (actor.role !== UserRole.ADMIN)
|
if (actor.role !== UserRole.ADMIN)
|
||||||
throw new Error("Nur Kita-Admins dürfen Personal verwalten.");
|
throw new UserFacingError("Nur Kita-Admins dürfen Personal verwalten.");
|
||||||
}
|
}
|
||||||
export function assertStaffAccess(
|
export function assertStaffAccess(
|
||||||
actor: StaffActor,
|
actor: StaffActor,
|
||||||
@@ -200,10 +202,10 @@ export function assertStaffAccess(
|
|||||||
actor.role as "ADMIN" | "ERZIEHER",
|
actor.role as "ADMIN" | "ERZIEHER",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
throw new Error("Kein Zugriff auf dieses Personalprofil.");
|
throw new UserFacingError("Kein Zugriff auf dieses Personalprofil.");
|
||||||
if (adminOnly) requireAdmin(actor);
|
if (adminOnly) requireAdmin(actor);
|
||||||
else if (actor.role !== UserRole.ADMIN && profile.userId !== actor.id)
|
else if (actor.role !== UserRole.ADMIN && profile.userId !== actor.id)
|
||||||
throw new Error("Du kannst nur dein eigenes Personalprofil verwalten.");
|
throw new UserFacingError("Du kannst nur dein eigenes Personalprofil verwalten.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStaffProfile(actor: StaffActor, profileId?: string) {
|
export async function getStaffProfile(actor: StaffActor, profileId?: string) {
|
||||||
@@ -212,7 +214,7 @@ export async function getStaffProfile(actor: StaffActor, profileId?: string) {
|
|||||||
actor.role as "ADMIN" | "ERZIEHER",
|
actor.role as "ADMIN" | "ERZIEHER",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
throw new Error("Kein Zugriff auf den Personalbereich.");
|
throw new UserFacingError("Kein Zugriff auf den Personalbereich.");
|
||||||
const profile = await prisma.staffProfile.findFirst({
|
const profile = await prisma.staffProfile.findFirst({
|
||||||
where: {
|
where: {
|
||||||
kitaId: actor.kitaId,
|
kitaId: actor.kitaId,
|
||||||
@@ -231,7 +233,7 @@ function ensureDayEditable(profile: StaffProfileData, key: string) {
|
|||||||
(w) => dateKey(w.weekStart) === weekStart(key),
|
(w) => dateKey(w.weekStart) === weekStart(key),
|
||||||
);
|
);
|
||||||
if (sheet && sheet.status !== "DRAFT")
|
if (sheet && sheet.status !== "DRAFT")
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Diese Woche ist eingereicht oder genehmigt. Der Admin muss sie zuerst zur Korrektur zurückgeben.",
|
"Diese Woche ist eingereicht oder genehmigt. Der Admin muss sie zuerst zur Korrektur zurückgeben.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -248,7 +250,7 @@ function ensureRangeEditable(
|
|||||||
addDays(dateKey(w.weekStart), 6) >= start,
|
addDays(dateKey(w.weekStart), 6) >= start,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Im Zeitraum liegt eine eingereichte oder genehmigte Woche. Bitte zuerst zur Korrektur zurückgeben.",
|
"Im Zeitraum liegt eine eingereichte oder genehmigte Woche. Bitte zuerst zur Korrektur zurückgeben.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -263,7 +265,7 @@ function ensureModelEditable(profile: StaffProfileData, from: string) {
|
|||||||
dateKey(l.endDate) >= from,
|
dateKey(l.endDate) >= from,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Ab diesem Datum bestehen bereits Freigaben. Bitte betroffene Wochen zurückgeben bzw. Abwesenheiten stornieren oder einen späteren Beginn wählen.",
|
"Ab diesem Datum bestehen bereits Freigaben. Bitte betroffene Wochen zurückgeben bzw. Abwesenheiten stornieren oder einen späteren Beginn wählen.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -278,7 +280,7 @@ function assertVacationFunds(
|
|||||||
)) {
|
)) {
|
||||||
const account = profile.leaveAccounts.find((a) => a.year === year);
|
const account = profile.leaveAccounts.find((a) => a.year === year);
|
||||||
if (!account)
|
if (!account)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
`Für ${year} muss der Admin zuerst ein Urlaubskonto hinterlegen.`,
|
`Für ${year} muss der Admin zuerst ein Urlaubskonto hinterlegen.`,
|
||||||
);
|
);
|
||||||
const balance = vacationBalance(
|
const balance = vacationBalance(
|
||||||
@@ -289,7 +291,7 @@ function assertVacationFunds(
|
|||||||
.filter((d) => Number(d.date.slice(0, 4)) === year)
|
.filter((d) => Number(d.date.slice(0, 4)) === year)
|
||||||
.reduce((sum, d) => sum + d.vacationUnits, 0);
|
.reduce((sum, d) => sum + d.vacationUnits, 0);
|
||||||
if (balance.available - balance.pending < requested)
|
if (balance.available - balance.pending < requested)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
`Für ${year} reichen die noch nicht verplanten Urlaubstage nicht aus.`,
|
`Für ${year} reichen die noch nicht verplanten Urlaubstage nicht aus.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -306,7 +308,7 @@ export async function executeStaffCommand(
|
|||||||
actor.role as "ADMIN" | "ERZIEHER",
|
actor.role as "ADMIN" | "ERZIEHER",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
throw new Error("Kein Zugriff auf den Personalbereich.");
|
throw new UserFacingError("Kein Zugriff auf den Personalbereich.");
|
||||||
for (let attempt = 0; ; attempt++) {
|
for (let attempt = 0; ; attempt++) {
|
||||||
try {
|
try {
|
||||||
return await prisma.$transaction(
|
return await prisma.$transaction(
|
||||||
@@ -318,13 +320,15 @@ export async function executeStaffCommand(
|
|||||||
kitaId: actor.kitaId,
|
kitaId: actor.kitaId,
|
||||||
role: actor.role,
|
role: actor.role,
|
||||||
privacyPolicyAcceptedAt: { not: null },
|
privacyPolicyAcceptedAt: { not: null },
|
||||||
|
emailVerifiedAt: { not: null },
|
||||||
|
disabledAt: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
!dbActor ||
|
!dbActor ||
|
||||||
(dbActor.lockedUntil && dbActor.lockedUntil > new Date())
|
(dbActor.lockedUntil && dbActor.lockedUntil > new Date())
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Dein Konto hat keinen Zugriff auf den Personalbereich.",
|
"Dein Konto hat keinen Zugriff auf den Personalbereich.",
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
@@ -344,7 +348,7 @@ export async function executeStaffCommand(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!educator?.staffProfile?.user)
|
if (!educator?.staffProfile?.user)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Bitte zuerst E-Mail-Adresse und Personalprofil ergänzen.",
|
"Bitte zuerst E-Mail-Adresse und Personalprofil ergänzen.",
|
||||||
);
|
);
|
||||||
return inviteStaff(
|
return inviteStaff(
|
||||||
@@ -366,12 +370,12 @@ export async function executeStaffCommand(
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
if (command === "educatorUpdate" && !existing)
|
if (command === "educatorUpdate" && !existing)
|
||||||
throw new Error("Fachkraft nicht gefunden.");
|
throw new UserFacingError("Fachkraft nicht gefunden.");
|
||||||
if (
|
if (
|
||||||
existing?.staffProfile?.user &&
|
existing?.staffProfile?.user &&
|
||||||
existing.staffProfile.user.email !== input.email
|
existing.staffProfile.user.email !== input.email
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Die Adresse eines verknüpften Accounts kann hier nicht geändert werden.",
|
"Die Adresse eines verknüpften Accounts kann hier nicht geändert werden.",
|
||||||
);
|
);
|
||||||
if (existing?.staffProfile)
|
if (existing?.staffProfile)
|
||||||
@@ -417,7 +421,7 @@ export async function executeStaffCommand(
|
|||||||
(profile.user.kitaId !== actor.kitaId ||
|
(profile.user.kitaId !== actor.kitaId ||
|
||||||
profile.user.role !== UserRole.ERZIEHER)
|
profile.user.role !== UserRole.ERZIEHER)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Der verknüpfte Account gehört nicht zum Fachpersonal dieser Kita.",
|
"Der verknüpfte Account gehört nicht zum Fachpersonal dieser Kita.",
|
||||||
);
|
);
|
||||||
if (profile.userId) {
|
if (profile.userId) {
|
||||||
@@ -437,7 +441,7 @@ export async function executeStaffCommand(
|
|||||||
}
|
}
|
||||||
if (command === "emailPreference") {
|
if (command === "emailPreference") {
|
||||||
if (actor.role !== UserRole.ERZIEHER)
|
if (actor.role !== UserRole.ERZIEHER)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Diese Einstellung ist für Fachkräfte vorgesehen.",
|
"Diese Einstellung ist für Fachkräfte vorgesehen.",
|
||||||
);
|
);
|
||||||
await tx.user.update({
|
await tx.user.update({
|
||||||
@@ -488,7 +492,7 @@ export async function executeStaffCommand(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (protectedWeek || protectedLeave)
|
if (protectedWeek || protectedLeave)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"An diesem Tag bestehen bereits Freigaben. Bitte zuerst zurückgeben oder stornieren.",
|
"An diesem Tag bestehen bereits Freigaben. Bitte zuerst zurückgeben oder stornieren.",
|
||||||
);
|
);
|
||||||
if (input.remove)
|
if (input.remove)
|
||||||
@@ -530,18 +534,18 @@ export async function executeStaffCommand(
|
|||||||
input.employmentEnd &&
|
input.employmentEnd &&
|
||||||
input.employmentEnd < input.employmentStart
|
input.employmentEnd < input.employmentStart
|
||||||
)
|
)
|
||||||
throw new Error("Das Beschäftigungsende liegt vor dem Beginn.");
|
throw new UserFacingError("Das Beschäftigungsende liegt vor dem Beginn.");
|
||||||
if (
|
if (
|
||||||
input.trackingStart < input.employmentStart ||
|
input.trackingStart < input.employmentStart ||
|
||||||
(input.employmentEnd && input.trackingStart > input.employmentEnd)
|
(input.employmentEnd && input.trackingStart > input.employmentEnd)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Der Erfassungsbeginn muss in der Beschäftigung liegen.",
|
"Der Erfassungsbeginn muss in der Beschäftigung liegen.",
|
||||||
);
|
);
|
||||||
const educator = await tx.educator.findFirst({
|
const educator = await tx.educator.findFirst({
|
||||||
where: { id: input.educatorId, kitaId: actor.kitaId },
|
where: { id: input.educatorId, kitaId: actor.kitaId },
|
||||||
});
|
});
|
||||||
if (!educator) throw new Error("Fachkraft nicht gefunden.");
|
if (!educator) throw new UserFacingError("Fachkraft nicht gefunden.");
|
||||||
const existing = await tx.staffProfile.findFirst({
|
const existing = await tx.staffProfile.findFirst({
|
||||||
where: { educatorId: educator.id, kitaId: actor.kitaId },
|
where: { educatorId: educator.id, kitaId: actor.kitaId },
|
||||||
include: profileInclude,
|
include: profileInclude,
|
||||||
@@ -552,7 +556,7 @@ export async function executeStaffCommand(
|
|||||||
existing.leaves.length ||
|
existing.leaves.length ||
|
||||||
existing.weeks.length)
|
existing.weeks.length)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Die Grunddaten sind nach Beginn der Erfassung geschützt. Verwende datierte Arbeitszeitmodelle und begründete Zeitkorrekturen.",
|
"Die Grunddaten sind nach Beginn der Erfassung geschützt. Verwende datierte Arbeitszeitmodelle und begründete Zeitkorrekturen.",
|
||||||
);
|
);
|
||||||
const values = {
|
const values = {
|
||||||
@@ -587,7 +591,7 @@ export async function executeStaffCommand(
|
|||||||
where: { id: profileId, kitaId: actor.kitaId },
|
where: { id: profileId, kitaId: actor.kitaId },
|
||||||
include: profileInclude,
|
include: profileInclude,
|
||||||
});
|
});
|
||||||
if (!profile) throw new Error("Personalprofil nicht gefunden.");
|
if (!profile) throw new UserFacingError("Personalprofil nicht gefunden.");
|
||||||
assertStaffAccess(actor, profile);
|
assertStaffAccess(actor, profile);
|
||||||
const holidays = new Set(
|
const holidays = new Set(
|
||||||
(
|
(
|
||||||
@@ -679,7 +683,7 @@ export async function executeStaffCommand(
|
|||||||
})
|
})
|
||||||
.parse(data);
|
.parse(data);
|
||||||
if (input.effectiveFrom < dateKey(profile.employmentStart))
|
if (input.effectiveFrom < dateKey(profile.employmentStart))
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Das Modell darf nicht vor Beschäftigungsbeginn gelten.",
|
"Das Modell darf nicht vor Beschäftigungsbeginn gelten.",
|
||||||
);
|
);
|
||||||
ensureModelEditable(profile, input.effectiveFrom);
|
ensureModelEditable(profile, input.effectiveFrom);
|
||||||
@@ -715,7 +719,7 @@ export async function executeStaffCommand(
|
|||||||
input.employmentEnd &&
|
input.employmentEnd &&
|
||||||
input.employmentEnd < dateKey(profile.employmentStart)
|
input.employmentEnd < dateKey(profile.employmentStart)
|
||||||
)
|
)
|
||||||
throw new Error("Das Beschäftigungsende liegt vor dem Beginn.");
|
throw new UserFacingError("Das Beschäftigungsende liegt vor dem Beginn.");
|
||||||
if (
|
if (
|
||||||
input.employmentEnd &&
|
input.employmentEnd &&
|
||||||
(input.employmentEnd < dateKey(profile.trackingStart) ||
|
(input.employmentEnd < dateKey(profile.trackingStart) ||
|
||||||
@@ -728,7 +732,7 @@ export async function executeStaffCommand(
|
|||||||
dateKey(l.endDate) > input.employmentEnd!,
|
dateKey(l.endDate) > input.employmentEnd!,
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Nach diesem Ende bestehen Erfassungsbeginn, Arbeitszeiten oder aktive Anträge. Bitte zuerst korrigieren.",
|
"Nach diesem Ende bestehen Erfassungsbeginn, Arbeitszeiten oder aktive Anträge. Bitte zuerst korrigieren.",
|
||||||
);
|
);
|
||||||
const oldEnd = profile.employmentEnd
|
const oldEnd = profile.employmentEnd
|
||||||
@@ -774,7 +778,7 @@ export async function executeStaffCommand(
|
|||||||
dateKey(l.endDate) >= input.date,
|
dateKey(l.endDate) >= input.date,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Bitte die genehmigte Abwesenheit zuerst stornieren.",
|
"Bitte die genehmigte Abwesenheit zuerst stornieren.",
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
@@ -782,7 +786,7 @@ export async function executeStaffCommand(
|
|||||||
(profile.employmentEnd &&
|
(profile.employmentEnd &&
|
||||||
input.date > dateKey(profile.employmentEnd))
|
input.date > dateKey(profile.employmentEnd))
|
||||||
)
|
)
|
||||||
throw new Error("Der Tag liegt außerhalb der Beschäftigung.");
|
throw new UserFacingError("Der Tag liegt außerhalb der Beschäftigung.");
|
||||||
if (input.remove)
|
if (input.remove)
|
||||||
await tx.staffDayOverride.deleteMany({
|
await tx.staffDayOverride.deleteMany({
|
||||||
where: { profileId, date: dateOnly(input.date) },
|
where: { profileId, date: dateOnly(input.date) },
|
||||||
@@ -818,7 +822,7 @@ export async function executeStaffCommand(
|
|||||||
.parse(data);
|
.parse(data);
|
||||||
const used = vacationBalance({ ...input }, profile.leaves);
|
const used = vacationBalance({ ...input }, profile.leaves);
|
||||||
if (used.available < 0 || used.available < used.pending)
|
if (used.available < 0 || used.available < used.pending)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Der Anspruch reicht nicht für bereits genehmigten oder beantragten Urlaub.",
|
"Der Anspruch reicht nicht für bereits genehmigten oder beantragten Urlaub.",
|
||||||
);
|
);
|
||||||
await tx.staffLeaveAccount.upsert({
|
await tx.staffLeaveAccount.upsert({
|
||||||
@@ -855,7 +859,7 @@ export async function executeStaffCommand(
|
|||||||
})
|
})
|
||||||
.parse(data);
|
.parse(data);
|
||||||
if (input.date < dateKey(profile.trackingStart))
|
if (input.date < dateKey(profile.trackingStart))
|
||||||
throw new Error("Korrekturen müssen ab Erfassungsbeginn liegen.");
|
throw new UserFacingError("Korrekturen müssen ab Erfassungsbeginn liegen.");
|
||||||
await tx.staffAdjustment.create({
|
await tx.staffAdjustment.create({
|
||||||
data: {
|
data: {
|
||||||
profileId,
|
profileId,
|
||||||
@@ -873,17 +877,17 @@ export async function executeStaffCommand(
|
|||||||
}
|
}
|
||||||
if (command === "time" || command === "deleteTime") {
|
if (command === "time" || command === "deleteTime") {
|
||||||
if (!profile.educator.active)
|
if (!profile.educator.active)
|
||||||
throw new Error("Die Fachkraft ist nicht mehr aktiv.");
|
throw new UserFacingError("Die Fachkraft ist nicht mehr aktiv.");
|
||||||
const existing = data.id
|
const existing = data.id
|
||||||
? await tx.staffTimeEntry.findFirst({
|
? await tx.staffTimeEntry.findFirst({
|
||||||
where: { id: idSchema.parse(data.id), profileId },
|
where: { id: idSchema.parse(data.id), profileId },
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
if (data.id && !existing)
|
if (data.id && !existing)
|
||||||
throw new Error("Zeiteintrag nicht gefunden.");
|
throw new UserFacingError("Zeiteintrag nicht gefunden.");
|
||||||
if (existing) ensureDayEditable(profile, dateKey(existing.date));
|
if (existing) ensureDayEditable(profile, dateKey(existing.date));
|
||||||
if (command === "deleteTime") {
|
if (command === "deleteTime") {
|
||||||
if (!existing) throw new Error("Zeiteintrag nicht gefunden.");
|
if (!existing) throw new UserFacingError("Zeiteintrag nicht gefunden.");
|
||||||
await tx.staffTimeEntry.delete({ where: { id: existing.id } });
|
await tx.staffTimeEntry.delete({ where: { id: existing.id } });
|
||||||
await history(
|
await history(
|
||||||
"Arbeitszeit gelöscht",
|
"Arbeitszeit gelöscht",
|
||||||
@@ -907,7 +911,7 @@ export async function executeStaffCommand(
|
|||||||
(profile.employmentEnd &&
|
(profile.employmentEnd &&
|
||||||
input.date > dateKey(profile.employmentEnd))
|
input.date > dateKey(profile.employmentEnd))
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Bitte einen vergangenen oder heutigen Tag innerhalb des Erfassungszeitraums wählen.",
|
"Bitte einen vergangenen oder heutigen Tag innerhalb des Erfassungszeitraums wählen.",
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
@@ -915,14 +919,14 @@ export async function executeStaffCommand(
|
|||||||
(p) => dateKey(p.effectiveFrom) <= input.date,
|
(p) => dateKey(p.effectiveFrom) <= input.date,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Bitte zuerst ein Arbeitszeitmodell hinterlegen.",
|
"Bitte zuerst ein Arbeitszeitmodell hinterlegen.",
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
input.endMinute <= input.startMinute ||
|
input.endMinute <= input.startMinute ||
|
||||||
input.breakMinutes >= input.endMinute - input.startMinute
|
input.breakMinutes >= input.endMinute - input.startMinute
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Ende muss nach Beginn liegen; die Pause muss kürzer als der Arbeitsblock sein.",
|
"Ende muss nach Beginn liegen; die Pause muss kürzer als der Arbeitsblock sein.",
|
||||||
);
|
);
|
||||||
ensureDayEditable(profile, input.date);
|
ensureDayEditable(profile, input.date);
|
||||||
@@ -935,7 +939,7 @@ export async function executeStaffCommand(
|
|||||||
e.endMinute > input.startMinute,
|
e.endMinute > input.startMinute,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Dieser Arbeitsblock überschneidet sich mit einem vorhandenen Eintrag.",
|
"Dieser Arbeitsblock überschneidet sich mit einem vorhandenen Eintrag.",
|
||||||
);
|
);
|
||||||
const absences = profile.leaves
|
const absences = profile.leaves
|
||||||
@@ -961,7 +965,7 @@ export async function executeStaffCommand(
|
|||||||
worked + reservedMinutes >
|
worked + reservedMinutes >
|
||||||
scheduledMinutes(profile, input.date, holidays)
|
scheduledMinutes(profile, input.date, holidays)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Arbeitszeit und genehmigte Abwesenheit überschreiten die Sollzeit. Bitte die Abwesenheit zuerst korrigieren.",
|
"Arbeitszeit und genehmigte Abwesenheit überschreiten die Sollzeit. Bitte die Abwesenheit zuerst korrigieren.",
|
||||||
);
|
);
|
||||||
const values = {
|
const values = {
|
||||||
@@ -988,7 +992,7 @@ export async function executeStaffCommand(
|
|||||||
const start = weekStart(dateSchema.parse(data.weekStart));
|
const start = weekStart(dateSchema.parse(data.weekStart));
|
||||||
const end = addDays(start, 6);
|
const end = addDays(start, 6);
|
||||||
if (end < dateKey(profile.trackingStart) || start > todayKey())
|
if (end < dateKey(profile.trackingStart) || start > todayKey())
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Diese Woche liegt außerhalb der bisherigen Erfassung.",
|
"Diese Woche liegt außerhalb der bisherigen Erfassung.",
|
||||||
);
|
);
|
||||||
const existing = profile.weeks.find(
|
const existing = profile.weeks.find(
|
||||||
@@ -996,7 +1000,7 @@ export async function executeStaffCommand(
|
|||||||
);
|
);
|
||||||
if (command === "weekSubmit") {
|
if (command === "weekSubmit") {
|
||||||
if (existing && existing.status !== "DRAFT")
|
if (existing && existing.status !== "DRAFT")
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Die Woche ist bereits eingereicht oder genehmigt.",
|
"Die Woche ist bereits eingereicht oder genehmigt.",
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
@@ -1008,7 +1012,7 @@ export async function executeStaffCommand(
|
|||||||
: start),
|
: start),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Bitte zuerst ein Arbeitszeitmodell für die Woche hinterlegen.",
|
"Bitte zuerst ein Arbeitszeitmodell für die Woche hinterlegen.",
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
@@ -1019,7 +1023,7 @@ export async function executeStaffCommand(
|
|||||||
dateKey(l.endDate) >= start,
|
dateKey(l.endDate) >= start,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Bitte zuerst offene Abwesenheitsanträge dieser Woche klären.",
|
"Bitte zuerst offene Abwesenheitsanträge dieser Woche klären.",
|
||||||
);
|
);
|
||||||
const days = calculateWeek(
|
const days = calculateWeek(
|
||||||
@@ -1030,7 +1034,7 @@ export async function executeStaffCommand(
|
|||||||
profile.leaves,
|
profile.leaves,
|
||||||
);
|
);
|
||||||
if (days.some((d) => d.date > todayKey() && d.expected > 0))
|
if (days.some((d) => d.date > todayKey() && d.expected > 0))
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Die Woche kann erst nach ihrem letzten vorgesehenen Arbeitstag eingereicht werden.",
|
"Die Woche kann erst nach ihrem letzten vorgesehenen Arbeitstag eingereicht werden.",
|
||||||
);
|
);
|
||||||
await tx.staffWeek.upsert({
|
await tx.staffWeek.upsert({
|
||||||
@@ -1065,7 +1069,7 @@ export async function executeStaffCommand(
|
|||||||
}
|
}
|
||||||
requireAdmin(actor);
|
requireAdmin(actor);
|
||||||
if (profile.userId === actor.id)
|
if (profile.userId === actor.id)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Eigene Wochen dürfen nicht selbst freigegeben werden.",
|
"Eigene Wochen dürfen nicht selbst freigegeben werden.",
|
||||||
);
|
);
|
||||||
const input = z
|
const input = z
|
||||||
@@ -1077,7 +1081,7 @@ export async function executeStaffCommand(
|
|||||||
? existing.status !== "SUBMITTED"
|
? existing.status !== "SUBMITTED"
|
||||||
: existing.status === "DRAFT")
|
: existing.status === "DRAFT")
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Diese Woche kann in ihrem aktuellen Status nicht bearbeitet werden.",
|
"Diese Woche kann in ihrem aktuellen Status nicht bearbeitet werden.",
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
@@ -1089,7 +1093,7 @@ export async function executeStaffCommand(
|
|||||||
dateKey(l.endDate) >= start,
|
dateKey(l.endDate) >= start,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Bitte offene Abwesenheiten zuerst klären; gegebenenfalls die Woche zur Korrektur zurückgeben.",
|
"Bitte offene Abwesenheiten zuerst klären; gegebenenfalls die Woche zur Korrektur zurückgeben.",
|
||||||
);
|
);
|
||||||
const days = calculateWeek(
|
const days = calculateWeek(
|
||||||
@@ -1131,13 +1135,13 @@ export async function executeStaffCommand(
|
|||||||
const existing = data.id
|
const existing = data.id
|
||||||
? profile.leaves.find((l) => l.id === idSchema.parse(data.id))
|
? profile.leaves.find((l) => l.id === idSchema.parse(data.id))
|
||||||
: undefined;
|
: undefined;
|
||||||
if (data.id && !existing) throw new Error("Antrag nicht gefunden.");
|
if (data.id && !existing) throw new UserFacingError("Antrag nicht gefunden.");
|
||||||
if (command === "leaveCancel") {
|
if (command === "leaveCancel") {
|
||||||
if (
|
if (
|
||||||
!existing ||
|
!existing ||
|
||||||
!["PENDING", "APPROVED"].includes(existing.status)
|
!["PENDING", "APPROVED"].includes(existing.status)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Dieser Antrag kann nicht zurückgezogen werden.",
|
"Dieser Antrag kann nicht zurückgezogen werden.",
|
||||||
);
|
);
|
||||||
const status =
|
const status =
|
||||||
@@ -1168,7 +1172,7 @@ export async function executeStaffCommand(
|
|||||||
if (command === "leaveReview") {
|
if (command === "leaveReview") {
|
||||||
requireAdmin(actor);
|
requireAdmin(actor);
|
||||||
if (profile.userId === actor.id)
|
if (profile.userId === actor.id)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Eigene Anträge dürfen nicht selbst genehmigt werden.",
|
"Eigene Anträge dürfen nicht selbst genehmigt werden.",
|
||||||
);
|
);
|
||||||
const input = z
|
const input = z
|
||||||
@@ -1178,7 +1182,7 @@ export async function executeStaffCommand(
|
|||||||
!existing ||
|
!existing ||
|
||||||
!["PENDING", "CANCEL_REQUESTED"].includes(existing.status)
|
!["PENDING", "CANCEL_REQUESTED"].includes(existing.status)
|
||||||
)
|
)
|
||||||
throw new Error("Dieser Antrag ist nicht mehr offen.");
|
throw new UserFacingError("Dieser Antrag ist nicht mehr offen.");
|
||||||
const cancelling = existing.status === "CANCEL_REQUESTED";
|
const cancelling = existing.status === "CANCEL_REQUESTED";
|
||||||
if (input.approve)
|
if (input.approve)
|
||||||
ensureRangeEditable(
|
ensureRangeEditable(
|
||||||
@@ -1204,7 +1208,7 @@ export async function executeStaffCommand(
|
|||||||
profile.leaves,
|
profile.leaves,
|
||||||
);
|
);
|
||||||
if (balance.available < (existing.minutes ?? 0))
|
if (balance.available < (existing.minutes ?? 0))
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Das bestätigte Zeitguthaben reicht nicht aus.",
|
"Das bestätigte Zeitguthaben reicht nicht aus.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1220,7 +1224,7 @@ export async function executeStaffCommand(
|
|||||||
worked + d.creditMinutes >
|
worked + d.creditMinutes >
|
||||||
scheduledMinutes(profile, d.date, holidays)
|
scheduledMinutes(profile, d.date, holidays)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"An diesem Tag bestehen Arbeitszeiten. Bitte Zeitraum oder Arbeitszeit zuerst korrigieren.",
|
"An diesem Tag bestehen Arbeitszeiten. Bitte Zeitraum oder Arbeitszeit zuerst korrigieren.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1267,7 +1271,7 @@ export async function executeStaffCommand(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!profile.educator.active)
|
if (!profile.educator.active)
|
||||||
throw new Error("Die Fachkraft ist nicht mehr aktiv.");
|
throw new UserFacingError("Die Fachkraft ist nicht mehr aktiv.");
|
||||||
const input = z
|
const input = z
|
||||||
.object({
|
.object({
|
||||||
kind: z.nativeEnum(StaffLeaveKind),
|
kind: z.nativeEnum(StaffLeaveKind),
|
||||||
@@ -1280,9 +1284,9 @@ export async function executeStaffCommand(
|
|||||||
.parse(data);
|
.parse(data);
|
||||||
if (input.kind === "SICKNESS") requireAdmin(actor);
|
if (input.kind === "SICKNESS") requireAdmin(actor);
|
||||||
if (existing && existing.status !== "PENDING")
|
if (existing && existing.status !== "PENDING")
|
||||||
throw new Error("Nur offene Anträge können bearbeitet werden.");
|
throw new UserFacingError("Nur offene Anträge können bearbeitet werden.");
|
||||||
if (input.startDate < dateKey(profile.trackingStart))
|
if (input.startDate < dateKey(profile.trackingStart))
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Abwesenheiten bitte ab Erfassungsbeginn erfassen.",
|
"Abwesenheiten bitte ab Erfassungsbeginn erfassen.",
|
||||||
);
|
);
|
||||||
ensureRangeEditable(profile, input.startDate, input.endDate);
|
ensureRangeEditable(profile, input.startDate, input.endDate);
|
||||||
@@ -1295,7 +1299,7 @@ export async function executeStaffCommand(
|
|||||||
dateKey(l.endDate) >= input.startDate,
|
dateKey(l.endDate) >= input.startDate,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Der Zeitraum überschneidet sich mit einer offenen oder genehmigten Abwesenheit.",
|
"Der Zeitraum überschneidet sich mit einer offenen oder genehmigten Abwesenheit.",
|
||||||
);
|
);
|
||||||
const days = calculateLeaveDays(profile, holidays, input);
|
const days = calculateLeaveDays(profile, holidays, input);
|
||||||
@@ -1317,7 +1321,7 @@ export async function executeStaffCommand(
|
|||||||
)
|
)
|
||||||
.reduce((sum, l) => sum + (l.minutes ?? 0), 0);
|
.reduce((sum, l) => sum + (l.minutes ?? 0), 0);
|
||||||
if (balance.available - pending < (input.minutes ?? 0))
|
if (balance.available - pending < (input.minutes ?? 0))
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Das noch nicht verplante bestätigte Zeitguthaben reicht nicht aus.",
|
"Das noch nicht verplante bestätigte Zeitguthaben reicht nicht aus.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1326,7 +1330,7 @@ export async function executeStaffCommand(
|
|||||||
if (
|
if (
|
||||||
profile.timeEntries.some((e) => dateKey(e.date) === day.date)
|
profile.timeEntries.some((e) => dateKey(e.date) === day.date)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new UserFacingError(
|
||||||
"Für Krankheitstage mit Arbeitszeit bitte zuerst die Arbeitszeit korrigieren.",
|
"Für Krankheitstage mit Arbeitszeit bitte zuerst die Arbeitszeit korrigieren.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1380,7 +1384,7 @@ export async function executeStaffCommand(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new Error("Unbekannte Aktion.");
|
throw new UserFacingError("Unbekannte Aktion.");
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
export function hashToken(token: string): string {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
export function newToken(): string { return randomBytes(32).toString("hex"); }
|
||||||
|
export function isPublicToken(token: unknown): token is string {
|
||||||
|
return typeof token === "string" && /^(?:[a-f0-9]{64}|[a-f0-9-]{36})$/i.test(token);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function serializableTransaction<T>(operation: (tx: Prisma.TransactionClient) => Promise<T>): Promise<T> {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try { return await prisma.$transaction(operation, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); }
|
||||||
|
catch (error) {
|
||||||
|
if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== "P2034" || attempt >= 2) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
-7
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import { NextResponse } from "next/server";
|
import { 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;
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+6
@@ -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;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { after, test } from "node:test";
|
import { createRequire } from "node:module";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { after, test, mock } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { UserRole } from "@prisma/client";
|
import type { UserRole } from "@prisma/client";
|
||||||
@@ -16,11 +18,8 @@ import {
|
|||||||
} from "../src/lib/alarms/service";
|
} from "../src/lib/alarms/service";
|
||||||
import { deliverAlarmEmails } from "../src/lib/alarms/email";
|
import { deliverAlarmEmails } from "../src/lib/alarms/email";
|
||||||
import { authorizeAlarmJob } from "../src/lib/alarms/job";
|
import { authorizeAlarmJob } from "../src/lib/alarms/job";
|
||||||
import { updateAssignmentAction } from "../src/app/dashboard/notdienst/plan/actions";
|
|
||||||
import {
|
|
||||||
createNotdienstSwapRequest,
|
|
||||||
acceptNotdienstSwapRequest,
|
|
||||||
} from "../src/app/dashboard/kalender/actions";
|
|
||||||
import {
|
import {
|
||||||
berlinInstant,
|
berlinInstant,
|
||||||
endOfAlarm,
|
endOfAlarm,
|
||||||
@@ -31,13 +30,23 @@ import { dateOnly } from "../src/lib/staff/calculations";
|
|||||||
const db = new URL(process.env.DATABASE_URL ?? "postgresql://invalid");
|
const db = new URL(process.env.DATABASE_URL ?? "postgresql://invalid");
|
||||||
if (
|
if (
|
||||||
db.hostname !== "127.0.0.1" ||
|
db.hostname !== "127.0.0.1" ||
|
||||||
db.port !== "55439" ||
|
db.port !== "5433" ||
|
||||||
db.pathname !== "/kita_staff_test"
|
db.pathname !== "/kita_security_test"
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Alarmtests benötigen die isolierte Datenbank 127.0.0.1:55439/kita_staff_test.",
|
"Alarmtests benötigen die isolierte Datenbank 127.0.0.1:5433/kita_security_test.",
|
||||||
);
|
);
|
||||||
process.env.AUTH_URL = "http://127.0.0.1:3008";
|
process.env.AUTH_URL = "http://127.0.0.1:3008";
|
||||||
|
let testUser: { id: string; role: UserRole; kitaId: string; familyId: string } | null = null;
|
||||||
|
const testSession = () => { if (!testUser) throw new Error('No test session'); return { user: testUser }; };
|
||||||
|
mock.module('../src/lib/auth-utils.ts', { namedExports: {
|
||||||
|
requireKitaSession: async () => testSession(),
|
||||||
|
requireRole: async (roles: UserRole[]) => { const session = testSession(); if (!roles.includes(session.user.role)) throw new Error('Forbidden'); return session; },
|
||||||
|
} });
|
||||||
|
const loadAfterMocks = createRequire(resolve(process.cwd(), 'tests/notdienst-alarm.test.ts'));
|
||||||
|
const { updateAssignmentAction } = loadAfterMocks('../src/app/dashboard/notdienst/plan/actions') as typeof import('../src/app/dashboard/notdienst/plan/actions');
|
||||||
|
const { createNotdienstSwapRequest, acceptNotdienstSwapRequest } = loadAfterMocks('../src/app/dashboard/kalender/actions') as typeof import('../src/app/dashboard/kalender/actions');
|
||||||
|
|
||||||
const tenants: string[] = [];
|
const tenants: string[] = [];
|
||||||
after(async () => {
|
after(async () => {
|
||||||
await prisma.kita.deleteMany({ where: { id: { in: tenants } } });
|
await prisma.kita.deleteMany({ where: { id: { in: tenants } } });
|
||||||
@@ -67,6 +76,7 @@ async function fixture() {
|
|||||||
lastName: role,
|
lastName: role,
|
||||||
passwordHash: "test-only",
|
passwordHash: "test-only",
|
||||||
privacyPolicyAcceptedAt: new Date(),
|
privacyPolicyAcceptedAt: new Date(),
|
||||||
|
emailVerifiedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const adminUser = await makeUser("ADMIN"),
|
const adminUser = await makeUser("ADMIN"),
|
||||||
@@ -132,11 +142,11 @@ async function fixture() {
|
|||||||
who = coord,
|
who = coord,
|
||||||
time = now,
|
time = now,
|
||||||
) => executeAlarmCommand(who, name, { alarmId, ...data }, time);
|
) => executeAlarmCommand(who, name, { alarmId, ...data }, time);
|
||||||
const recipients = (alarmId: string) =>
|
const recipients = async (alarmId: string) => {
|
||||||
prisma.notdienstAlarmRecipient.findMany({
|
const rows = await prisma.notdienstAlarmRecipient.findMany({ where: { alarmId }, orderBy: { userId: "asc" } });
|
||||||
where: { alarmId },
|
const jobs = await prisma.notdienstAlarmEmail.findMany({ where: { alarmId, kind: "REQUEST" } });
|
||||||
orderBy: { userId: "asc" },
|
return rows.map(row => ({ ...row, token: jobs.find(job => job.userId === row.userId)?.path.split("/").pop() ?? row.token }));
|
||||||
});
|
};
|
||||||
return {
|
return {
|
||||||
kita,
|
kita,
|
||||||
family,
|
family,
|
||||||
@@ -203,6 +213,7 @@ test("Gesicherte Konten und fehlende Einwilligung verhindern Verwaltungszugriff"
|
|||||||
where: { id: f.admin.id },
|
where: { id: f.admin.id },
|
||||||
data: {
|
data: {
|
||||||
privacyPolicyAcceptedAt: new Date(),
|
privacyPolicyAcceptedAt: new Date(),
|
||||||
|
emailVerifiedAt: new Date(),
|
||||||
lockedUntil: new Date(Date.now() + 3600000),
|
lockedUntil: new Date(Date.now() + 3600000),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -247,21 +258,7 @@ test("Aktive Alarme sperren manuelle Zuordnung sowie neue und bereits angefragte
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await f.create();
|
await f.create();
|
||||||
const names = [
|
function login(actor: AlarmActor, familyId: string) { testUser = { ...actor, familyId }; }
|
||||||
"TEST_MODE",
|
|
||||||
"TEST_USER_ID",
|
|
||||||
"TEST_USER_ROLE",
|
|
||||||
"TEST_KITA_ID",
|
|
||||||
"TEST_FAMILY_ID",
|
|
||||||
];
|
|
||||||
const previous = names.map((key) => process.env[key]);
|
|
||||||
function login(actor: AlarmActor, familyId: string) {
|
|
||||||
process.env.TEST_MODE = "true";
|
|
||||||
process.env.TEST_USER_ID = actor.id;
|
|
||||||
process.env.TEST_USER_ROLE = actor.role;
|
|
||||||
process.env.TEST_KITA_ID = actor.kitaId;
|
|
||||||
process.env.TEST_FAMILY_ID = familyId;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
login(f.admin, f.family.id);
|
login(f.admin, f.family.id);
|
||||||
const manual = await updateAssignmentAction(f.plan.id, date, otherChild.id);
|
const manual = await updateAssignmentAction(f.plan.id, date, otherChild.id);
|
||||||
@@ -300,10 +297,7 @@ test("Aktive Alarme sperren manuelle Zuordnung sowie neue und bereits angefragte
|
|||||||
otherChild.id,
|
otherChild.id,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
names.forEach((key, index) => {
|
testUser = null;
|
||||||
if (previous[index] === undefined) delete process.env[key];
|
|
||||||
else process.env[key] = previous[index];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ test(
|
|||||||
moduleMock.module("../src/lib/auth-utils.ts", {
|
moduleMock.module("../src/lib/auth-utils.ts", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
requireKitaSession: async () => ({ user }),
|
requireKitaSession: async () => ({ user }),
|
||||||
|
getKitaSession: async () => ({ user }),
|
||||||
requireRole: async (roles: UserRole[]) => {
|
requireRole: async (roles: UserRole[]) => {
|
||||||
assert.ok(roles.includes(user.role));
|
assert.ok(roles.includes(user.role));
|
||||||
return { user };
|
return { user };
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports -- Standalone Node integration test. */
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const { hash } = require('bcryptjs');
|
||||||
|
const { randomUUID } = require('node:crypto');
|
||||||
|
const fs = require('node:fs/promises');
|
||||||
|
const path = require('node:path');
|
||||||
|
const origin = 'http://localhost:3057';
|
||||||
|
const testUrl = process.env.SECURITY_TEST_DATABASE_URL;
|
||||||
|
if (!testUrl || !['127.0.0.1','localhost'].includes(new URL(testUrl).hostname) || !new URL(testUrl).pathname.endsWith('/kita_security_test')) throw new Error('Use only the dedicated local test database.');
|
||||||
|
const prisma = new PrismaClient({ datasourceUrl: testUrl });
|
||||||
|
const prefix = `http-security-${randomUUID()}`;
|
||||||
|
let attachmentId;
|
||||||
|
const upload = path.resolve(process.env.UPLOAD_DIR, 'announcements');
|
||||||
|
let checks = 0;
|
||||||
|
function checked() { checks++; }
|
||||||
|
async function login(email, password) {
|
||||||
|
const jar = new Map();
|
||||||
|
function cookies(response) {
|
||||||
|
for (const cookie of response.headers.getSetCookie()) {
|
||||||
|
const item = cookie.split(';')[0]; const index = item.indexOf('=');
|
||||||
|
jar.set(item.slice(0,index), item.slice(index + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const csrf = await fetch(origin + '/api/auth/csrf'); cookies(csrf);
|
||||||
|
const { csrfToken } = await csrf.json();
|
||||||
|
const response = await fetch(origin + '/api/auth/callback/credentials', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: origin,
|
||||||
|
Cookie: [...jar].map(([key,value]) => `${key}=${value}`).join('; '), 'X-Auth-Return-Redirect': '1' },
|
||||||
|
body: new URLSearchParams({ csrfToken, email, password, callbackUrl: origin + '/dashboard' }), redirect: 'manual',
|
||||||
|
});
|
||||||
|
cookies(response);
|
||||||
|
return [...jar].map(([key,value]) => `${key}=${value}`).join('; ');
|
||||||
|
}
|
||||||
|
async function main() {
|
||||||
|
const password = 'Local-http-test-password';
|
||||||
|
const passwordHash = await hash(password, 12);
|
||||||
|
const kitaA = await prisma.kita.create({ data: { name: prefix + '-A', slug: prefix + '-a' } });
|
||||||
|
const kitaB = await prisma.kita.create({ data: { name: prefix + '-B', slug: prefix + '-b' } });
|
||||||
|
const makeUser = (kita,label) => prisma.user.create({ data: { email: `${label}.${prefix}@example.invalid`, firstName: label, lastName: 'HTTP', kitaId: kita.id,
|
||||||
|
role: 'ADMIN', passwordHash, emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } });
|
||||||
|
const userA = await makeUser(kitaA,'a'); const userB = await makeUser(kitaB,'b');
|
||||||
|
const page = await fetch(origin + '/login');
|
||||||
|
const html = await page.text();
|
||||||
|
assert.equal(page.status, 200); checked();
|
||||||
|
assert.equal(page.headers.get('x-content-type-options'), 'nosniff'); checked();
|
||||||
|
assert.equal(page.headers.get('x-frame-options'), 'DENY'); checked();
|
||||||
|
assert.equal(page.headers.get('referrer-policy'), 'no-referrer'); checked();
|
||||||
|
const csp = page.headers.get('content-security-policy');
|
||||||
|
assert.ok(csp.includes("'strict-dynamic'") && !csp.includes("'unsafe-eval'")); checked();
|
||||||
|
const nonce = csp.match(/'nonce-([^']+)'/)[1];
|
||||||
|
assert.ok(html.includes(`nonce="${nonce}"`)); checked();
|
||||||
|
assert.ok(!html.includes('umami-before-send')); checked();
|
||||||
|
for (const headers of [ {}, { Origin: 'https://evil.invalid' }, { Origin: 'https://evil.invalid', 'x-forwarded-host': 'evil.invalid' } ]) {
|
||||||
|
const blocked = await fetch(origin + '/register', { method: 'POST', headers: { ...headers, 'Next-Action': 'invalid-test-action' }, body: '[]', redirect: 'manual' });
|
||||||
|
assert.equal(blocked.status,403); checked();
|
||||||
|
}
|
||||||
|
for (const url of ['/api/announcements/attachments/invalid', 'https://images.unsplash.com/unknown-image']) {
|
||||||
|
const optimized = await fetch(origin + '/_next/image?' + new URLSearchParams({ url, w: '640', q: '75' }));
|
||||||
|
assert.equal(optimized.status,400); checked();
|
||||||
|
}
|
||||||
|
const cookieA = await login(userA.email,password);
|
||||||
|
const cookieB = await login(userB.email,password);
|
||||||
|
const getSession = async cookie => (await fetch(origin + '/api/auth/session', { headers: { Cookie: cookie } })).json();
|
||||||
|
assert.equal((await getSession(cookieA))?.user.id, userA.id); checked();
|
||||||
|
assert.equal((await fetch(origin + '/admin', { headers: { Cookie: cookieA }, redirect: 'manual' })).status, 307); checked();
|
||||||
|
assert.equal((await fetch(origin + '/dashboard', { redirect: 'manual' })).status, 307); checked();
|
||||||
|
const privatePage = await fetch(origin + '/dashboard', { headers: { Cookie: cookieA }, redirect: 'manual' });
|
||||||
|
assert.equal(privatePage.status, 200); checked();
|
||||||
|
assert.match(privatePage.headers.get('cache-control'), /no-store/); checked();
|
||||||
|
attachmentId = randomUUID();
|
||||||
|
await prisma.announcement.create({ data: { kitaId: kitaB.id, authorId: userB.id, title: 'Private attachment', content: 'Private',
|
||||||
|
attachments: { create: { id: attachmentId, fileName: 'bad.html', fileType: 'text/html', fileUrl: '/api/' + attachmentId } } } });
|
||||||
|
await fs.mkdir(upload, { recursive: true }); await fs.writeFile(path.join(upload,attachmentId), '<script>bad()</script>');
|
||||||
|
const attachment = cookie => fetch(origin + '/api/announcements/attachments/' + attachmentId, { headers: { Cookie: cookie } });
|
||||||
|
assert.equal((await attachment(cookieA)).status, 404); checked();
|
||||||
|
const own = await attachment(cookieB); assert.equal(own.status,200); checked();
|
||||||
|
assert.equal(own.headers.get('content-type'),'application/octet-stream'); checked();
|
||||||
|
assert.match(own.headers.get('content-disposition'),/^attachment;/); checked();
|
||||||
|
assert.equal(own.headers.get('content-security-policy'),"default-src 'none'; sandbox"); checked();
|
||||||
|
await prisma.user.update({ where: { id: userB.id }, data: { privacyPolicyAcceptedAt: null } });
|
||||||
|
assert.equal((await attachment(cookieB)).status,401); checked();
|
||||||
|
await prisma.user.update({ where: { id: userA.id }, data: { disabledAt: new Date(), sessionVersion: { increment: 1 } } });
|
||||||
|
assert.equal((await getSession(cookieA))?.user,undefined); checked();
|
||||||
|
await prisma.user.update({ where: { id: userA.id }, data: { disabledAt: null } });
|
||||||
|
assert.equal((await getSession(cookieA))?.user,undefined); checked();
|
||||||
|
const newCookie = await login(userA.email,password);
|
||||||
|
assert.equal((await getSession(newCookie))?.user.id,userA.id); checked();
|
||||||
|
await prisma.user.delete({ where: { id: userA.id } });
|
||||||
|
assert.equal((await getSession(newCookie))?.user,undefined); checked();
|
||||||
|
// Auth.js must reject trusted-but-obsolete token payloads rather than constructing a ghost session.
|
||||||
|
const { encode } = await import('@auth/core/jwt');
|
||||||
|
for (const payload of [{ role: 'ADMIN', kitaId: kitaA.id }, { id: userB.id, role: 'ADMIN', kitaId: kitaB.id },
|
||||||
|
{ id: 123, sessionVersion: 0, authenticatedAt: Date.now() },
|
||||||
|
{ id: 'deleted-' + prefix, role: 'ADMIN', kitaId: kitaA.id, sessionVersion: 0, authenticatedAt: Date.now() }]) {
|
||||||
|
const jwt = await encode({ token: payload, secret: 'security-test-only-not-a-production-secret', salt: 'authjs.session-token' });
|
||||||
|
assert.equal((await getSession('authjs.session-token=' + jwt))?.user,undefined); checked();
|
||||||
|
}
|
||||||
|
console.log(`${checks} HTTP security checks passed against the production build.`);
|
||||||
|
}
|
||||||
|
main().finally(async () => {
|
||||||
|
if (attachmentId) await fs.rm(path.join(upload,attachmentId),{ force: true });
|
||||||
|
await prisma.kita.deleteMany({ where: { slug: { startsWith: prefix } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}).catch(error => { console.error(error); process.exitCode = 1; });
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
/* 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 === 'server-only') return {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
process.env.AUTH_URL = 'http://127.0.0.1:3008';
|
||||||
|
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({ '@/lib/mail': emailMock }); 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.notdienstAlarm.create({ data: { kitaId: kitaA.id, assignmentId: assignment.id, activeAssignmentId: assignment.id, familyId: familyA.id, familyName: 'A', childName: 'A Test', date: assignment.date, expiresAt: new Date(Date.now() - 1000), deadline: new Date(Date.now() + 3600000), recipients: { create: { userId: parentA.id, token: hashToken(token), sessionVersion: parentA.sessionVersion } } } });
|
||||||
|
const confirm = load('src/app/alert/[token]/actions.ts').confirmAlertAction;
|
||||||
|
assert.ok((await confirm(token)).error);
|
||||||
|
await prisma.notdienstAlarm.update({ where: { id: alert.id }, data: { expiresAt: new Date(Date.now() + 86400000) } });
|
||||||
|
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,41);
|
||||||
|
});
|
||||||
|
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.notdienstAlarmRecipient.findMany({ where: { alarm: { assignmentId: assignment.id } } });
|
||||||
|
assert.ok(alerts.length > 0);
|
||||||
|
assert.equal(alerts.some(alert => alert.userId === unverified.id),false);
|
||||||
|
assert.equal(emails.slice(before).some(email => email.to === unverified.email),false);
|
||||||
|
assert.equal(emails.length - before,alerts.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('new alarm links reject stored hashes and stay invalid after account unlock', async () => {
|
||||||
|
const load = loader(); const { newToken, hashToken } = load('src/lib/tokens.ts');
|
||||||
|
const { getPublicAlarm } = load('src/lib/alarms/service.ts');
|
||||||
|
const user = await prisma.user.create({ data: { email: `alarm-version.${prefix}@example.invalid`, firstName: 'Parent', lastName: 'Test', role: 'ELTERN', kitaId: kitaA.id, familyId: familyA.id, passwordHash: passHash, emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } });
|
||||||
|
const token = newToken();
|
||||||
|
await prisma.notdienstAlarm.create({ data: { kitaId: kitaA.id, familyId: familyA.id, familyName: 'A', childName: 'A', date: new Date(), expiresAt: new Date(Date.now() + 86400000), deadline: new Date(Date.now() + 3600000), recipients: { create: { userId: user.id, token: hashToken(token), sessionVersion: user.sessionVersion } } } });
|
||||||
|
assert.ok(await getPublicAlarm(token));
|
||||||
|
assert.equal(await getPublicAlarm(hashToken(token)), null);
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: { disabledAt: new Date(), sessionVersion: { increment: 1 } } });
|
||||||
|
assert.equal(await getPublicAlarm(token), null);
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: { disabledAt: null } });
|
||||||
|
assert.equal(await getPublicAlarm(token), null);
|
||||||
|
});
|
||||||
|
test('new staff and household event relationships reject foreign tenants in the database', async () => {
|
||||||
|
const educator = await prisma.educator.create({ data: { kitaId: kitaA.id, firstName: 'Staff', lastName: 'Test' } });
|
||||||
|
await assert.rejects(prisma.staffProfile.create({ data: { kitaId: kitaB.id, educatorId: educator.id, employmentStart: new Date(), trackingStart: new Date() } }));
|
||||||
|
const event = await prisma.termin.create({ data: { kitaId: kitaA.id, title: 'Tenant event', type: 'SONSTIGES', status: 'CONFIRMED', startDate: new Date(), endDate: new Date(Date.now() + 3600000), createdById: adminA.id } });
|
||||||
|
await assert.rejects(prisma.familyEventParticipation.create({ data: { kitaId: kitaA.id, eventId: event.id, familyId: familyB.id, status: 'ATTENDING' } }));
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { after, test } from "node:test";
|
import { after, test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { hashToken } from "../src/lib/tokens";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { UserRole } from "@prisma/client";
|
import { UserRole } from "@prisma/client";
|
||||||
import { prisma } from "../src/lib/prisma";
|
import { prisma } from "../src/lib/prisma";
|
||||||
@@ -18,11 +19,11 @@ process.env.AUTH_URL = "http://127.0.0.1:3008";
|
|||||||
const db = new URL(process.env.DATABASE_URL ?? "postgresql://invalid");
|
const db = new URL(process.env.DATABASE_URL ?? "postgresql://invalid");
|
||||||
if (
|
if (
|
||||||
db.hostname !== "127.0.0.1" ||
|
db.hostname !== "127.0.0.1" ||
|
||||||
db.port !== "55439" ||
|
db.port !== "5433" ||
|
||||||
db.pathname !== "/kita_staff_test"
|
db.pathname !== "/kita_security_test"
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Integrationstests benötigen die isolierte Datenbank 127.0.0.1:55439/kita_staff_test.",
|
"Integrationstests benötigen die isolierte Datenbank 127.0.0.1:5433/kita_security_test.",
|
||||||
);
|
);
|
||||||
const tenants: string[] = [];
|
const tenants: string[] = [];
|
||||||
after(async () => {
|
after(async () => {
|
||||||
@@ -44,6 +45,7 @@ async function fixture() {
|
|||||||
lastName: role,
|
lastName: role,
|
||||||
role,
|
role,
|
||||||
privacyPolicyAcceptedAt: new Date(),
|
privacyPolicyAcceptedAt: new Date(),
|
||||||
|
emailVerifiedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const adminUser = await createUser("ADMIN"),
|
const adminUser = await createUser("ADMIN"),
|
||||||
@@ -121,6 +123,7 @@ test("E-Mail-Aufträge gehen an alle Admins derselben Kita; Versandfehler bleibe
|
|||||||
lastName: "Admin",
|
lastName: "Admin",
|
||||||
role: "ADMIN",
|
role: "ADMIN",
|
||||||
privacyPolicyAcceptedAt: new Date(),
|
privacyPolicyAcceptedAt: new Date(),
|
||||||
|
emailVerifiedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const result = await f.run(f.staff, "leave", {
|
const result = await f.run(f.staff, "leave", {
|
||||||
@@ -598,7 +601,7 @@ test("Aktivierungslinks verknüpfen nur passende Accounts und werden bei Erneuer
|
|||||||
assert.ok(second.invite);
|
assert.ok(second.invite);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await prisma.verificationToken.count({
|
await prisma.verificationToken.count({
|
||||||
where: { token: first.invite.token },
|
where: { token: hashToken(first.invite.token) },
|
||||||
}),
|
}),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
@@ -704,6 +707,7 @@ test("Entzogene Rollen, fehlender Consent und gesperrte Accounts können keine P
|
|||||||
where: { id: f.staff.id },
|
where: { id: f.staff.id },
|
||||||
data: {
|
data: {
|
||||||
privacyPolicyAcceptedAt: new Date(),
|
privacyPolicyAcceptedAt: new Date(),
|
||||||
|
emailVerifiedAt: new Date(),
|
||||||
lockedUntil: new Date(Date.now() + 60000),
|
lockedUntil: new Date(Date.now() + 60000),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -749,7 +753,7 @@ test("Gemeinsame Anlage speichert Fachkraft, Profil, Account und wiederholbaren
|
|||||||
assert.ok(result.invite);
|
assert.ok(result.invite);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
await prisma.verificationToken.findUnique({
|
await prisma.verificationToken.findUnique({
|
||||||
where: { token: result.invite.token },
|
where: { token: hashToken(result.invite.token) },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -927,7 +931,7 @@ test("Einladung aus Teamliste erneuert nur eigene ausstehende Aktivierung", asyn
|
|||||||
assert.notEqual(renewed.invite.token, result.invite?.token);
|
assert.notEqual(renewed.invite.token, result.invite?.token);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await prisma.verificationToken.count({
|
await prisma.verificationToken.count({
|
||||||
where: { token: result.invite?.token },
|
where: { token: hashToken(result.invite!.token) },
|
||||||
}),
|
}),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user