Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0de3e9ae7e | ||
|
|
d4aa90422f | ||
|
|
abe73bb9ac | ||
|
|
1472779781 | ||
|
|
661776e609 | ||
|
|
061024d901 | ||
|
|
99d3a63eed | ||
|
|
00f602a7aa | ||
|
|
e50a8c646b | ||
|
|
e6a30be579 | ||
|
|
b915857951 | ||
|
|
add96fb40b | ||
|
|
4c99662f6e | ||
|
|
a9f57acf70 | ||
|
|
8915fe7df6 | ||
|
|
656ada80bd | ||
|
|
4702bf5abb | ||
|
|
979f795341 | ||
|
|
2d3b82eb80 | ||
|
|
8b32e4a832 | ||
|
|
a96ba0fd84 | ||
|
|
90b8843cda | ||
|
|
043fed2e46 |
@@ -1,4 +1,4 @@
|
||||
FROM node:22-alpine AS base
|
||||
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -28,6 +28,10 @@ COPY . .
|
||||
|
||||
RUN npm run build:prod
|
||||
|
||||
# Separate administrative image; never ship the migration CLI in the app runner.
|
||||
FROM builder AS migrator
|
||||
CMD ["npm", "run", "db:migrate:deploy"]
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
ENV NODE_ENV=production
|
||||
@@ -35,10 +39,11 @@ ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV UPLOAD_DIR=/app/uploads/announcements
|
||||
|
||||
COPY --from=builder /app ./
|
||||
|
||||
RUN mkdir -p /app/uploads/announcements && chmod +x /app/scripts/start-production.sh
|
||||
|
||||
RUN addgroup -S -g 1001 app && adduser -S -u 1001 -G app app
|
||||
COPY --from=builder --chown=app:app /app/.next/standalone ./
|
||||
COPY --from=builder --chown=app:app /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=app:app /app/public ./public
|
||||
RUN mkdir -p /app/uploads/announcements/announcements /app/.next/cache && chown -R app:app /app/uploads /app/.next/cache
|
||||
USER app
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["./scripts/start-production.sh"]
|
||||
CMD ["sh", "scripts/start-production.sh"]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Deployment und Mailversand
|
||||
|
||||
Die aktuelle Anleitung steht in [README.md](README.md), die Konfigurationsvorlage in [production.env.example](production.env.example).
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -1,55 +1,87 @@
|
||||
# Kita-Planer Deployment
|
||||
# Sicherer Betrieb und Rollout
|
||||
|
||||
Dieses Verzeichnis enthaelt die ausfuellbaren Vorlagen fuer das Production-Deployment.
|
||||
Die Anwendung startet ohne Schemaänderungen und ohne Demo-Seeding. Datenbankmigrationen laufen separat mit einer administrativen Rolle. Der App-Container verwendet ausschließlich `kita_runtime`, läuft als UID/GID 1001 und hat ein schreibgeschütztes Dateisystem mit persistenten Upload- und Cache-Volumes.
|
||||
|
||||
## Dateien
|
||||
## Konfiguration
|
||||
|
||||
- `production.env.example`: Vorlage fuer alle benoetigten Environment Variables.
|
||||
- `production-checklist.md`: Checkliste fuer Domain, Datenbank, Auth, Mail, Uploads und Rechtliches.
|
||||
- `../Dockerfile`: Container-Build fuer Coolify oder andere Docker-Hosts.
|
||||
- `../docker-compose.coolify.yml`: Compose-Vorlage fuer Coolify mit required Environment Variables.
|
||||
Werte aus `production.env.example` als Hosting-Secrets hinterlegen. 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. Administrative Zugangsdaten werden im App-Service ausdrücklich mit dem harmlosen Platzhalter `unavailable-in-app` überschrieben (leere Werte entfernt Coolify bei der Verarbeitung), damit eine zusätzliche globale `env_file`-Weitergabe durch das Hosting sie nicht in den App-Container einschleust. Der Start entfernt diese Variablen zusätzlich vor dem App- und Worker-Prozess. 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.
|
||||
|
||||
## Empfohlener Ablauf mit Coolify und Gitea
|
||||
`AUTH_URL`, `NEXTAUTH_URL` und `NEXT_PUBLIC_SITE_URL` müssen dieselbe kanonische HTTPS-Origin verwenden. Der Reverse Proxy terminiert TLS, routet intern auf App-Port 3000 und überschreibt weitergeleitete Host-/IP-Header. `TRUSTED_CLIENT_IP_HEADER` nur setzen, wenn der Proxy den gewählten Header (`x-real-ip` oder `cf-connecting-ip`) zuverlässig überschreibt. Ohne diese Einstellung gelten weiterhin gemeinsame globale und identitätsbezogene Datenbanklimits. Proxyseitig Body-Limit von höchstens 26 MiB, Timeouts und zusätzliche Request-Limits setzen.
|
||||
|
||||
1. Repository in deine Gitea-Instanz pushen.
|
||||
2. In Coolify eine neue Application aus diesem Gitea-Repository anlegen.
|
||||
3. Als Build Pack `Docker Compose` waehlen.
|
||||
4. Als Compose-Datei `docker-compose.coolify.yml` eintragen.
|
||||
5. Die bestehende Coolify-Postgres-Datenbank ueber ihre interne `DATABASE_URL` verbinden.
|
||||
6. Environment Variables aus `production.env.example` in Coolify eintragen.
|
||||
7. Domain auf den `app`-Service mit internem Port `3000` zeigen lassen.
|
||||
8. Deploy starten.
|
||||
Compose startet PostgreSQL standardmäßig intern. Bei einer bereits extern verwalteten Coolify-Datenbank den Stack entsprechend anpassen und deren interne URL verwenden; niemals versehentlich einen neuen leeren Datenbankdienst als Ersatz anschließen. pgAdmin ist nur im Profil `maintenance` und nur auf Loopback verfügbar; Zugriff über SSH-Tunnel.
|
||||
|
||||
Beim Container-Start fuehrt `scripts/start-production.sh` automatisch aus:
|
||||
## Neustart mit leerer Datenbank
|
||||
|
||||
```bash
|
||||
npx prisma db push
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
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.
|
||||
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.
|
||||
|
||||
## Welche Coolify-Postgres-URL?
|
||||
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.
|
||||
|
||||
Wenn die App und die Postgres-DB im selben Coolify-Projekt/Netzwerk laufen, nimm
|
||||
die interne Datenbank-URL aus Coolify, nicht die oeffentliche externe URL. Das ist
|
||||
stabiler und vermeidet unnoetigen Traffic ueber den Host.
|
||||
## Auswirkungen auf bestehende Konten
|
||||
|
||||
Das Format bleibt:
|
||||
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.
|
||||
|
||||
```txt
|
||||
postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
|
||||
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.
|
||||
|
||||
## Dateien und Wartung
|
||||
|
||||
`UPLOAD_DIR` bezeichnet die bisherige Basis; die Anwendung ergänzt `announcements`. Compose verwendet unverändert `/app/uploads/announcements/announcements`. Backfill muss genau dieselben Dateien sehen. Fehlende Dateien werden mit Größe 0 erfasst und als Anzahl gemeldet: Ursache und Datenverlust prüfen. Solange Größenwerte `NULL` sind, blockiert die Anwendung neue Anhänge, damit das Gesamtlimit nicht umgangen werden kann.
|
||||
|
||||
Grenzen: 10 MiB pro Datei, fünf Dateien und 25 MiB pro Nachricht; standardmäßig 256 MiB pro Kita und 10 GiB insgesamt, konfigurierbar über die beiden Quota-Variablen. Erlaubt sind PDF, PNG und JPEG mit passenden Dateisignaturen und Endungen. Das ist keine Virenprüfung oder vollständige PDF-Bereinigung. Downloads erfordern Sitzung, Consent und die passende Kita; sie werden als Anhang ausgeliefert.
|
||||
|
||||
`npm run uploads:cleanup` zeigt zunächst nur verwaiste Dateien mit mindestens 24 Stunden Alter. Nach Prüfung entfernt `npm run uploads:cleanup -- --delete` diese Dateien. Backups, Speicherüberwachung und eine Aufbewahrungs-/Löschstrategie für Backups separat einrichten. Dateien gelöschter Datensätze werden auch unmittelbar bestmöglich entfernt.
|
||||
|
||||
Die SQL-Migrationen ergänzen **45 zusammengesetzte Tenant-Fremdschlüssel**, die Prisma wegen der vorhandenen optionalen Cascade-/SetNull-Beziehungen nicht vollständig abbildet. Zukünftige generierte Migrationen auf unbeabsichtigtes Entfernen dieser Constraints prüfen. Kein `prisma db push` auf verwalteten Datenbanken einsetzen. Runtime-Grants nach zukünftigen Migrationen erneut bereitstellen.
|
||||
|
||||
## Verifikation und Updates
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run build:prod
|
||||
npx tsc --noEmit
|
||||
npm run lint -- --quiet
|
||||
npm audit --omit=dev
|
||||
```
|
||||
|
||||
## Wichtige Hinweise
|
||||
Die Integrationstests setzen eine ausdrücklich isolierte lokale PostgreSQL-Datenbank namens `kita_security_test` voraus; `SECURITY_TEST_DATABASE_URL` darf keine Anwendungsdaten enthalten. `npm run test:security` prüft Aktionen und echte DB-Transaktionen. `npm run test:security:http` benötigt den Production-Server auf localhost:3057 mit derselben Testdatenbank, `UPLOAD_DIR` und dem im Test dokumentierten Test-Secret. Niemals dieses Test-Secret in Produktion verwenden.
|
||||
|
||||
- Das aktuelle Projekt nutzt noch `prisma db push` statt versionierter Prisma-Migrationen.
|
||||
Fuer den ersten MVP-Deploy ist das praktikabel, fuer laengerfristigen Betrieb sollten
|
||||
Migrationen eingefuehrt werden.
|
||||
- `UPLOAD_DIR` muss persistent sein. Die Coolify-Compose-Datei mountet dafuer ein
|
||||
Docker-Volume nach `/app/uploads`.
|
||||
- `NEXT_PUBLIC_SITE_URL` muss die echte Domain enthalten, damit OpenGraph-Bilder in
|
||||
WhatsApp, LinkedIn und Co. korrekt als absolute URL aufgeloest werden.
|
||||
- `NEXT_PUBLIC_SITE_URL` wird auch als Build-Arg an Docker uebergeben. In Coolify sollte
|
||||
diese Variable deshalb fuer Build und Runtime verfuegbar sein.
|
||||
Docker-Images sind auf Digests fixiert. Neue Sicherheitsreleases regelmäßig prüfen und die Digests bewusst aktualisieren. Zum Prüfzeitpunkt verbleibt eine ungepatchte `braces`-Schwachstelle in der Entwicklungs-Lint-Kette; `npm audit --force` löst sie nicht sinnvoll. Der Runtime-Audit ist ohne bekannte Findings. NextAuth bleibt eine Beta-Abhängigkeit und benötigt weiterhin Update-Beobachtung.
|
||||
|
||||
## Familieneinladungen und Abwesenheiten
|
||||
|
||||
Die Migration `20261010000000_family_invitation_flows` ergänzt den Versandstatus und die mandantengebundene Empfängerreferenz. Der bestehende Migrationsdienst spielt sie vor dem App-Start ein; kein `prisma db push` und kein erneutes Löschen erforderlich. Fehlgeschlagene Familieneinladungen können in der Familienübersicht erneut versendet werden. Fachliche Abläufe: [Familien und Abwesenheiten](../docs/FAMILIEN_UND_ABWESENHEITEN.md).
|
||||
|
||||
|
||||
## Notdienst-Erinnerungen und Veröffentlichungsmails
|
||||
|
||||
Die Migration `20261012000000_notdienst_notifications` ergänzt Mailaufträge und die monatliche Erinnerungsrunde. Vor dem Start der neuen App `npm run db:migrate:deploy` ausführen und die Runtime-Grants erneut bereitstellen. Der bestehende Initialisierungsdienst erledigt beide Schritte im Compose-Deployment. Kein `db push` verwenden; die bisherigen Tenant-Constraints bleiben erhalten.
|
||||
|
||||
`NOTDIENST_JOB_SECRET` (mindestens 32 Zeichen), `AUTH_URL`/`NEXT_PUBLIC_SITE_URL`, `RESEND_API_KEY` und `EMAIL_FROM` müssen eingerichtet sein. Ein neues Secret oder ein zusätzlicher externer Cronjob ist nicht nötig, wenn der vorhandene Notdienstjob bereits konfiguriert ist. Der Produktionsstart überwacht Webserver und Worker gemeinsam: der Worker wird nach einem Abbruch neu gestartet; beim Ende des Webservers werden beide Prozesse beendet.
|
||||
|
||||
Der geschützte `POST /api/jobs/notdienstalarme` verarbeitet weiterhin Alarme und zusätzlich Planungserinnerungen/gespeicherte Benachrichtigungen. Die JSON-Antwort ergänzt `planning` mit `queued`, `sent`, `failed` und `skipped`. Ein Lauf bearbeitet bis zu 100 fällige Kitas und zwölf Planungsmails (vier parallel). Der Worker ruft die Route minütlich auf. Versanddetails und manuelle Wiederholung finden sich in der Monatsplanung.
|
||||
|
||||
Die automatische Runde läuft einmal pro Kita und Folgemonat, ab 9 Uhr Berliner Zeit, standardmäßig ab dem 19. (sieben Tage vor Ende der Eintragsfrist am 26.). Ein verpasster Termin wird bis zum 26. nachgeholt. Die Einstellung 0 schaltet diese Runde ab; ein bereits veröffentlichter Plan beendet Verfügbarkeitserinnerungen. Manuelle Erinnerungen sind für den offenen Folgemonat möglich und je Konto/Tag mit der automatischen Runde zusammen dedupliziert.
|
||||
|
||||
Veröffentlichung und Mailvorbereitung sind eine Transaktion. Anschließend wird der Versand versucht; ein Mailfehler setzt den Plan nicht zurück. Wiederholungen prüfen aktuelle Konten, Haushalt, aktive Kinder und Einteilungen erneut. Pro Konto wird eine Mail mit allen aktuellen eigenen Diensten vorbereitet. Versandaufträge erhalten einen stabilen Idempotenzschlüssel je Nachrichteninhalt und eine fünfminütige Reservierung; Fehler erhalten zunehmende Wartezeiten und bleiben sichtbar. Der Mailtransport bricht HTTP-Anfragen nach zehn Sekunden ab. Anbieterannahme, wiederholter Versand nach unbekanntem Transportausgang und tatsächliche Postfachzustellung sind weiterhin durch die Garantien des Mailanbieters begrenzt.
|
||||
|
||||
@@ -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,60 +1,39 @@
|
||||
# Production Checklist
|
||||
# Production-Checkliste
|
||||
|
||||
## 1. Domain & Basis-URLs
|
||||
## Vor dem Rollout mit bestehenden Daten
|
||||
|
||||
- [ ] Produktionsdomain festlegen: `https://[deine-domain.de]`
|
||||
- [ ] `NEXT_PUBLIC_SITE_URL` auf die Produktionsdomain setzen
|
||||
- [ ] `AUTH_URL` und `NEXTAUTH_URL` auf die Produktionsdomain setzen
|
||||
- [ ] Nach Deployment die WhatsApp-/LinkedIn-Vorschau mit der finalen URL testen
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
- [ ] Interne `DATABASE_URL` der bestehenden Coolify-Postgres-DB kopieren
|
||||
- [ ] `DATABASE_URL` in der Coolify-App eintragen
|
||||
- [ ] Beim ersten App-Start wird das Schema automatisch mit `npx prisma db push --skip-generate` angewendet
|
||||
- [ ] Fuer spaetere Releases idealerweise Prisma-Migrationen einfuehren
|
||||
## Hosting und Konfiguration
|
||||
|
||||
## 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`
|
||||
- [ ] Keine `.env`-Dateien mit echten Secrets committen
|
||||
- [ ] Dashboard ist per Metadata auf `noindex,nofollow` gesetzt
|
||||
- [ ] Produktionsdomain auf HTTPS betreiben
|
||||
## Nach dem Rollout
|
||||
|
||||
## 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
|
||||
- [ ] `RESEND_API_KEY` fuer Produktion setzen
|
||||
- [ ] `EMAIL_FROM` auf eine verifizierte Absenderadresse setzen
|
||||
- [ ] `ADMIN_EMAIL` fuer Kontaktformular setzen
|
||||
- [ ] Kontaktformular und Einladung/E-Mail-Flows einmal in Produktion testen
|
||||
|
||||
## 5. Uploads
|
||||
|
||||
- [ ] Coolify-Volume `kita_planer_uploads` fuer `/app/uploads` verwenden
|
||||
- [ ] Sicherstellen, dass Uploads nicht aus Versehen oeffentlich ausgeliefert werden
|
||||
- [ ] Backup-Strategie fuer Uploads klaeren
|
||||
|
||||
## 6. Rechtliches
|
||||
|
||||
- [ ] Platzhalter im Impressum ersetzen
|
||||
- [ ] Platzhalter in der Datenschutzerklaerung ersetzen
|
||||
- [ ] Hosting-Anbieter und Mail-Anbieter in Datenschutztext eintragen
|
||||
- [ ] Rechtliche Texte vor Livegang pruefen lassen
|
||||
|
||||
## 7. Build & Smoke Test
|
||||
|
||||
- [ ] Dependencies installieren: `npm ci`
|
||||
- [ ] Production Build erstellen: `npm run build:prod`
|
||||
- [ ] App starten: `npm run start:prod`
|
||||
- [ ] Startseite, Login, Dashboard und Kontaktformular testen
|
||||
|
||||
## 8. Coolify
|
||||
|
||||
- [ ] Code nach Gitea pushen
|
||||
- [ ] In Coolify eine Application aus dem Gitea-Repository anlegen
|
||||
- [ ] Build Pack `Docker Compose` waehlen
|
||||
- [ ] Compose-Datei `docker-compose.coolify.yml` verwenden
|
||||
- [ ] Environment Variables in Coolify eintragen, nicht ins Repo committen
|
||||
- [ ] Domain auf den `app`-Service und Container-Port `3000` routen
|
||||
- [ ] Upload-Volume `kita_planer_uploads` bleibt zwischen Deployments erhalten
|
||||
Details und Befehle stehen in [README.md](README.md). Lokale Tests ersetzen nicht die Kontrolle der produktiven TLS-, Proxy-, Backup-, Mail- und Container-Konfiguration.
|
||||
|
||||
@@ -11,6 +11,10 @@ POSTGRES_DB="kita_planer"
|
||||
POSTGRES_USER="kita_planer"
|
||||
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_DEFAULT_EMAIL="[admin@deine-domain.de]"
|
||||
PGADMIN_DEFAULT_PASSWORD="[GENERATE_ME]"
|
||||
@@ -36,9 +40,22 @@ RESEND_API_KEY="re_[PRODUCTION_API_KEY]"
|
||||
EMAIL_FROM="Kita-Planer <[absender@deine-domain.de]>"
|
||||
ADMIN_EMAIL="[kontakt@deine-domain.de]"
|
||||
|
||||
# Automatische Notdienst-Fristpruefung, Verfuegbarkeitserinnerungen und Mail-Wiederholungen.
|
||||
# Eigenes Secret, mindestens 32 Zeichen; generieren mit: openssl rand -hex 32
|
||||
NOTDIENST_JOB_SECRET="[GENERATE_ME]"
|
||||
|
||||
# Persistenter Upload-Speicher fuer Anhaenge vom Schwarzen Brett.
|
||||
# Muss auf dem Server dauerhaft erhalten bleiben und darf nicht im Repo liegen.
|
||||
UPLOAD_DIR="/var/lib/kita-planer/uploads/announcements"
|
||||
|
||||
# Optional: Nur bewusst fuer initiales Demo-Seeding in Produktion setzen.
|
||||
# ALLOW_DATABASE_SEED="true"
|
||||
# Standard: 256 MiB je Kita, 10 GiB insgesamt. Compose verwendet diese Werte.
|
||||
UPLOAD_TENANT_QUOTA_BYTES="268435456"
|
||||
UPLOAD_GLOBAL_QUOTA_BYTES="10737418240"
|
||||
|
||||
# Compose setzt UPLOAD_DIR fest auf /app/uploads/announcements.
|
||||
# Die Anwendung ergänzt /announcements: bestehende Dateien liegen dort unter
|
||||
# /app/uploads/announcements/announcements. Diesen Pfad beim Backfill beibehalten.
|
||||
|
||||
# Demo-Seeding ist in Produktion deaktiviert.
|
||||
# Nur hinter einem Proxy setzen, der diesen Header überschreibt:
|
||||
# TRUSTED_CLIENT_IP_HEADER="x-real-ip"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Historical upgrade helper, retained for compatibility tests only.
|
||||
-- Production uses prisma/migrations/20261010000000_family_invitation_flows.
|
||||
-- Additive upgrade before Prisma db push; safe to run repeatedly.
|
||||
-- On a fresh database Prisma creates the entire schema afterwards.
|
||||
DO $upgrade$
|
||||
BEGIN
|
||||
IF to_regclass('invitations') IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
ALTER TABLE "invitations"
|
||||
ADD COLUMN IF NOT EXISTS "userId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "sentAt" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "lastAttemptAt" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "leaseUntil" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS "failed" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "invitations_userId_key" ON "invitations"("userId");
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'invitations_userId_fkey'
|
||||
AND conrelid = 'invitations'::regclass
|
||||
) THEN
|
||||
ALTER TABLE "invitations"
|
||||
ADD CONSTRAINT "invitations_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END
|
||||
$upgrade$;
|
||||
@@ -0,0 +1,162 @@
|
||||
-- HISTORISCHER UPGRADEPFAD: aktuelle Releases verwenden prisma/migrations und deploy/README.md.
|
||||
-- Nicht zusätzlich zu versionierten Migrationen oder auf bereits aktualisierten Datenbanken ausführen.
|
||||
-- Additives Upgrade für einen Personalbereich einschließlich vorhandener Mail-Erweiterung.
|
||||
-- Beschreibt ausschließlich den damaligen Ausgangsstand vor Einführung der Migrationshistorie.
|
||||
BEGIN;
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NotdienstAlarmStatus" AS ENUM ('PENDING', 'CONFIRMED', 'DECLINED', 'LATE_CONFIRMATION', 'REVIEW_REQUIRED', 'CANCELLED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "notdienst_alerts" ADD COLUMN "alarmId" TEXT;
|
||||
|
||||
-- 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,
|
||||
|
||||
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 "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 "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,39 @@
|
||||
-- HISTORISCHER UPGRADEPFAD: aktuelle Releases verwenden prisma/migrations und deploy/README.md.
|
||||
-- Nicht zusätzlich zu versionierten Migrationen oder auf bereits aktualisierten Datenbanken ausführen.
|
||||
-- Nur E-Mail-Erweiterung für einen bereits mit 043fed2 eingeführten Personalbereich.
|
||||
-- Beschreibt ausschließlich den damaligen Ausgangsstand vor Einführung der Migrationshistorie.
|
||||
BEGIN;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "staffDecisionEmails" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- 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")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_emails_profileId_sentAt_skippedAt_idx" ON "staff_emails"("profileId", "sentAt", "skippedAt");
|
||||
|
||||
-- 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;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,288 @@
|
||||
-- HISTORISCHER UPGRADEPFAD: aktuelle Releases verwenden prisma/migrations und deploy/README.md.
|
||||
-- Nicht zusätzlich zu versionierten Migrationen oder auf bereits aktualisierten Datenbanken ausführen.
|
||||
-- Einmalige Einführung des vollständigen Personalbereichs inklusive E-Mails ab ec3f4f9.
|
||||
-- Beschreibt ausschließlich den damaligen Ausgangsstand vor Einführung der Migrationshistorie.
|
||||
BEGIN;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- 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")
|
||||
);
|
||||
|
||||
-- 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");
|
||||
|
||||
-- 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;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-kita_planer}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-kita_planer}
|
||||
@@ -27,7 +27,14 @@ services:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
HOSTNAME: 0.0.0.0
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-kita_planer}:${POSTGRES_PASSWORD:?}@postgres:5432/${POSTGRES_DB:-kita_planer}?schema=public
|
||||
# A nonempty sentinel survives hosting Compose parsers and blocks global env_file credentials.
|
||||
DATABASE_URL: "unavailable-in-app"
|
||||
MIGRATION_DATABASE_URL: "unavailable-in-app"
|
||||
APP_DATABASE_PASSWORD: "unavailable-in-app"
|
||||
POSTGRES_PASSWORD: "unavailable-in-app"
|
||||
PGADMIN_DEFAULT_PASSWORD: "unavailable-in-app"
|
||||
APP_DATABASE_PASSWORD_FILE: /app/runtime-secrets/database-password
|
||||
APP_DATABASE_NAME: ${POSTGRES_DB:-kita_planer}
|
||||
AUTH_SECRET: ${AUTH_SECRET:?}
|
||||
AUTH_URL: ${AUTH_URL:?}
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL:?}
|
||||
@@ -38,18 +45,61 @@ services:
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:?}
|
||||
EMAIL_FROM: ${EMAIL_FROM:?}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:?}
|
||||
NOTDIENST_JOB_SECRET: ${NOTDIENST_JOB_SECRET:?}
|
||||
UPLOAD_DIR: /app/uploads/announcements
|
||||
UPLOAD_TENANT_QUOTA_BYTES: ${UPLOAD_TENANT_QUOTA_BYTES:-268435456}
|
||||
UPLOAD_GLOBAL_QUOTA_BYTES: ${UPLOAD_GLOBAL_QUOTA_BYTES:-10737418240}
|
||||
TRUSTED_CLIENT_IP_HEADER: ${TRUSTED_CLIENT_IP_HEADER:-}
|
||||
volumes:
|
||||
- kita_planer_uploads:/app/uploads
|
||||
- kita_planer_next_cache:/app/.next/cache
|
||||
- kita_planer_runtime_secrets:/app/runtime-secrets:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
expose:
|
||||
- "3000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=64m,mode=1777
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
pids_limit: 256
|
||||
mem_limit: 1g
|
||||
cpus: "1.0"
|
||||
restart: unless-stopped
|
||||
|
||||
migrate:
|
||||
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:
|
||||
image: dpage/pgadmin4:latest
|
||||
profiles: ["maintenance"]
|
||||
image: dpage/pgadmin4@sha256:c332c5f6dfba995d9ebc4af261d93506d6876085d712eaaa3defc8dd1a3f26de
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:?}
|
||||
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:?}
|
||||
@@ -61,10 +111,12 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "80"
|
||||
- "127.0.0.1:5050:80"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
kita_planer_postgres_data:
|
||||
kita_planer_uploads:
|
||||
kita_planer_next_cache:
|
||||
kita_planer_runtime_secrets:
|
||||
kita_planer_pgadmin_data:
|
||||
|
||||
@@ -2,14 +2,14 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
|
||||
container_name: kita_planer_db
|
||||
environment:
|
||||
POSTGRES_USER: kita
|
||||
POSTGRES_PASSWORD: kita
|
||||
POSTGRES_DB: kita_planer
|
||||
ports:
|
||||
- "5433:5432"
|
||||
- "127.0.0.1:5433:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
# Prüfung der Bedienabläufe im Kita-Planer
|
||||
|
||||
Stand: 9. Oktober 2026 · geprüfter Code: `4702bf5abb0037ab9e0913dbef01ffbe21bbb96d` (`origin/main` zum Prüfzeitpunkt).
|
||||
|
||||
Fortschreibung: F01 (Familieneinladungen), F02 (Abwesenheitsverwaltung) und die Entfernung des künstlichen Gruppenfilters aus F12 wurden anschließend auf `codex/family-absence-flows` bearbeitet. Den neuen Ablauf und die Deployment-Schritte beschreibt [Familien und Abwesenheiten](FAMILIEN_UND_ABWESENHEITEN.md). F03, F04 und F05 (Verfügbarkeit bei manueller Zuweisung sowie Erstveröffentlichung) sind inzwischen ebenfalls umgesetzt; den aktuellen Stand und die Prüfungen dokumentiert die Ergänzung vom 12.10.2026 in [Funktionsübersicht](FUNKTIONSUEBERSICHT.md). F06 (Agenda-Details und Mitbringen), F09 (Arbeitszeitkontext und Ein-Tages-Anträge), F10 (personenbezogene Ämter-Schaltflächen) und F11 (Passwort-Wiederherstellung/-Änderung) sind ebenfalls umgesetzt; F08 enthält nun kontoabhängiges Ausblenden des Einrichtungshinweises. Die weiteren Teile von F08 und F11 bleiben durch diese Ergänzung unberührt. F07 (Mitteilungskorrektur, Verwaltung älterer Beiträge und getrennte Versandwiederholung) ist mit der Ergänzung vom 13.10.2026 ebenfalls umgesetzt. Die folgenden Befunde dokumentieren weiterhin den ursprünglichen Prüfstand.
|
||||
|
||||
## Umfang und Methode
|
||||
|
||||
Geprüft wurden die zentralen Abläufe für Eltern, Fachkräfte, Kita-Admins und Notdienst-Koordination: Einrichtung, Familien, Profile, Einladungen, Abwesenheiten, Arbeitszeit und Urlaub, Kalender und Anmeldungen, Mitteilungen, Dienste, Notdienstplanung und Alarmierung. Login, Kontoverwaltung und die Superadmin-Oberflächen wurden ergänzend im Quellcode geprüft.
|
||||
|
||||
Grundlage sind die aktuellen Seiten, Formulare, Server-Aktionen und Datenmodelle. Einzelne Abläufe wurden zusätzlich in einer lokalen Vorschau mit synthetischen Testkonten nachvollzogen: Familienverwaltung, Eltern-Abwesenheiten sowie Kalender in Agenda- und Listenansicht. Dabei wurden keine fachlichen Datensätze angelegt, geändert oder gelöscht und keine E-Mails verschickt. Die produktive Website wurde nicht getestet.
|
||||
|
||||
Die folgenden Befunde beschreiben den bestehenden Stand. Die vorgeschlagenen Zielabläufe sind noch nicht umgesetzt. Diese Prüfung ist kein vollständiger Sicherheitstest oder Test aller Berechnungen. Die ältere `FUNKTIONSUEBERSICHT.md` wurde nicht als Beleg für fehlende Funktionen verwendet: Kalender-Anmeldungen, Mitteilungen, Personalverwaltung und Notdienstalarm sind inzwischen vorhanden.
|
||||
|
||||
## Bereits mit dem Nutzer geklärt
|
||||
|
||||
- Eltern dürfen eigene Abwesenheitsmeldungen **für zukünftige Tage** ändern und zurücknehmen. Für laufende Tage ist die Kita zuständig.
|
||||
- **Kita-Admins dürfen innerhalb ihrer eigenen Kita weitere Admins und die Notdienst-Koordination ernennen.**
|
||||
|
||||
Diese Regeln sind für die spätere Umsetzung verbindlich. Die Datumsregel muss auch auf dem Server gelten. Für Meldungen, die einen laufenden und zukünftige Tage umfassen, ist die Behandlung des zukünftigen Teilzeitraums noch zu konkretisieren.
|
||||
|
||||
## Prioritäten
|
||||
|
||||
„Hoch“ bezeichnet einen blockierten Ablauf, widersprüchliche Rückmeldungen oder fachlich irreführende Angaben. „Mittel“ bezeichnet vermeidbare Nacharbeit oder einen fehlenden direkten Weg. „Niedrig“ bezeichnet kleinere Verbesserungen bei Benennung und Orientierung.
|
||||
|
||||
| ID | Priorität | Bereich | Wichtigster Befund |
|
||||
| --- | --- | --- | --- |
|
||||
| F01 | Hoch | Familieneinladungen | Gespeicherte Familie erscheint bei Mailfehler als fehlgeschlagener Vorgang; erneutes Senden fehlt. |
|
||||
| F02 | Hoch | Kinder-Abwesenheiten | Elternlink führt zu „Zugriff verweigert“; Ändern und Zurücknehmen fehlen. |
|
||||
| F03 | Hoch | Notdienst-Erinnerungen | Oberfläche verspricht Automatik, die Einstellung wird dafür nicht verwendet; unvollständige Rückmeldungen werden übergangen. |
|
||||
| F04 | Hoch | Rollenverwaltung | Weitere Kita-Admins und Koordination lassen sich nicht regulär ernennen. |
|
||||
| F05 | Mittel | Notdienstplan | Manuelle Einteilung zeigt Verfügbarkeit nicht am Auswahlpunkt; Erstveröffentlichung verschickt keine Nachricht. |
|
||||
| F06 | Mittel | Kalender und Mitbringen | Funktionen hängen von der Ansicht ab; Mitbring-Einträge lassen sich nur löschen und neu anlegen. |
|
||||
| F07 | Mittel | Mitteilungen | Korrektur und gezieltes erneutes Senden fehlen. |
|
||||
| F08 | Mittel | Eigene Ersteinrichtung | Haushalt, Kinder und Kontakt werden separat gespeichert; Admins ohne eigene Kinder gelten als unvollständig. |
|
||||
| F09 | Mittel | Personal | Neue Zeiterfassung verliert den betrachteten Zeitraum; Ein-Tages-Anträge verlangen zweimal das Datum. |
|
||||
| F10 | Niedrig | Ämter und Dienste | Gleich benannte Ämter-Schaltflächen lassen die betroffene Person erst nach dem Öffnen erkennen. |
|
||||
| F11 | Mittel | Zugang und Wiederherstellung | Passwort-Wiederherstellung fehlt; alte Aktivierungslinks und seltene Alt-Konten enden in Sackgassen. |
|
||||
| F12 | Hoch / Niedrig | Filter und Anzeigen | Abwesenheitsgruppen sind aus Namen erfunden; die Notdienst-Zahl im Menü ist fest eingetragen. |
|
||||
|
||||
## F01 – Familieneinladungen sauber abschließen und erneut senden
|
||||
|
||||
**Aktueller Ablauf:** Admin legt eine Familie samt Eltern und Kindern an. Die Datenbank speichert die Familie, Konten und Aktivierungslinks. Anschließend werden die Einladungsmails versendet. Scheitert eine Mail, meldet die Aktion einen Formularfehler; der erfolgreiche Abschluss und die Aktualisierung der Ansicht bleiben aus. Ein erneuter Speicherversuch kann dann an den bereits angelegten E-Mail-Adressen scheitern. Bei mehreren Einladungen stoppt der Versand bereits beim ersten Fehler.
|
||||
|
||||
Die Familienliste zeigt offene Einladungen, bietet aber weder dort noch in „Details“ eine Funktion zum erneuten Senden. Das wurde im lokalen Browser bestätigt. Auch der Hinweis eines abgelaufenen Aktivierungslinks, beim Admin einen neuen Link anzufordern, hat damit keinen passenden Bedienweg.
|
||||
|
||||
**Vorgeschlagener Zielablauf:** Familie einmal anlegen → Erfolg anzeigen und Liste aktualisieren → Versandstatus je Elternteil anzeigen → bei Bedarf ausschließlich die Einladung erneut senden. Der Datensatz bleibt unabhängig vom Mailversand erfolgreich angelegt. Fehlgeschlagene Mails werden gespeichert und gezielt wiederholt; weitere Empfänger werden trotz eines einzelnen Fehlers bearbeitet.
|
||||
|
||||
**Belege:** `src/app/dashboard/families/actions.ts:125`, `:277`, `:459`; `src/app/dashboard/families/add-family-dialog.tsx`; `src/app/dashboard/families/_components/families-list-client.tsx`; `src/app/dashboard/families/edit-family-dialog.tsx`.
|
||||
|
||||
**Abnahme:** Simulierter Mailfehler führt zu genau einer Familie, sichtbarem Versandfehler und einem funktionierenden erneuten Senden ohne erneute Personeneingabe. Der kürzlich verbesserte Fachkräfte-Ablauf dient als Vorlage.
|
||||
|
||||
## F02 – Eigene Abwesenheiten direkt verwalten
|
||||
|
||||
**Aktueller Ablauf:** Eltern melden ein Kind ab und sehen die Meldung auf der Übersicht. Dort fehlen Änderungs- und Rücknahmeaktionen. „Alle Abwesenheiten“ führt auf eine Seite, die nur Admins, Koordination und Fachkräfte betreten dürfen. Der Elternaccount erreicht im lokalen Browser „Zugriff verweigert“.
|
||||
|
||||
Die vorhandene Server-Aktion `deleteAbsence` wird von keiner Oberfläche verwendet. Sie prüft die Haushaltszugehörigkeit, aber bisher keine Beschränkung auf zukünftige Tage. Eine Aktion zum Ändern fehlt. Die Kita-Übersicht bietet ebenfalls nur die Anzeige, keinen direkten Korrekturweg für laufende Meldungen.
|
||||
|
||||
**Vorgeschlagener Zielablauf:** „Meine Abwesenheiten“ → Meldung ansehen → zukünftige Tage bearbeiten oder zurücknehmen. Für laufende Tage führt ein verständlicher Hinweis zur Kita; berechtigte Kita-Nutzer können die Meldung in ihrer Übersicht korrigieren. Eltern bekommen eine eigene Haushaltsansicht. Die kitaweite Übersicht behält ihre bestehenden Zugriffsgrenzen.
|
||||
|
||||
**Belege:** `src/app/dashboard/absence-card.tsx:234`, `:241`; `src/app/dashboard/admin/abwesenheiten/page.tsx:16`; `src/actions/absences.ts:107`; `src/app/dashboard/admin/abwesenheiten/absence-table.tsx`.
|
||||
|
||||
**Abnahme:** Eltern erreichen ihre Meldungen ohne Zugriffsfehler. Änderungen gelten nur für den eigenen Haushalt und die erlaubten Tage; Kita-Korrekturen sind innerhalb derselben Kita möglich. Für die Datumsgrenze gilt die lokale Kita-Zeitzone.
|
||||
|
||||
## F03 – Notdienst-Einstellungen und Erinnerungen verständlich machen
|
||||
|
||||
**Aktueller Ablauf:** „Mindesteinteilungen pro Kind/Monat“ wird als Richtmaß der automatischen Verteilung erklärt. Tatsächlich bestimmt der Wert die erforderliche Anzahl gemeldeter Verfügbarkeiten. „Erinnerungsfrist“ verspricht automatische E-Mails; im geprüften Code wird die Einstellung gespeichert und angezeigt, aber von keinem Erinnerungsjob ausgewertet. Die Eingabesperre folgt stattdessen dem fest codierten Stichtag 26. Der vorhandene Notdienstalarm-Job betrifft Alarm-Erinnerungen und ersetzt diese Verfügbarkeits-Erinnerung nicht.
|
||||
|
||||
Die manuelle Erinnerungsaktion behandelt eine Familie schon nach einem einzigen Verfügbarkeitseintrag als beantwortet. Familien unterhalb ihrer geforderten Anzahl werden dadurch nicht angeschrieben. Einzelne Versandfehler landen im Log; die Oberfläche zeigt nur die Anzahl erfolgreicher Mails. Erneutes Auslösen wiederholt auch bereits erfolgreich versendete Mails.
|
||||
|
||||
Zusätzlich kann die Planung einen beliebigen Monat anzeigen. Der Link in einer Erinnerung führt dagegen ohne Monatsangabe zur Elternseite, die den nächsten Monat öffnet. Erinnerung und Eingabe können dadurch unterschiedliche Monate betreffen.
|
||||
|
||||
**Vorgeschlagener Zielablauf:** „Erforderliche Verfügbarkeiten“ und tatsächlichen Abgabeschluss klar benennen → Rücklauf als „fehlt / unvollständig / vollständig“ zeigen → Empfänger vor dem Versand sichtbar machen → Versandstatus und gezieltes Wiederholen anbieten. Eine automatische Erinnerung benötigt einen echten Job und eine passende Einstellung. Erinnerungen müssen den betroffenen Monat öffnen oder auf den tatsächlich bearbeitbaren Monat beschränkt sein.
|
||||
|
||||
**Belege:** `src/app/dashboard/notdienst/plan/_components/settings-modal.tsx:76`, `:95`; `src/app/dashboard/notdienst/page.tsx:52`; `src/lib/date-utils.ts:14`; `src/app/dashboard/notdienst/plan/actions.ts:426`, `:485`, `:503`, `:532`.
|
||||
|
||||
**Abnahme:** Texte entsprechen der tatsächlichen Berechnung und Automatik. Auch teilweise ausgefüllte Haushalte werden berücksichtigt. Der E-Mail-Link führt zur richtigen Eingabe; fehlgeschlagene Zustellungen können einzeln wiederholt werden.
|
||||
|
||||
## F04 – Rollen dort verwalten, wo Personen verwaltet werden
|
||||
|
||||
**Aktueller Ablauf:** Registrierung und Kita-Einrichtung erzeugen den ersten Admin. Familienanlage erzeugt Elternkonten; die Fachkräfte-Anlage erzeugt Fachkraftkonten. Es gibt keinen regulären Weg, einen weiteren Kita-Admin oder eine Notdienst-Koordination zu ernennen. Auch die Superadmin-Aktionen bieten keine Rollenänderung.
|
||||
|
||||
Ein festes Amt wie „Vorstand“ ist eine gesonderte Zuordnung und verleiht keine Admin-Rechte. Diese Trennung ist in der Bedienung nicht ausreichend erkennbar.
|
||||
|
||||
**Vorgeschlagener Zielablauf, vom Nutzer bestätigt:** Kita-Admin öffnet eine Person innerhalb seiner Kita → wählt deren Zugriffsrolle → speichert die Änderung. Zuständigkeiten/Ämter und Zugriffsrechte erhalten klar getrennte Bezeichnungen. Mindestens ein erreichbarer Kita-Admin muss bestehen bleiben. Dies muss auch bei der eigenen Kontolöschung berücksichtigt werden: `deleteMyAccount` prüft derzeit Notdienst-Verpflichtungen, aber keine letzte Admin-Rolle.
|
||||
|
||||
**Belege:** `src/app/register/actions.ts:91`; `src/app/onboarding/actions.ts:130`; `src/app/dashboard/families/actions.ts:236`, `:404`; `src/app/admin/actions.ts`; `src/app/dashboard/families/duty-manager.tsx`; `src/app/dashboard/profil/actions.ts:286`.
|
||||
|
||||
**Abnahme:** Rollenänderungen wirken innerhalb der eigenen Kita und sind nachvollziehbar. Ein Amt allein verändert keine Berechtigungen. Bestehende Fachkraftprofile und Haushaltszuordnungen werden bei einer Rollenänderung ausdrücklich berücksichtigt.
|
||||
|
||||
## F05 – Manuelle Notdienstplanung ohne Gegenprüfung in einer zweiten Ansicht
|
||||
|
||||
**Aktueller Ablauf:** Bei der manuellen Einteilung enthält die Auswahl sämtliche aktiven Kinder samt Familie und bisheriger Einteilungszahl. Ob die Familie am gewählten Tag verfügbar ist, steht nicht in dieser Auswahl. Die Koordination muss die Verfügbarkeit in einer anderen Ansicht prüfen. Geschwister erscheinen als separate Auswahlmöglichkeiten für denselben Haushalt.
|
||||
|
||||
Beim erstmaligen Veröffentlichen wird der Plan freigegeben, aber keine E-Mail versendet. Spätere Änderungen können dagegen Benachrichtigungen auslösen. Das ist eine uneinheitliche Kommunikation; eine Mail zur Erstveröffentlichung ist ein Verbesserungsvorschlag, bisher keine bestätigte Produktregel.
|
||||
|
||||
**Vorgeschlagener Zielablauf:** Tag auswählen → verfügbare Haushalte direkt erkennen → Einteilung vornehmen. Andere Haushalte bleiben für bewusst begründete Sonderfälle auswählbar und sind gekennzeichnet. Veröffentlichung bietet einen klar bezeichneten Schritt zur Information der Familien mit sichtbarem Versandstatus.
|
||||
|
||||
**Belege:** `src/app/dashboard/notdienst/plan/page.tsx:118`; `src/app/dashboard/notdienst/plan/plan-view.tsx:643`; `src/app/dashboard/notdienst/plan/actions.ts:139`, `:353`.
|
||||
|
||||
Der Übergang vom Plan zum Notdienstalarm ist dagegen bereits sinnvoll: Der Link übergibt die konkrete Einteilung, sodass diese im Alarmformular nicht erneut gesucht werden muss.
|
||||
|
||||
## F06 – Kalenderfunktionen in jeder Ansicht erreichbar machen
|
||||
|
||||
**Aktueller Ablauf:** Die Listenansicht bietet „Details“ einschließlich Mitbring-Liste. Die Monatsansicht bietet diese Inhalte über den Tagesdialog. Die Agenda zeigt zwar Termine und RSVP-Antworten, bietet aber keinen entsprechenden Detailzugang und keine Mitbring-Liste. Eltern müssen die Ansicht wechseln, um denselben Termin vollständig zu bearbeiten. Der Unterschied wurde im lokalen Browser bestätigt.
|
||||
|
||||
Mitbring-Einträge besitzen nur Hinzufügen und Löschen. Eine Mengenänderung oder ein Tippfehler erfordert Löschen und erneute Eingabe.
|
||||
|
||||
**Vorgeschlagener Zielablauf:** Einheitlicher Termindialog aus Monat, Agenda und Liste → Beschreibung, Anmeldung und Mitbringen am selben Ort. Eigene Mitbring-Einträge lassen sich direkt bearbeiten. Bestehende Berechtigungen für fremde Einträge bleiben maßgeblich. Aktueller Zeitraum und Filter bleiben beim Öffnen und Schließen erhalten.
|
||||
|
||||
**Belege:** `src/app/dashboard/kalender/_components/calendar-overview-client.tsx:329`, `:856`; `src/app/dashboard/kalender/_components/termin-list.tsx:326`, `:429`; `src/app/dashboard/kalender/_components/mitbringsel-list.tsx:32`, `:46`; `src/app/dashboard/kalender/actions.ts:1048`, `:1089`.
|
||||
|
||||
Die Anmeldung pro Familie oder Kind ist bereits vorhanden und integriert. Sie muss dafür nicht neu gebaut werden.
|
||||
|
||||
## F07 – Mitteilungen korrigieren und Versand unabhängig wiederholen
|
||||
|
||||
**Aktueller Ablauf:** Admins können eine Mitteilung erstellen und optional per E-Mail versenden. Nach der Veröffentlichung gibt es keine Bearbeitungsfunktion und keinen erneuten Versand. Die Admin-Seite zeigt nur die letzten acht Mitteilungen, ohne weiterführende Verwaltungsansicht. Eine Korrektur führt deshalb praktisch zu einem weiteren Beitrag; ein Mailfehler hat keinen Wiederholungsweg.
|
||||
|
||||
Positiv: Die Aktion behandelt einen Mailfehler bereits als Warnung zu einer erfolgreich gespeicherten Mitteilung. Die Familienanlage sollte denselben verständlichen Abschluss bieten.
|
||||
|
||||
**Vorgeschlagener Zielablauf:** Veröffentlichte Mitteilung öffnen → Inhalt korrigieren und Änderungsstand sichtbar machen → bei Bedarf über die Korrektur informieren. Für den Versand gibt es einen eigenen Status und eine gezielte Wiederholung. Ältere Mitteilungen bleiben für Admins verwaltbar.
|
||||
|
||||
**Belege:** `src/actions/announcements.ts:80`, `:172`, `:203`, `:211`; `src/app/dashboard/admin/news/page.tsx:50`, `:116`.
|
||||
|
||||
## F08 – Die eigene Ersteinrichtung zusammenführen und rollenabhängig gestalten
|
||||
|
||||
**Aktueller Ablauf:** Wer noch keinen eigenen Haushalt hat, legt zuerst dessen Namen an, schließt den Dialog, legt Kinder in einem anderen Dialog an und ergänzt Kontaktdaten in einem weiteren Formular. Der Einrichtungshinweis verlangt einen Haushalt samt Kindern für alle Rollen außer Fachkräften – also auch für Admins oder Koordination ohne eigene Kita-Kinder.
|
||||
|
||||
Dieser Befund betrifft die eigene Ersteinrichtung. Eltern, die über die Familienverwaltung eingeladen wurden, haben ihren Haushalt bereits; dort werden Eltern und Kinder zusammen erfasst.
|
||||
|
||||
**Vorgeschlagener Zielablauf:** Eine geführte Einrichtung mit bekannten Angaben, Kontakten und – wenn relevant – Haushalt samt Kindern. Nach jedem Schritt ist der nächste direkt erreichbar. Admins und Koordination können angeben, ob sie auch als Eltern teilnehmen; fehlende eigene Kinder dürfen sonst keinen dauerhaften Unvollständigkeitshinweis erzeugen. Diese Rollenvariante ist noch fachlich zu bestätigen.
|
||||
|
||||
Kleine Zusatzkorrektur: „Hinweis dauerhaft ausblenden“ wird heute über einen browserweiten Schlüssel gespeichert. Die Einstellung sollte dem jeweiligen Nutzer zugeordnet sein, damit ein anderes Konto am selben Gerät seine eigenen Hinweise behält.
|
||||
|
||||
**Belege:** `src/app/dashboard/profil/_components/create-family-dialog.tsx:29`, `:57`; `src/app/dashboard/profil/page.tsx`; `src/components/OnboardingDialog.tsx:46`, `:55`.
|
||||
|
||||
## F09 – Personalformulare mit dem betrachteten Tag verbinden
|
||||
|
||||
**Aktueller Ablauf:** „Arbeitszeit erfassen“ erhält nur das Personalprofil. Ein neuer Eintrag startet immer mit dem heutigen Datum, 08:00–16:00 Uhr und 30 Minuten Pause – auch wenn gerade eine andere Woche betrachtet wird. Nach dem Speichern schließt sich das Formular. Für weitere Arbeitsblöcke muss die Fachkraft es erneut öffnen und den Tag wieder wählen.
|
||||
|
||||
Freizeitausgleich und halber Urlaubstag verlangen laut Formular „Von = Bis“, zeigen aber zwei unabhängige Datumsfelder. Ein einzelner Tag muss damit zweimal angegeben werden.
|
||||
|
||||
**Vorgeschlagener Zielablauf:** „Zeit erfassen“ am konkreten Tag mit vorausgewähltem Datum. „Speichern und weiteren Block erfassen“ behält den Tag bei. Bei Ein-Tages-Anträgen genügt ein Datum; das Ende wird daraus abgeleitet. Tatsächliche Arbeitszeiten bleiben ausdrücklich eingegebene Werte, auch bei festen Arbeitstagen.
|
||||
|
||||
Für die erstmalige Personaleinrichtung wäre ergänzend ein geführter Abschluss für Arbeitsmodell, Urlaubskonto und Anfangssaldo hilfreich. Die getrennten Einstellungen sind für spätere Änderungen sinnvoll; die bisher vereinbarte Anlage mit anschließender Profileinrichtung ist daher kein Fehler.
|
||||
|
||||
**Belege:** `src/app/dashboard/personal/page.tsx:287`; `src/app/dashboard/personal/forms.tsx:368`, `:375`, `:477`, `:498`, `:643`.
|
||||
|
||||
## F10 – Ämter-Schaltflächen eindeutig einer Person zuordnen
|
||||
|
||||
**Aktueller Ablauf:** Eine Familie mit zwei Elternkonten bekommt zwei nahezu gleich beschriftete „Ämter“-Schaltflächen. Erst nach dem Öffnen zeigt der Dialog, welches Elternteil gemeint ist. Das wurde im lokalen Browser bestätigt. Ein neues Amt anzulegen und es der gerade geöffneten Person zuzuweisen sind außerdem separate Aktionen.
|
||||
|
||||
**Vorgeschlagener Zielablauf:** „Ämter: Vorname“ oder eine gemeinsame Familienansicht mit klar getrennten Personen. Im Personenkontext kann „Amt anlegen und zuweisen“ beide Schritte verbinden.
|
||||
|
||||
Die festen persönlichen Ämter und die wechselnden Elterndienste sind fachlich verschiedene Dinge. Verständliche Bezeichnungen sollten diese Unterscheidung sichtbar machen.
|
||||
|
||||
**Belege:** `src/app/dashboard/families/_components/families-list-client.tsx:292`; `src/app/dashboard/families/duty-manager.tsx:107`, `:123`; `prisma/schema.prisma` (`ParentDuty`, `DutyType`).
|
||||
|
||||
## F11 – Zugang wiederherstellen statt außerhalb der Anwendung lösen
|
||||
|
||||
**Aktueller Ablauf:** Im Login fehlt „Passwort vergessen“, im Profil die eigene Passwortänderung. Ein bereits verwendeter Aktivierungslink kann nach dem Löschen des Tokens eine allgemeine 404-Seite liefern. Abgelaufene Links verweisen auf den Admin; bei Familien fehlt dessen erneuter Versandweg aus F01.
|
||||
|
||||
Ein seltener Alt-Daten-Fall führt zusätzlich auf „Datenschutz-Einwilligung erforderlich“, bietet dort aber nur einen Verweis an Admin oder Support. Die Seite hat keinen direkten Abschluss; auch die bestehenden Support-Aktionen bieten dafür keinen passenden Korrekturablauf. Der normale Registrierungs- und Einladungsablauf erfasst die Einwilligung bereits.
|
||||
|
||||
**Vorgeschlagener Zielablauf:** Passwort-Wiederherstellung über einen zeitlich begrenzten Link; eindeutige Weiterleitung zum Login nach bereits erfolgter Aktivierung; passende Möglichkeit für einen neuen Einladungslink. Alt-Konten können eine fehlende Einwilligung innerhalb der Anwendung nachholen.
|
||||
|
||||
**Belege:** `src/app/login/login-form.tsx`; `src/app/dashboard/profil/page.tsx`; `src/app/invite/[token]/page.tsx:39`, `:84`; `src/app/invite/[token]/actions.ts:153`; `src/app/onboarding/consent/page.tsx:46`; `src/app/admin/actions.ts`.
|
||||
|
||||
## F12 – Fachliche Filter und Statuszahlen verlässlich anzeigen
|
||||
|
||||
**Hoch:** Der Gruppenfilter in der Kita-Abwesenheitsübersicht ordnet Kinder über den Zeichencode des ersten Buchstabens ihres Namens „Krippe“ oder „Elementar“ zu. Das ist keine tatsächliche Gruppenzuordnung. Der Filter kann Kinder aus einer vermeintlich fachlichen Übersicht entfernen und dadurch falsche Schlüsse nahelegen.
|
||||
|
||||
**Niedrig:** Das Menü zeigt beim Notdienst fest die Zahl „4“. Sie wird nicht aus offenen Aufgaben berechnet und verändert sich durch deren Erledigung nicht.
|
||||
|
||||
**Vorgeschlagener Zielablauf:** Gruppenfilter erst mit einer echten gespeicherten Gruppenzuordnung anbieten; bis dahin ausblenden. Statuszahlen aus den jeweiligen offenen Aufgaben berechnen und ihre Bedeutung benennen oder weglassen.
|
||||
|
||||
**Belege:** `src/app/dashboard/admin/abwesenheiten/_components/absences-client.tsx:69`, `:77`; `src/components/dashboard/sidebar-nav.tsx:60`.
|
||||
|
||||
## Bereits sinnvolle Abläufe und Prüfungsabdeckung
|
||||
|
||||
| Bereich | Ergebnis der Ablaufprüfung |
|
||||
| --- | --- |
|
||||
| Fachkraft anlegen und aktivieren | Name, Pflicht-E-Mail und Starttermin gemeinsam; Profilübergang sowie Einladungsstatus und erneutes Senden vorhanden. |
|
||||
| Familie anlegen | Eltern und Kinder gemeinsam erfassbar; hauptsächlich der Abschluss bei Mailproblemen und der erneute Versand brauchen Verbesserung (F01). |
|
||||
| Profil und Kinderverwaltung | Eigene Kinder und Kontaktdaten bearbeitbar; Ersteinrichtung und Wiederherstellung siehe F08/F11. |
|
||||
| Adressbuch | Suchen, Filtern und Kontakt öffnen am selben Ort; Freigabe im Profil ist eine nachvollziehbare gesonderte Einstellung. |
|
||||
| Kinder-Abwesenheiten | Meldung direkt vom Dashboard möglich; Verwaltung und Gruppenfilter siehe F02/F12. |
|
||||
| Arbeitszeit und Urlaub | Zeiten, Wochenfreigaben, Anträge, feste Arbeitsmodelle und Konten integriert; Eingabekontext siehe F09. |
|
||||
| Feiertage im Personalbereich | Eintrag im selben Personalbereich; nicht automatisch mit Kita-Schließtagen gleichsetzen, da Schließtage Arbeitstage sein können. |
|
||||
| Kalender und RSVP | Anmeldung pro Familie/Kind sowie Terminverwaltung vorhanden; Detailzugang und Mitbringen siehe F06. |
|
||||
| Terminanfragen und Notdiensttausch | Anfrage und Antwortaktionen vorhanden; Freigabe bzw. Zustimmung anderer Personen ist ein notwendiger fachlicher Schritt. |
|
||||
| Mitteilungen | Dashboard-Anzeige und eigenes Archiv vorhanden; Korrektur und Versandverwaltung siehe F07. |
|
||||
| Wechselnde Elterndienste | Dienstarten und Einteilung auf derselben Verwaltungsseite; fachliche Unterscheidung zu persönlichen Ämtern siehe F10. |
|
||||
| Notdienstplanung | Automatischer Vorschlag, Vorschau, manuelle Einteilung und Veröffentlichung vorhanden; F03/F05 betreffen Orientierung und Kommunikation. |
|
||||
| Notdienstalarm | Einteilung aus dem Plan vorausgewählt; Rückmeldung, Frist und Mailstatus integriert. Separater Alarmdialog ist fachlich sinnvoll. |
|
||||
| Kita-Einrichtung, Login und Superadmin | Bestehende Einrichtung, Diagnose und Support geprüft; Zugriffsrollen und Wiederherstellung siehe F04/F11. Keine produktiven Support-Aktionen ausgeführt. |
|
||||
|
||||
„Vorhanden“ bedeutet hier, dass der Bedienweg im aktuellen Code nachvollziehbar ist. Es ist keine Zusicherung, dass jede Ausnahme oder Berechnung bereits durch diese Prüfung getestet wurde.
|
||||
|
||||
## Empfohlene Umsetzungspakete
|
||||
|
||||
1. **Sackgassen und falsche Anzeigen beseitigen:** Eltern-Abwesenheitslink und eigene Verwaltungsansicht (F02), erfundenen Gruppenfilter entfernen (F12), gespeicherte Familien bei Mailfehler korrekt abschließen und Einladungen erneut senden (F01).
|
||||
2. **Notdienst-Rücklauf verlässlich machen:** Einstellungen richtig benennen, unvollständige Meldungen erkennen, Monatsbezug und Versandstatus korrigieren (F03); anschließend manuelle Einteilung und Veröffentlichung vereinfachen (F05).
|
||||
3. **Personen und Zugänge verwalten:** Kita-interne Rollenverwaltung gemäß Nutzerentscheidung (F04), Passwort- und Aktivierungs-Wiederherstellung (F11).
|
||||
4. **Alltagskorrekturen direkt ermöglichen:** Einheitliche Kalenderdetails und editierbare Mitbring-Einträge (F06), bearbeitbare Mitteilungen mit eigenständigem Versandstatus (F07).
|
||||
5. **Weniger wiederholte Eingaben:** Tagesbezogene Arbeitszeitformulare und Ein-Tages-Anträge (F09), geführte Ersteinrichtung (F08), eindeutig beschriftete Ämter (F10) und echte Menü-Zahlen (F12).
|
||||
|
||||
Vor der Umsetzung der jeweiligen Zusatzregel noch zu entscheiden: Umgang mit mehrtägigen Abwesenheiten über die Grenze „heute“ hinweg; Teilnahme von Admins/Koordination ohne eigene Kinder; automatische Verfügbarkeits-Erinnerungen samt Termin; E-Mail zur ersten Planveröffentlichung. Diese Punkte verhindern nicht die bereits eindeutig beschriebenen Korrekturen.
|
||||
|
||||
## Verifikation und nächster Arbeitsstand
|
||||
|
||||
Die Quellcodebefunde wurden gegen den genannten Commit geprüft. Im lokalen Browser bestätigt wurden insbesondere der nicht nutzbare Eltern-Abwesenheitslink, die fehlenden Einladungsaktionen in Familien-Details, gleich benannte Ämter-Schaltflächen sowie unterschiedliche Detailzugänge im Kalender.
|
||||
|
||||
Dieses Dokument liegt auf der eigenen Arbeitsbranch `codex/flow-audit`. Für die Dokumentation wurden keine Anwendungstests oder ein neuer Build benötigt. Bei der Umsetzung gehören gezielte Tests der Datums-/Rollenregeln und der Mailfehlerfälle zu den jeweiligen Paketen.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Familieneinladungen und Abwesenheitsverwaltung
|
||||
|
||||
Stand: 9. Oktober 2026. Umsetzung der Befunde F01 und F02 aus der [Ablaufprüfung](ABLAUFPRUEFUNG.md), einschließlich Entfernung des künstlichen Gruppenfilters aus F12.
|
||||
|
||||
## Familieneinladungen
|
||||
|
||||
- Die Familienanlage erfasst weiterhin Eltern und Kinder gemeinsam. Die Familie, Konten, Aktivierungslinks und Versandaufträge werden gemeinsam gespeichert.
|
||||
- Scheitert danach der Mailversand, bleibt die Familienanlage erfolgreich. Das Formular schließt, die Liste aktualisiert sich und eine Warnung weist auf den erneuten Versand hin. Auch die Ergänzung eines Elternteils in einer bestehenden Familie folgt diesem Ablauf.
|
||||
- Jeder Empfänger wird bearbeitet, auch wenn eine andere Einladung fehlschlägt.
|
||||
- Tabelle, Kartenansicht und Familien-Details zeigen den Status je Elternkonto: aktiviert, Aktivierung offen, Versand läuft, versendet, fehlgeschlagen oder abgelaufen. Soweit vorhanden, wird das Ablaufdatum angezeigt.
|
||||
- Kita-Admins können die Einladung innerhalb ihrer Kita beim jeweiligen Elternteil erneut senden. Aktivierte Konten werden nicht erneut eingeladen. Parallel ausgelöste Versandversuche werden gegen doppelte Bearbeitung geschützt.
|
||||
- Ein noch gültiger, fehlgeschlagener Versand wird mit demselben Link wiederholt. Bei abgelaufenem oder zuvor erfolgreich versendetem Link wird ein neuer Link für sieben Tage erstellt; ältere Aktivierungslinks des Kontos werden ersetzt. Direkt nach einem erfolgreichen Versand gilt eine Minute Abstand bis zum erneuten Versand.
|
||||
- Wird die Adresse eines noch nicht aktivierten Kontos korrigiert, versendet das Speichern automatisch eine neue Einladung an diese Adresse. Solange die Adressänderung ungespeichert ist, ist der separate erneute Versand gesperrt.
|
||||
- Bereits bestehende Elternkonten und Aktivierungslinks bleiben gültig. Alte Konten ohne gespeicherten Versandstatus erscheinen als „Aktivierung offen“; beim erneuten Senden wird der Status ergänzt. Das Löschen und Neuanlegen einer Familie ist dafür nicht nötig.
|
||||
- Nach der Aktivierung wird der Einladungsstatus abgeschlossen. „Versendet“ bedeutet, dass der Mailtransport die Nachricht angenommen hat; eine tatsächliche Zustellung oder ein späterer Bounce wird hier nicht überwacht.
|
||||
|
||||
Bei Eingabefehlern bleiben die Formulardaten erhalten. Nach einem erfolgreichen Vorgang öffnet sich das Formular wieder mit einem frischen Zustand. Die Kinder-Schaltfläche entfernt das tatsächlich gewählte Kind aus dem Entwurf.
|
||||
|
||||
## Kinder-Abwesenheiten
|
||||
|
||||
„Meine Abwesenheiten“ ist über das Menü und die Dashboard-Karte erreichbar. Die Seite zeigt ausschließlich Meldungen des eigenen Haushalts, getrennt nach aktuellen/zukünftigen und vergangenen Meldungen, mit jeweils bis zu 20 Meldungen pro Seite.
|
||||
|
||||
| Rolle | Neue Meldung | Ändern oder zurücknehmen |
|
||||
| --- | --- | --- |
|
||||
| Eltern | Eigene aktive Kinder, ab heute | Eigene Meldungen, deren Beginn nach heute liegt; auch der neue Beginn muss zukünftig bleiben. |
|
||||
| Kita-Admin, Notdienst-Koordination, Fachkraft | Der bestehende Dashboard-Meldeweg betrifft den eigenen Haushalt. | Vorhandene Meldungen innerhalb der eigenen Kita, einschließlich laufender Meldungen, in der Kita-Tagesübersicht korrigieren oder zurücknehmen. |
|
||||
|
||||
Die vom Nutzer bestätigte Mehrtagesregel gilt für die gesamte Meldung: **Sobald sie begonnen hat, übernimmt die Kita auch die noch zukünftigen Tage.** Eltern können die zukünftige Hälfte einer laufenden Meldung nicht eigenständig abtrennen.
|
||||
|
||||
Die Bearbeitung zeigt Zeitraum, Grund und Bemerkung in einem Dialog. Bei Verschieben des Beginns hinter das bisherige Ende wird das Ende angepasst. Eine Rücknahme erhält einen eigenen Bestätigungsdialog. Laufende Meldungen zeigen Eltern einen verständlichen Hinweis auf die Kita.
|
||||
|
||||
Die Regeln gelten auch serverseitig:
|
||||
|
||||
- Aktuelle Rollen und Haushaltszugehörigkeit werden vor Schreibzugriffen aus der Datenbank geprüft.
|
||||
- Kalendertage werden unabhängig von der Server-Zeitzone als UTC-Datum gespeichert; die Grenze „heute“ folgt `Europe/Berlin`.
|
||||
- Ungültige Tage, umgekehrte Zeiträume und Überschneidungen werden abgewiesen. Gleichzeitige Meldungen für dasselbe Kind werden serialisiert.
|
||||
- Ein veralteter Bearbeitungsstand überschreibt keine zwischenzeitliche Änderung. Die Oberfläche bietet bei diesem Konflikt das Aktualisieren der Ansicht an.
|
||||
- Erfolgreiche Änderungen aktualisieren Dashboard, eigene Verwaltung und Kita-Tagesübersicht.
|
||||
|
||||
Der bisherige Gruppenfilter „Krippe/Elementar“ wurde entfernt, weil keine tatsächliche Gruppenzuordnung hinterlegt ist. Ein fachlicher Gruppenfilter bleibt ein eigener späterer Ausbau.
|
||||
|
||||
## Deployment und Betrieb
|
||||
|
||||
Das Prisma-Modell `Invitation` erhält einen optionalen Bezug zum Elternkonto sowie Versandzeit, Versuchszähler, Fehlerstatus und eine zeitlich begrenzte Versandsperre. Vorhandene Datensätze müssen nicht neu angelegt werden.
|
||||
|
||||
Der vorgeschaltete Compose-Dienst `migrate` führt `prisma migrate deploy` und die eingeschränkten Runtime-Grants aus. Die versionierte Migration `20261010000000_family_invitation_flows` ergänzt die Felder und prüft die Mandantenzuordnung des Empfängers. Der App-Start benötigt keine Schema- oder Administrationsrechte. **Es sind keine neuen Umgebungsvariablen nötig.** Resend-Absender, API-Schlüssel und App-Adresse werden wie bisher verwendet.
|
||||
|
||||
Bei einem eigenen Startablauf die versionierten Migrationen und Runtime-Grants vor dem neuen App-Code ausführen; siehe die zentrale Deployment-Anleitung. Kein `prisma db push` und kein historisches SQL-Upgrade zusätzlich zu den Migrationen verwenden.
|
||||
|
||||
Die normalen Sicherungs- und Schema-Prüfschritte gelten weiterhin; siehe [Deployment und Mailversand](../deploy/DEPLOYMENT_UND_MAILVERSAND.md). Für Familieneinladungen ist kein zusätzlicher Cronjob erforderlich. Fehlgeschlagene oder unterbrochene Versandversuche werden gezielt durch Kita-Admins wiederholt.
|
||||
|
||||
## Prüfung
|
||||
|
||||
- Unit-Tests für echte Kalendertage, Berliner Mitternachts-/Zeitumstellungsgrenzen und Einladungszustände.
|
||||
- PostgreSQL-Integrationstests mit simuliertem Mailtransport: Speicherung trotz Mailfehler, zweiter Empfänger, gezieltes erneutes Senden, Ablauf/Erneuerung, Alt-Konten, paralleler Versand, Aktivierungsabschluss, Adresskorrektur und Berechtigungen.
|
||||
- PostgreSQL-Integrationstests für Eltern- und Kita-Korrekturen, begonnene Mehrtagesmeldungen, fremde Haushalte/Kitas, Überschneidungen, konkurrierende Erstellung und veraltete Bearbeitungsstände.
|
||||
- Wiederholbarkeit des SQL-Upgrades auf leerem und bestehendem Testschema mit Erhalt vorhandener Tokens.
|
||||
- Lokale Browserprüfung mit deaktiviertem echtem Mailversand: Anlage und Bearbeitung schließen trotz Versandwarnung; Formulare behalten Fehler-Eingaben und lassen sich erneut öffnen; zukünftige Elternmeldung und laufende Kita-Meldung werden erfolgreich korrigiert.
|
||||
- TypeScript, ESLint und Produktionsbuild.
|
||||
|
||||
Testbefehle:
|
||||
|
||||
```sh
|
||||
npm run test:family-absence
|
||||
TEST_DATABASE_URL='postgresql://USER@127.0.0.1:PORT/TEST_DATABASE' npm run test:family-absence:integration
|
||||
```
|
||||
|
||||
Integrationstests akzeptieren nur eine ausdrücklich benannte lokale Testdatenbank. Die Browserprüfung nutzt ausschließlich synthetische Daten und verschickt keine echten E-Mails.
|
||||
@@ -0,0 +1,554 @@
|
||||
# Kita-Planer – Funktionsübersicht und aktueller Stand
|
||||
|
||||
## Ergänzung vom 13.10.2026: Landingpage und SEO
|
||||
|
||||
- Die öffentliche Landingpage beschreibt jetzt sechs Bereiche, einschließlich Personal/Arbeitszeit mit eigener Detailseite `/features/personal-arbeitszeit`. Rollenvergabe, Wiederherstellung des Kontozugangs, Kalender-Rückmeldungen, Mitbringkorrekturen, zukünftige Abwesenheitsänderungen, Notdienst-Erinnerungen/Veröffentlichung und Mitteilungskorrekturen samt Versandübersicht sind in den passenden Texten ergänzt. Die Darstellung behält das vorhandene Design bei und ist auf Desktop sowie bei 390 und 360 Pixeln geprüft; der mobile Logo-Umbruch wurde behoben.
|
||||
- Veraltete Aussagen zu „1-Klick-Alarmierung“, Raumbuchungen und Lesebestätigungen wurden durch die tatsächlich vorhandenen Abläufe ersetzt. Der persönliche Ungelesen-Status wird nicht als Nachweis einer tatsächlichen Kenntnisnahme oder als Admin-Auswertung beworben.
|
||||
- `src/lib/marketing/seo.ts` bündelt öffentliche Domain, Titel, Beschreibungen, Canonicals und Vorschaudaten. Öffentliche Seiten besitzen eigene absolute Canonicals und `og:url`; Titel wiederholen den Markennamen nicht. Open Graph und Twitter verwenden passende Beschreibungen und das vorhandene, tatsächlich 1200 × 630 Pixel große Bild. Sein Alternativtext beschreibt die sichtbaren Spielklötze.
|
||||
- Die Startseite liefert JSON-LD für WebSite, WebPage und SoftwareApplication; Feature-Seiten ergänzen BreadcrumbList und ihre eigene WebPage. Die Daten stammen aus den vorhandenen Produktbeschreibungen, enthalten keine erfundenen Bewertungen, Preise oder Anbieterangaben und werden gegen eingeschleuste HTML-Script-Enden abgesichert. Die Scripts verwenden den vorhandenen CSP-Nonce. Dies ist keine Zusage besonderer Google-Suchergebnisdarstellungen.
|
||||
- Die Sitemap enthält ausschließlich neun öffentliche Canonical-Seiten: Startseite, sechs Feature-Seiten, Impressum und Datenschutz. Das Änderungsdatum folgt der tatsächlichen Änderung der Marketinginhalte (`marketingLastModified`) und wechselt nicht mehr bei jedem Build. Für rechtliche Seiten wird kein unbekanntes Änderungsdatum erfunden. Bei weiteren Änderungen der Produkttexte ist dieses Datum gezielt fortzuschreiben.
|
||||
- Indexierung ist im Root-Layout standardmäßig ausgeschaltet; nur die öffentlichen Seiten schalten sie ausdrücklich ein. Private Seiten behalten `noindex, nofollow` und `private, no-store` auch als HTTP-Header. Login, Registrierung und Wiederherstellungs-Anforderungsformular bleiben für Crawler erreichbar, damit sie die `noindex`-Markierung lesen können. Interne und Tokenrouten bleiben zusätzlich in robots.txt ausgeschlossen. robots.txt ist keine Zugriffskontrolle und eine blockierte URL allein garantiert keine Entfernung aus einem Suchindex.
|
||||
|
||||
Prüfung: `npm run test:seo` (11 Tests gegen tatsächliche Metadaten-/Sitemap-/JSON-LD-Funktionen) sowie `TEST_SEO_ORIGIN=http://127.0.0.1:PORT npm run test:seo:http` (24 Tests gegen den lokal gestarteten Standalone-Produktionsbuild). Die HTTP-Prüfung umfasst alle neun öffentlichen HTML-Seiten, Social-Crawler-Metadaten direkt im Head, strukturierte Daten und CSP-Nonce, Sitemap, robots.txt, tatsächliche PNG-Bildmaße, private Seiten/Weiterleitungen und unbekannte Feature-URLs mit 404/noindex. Insgesamt 35 Tests erfolgreich, keine übersprungenen Tests. Build und TypeScript erfolgreich; ESLint ohne Fehler, mit fünf bereits bestehenden Warnungen.
|
||||
|
||||
Die bestehende Produktions-Landingpage, robots.txt und Sitemap wurden zusätzlich lesend abgeglichen. Die neuen Änderungen wurden lokal geprüft und sind noch nicht veröffentlicht. Für diese SEO-Ergänzung gibt es keine weitere Datenbankmigration; der Branch baut auf der vorherigen Mitteilungs-Etappe mit deren eigener Migration auf. Es wurden keine produktiven Daten verändert, Anfragen versendet oder Analytics-Ereignisse ausgelöst.
|
||||
|
||||
Offen: Impressum und Datenschutzhinweise enthalten im bestehenden Seiteninhalt weiterhin Platzhalter für Anbieter, Anschrift, Kontakt, verantwortliche Stelle und Dienstleister. Diese Angaben müssen von der verantwortlichen Person geliefert werden; die Metadaten-Ergänzung ersetzt sie nicht. Search-Console-Zugriff, tatsächlicher Google-Indexierungsstand und reale Nutzungs-/Performance-Messwerte waren nicht Teil der verfügbaren Prüfung.
|
||||
|
||||
Grundlagen: [Google zu Canonical-URLs](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls), [Sitemap-Änderungsdatum](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap) und [noindex](https://developers.google.com/search/docs/crawling-indexing/block-indexing); außerdem die mit Next.js 16.4 gelieferten Metadaten- und JSON-LD-Guides.
|
||||
|
||||
|
||||
## Ergänzung vom 13.10.2026: Mitteilungen korrigieren und Versand verwalten
|
||||
|
||||
- Unter `/dashboard/admin/news` können Kita-Admins alle eigenen Beiträge nach Titel/Text durchsuchen und in Seiten mit zwölf Meldungen durchblättern. „Bearbeiten & Versand“ öffnet die Korrektur und den Versandstatus. Eltern- und Koordinationskonten erhalten keinen Verwaltungszugriff.
|
||||
- Titel und Markdown-Text sind bearbeitbar. Empfängergruppe, ursprünglicher Autor und geschützte Anhänge bleiben erhalten. Gleichzeitige bzw. veraltete Korrekturen überschreiben keinen neueren Stand; Akteur, Kita, aktive Sitzung und Adminrolle werden beim Speichern erneut geprüft.
|
||||
- Eine tatsächliche Textänderung erhält einen sichtbaren Aktualisierungszeitpunkt, setzt bisherige Lesemarkierungen zurück und rückt den Beitrag im Elternfeed nach vorne. Unverändertes Speichern erhält Lesestatus und Beitragsfassung. Ein noch geöffnetes altes Dialogfenster kann eine neuere Fassung nicht als gelesen markieren.
|
||||
- Die aktuelle Fassung kann beim Speichern ausdrücklich auch per E-Mail vorgemerkt werden. Einmalige Aufträge je Beitrag/Fassung/Konto entstehen innerhalb derselben Datenbanktransaktion. Ohne ausdrückliche Auswahl erzeugt eine Korrektur keine neue Mailrunde. Ungesendete Aufträge älterer Fassungen werden übersprungen; historische erfolgreiche Sendungen bleiben in der Versandübersicht nachvollziehbar.
|
||||
- Jede Person hat einen eigenen Status: ausstehend, gesendet, fehlgeschlagen oder übersprungen. „Gesendet“ bedeutet die Annahme durch den Maildienst, keine Lesebestätigung oder garantierte Postfachzustellung. Bestehende Beiträge erhalten keinen erfundenen historischen Versandstatus. Aktive, verifizierte Konten mit Zustimmung und passender Empfängergruppe werden vorbereitet; vor jedem Versand werden diese Voraussetzungen erneut geprüft. Haushaltskonten behalten ihre Eltern-Mailzugehörigkeit bei Admin-/Koordinationsrechten; Fachkräfte mit Adminrechten erhalten über ihr verknüpftes Personalprofil weiterhin Teammeldungen. Systemadmins gehören nicht zu diesen Empfängergruppen.
|
||||
- Erste Versuche laufen nach der Antwort über Next.js `after`. Die gespeicherten Aufträge überleben Neustarts und werden zusätzlich vom bestehenden geschützten Hintergrundjob `/api/jobs/notdienstalarme` verarbeitet: zwölf fällige Aufträge je Lauf, bis zu vier parallel, wiederholte Fehler mit wachsendem Abstand bis sechs Stunden. Reservierungen laufen nach fünf Minuten ab, Wiederholungen behalten denselben Idempotenzschlüssel. Erfolgreiche Sendungen werden nicht erneut vorgemerkt. Der Maildienst begrenzt die zeitliche Gültigkeit seiner Idempotenzschlüssel; eine absolute Zustellgarantie über beliebig lange Abbrüche gibt es nicht.
|
||||
- „Fehlgeschlagene erneut versuchen“ zieht ausschließlich fehlgeschlagene Aufträge der eigenen Kita und aktuellen Beitragsfassung vor. Während eines laufenden Versands wird eine Korrektur kurzzeitig zurückgewiesen, damit unterschiedliche Texte nicht unter derselben Fassung versendet werden. Verwaltung und Versandversuche haben eigene Anfragelimits.
|
||||
- Aufträge mit Anhängen werden erst nach vollständiger Speicherung freigegeben. Dateischreibfehler nehmen Beitrag und Aufträge gemeinsam zurück. Nach einem Abbruch zwischen fertig geschriebenen Dateien und Freigabe prüft der Hintergrundlauf nach fünf Minuten die gespeicherten Dateigrößen und gibt nur vollständige Anhänge frei. Fehlende oder teilweise geschriebene Dateien bleiben gesperrt und benötigen eine Prüfung durch die Hosting-Administration.
|
||||
|
||||
Vor dem produktiven Start die Migration `20261012010000_announcement_delivery` über den bestehenden Maintenance-/Deployment-Ablauf anwenden (`npm run db:migrate:deploy`) und die Runtime-Berechtigungen wie gewohnt erneuern (`npm run db:provision-runtime`). Sie ergänzt Beitragsfassung, Änderungszeitpunkt, Freigabe und Versandaufträge; bestehende Beiträge behalten ihre bisherige Reihenfolge. Zwei zusammengesetzte Fremdschlüssel sichern die Beziehungen zur selben Kita zusätzlich in PostgreSQL. Keine neue Umgebungsvariable: bestehende Mailkonfiguration und App-Adresse werden verwendet. Automatische Wiederholungen benötigen den bereits vorhandenen Worker mit `NOTDIENST_JOB_SECRET` (mindestens 32 Zeichen); der gewöhnliche `npm run start` startet diesen Worker nicht. Fehlende Jobkonfiguration wird in der Versandansicht angezeigt.
|
||||
|
||||
Prüfbefehl: `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_announcements_test npm run test:announcements` nach Anwendung der Migrationen auf dieser separaten lokalen Datenbank. 31 neue PostgreSQL-Integrationstests erfolgreich, darunter drei konkurrierende Worker mit 24 Empfängern. Dazu 84 bestehende Sicherheits-, Notdienst-, Mailtransport- und Elternablaufprüfungen (115 insgesamt, keine übersprungen). Die Tests ersetzen Request-/UI-Grenzen, Mailtransport und bei Dateiabbruchtests die Dateisystemgrenze; Anwendungscode und PostgreSQL-Transaktionen werden tatsächlich ausgeführt. Produktionsbuild und TypeScript erfolgreich; ESLint ohne Fehler mit fünf bestehenden Warnungen.
|
||||
|
||||
Browser-Abnahme mit synthetischen Konten auf Desktop und bei 390 Pixeln: Suche und zweite Verwaltungsseite, ältere Meldung direkt korrigieren, isolierte Wiederholung einer fehlgeschlagenen Sendung, Korrektur ohne neue Mail, ausdrücklicher Versand der aktuellen Fassung sowie frühere Versandstände. Eltern sehen ältere Korrekturen wieder vorne und ungelesen; Öffnen speichert die aktuelle Lesemarkierung. Keine horizontale Überbreite. Der echte geschützte Hintergrundjob hat eine fällige synthetische Sendung verarbeitet. Es wurden keine produktiven Daten verändert und keine echten E-Mails verschickt.
|
||||
|
||||
|
||||
## Ergänzung vom 12.10.2026: Kontozugang und weniger wiederholte Eingaben
|
||||
|
||||
- Die Ämter-Schaltflächen nennen die betroffene Person („Ämter: Vorname“) und tragen einen vollständigen zugänglichen Namen. Der Einrichtungshinweis wird je Konto in diesem Browser ausgeblendet. Das alte gemeinsame Ausblenden gilt nicht mehr für alle Konten; fehlender Browser-Speicher verhindert die Nutzung nicht.
|
||||
- Unter `/passwort-vergessen` können aktivierte Konten einen Wiederherstellungslink anfordern. Die öffentliche Antwort bleibt für bestehende, unbekannte, deaktivierte und begrenzte Anfragen gleich. Kontosuche und Mailversand laufen nach der Antwort über Next.js `after`; ein Fehler verrät öffentlich keine Kontodaten.
|
||||
- Reset-Links sind 30 Minuten gültig, einmal nutzbar und an die aktuelle Sitzungsfassung gebunden. Die vorhandene Verifikationstabelle speichert ausschließlich den Tokenhash und einen eigenen Reset-Zweck. Einladungslinks können kein Passwort zurücksetzen. Neue Reset-Anfragen ersetzen ältere Links; zwischenzeitliche E-Mail-/Passwortänderungen, administrative Kontosperren oder Löschungen verhindern die Verwendung alter Links. Fehlgeschlagener Versand entfernt ausschließlich den eigenen neu erzeugten Link.
|
||||
- Im Profil lässt sich das Passwort mit dem aktuellen Passwort und übereinstimmender Bestätigung ändern. Sowohl Wiederherstellung als auch eigene Änderung erhöhen die Sitzungsfassung und beenden alte Anmeldungen; eine neue Anmeldung ist erforderlich. Persönliche Alarmlinks, die an diese frühere Sitzungsfassung gebunden sind, werden ebenfalls ungültig. Kontoaktivierung, Kita, Haushalt, Rollen und Einwilligungen bleiben erhalten. Es gelten weiterhin mindestens acht Zeichen und die bcrypt-Grenze von 72 UTF-8-Bytes.
|
||||
- Anfragebegrenzungen: drei Reset-Anfragen je E-Mail/Stunde, fünf Reset-Abschlussversuche je Link/15 Minuten und fünf eigene Passwortänderungsversuche je Konto/15 Minuten; zusätzlich die bestehenden gemeinsamen und ggf. Proxy-IP-Limits. Bestehende Mailkonfiguration und App-Adresse werden verwendet. Keine neue Umgebungsvariable oder Datenbankmigration erforderlich.
|
||||
- Die Kalender-Agenda öffnet den bereits vorhandenen Tagesdetaildialog mit Beschreibung, Rückmeldung, Mitbringliste und berechtigten Verwaltungsaktionen. Ansicht und Suche bleiben beim Schließen erhalten. Die Agenda passt sich auf schmalen Bildschirmen an.
|
||||
- Neue Arbeitszeitformulare übernehmen die betrachtete Woche bzw. den ausgewählten Tag. Tage in der Zukunft sind nicht erfassbar. „Speichern und weiteren Block erfassen“ hält den tatsächlich eingegebenen Tag fest, leert die Uhrzeiten, setzt die Pause auf null und fokussiert den Beginn. Bestehende Zeiteinträge werden weiterhin einzeln bearbeitet; serverseitige Zeit-, Überschneidungs- und Freigaberegeln bleiben maßgeblich.
|
||||
- Freizeitausgleich und halber Urlaubstag benötigen ein Datum. Das Ende wird aus diesem Datum übernommen; mehrtägiger Urlaub und Krankheit behalten Von/Bis. Die Berechnungs- und Genehmigungsregeln ändern sich nicht.
|
||||
|
||||
Prüfbefehl: `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_account_test npm run test:account` nach Anwendung der vorhandenen Migrationen auf dieser separaten lokalen Testdatenbank. Die 20 neuen PostgreSQL-Tests prüfen den tatsächlichen Passwortdienst, Server Actions, bcrypt und JWT-Entwertung mit ersetztem Mailtransport. Dazu 139 bestehende Sicherheits-, Rollen-, Kalender-, Familien- und Personaltests erfolgreich (159 insgesamt, keine übersprungenen Tests).
|
||||
|
||||
Browser-Abnahme mit synthetischen Konten auf Desktop und bei 390 Pixeln: neutrale Reset-Bestätigung und tatsächliche nachgelagerte Vorbereitung, gültige/ungültige Linkseiten, Passwortformular im Profil, getrennte Einrichtungshinweise bei Konto-Wechsel, eindeutige Ämter, Agenda-Details samt gespeichertem Mitbringbeitrag und erhaltenem Suchfilter. Arbeitszeit in vergangener Woche und am ausgewählten Tag geprüft; zwei Blöcke mit 60 und 45 Minuten am selben eingegebenen Tag gespeichert. Halber Urlaubstag mit identischem Start/Ende gespeichert; Freizeitausgleich zeigt ebenfalls nur ein Datum. Passwortänderungen selbst wurden in den Integrationstests ausgeführt. Es wurden keine echten Mails verschickt oder produktiven Daten verändert. Produktionsbuild und TypeScript erfolgreich; ESLint ohne Fehler mit fünf bestehenden Warnungen.
|
||||
|
||||
|
||||
## Ergänzung vom 12.10.2026: Manuelle Planung und Zugriffsrechte
|
||||
|
||||
- Die manuelle Notdienst-Zuweisung gruppiert verfügbare Haushalte zuerst. Familien ohne Verfügbarkeit für den gewählten Tag bleiben als abgestimmter Sonderfall auswählbar und werden nach einer solchen Einteilung ausdrücklich gekennzeichnet. Geschwister zählen als ein Haushalt; Einteilungszahlen gelten ebenfalls je Haushalt. Die Anzeige verwendet dieselben gültigen Monatstage wie die Planung. Bestehende Alarm- und Schließtagssperren bleiben bestehen.
|
||||
- Kita-Admins verwalten Zugriffsrollen unter `/dashboard/zugriffsrechte` sowie direkt bei Personen in Familien- und Teamverwaltung. Die Übersicht enthält auch Konten ohne eigenen Haushalt, eine Suche und die letzten 15 protokollierten Rollenänderungen der eigenen Kita.
|
||||
- Haushaltskonten können Eltern, Notdienst-Koordination oder Kita-Admin sein. Ohne Haushalt stehen Koordination und Kita-Admin zur Verfügung. Konten mit Personalprofil können Fachkraft oder Kita-Admin sein; Arbeitszeiten, Anträge und Haushalt bleiben erhalten. Ein Personalprofil wird durch eine Rollenänderung nicht neu angelegt. Systemadmins werden hier nicht verwaltet. Ämter wie „Vorstand“ verleihen keine Zugriffsrechte.
|
||||
- Rollenänderungen prüfen Akteur und Zielkonto erneut innerhalb einer serialisierbaren Transaktion. Eine zwischenzeitlich geänderte Rolle wird nicht überschrieben. Das bestehende Login liest die Rolle bei jeder Anfrage neu; eigene Herabstufung führt zurück zur Übersicht. Offene Aktivierungslinks benötigen weiterhin die Aktivierung, bevor Zugang entsteht.
|
||||
- Mindestens ein aktiver Kita-Admin mit verifiziertem Konto, Passwort und Datenschutzzustimmung bleibt erhalten. Herabstufung, eigene Kontolöschung, Support-Löschung/-Sperre und eine E-Mail-Änderung mit erneuter Aktivierung berücksichtigen denselben Schutz. Offene Einladungen, deaktivierte/gesperrte Konten oder fehlende Zustimmung zählen nicht als Ersatz. Gleichzeitige Änderungen werden abgefangen.
|
||||
- Eltern mit Admin-/Koordinationsrolle erhalten weiterhin Haushaltsalarme, können über ihre persönlichen Links antworten und erhalten Mails nach manueller Umteilung. Fachkräfte mit Admin-Rolle können ihr verknüpftes Personalprofil weiter nutzen; persönliche Benachrichtigungseinstellungen und offene Fachkraftaktivierungen bleiben funktionsfähig.
|
||||
- Kein Schema-Upgrade erforderlich.
|
||||
|
||||
Prüfbefehl: `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_roles_test npm run test:roles` nach Anwendung der vorhandenen Migrationen auf dieser separaten lokalen Testdatenbank. Die 27 Rollen-/PostgreSQL-Tests und sechs tatsächlichen Komponenten-Rendertests verwenden synthetische Konten und ersetzte Mailtransporte.
|
||||
|
||||
Abnahme: 33 neue Tests und 193 bestehende Sicherheits-, Planungs-, Kalender-, Versand-, Familien-, Personal- und Alarmtests erfolgreich (226 insgesamt, keine übersprungenen Tests). Browserprüfung auf Desktop und bei 390 Pixeln: verfügbare und bewusst abweichende Einteilung, Rollenänderung aus Familien-/Teamverwaltung, Suche, Änderungshistorie, Schutz für einzigen Admin und eigene Herabstufung mit unmittelbar verweigertem Verwaltungszugriff. Produktionsbuild und TypeScript erfolgreich; ESLint ohne Fehler mit sieben bestehenden Warnungen.
|
||||
|
||||
|
||||
## Ergänzung vom 12.10.2026: Notdienst-Benachrichtigungen
|
||||
|
||||
- Automatische Erinnerungen erfassen auch unvollständige Verfügbarkeiten. Das Soll wird aus allen aktiven Kindern und die Istzahl aus unterschiedlichen gültigen Tagen berechnet. Admin-/Koordinationskonten mit eigenem Haushalt gehören zur Eltern-Empfängergruppe.
|
||||
- Die Erinnerungsfrist bezieht sich jetzt ausdrücklich auf den 26. als Ende der Eintragsfrist. Standard: sieben Tage vorher ab 9 Uhr Berliner Zeit; 0 deaktiviert die automatische Runde. Verpasste Läufe werden innerhalb der noch offenen Frist nachgeholt. Jede Kita/Folgemonat erhält eine automatische Runde; manuelle Erinnerungen sind je Konto/Tag dedupliziert.
|
||||
- Beim ersten Veröffentlichen werden Mailaufträge in derselben Transaktion gespeichert. Pro erreichbarem Konto wird eine gemeinsame Nachricht mit den aktuellen eigenen Diensten erzeugt. Mehrfaches Veröffentlichen erzeugt keine zweite Runde. Mailfehler ändern den veröffentlichten Status nicht.
|
||||
- Die Monatsplanung zeigt Versandzahlen und Einzelstatus sowie eine manuelle Wiederholung offener/fehlgeschlagener Nachrichten. Bereits versendete oder inzwischen überholte Aufträge werden dabei nicht neu verschickt.
|
||||
- Der bestehende geschützte Notdienstjob verarbeitet die Nachrichten automatisch. Gespeicherte Reservierungen, Idempotenzschlüssel, zunehmende Wiederholungsabstände und erneute Berechtigungs-/Einteilungsprüfung sichern die Verarbeitung. Der Produktionsstart startet einen abgebrochenen Worker erneut.
|
||||
- Additive Migration: `20261012000000_notdienst_notifications`. Sie erhält die bisherigen Constraints und ergänzt drei weitere Tenant-Fremdschlüssel. Einrichtung und Grenzen stehen in [deploy/README.md](../deploy/README.md).
|
||||
|
||||
Prüfbefehle: `npm run test:mail`, `npm run test:runtime`, `npm run test:notdienst` und `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_notifications_test npm run test:notdienst:notifications` nach Anwendung der Migrationen auf dieser separaten Testdatenbank. Die Versandtests verwenden ersetzte Transporte; reale Eltern werden nicht angeschrieben.
|
||||
|
||||
Abnahme: 33 neue Versand-Integrationstests, vier Tests des tatsächlichen SDK-Transports, zwei Supervisor-Tests, sechs Datums-/Fristtests sowie 106 bestehende Sicherheits-, Planungs-, Kalender- und Elternablauftests erfolgreich (151 insgesamt, keine übersprungenen Tests). Browserprüfung mit lokalem Mailserver: automatische Erinnerung einmalig, Veröffentlichung trotz simuliertem Versandfehler, sichtbare Einzelstatus, erfolgreiche Wiederholung und mobile Anzeige. TypeScript und Produktionsbuild erfolgreich; ESLint ohne Fehler mit acht bestehenden Warnungen.
|
||||
Zusätzliche Upgrade-Prüfung gegen das vorige Schema: vorhandene Familien, Konten, Kinder, veröffentlichter Plan und Einteilung unverändert erhalten; keine nachträglichen Veröffentlichungsmails angelegt. Die Zahl der ergänzenden Tenant-Fremdschlüssel steigt von 41 auf 44.
|
||||
|
||||
## Ergänzung vom 12.10.2026: Kalender und kleine Bedienungsverbesserungen
|
||||
|
||||
- Eigene Mitbringbeiträge lassen sich direkt mit Speichern/Abbrechen bearbeiten. Enter speichert; Escape schließt den Termindialog und verwirft den Entwurf. Eingaben werden auf 1 bis 500 Zeichen begrenzt; fremde Beiträge können auch von Admin/Koordination nicht umgeschrieben werden. Ein bestätigter Termin mit aktiver Mitbringliste und die Kita-/Autorzuordnung werden serverseitig geprüft.
|
||||
- Eltern mit Admin- oder Koordinationsrolle können für den eigenen Haushalt Dienste tauschen. Kalenderknöpfe und offene eingehende/ausgehende Anfragen stehen ihnen ebenfalls zur Verfügung. Tauschmails berücksichtigen verifizierte, aktive Elternkonten aller drei Rollen im jeweils beteiligten Haushalt. Konten ohne eigenen Haushalt erhalten dadurch kein Tauschrecht; fremde Angebote und aktive Alarme bleiben gesperrt.
|
||||
- Die fest eingetragene „4“ am Menüpunkt Notdienst wurde entfernt.
|
||||
- Kein Schema-Upgrade erforderlich. Automatische Erinnerungen und Veröffentlichungsmails bleiben die nächste Etappe.
|
||||
|
||||
Prüfbefehl für die neuen Abläufe: `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_quickwins_test npm run test:calendar:integration`. Er verlangt eine ausdrücklich benannte lokale Testdatenbank mit den vorhandenen Migrationen. Die 26 Fälle prüfen echte Server Actions und PostgreSQL; Mailtransport und Anmeldesession werden isoliert ersetzt. Die bestehenden Elternabläufe wurden ebenfalls mit 9 Integrationstests und 9 Berechnungstests geprüft.
|
||||
|
||||
Im Browser geprüft: eigener Beitrag speichern und abbrechen, Anzeige bei 390 Pixeln, fehlender Bearbeiten-Knopf bei fremden Beiträgen, Navigation ohne festen Zähler, Admin-Tauschanfrage mit eigenem Angebot sowie Anzeige und Annahme durch Koordination. E-Mails wurden dabei ausschließlich im Entwicklungsmodus simuliert.
|
||||
|
||||
Abnahme dieser Etappe: 26 neue Integrationstests, 18 bestehende Elternablauftests und 28 Sicherheitstests erfolgreich (72 insgesamt, keine übersprungenen Tests). TypeScript und Produktionsbuild erfolgreich; projektweites ESLint ohne Fehler mit acht bestehenden Warnungen.
|
||||
|
||||
## Ergänzung vom 12.10.2026: Verlässliche Notdienst-Planungsregeln
|
||||
|
||||
- Das Verfügbarkeitssoll wird aus allen aktiven Kindern des angemeldeten Haushalts berechnet. Ausgelassene, doppelte, fremde oder inaktive Kinder werden zurückgewiesen; doppelte Datumsangaben zählen nur einmal.
|
||||
- Oberfläche, Speicherung, Generierung und manuelle Zuweisung verwenden gemeinsame Regeln: nur Werktage des Planungsmonats, ohne bestätigte Schließtage und Teamtage. Mehrtägige Termine schließen beide Randtage ein; Kalenderzeiten werden in `Europe/Berlin` ausgewertet. Ausstehende/abgelehnte Termine und Termine anderer Kitas blockieren nichts.
|
||||
- Alte ungültige Verfügbarkeiten werden nicht für neue Pläne verwendet. Vor der Veröffentlichung werden Entwürfe auf zulässige Tage und aktive Kinder geprüft. Ungültige vorhandene Einteilungen bleiben sichtbar und können über „Manuell zuweisen“ entfernt werden; aktive Alarme und vergangene veröffentlichte Dienste behalten ihre Bearbeitungssperre.
|
||||
- Kindlöschung im Elternprofil und in der Familienverwaltung ist bei heutigen/zukünftigen veröffentlichten Notdiensten oder aktiven Alarmen gesperrt. Zuerst muss die Koordination die Einteilung klären. Prüfung und Löschung laufen in derselben serialisierbaren Transaktion mit einer Sperre der betroffenen Kinder.
|
||||
- Kein Schema-Upgrade erforderlich. Automatische Erinnerungen und Benachrichtigungen bei Erstveröffentlichung sind weiterhin offen.
|
||||
|
||||
Prüfbefehle: `npm run test:notdienst` (Datumsregeln) und `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_notdienst_test npm run test:notdienst:integration` (echte Server Actions und PostgreSQL). Der Integrationsbefehl verlangt ausdrücklich eine separate lokale Testdatenbank; fehlende Konfiguration führt zu einem Fehler. Zuvor die vorhandenen Migrationen auf dieser Testdatenbank anwenden.
|
||||
|
||||
Abnahme: 4 Datumstests, 34 PostgreSQL-Regressionstests und 28 bestehende Sicherheitstests erfolgreich. Im Browser geprüft: gesperrter Teamtag/Wochenende, Speicherung des vollständigen Haushaltssolls, Veröffentlichungssperre, Entfernen einer alten ungültigen Einteilung und gültige manuelle Zuweisung mit anschließender Veröffentlichung. TypeScript und Produktionsbuild erfolgreich; projektweites ESLint ohne Fehler mit acht bestehenden Warnungen.
|
||||
|
||||
## Ergänzung vom 09.10.2026: Notdienstalarm
|
||||
|
||||
Der Notdienstalarm ist inzwischen umgesetzt. Admins und Koordination alarmieren die eingeteilte Familie für einen ganzen Tag oder ein Zeitfenster. Persönliche Rückmeldelinks erzeugen eine gemeinsame Haushaltsantwort; Dashboard-Hinweise, eine 30-Minuten-Frist, Klärungsbedarf, verspätete Freigabe, telefonische Korrektur, Stornierung, Versandstatus und Hintergrundprüfung sind angebunden. Aktive Alarme sperren Planumzuordnung und Diensttausche. Siehe [NOTDIENSTALARM.md](NOTDIENSTALARM.md) für Bedienung und benötigte Hosting-Konfiguration.
|
||||
|
||||
Die folgende ursprüngliche Bestandsaufnahme bleibt als historischer Stand erhalten; darin als offen bezeichnete Alarmfunktionen sind durch diese Ergänzung überholt.
|
||||
|
||||
Stand: **07.10.2026**
|
||||
|
||||
Geprüfter Ausgangsbranch: `codex/notdienst-swaps`
|
||||
|
||||
Geprüfter Commit: `ec3f4f933d42068c9d8446dc95de9815d5779238`
|
||||
|
||||
Dokumentationsbranch: `codex/funktionsinventar`
|
||||
|
||||
**Ergänzung vom 07.10.2026:** Auf `codex/personalbereich` wurde anschließend der Personalbereich für Arbeitszeit, Urlaub und Freizeitausgleich umgesetzt. Diese Bestandsaufnahme bleibt als Beschreibung des Ausgangsstands erhalten; der neue Umfang, Berechnungsregeln, Einrichtung und aktuelle Prüfungen stehen in [PERSONALBEREICH.md](PERSONALBEREICH.md). Die Aussagen unten zu fehlender Zeiterfassung, Accountverknüpfung und Tests beziehen sich auf den Ausgangscommit.
|
||||
|
||||
**Ergänzung vom 08.10.2026:** Der Personalbereich enthält jetzt auch Aktivierungsmails, Antrags- und Entscheidungshinweise sowie eine Abschalteinstellung im Fachkraftprofil. Der aktuelle Umfang und die Versandkonfiguration stehen ebenfalls in [PERSONALBEREICH.md](PERSONALBEREICH.md).
|
||||
|
||||
## 1. Grundlage der Prüfung
|
||||
|
||||
Diese Bestandsaufnahme beschreibt die im Repository vorhandenen Produktfunktionen. Geprüft wurden Seiten und Navigation, Formulare und Dialoge, Server Actions, API-Routen, Datenmodell, E-Mail-Anbindung und Betriebsdateien. Die Planung in `planung.md`, die UI-Liste in `UI-TODO.md` und der frühere Live-Testplan wurden mit dem aktuellen Code abgeglichen.
|
||||
|
||||
**„Vorhanden“ bedeutet hier: Oberfläche und zugehörige Verarbeitung sind im Code angebunden. Es bedeutet keine Bestätigung durch einen aktuellen Live-Test.** Es wurden keine Produktionsdaten verändert, keine E-Mails verschickt und keine Datenbankänderungen ausgeführt. Die tatsächliche Zustellung von E-Mails, das Verhalten im Browser und die produktive Konfiguration bleiben gesondert zu prüfen.
|
||||
|
||||
Status:
|
||||
|
||||
| Kennzeichnung | Bedeutung |
|
||||
| --- | --- |
|
||||
| Vorhanden | Im aktuellen Code als erreichbarer Ablauf umgesetzt. |
|
||||
| Teilweise | Funktion vorhanden, aber mit konkreten Einschränkungen oder fehlender Anbindung. |
|
||||
| Vorbereitet | Datenmodell, Komponente oder Aktion vorhanden; vollständiger Nutzerablauf fehlt. |
|
||||
| Offen | In der Planung vorgesehen, im geprüften Code nicht umgesetzt. |
|
||||
|
||||
## 2. Gesamtüberblick
|
||||
|
||||
Die Anwendung besitzt bereits eine breite MVP-Grundlage für mehrere unabhängige Kitas: Registrierung, Einrichtung, Familien und Kinder, Notdienst-Verfügbarkeiten und Planung, Diensttausch, Kalender, Mitbringlisten, Abwesenheiten, Kontaktdaten und Superadmin-Support. Einige beworbene Abläufe sind jedoch noch nicht vollständig nutzbar, insbesondere Alarmierung, Eltern-Rückmeldungen zu Events und die Anzeige des Schwarzen Bretts für Eltern.
|
||||
|
||||
| Bereich | Stand | Wichtigste Einschränkung |
|
||||
| --- | --- | --- |
|
||||
| Öffentliche Website und Kontakt | Vorhanden | Impressum und Datenschutz enthalten Platzhalter. |
|
||||
| Registrierung, Login, Kita-Einrichtung | Vorhanden | Kein selbstständiger Passwort-Reset; Modulschalter wirken nur teilweise. |
|
||||
| Dashboard | Vorhanden | News-, RSVP- und Alarm-Komponenten sind nicht eingebunden. |
|
||||
| Profil und eigene Kinder | Vorhanden | Persönlicher Name, E-Mail und Rolle sind im Profil nur lesbar. |
|
||||
| Familienverwaltung und feste Ämter | Vorhanden | Kein vollständiger Einladungs-Support mit erneutem Versand/Widerruf. |
|
||||
| Notdienst-Verfügbarkeiten | Vorhanden | Wochenenden und Schließtage werden nur in der Oberfläche ausgeschlossen. |
|
||||
| Notdienst-Planung | Vorhanden | Keine automatische Erinnerung und keine E-Mail bei Erstveröffentlichung. |
|
||||
| Notdienst-Tausch | Vorhanden | Nur Elternrolle kann neue Tauschanfragen starten. |
|
||||
| Notdienst-Alarmierung | Vorbereitet | Auslösebutton ist auf keiner Seite eingebunden. |
|
||||
| Kalender und Mitbringlisten | Vorhanden | Keine Kollisionsprüfung für Raumbuchungen; Mitbringsel nicht direkt bearbeitbar. |
|
||||
| Event-Teilnahme/RSVP | Teilweise | Verwaltung vorhanden, Eltern-Eingabe nicht in eine Seite eingebunden. |
|
||||
| Abwesenheiten | Vorhanden | Gruppenfilter verwendet eine künstliche Zuordnung. |
|
||||
| Schwarzes Brett | Teilweise | Veröffentlichung vorhanden, Elternanzeige und Lesestatus fehlen im aktuellen Dashboard. |
|
||||
| Adressbuch | Vorhanden | Opt-in gilt für Kontaktdaten; weitere Haushaltsdaten bleiben sichtbar. |
|
||||
| ErzieherInnen-Stammdaten | Vorhanden | Stammdatensatz und Benutzerrolle `ERZIEHER` sind getrennte Konzepte. |
|
||||
| Superadmin-Verwaltung | Vorhanden | Einladungszähler verwenden ein anderes Modell als der reale Einladungsablauf. |
|
||||
| Betrieb und Qualität | Teilweise | TypeScript erfolgreich, ESLint mit Fehlern; keine automatisierte Testsuite gefunden. |
|
||||
|
||||
## 3. Rollen und Zugriffsbereiche
|
||||
|
||||
| Rolle | Aktuelle Möglichkeiten |
|
||||
| --- | --- |
|
||||
| `SUPERADMIN` | Systemweite Kita-Übersicht, Kita-Diagnosen, Support-Aktionen und Audit-Protokoll. Standardmäßig ohne Kita-Zuordnung und mit eigener Oberfläche unter `/admin`. |
|
||||
| `ADMIN` | Verwaltung der eigenen Kita: Familien, Ämter, ErzieherInnen, Dienste, Meldungen, Kalender und Notdienst. Mit eigenem Haushalt auch Elternfunktionen nutzbar. |
|
||||
| `KOORDINATOR` | Notdienstplanung, Kalenderverwaltung, Event-Anmeldungen und Abwesenheitsübersicht. Mit eigenem Haushalt auch Verfügbarkeiten eintragbar. |
|
||||
| `ERZIEHER` | Dashboard, Kalender, Adressbuch, Profil und Abwesenheitsübersicht. Kein Zugriff auf die Notdienst-Eingabeseite. |
|
||||
| `ELTERN` | Eigener Haushalt/Kinder, Verfügbarkeiten, Kalender, Terminanfragen, Mitbringsel, Diensttausch, Abwesenheitsmeldungen und Adressbuch. |
|
||||
|
||||
Navigation und serverseitige Rollenprüfungen sind vorhanden. Die Rolle `ERZIEHER` existiert im Datenmodell und wird in Berechtigungen verwendet. Die ErzieherInnen-Verwaltung erstellt jedoch nur Namen als Stammdaten und keine Benutzerkonten. Ein regulärer Dialog zur Einladung von Erzieher-Accounts oder zur Vergabe von Admin-/Koordinatorrollen wurde nicht gefunden.
|
||||
|
||||
Quellen: [Navigation](../src/components/dashboard/sidebar-nav.tsx), [Zugriffsprüfungen](../src/lib/auth-utils.ts), [Datenmodell](../prisma/schema.prisma).
|
||||
|
||||
## 4. Funktionsinventar nach Bereich
|
||||
|
||||
### 4.1 Öffentliche Website und Kontakt
|
||||
|
||||
- Startseite mit Produktbeschreibung, Funktionsvorschauen, Rollenbeschreibung, Einstieg in Registrierung und Login sowie Kontaktbereich.
|
||||
- Fünf öffentliche Detailseiten: Notdienst-Planer, Kita-Kalender, Eltern-Adressbuch, Krankmeldung und Schwarzes Brett.
|
||||
- Kontaktformular für allgemeine Fragen, Demo-Anfragen und individuelles Setup.
|
||||
- Eingaben: Name, optionale Kita, E-Mail-Adresse, Anfrageart und Nachricht.
|
||||
- Versand einer Anfrage an den konfigurierten Kontakt-Empfänger und einer Bestätigung an die anfragende Person; passende Antwortadressen werden gesetzt.
|
||||
- Bot-Schutz durch ein unsichtbares Zusatzfeld und ein Limit von fünf Kontaktanfragen je IP-Adresse pro Stunde. Das Limit liegt im Arbeitsspeicher des jeweiligen Prozesses.
|
||||
- Sitemap, Robots-Datei und Metadaten für öffentliche Seiten.
|
||||
- Optionales Umami-Tracking über Umgebungsvariablen, auf eine Liste öffentlicher Seiten beschränkt. Suchparameter und URL-Fragmente werden ausgeschlossen; Do-Not-Track wird berücksichtigt.
|
||||
- Impressum und Datenschutzerklärung sind als Seiten vorhanden, enthalten aber noch Betreiber-, Hosting- und weitere Platzhalter.
|
||||
|
||||
Quellen: [Startseite](../src/app/page.tsx), [Feature-Inhalte](../src/lib/marketing/features.ts), [Kontaktaktion](../src/actions/contact.ts), [Analytics](../src/components/analytics/umami-analytics.tsx), [Impressum](../src/app/impressum/page.tsx), [Datenschutz](../src/app/datenschutz/page.tsx).
|
||||
|
||||
### 4.2 Registrierung, Anmeldung und Einrichtung
|
||||
|
||||
- Gründerregistrierung mit Vorname, Nachname, E-Mail-Adresse, Passwort und verpflichtender Datenschutzannahme.
|
||||
- Prüfung auf bereits verwendete E-Mail-Adressen und Passwort-Hashing mit bcrypt.
|
||||
- Gründer erhält zunächst die Adminrolle ohne Kita-Zuordnung und wird direkt angemeldet.
|
||||
- Kita-Einrichtung mit Name, eindeutigem Slug, Aktivierung von Notdienst/Kalender/Adressbuch und Mindestzahl an Verfügbarkeiten pro Kind.
|
||||
- Bei aktivem Notdienstmodul ist mindestens ein Verfügbarkeitstag erforderlich; die Einrichtung erlaubt höchstens 31.
|
||||
- Kita-Anlage und Zuordnung des Gründers erfolgen in einer Datenbanktransaktion; erneute Einrichtung desselben Kontos wird abgefangen.
|
||||
- Login mit E-Mail und Passwort, Rückleitung zu einer internen Zielseite und allgemeine Fehlermeldung bei falschen Zugangsdaten.
|
||||
- Abmeldung über Desktop-Navigation und mobiles Menü.
|
||||
- Nach zehn Fehlversuchen wird das betreffende Konto für 15 Minuten gesperrt; erfolgreicher Login setzt den Zähler zurück.
|
||||
- JWT-basierte Sessions mit Benutzer-, Kita-, Familien- und Rolleninformationen. Rolle und Zuordnungen werden bei weiteren Session-Aufrufen mit der Datenbank abgeglichen.
|
||||
- Geschützte Seiten leiten nicht angemeldete Personen zum Login und Gründer ohne Kita zur Einrichtung weiter.
|
||||
- Datenschutzannahme wird mit Zeitpunkt und Versionskennung gespeichert. Die Sonderseite für fehlende Zustimmung verweist auf Support; sie enthält kein Formular zum Nachholen der Zustimmung.
|
||||
|
||||
**Einschränkungen:** Kein Passwort-vergessen-/Passwort-ändern-Ablauf. Die Gründerregistrierung setzt `emailVerifiedAt` unmittelbar, ohne Bestätigungsmail. Ein allgemeines IP-Limit für Login oder Registrierung ist nicht angebunden; das Kontaktformular hat sein eigenes Limit.
|
||||
|
||||
Quellen: [Registrierung](../src/app/register/actions.ts), [Einrichtung](../src/app/onboarding/actions.ts), [Login](../src/app/login/actions.ts), [Authentifizierung](../src/auth.ts), [Proxy](../src/proxy.ts), [Consent-Seite](../src/app/onboarding/consent/page.tsx).
|
||||
|
||||
### 4.3 Dashboard und Navigation
|
||||
|
||||
- Begrüßung mit Kita-Namen, Benutzernamen und aktuellem Datum.
|
||||
- Kennzahlen zu Familien, aktiven Kindern, eingetragenen Notdienst-Verfügbarkeiten des Folgemonats und rechnerisch heute anwesenden Kindern.
|
||||
- Direktes Melden einer Kinderabwesenheit und Übersicht der laufenden/bevorstehenden eigenen Abwesenheiten.
|
||||
- Schnellzugriffe auf Familienverwaltung, Kalender, Notdienstplanung und Adressbuch. Die drei letztgenannten berücksichtigen die Modulschalter bei der Anzeige.
|
||||
- Hinweisdialog bei unvollständigem Profil: Telefon, Adresse sowie gegebenenfalls Haushalt und Kinder. Fortschritt wird angezeigt; der Hinweis kann im lokalen Browser dauerhaft ausgeblendet werden.
|
||||
- Rollenabhängige Seitenleiste und ein auf kleinen Bildschirmen aufklappbares Menü.
|
||||
|
||||
**Einschränkungen:** Die Notdienstkennzahl zählt Verfügbarkeitseinträge, keine tatsächlich besetzten Plantage. Schnellzugriffe auf Verwaltung werden nicht vollständig nach Rolle ausgeblendet; die Zielseiten besitzen Rollenprüfungen. Die Notdienst-Badge `4` in der Seitenleiste ist fest hinterlegt.
|
||||
|
||||
Quellen: [Dashboard](../src/app/dashboard/page.tsx), [Profilhinweis](../src/components/OnboardingDialog.tsx), [Dashboard-Rahmen](../src/components/dashboard/dashboard-shell.tsx).
|
||||
|
||||
### 4.4 Profil, Haushalt und eigene Kinder
|
||||
|
||||
- Anzeige von Name, E-Mail-Adresse, Rolle, Kita, Haushalt, angelegtem Konto und zugewiesenen festen Ämtern.
|
||||
- Bearbeiten von Telefonnummer, Straße, Postleitzahl und Ort.
|
||||
- Adressbuchfreigabe erteilen oder zurückziehen; Zeitpunkt der Freigabe wird gespeichert.
|
||||
- Eigenen Haushalt anlegen, wenn noch keiner zugeordnet ist.
|
||||
- Eigene Kinder anlegen, bearbeiten und löschen; Vor-/Nachname sowie optionales Geburtsdatum.
|
||||
- Bei Eingabe eines Geburtsdatums: gültiges Datum, nicht in der Zukunft, höchstens sieben Jahre zurück.
|
||||
- Datenänderungen an Kindern werden auf den eigenen Haushalt und die eigene Kita begrenzt.
|
||||
- Eigenes Konto nach Bestätigung löschen. Bestehen aktuelle oder zukünftige veröffentlichte Notdienstpflichten des Haushalts, wird die Selbstlöschung abgelehnt.
|
||||
- Beim letzten Benutzer eines Haushalts wird die Familie samt abhängigen Kindern gelöscht; bei weiteren Benutzern bleibt der Haushalt bestehen.
|
||||
|
||||
**Einschränkungen:** Name, E-Mail und Rolle lassen sich im persönlichen Profil nicht ändern. Eine Sperre der Kinderlöschung bei bestehenden Notdienstpflichten ist in `deleteMyChild` nicht vorhanden, obwohl die Kontolöschung diese Pflichten prüft.
|
||||
|
||||
Quellen: [Profilseite](../src/app/dashboard/profil/page.tsx), [Profilaktionen](../src/app/dashboard/profil/actions.ts).
|
||||
|
||||
### 4.5 Familienverwaltung, Eltern-Einladungen und feste Ämter
|
||||
|
||||
- Adminübersicht aller Familien mit Eltern und Kindern; Suche nach Familie, Eltern, E-Mail und Kindern.
|
||||
- Umschalten zwischen Tabelle und Karten.
|
||||
- Neue Familie mit einem oder zwei Elternteilen sowie einem bis zehn Kindern anlegen.
|
||||
- Familienname, Namen/E-Mail-Adressen der Eltern und Kindernamen bearbeiten.
|
||||
- Zweiten Elternteil ergänzen, neue Kinder hinzufügen und Kinder entfernen. Der Bearbeitungsablauf verlangt mindestens ein verbleibendes Kind und höchstens zwei Elternteile.
|
||||
- Neue Elternkonten erhalten die Rolle `ELTERN` und einen Einladungslink zur Passwortvergabe mit sieben Tagen Gültigkeit.
|
||||
- Aktivierung prüft Link, Ablaufdatum, Passwortlänge, Passwortbestätigung und Datenschutzannahme; optionales Adressbuch-Opt-in.
|
||||
- Nach Aktivierung werden Passwort und Zustimmungen gespeichert, der Token gelöscht und die Person direkt angemeldet.
|
||||
- Die Familienliste zeigt anhand der Kontoverifikation, ob eine Einladung noch offen ist.
|
||||
- Feste Ämter mit Name und optionaler Beschreibung anlegen und löschen.
|
||||
- Ein oder mehrere Ämter an einzelne Benutzer vergeben und Zuordnungen entfernen; doppelte identische Zuordnungen werden verhindert.
|
||||
- Ämter sind im Profil und Adressbuch sichtbar.
|
||||
|
||||
**Einschränkungen:** Kein regulärer Dialog zum erneuten Versand oder Widerruf einer Einladung. Einladungen werden im tatsächlichen Ablauf in `VerificationToken` gespeichert. Das separate Modell `Invitation` ist dafür nicht angebunden. Scheitert der Mailversand nach der Datenanlage, bleibt die Familie angelegt und eine Fehlermeldung wird zurückgegeben. Eine vollständige Familienlöschung durch Kita-Admins ist nicht angeboten.
|
||||
|
||||
Quellen: [Familienaktionen](../src/app/dashboard/families/actions.ts), [Familienliste](../src/app/dashboard/families/_components/families-list-client.tsx), [Einladungsaktivierung](../src/app/invite/%5Btoken%5D/actions.ts), [Ämter](../src/app/dashboard/families/duty-actions.ts).
|
||||
|
||||
### 4.6 Notdienst-Verfügbarkeiten für Eltern
|
||||
|
||||
- Eingabe für den Folgemonat, bezogen auf aktive Kinder des eigenen Haushalts.
|
||||
- Mindestzahl an ausgewählten Tagen entspricht Anzahl der Kinder × Kita-Einstellung.
|
||||
- Bereits gespeicherte Haushaltseinträge werden geladen und können erneut gespeichert werden.
|
||||
- Monatskalender mit Terminen, Kategorien, Veranstaltungsdetails und auswählbaren Verfügbarkeitstagen.
|
||||
- Die Oberfläche lässt Montag bis Freitag zu und sperrt bestätigte Schließtage.
|
||||
- Auswahlfortschritt und Rückmeldung bei zu wenigen Tagen.
|
||||
- Die ausgewählten verschiedenen Tage werden beim Speichern reihum auf die angegebenen Kinder verteilt; bestehende Einträge dieser Kinder im Zielmonat werden ersetzt.
|
||||
- Eingabefrist ist im Code fest: bis einschließlich 26. des aktuellen Monats, gesperrt ab dem 27.
|
||||
- Ohne aktive eigene Kinder erscheint ein Hinweis mit Verweis zum Profil.
|
||||
|
||||
**Einschränkungen:** Die Server Action prüft Datumsformat, Zielmonat, Mindestzahl und Haushaltszugehörigkeit, aber keine Wochenenden oder Schließtage. Die Frist ist nicht im Einstellungsdialog konfigurierbar. Abwesenheitsmeldungen werden bei den Verfügbarkeiten nicht automatisch berücksichtigt.
|
||||
|
||||
Quellen: [Elternseite](../src/app/dashboard/notdienst/page.tsx), [Kalendereingabe](../src/app/dashboard/notdienst/notdienst-form.tsx), [Speichern](../src/app/dashboard/notdienst/actions.ts), [Fristregel](../src/lib/date-utils.ts).
|
||||
|
||||
### 4.7 Notdienst-Planungszentrale
|
||||
|
||||
- Admins und Koordinatoren können über Monat/Jahr navigieren; Vorgabe ist der Folgemonat.
|
||||
- Übersicht von Haushalten, Kindern, Verfügbarkeitstagen, Haushalten mit/ohne Rückmeldung und Planstatus.
|
||||
- Automatische Erzeugung eines monatlichen Entwurfs; ein bestehender Monatsplan verhindert erneute Generierung.
|
||||
- Für jeden Montag bis Freitag wird aus verfügbaren Kindern ein Haushalt ausgewählt, der im bisherigen Monatsdurchlauf möglichst selten eingeteilt wurde. Bei Gleichstand kommt Zufall zum Einsatz; der Zähler startet jeden Monat neu.
|
||||
- Tage ohne Verfügbarkeiten bleiben unbesetzt.
|
||||
- Vorschau und manuelle Zuordnung zu einem aktiven Kind der Kita; Tage können auch geleert werden.
|
||||
- Entwurf veröffentlichen; veröffentlichte Einteilungen erscheinen im Kalender.
|
||||
- Veröffentlichte Pläne nachbearbeiten, solange der betreffende Tag nicht vergangen ist.
|
||||
- Nach manuellen Änderungen eines veröffentlichten Plans werden E-Mails an betroffene Elternkonten der Rolle `ELTERN` angestoßen; betroffene offene Tauschanfragen werden ungültig.
|
||||
- Entwurfsplan mit Bestätigungsdialog zurücksetzen und löschen. Veröffentlichte Pläne werden serverseitig vom Reset ausgeschlossen.
|
||||
- Kita-Einstellungen für Mindestverfügbarkeiten und Erinnerungs-Vorlauf speichern.
|
||||
- Manuell Erinnerungen an Haushalte versenden, die im ausgewählten Monat gar keinen Verfügbarkeitseintrag haben.
|
||||
- „Als PDF exportieren“ öffnet den Browser-Druckdialog (`window.print()`); die PDF-Datei entsteht über die Druckfunktion des Browsers.
|
||||
|
||||
**Einschränkungen:** Keine garantierte optimale oder über mehrere Monate ausgeglichene Verteilung. Die Generierung berücksichtigt keine Feiertage, Schließtage, Teamtage oder Kinderabwesenheiten. Ein Erinnerungs-Vorlauf wird gespeichert, aber kein zeitgesteuerter Job verwendet ihn. Haushalte mit zu wenigen Einträgen erhalten über diese Erinnerungsaktion keine Erinnerung, sobald wenigstens ein Eintrag vorliegt. Beim erstmaligen Veröffentlichen wird keine Zuteilungsmail versendet; die entsprechende E-Mail-Vorlage allein ist kein angebundener Versand.
|
||||
|
||||
Quellen: [Planungsseite](../src/app/dashboard/notdienst/plan/page.tsx), [Planaktionen](../src/app/dashboard/notdienst/plan/actions.ts), [Planoberfläche](../src/app/dashboard/notdienst/plan/plan-view.tsx).
|
||||
|
||||
### 4.8 Notdienst-Tausch zwischen Familien
|
||||
|
||||
- Eltern können im Kalender einen fremden Notdienst anfragen und einen eigenen veröffentlichten Dienst zum Tausch anbieten; optionale Nachricht bis 500 Zeichen.
|
||||
- Beide Dienste müssen derselben Kita angehören, veröffentlicht und heute oder zukünftig sein. Ein Tausch im eigenen Haushalt wird abgewiesen.
|
||||
- Dienste mit wartender oder bestätigter Alarmierung sind vom Tausch ausgeschlossen.
|
||||
- Bereits beteiligte Dienste mit offenen Tauschanfragen können keine weitere offene Anfrage erhalten.
|
||||
- Der angefragte Haushalt erhält eine E-Mail und sieht die offene Anfrage im Kalender.
|
||||
- Angefragter Haushalt kann annehmen oder ablehnen; anfragender Haushalt kann zurückziehen.
|
||||
- Annahme vertauscht beide Kinder-Zuordnungen in einer Transaktion, speichert die Entscheidung und storniert weitere betroffene offene Anfragen.
|
||||
- Annahme und Ablehnung lösen Benachrichtigungen an beide Familien aus; Rücknahme speichert den Status ohne eigenen Mailversand.
|
||||
- Kalender und Notdienstansichten werden nach Änderungen neu validiert.
|
||||
|
||||
**Einschränkungen:** Neue Anfragen sind auf die exakte Rolle `ELTERN` beschränkt; Eltern mit Admin-/Koordinatorrolle können sie nicht starten. Die Oberfläche zeigt offene Anfragen, keine vollständige abgeschlossene Tauschhistorie. Eine erneute Verfügbarkeitsprüfung für die getauschten Tage erfolgt nicht.
|
||||
|
||||
Quellen: [Tauschaktionen](../src/app/dashboard/kalender/actions.ts), [Kalender und Anfragen](../src/app/dashboard/kalender/_components/calendar-overview-client.tsx), [Tauschdialog](../src/app/dashboard/kalender/_components/termin-list.tsx).
|
||||
|
||||
### 4.9 Notdienst-Alarmierung
|
||||
|
||||
Die am 09.10.2026 abgestimmte Erweiterung ist in [NOTDIENSTALARM_PLAN.md](NOTDIENSTALARM_PLAN.md) beschrieben. Sie ist zunächst geplant; der folgende Abschnitt beschreibt den vorhandenen Code.
|
||||
|
||||
Vorbereitet sind:
|
||||
|
||||
- Server Action zum Auslösen eines Alarms durch Admin/Koordinator für eine Einteilung.
|
||||
- Kita-Prüfung und Abweisung einer bereits alarmierten Einteilung.
|
||||
- Eigene Alarmdatensätze und E-Mails für die Elternkonten der eingeteilten Familie mit Rolle `ELTERN`.
|
||||
- Bestätigungsseite unter `/alert/[token]`; der Link erlaubt die Bestätigung ohne Login.
|
||||
- Statuswechsel von `PENDING` auf `CONFIRMED` mit Zeitstempel; erneute Bestätigung wird abgewiesen.
|
||||
- Vorbereitete Button-Komponente für das Auslösen.
|
||||
|
||||
**Aktueller Stand:** `AlertButton` wird von keiner Seite eingebunden. Ein erreichbarer Auslöseablauf und eine laufende Statusübersicht für die Koordination fehlen damit in der aktuellen Oberfläche. Das Datenmodell kann eine erkrankte Fachkraft und Notizen speichern, aber die Aktion nimmt nur die Einteilungs-ID entgegen. Eine Auswahl der Fachkraft, Alarmstornierung, Eskalation oder zeitliche Token-Gültigkeit ist nicht angebunden.
|
||||
|
||||
Quellen: [Alarmaktion](../src/app/dashboard/actions.ts), [ungebundener Alarmbutton](../src/app/dashboard/alert-button.tsx), [Bestätigung](../src/app/alert/%5Btoken%5D/actions.ts).
|
||||
|
||||
### 4.10 Kalender, Terminanfragen und Mitbringlisten
|
||||
|
||||
- Gemeinsamer Kalender für bestätigte Termine, eigene ausstehende Terminanfragen, geplante Elterndienste und veröffentlichte Notdienste.
|
||||
- Listen-, Monats- und Agendaansicht; Monatsnavigation und Sprung zu heute.
|
||||
- Tagesdialog mit Termindetails, Diensten und Mitbringlisten.
|
||||
- Suche nach Titel/Beschreibung und bei Diensten nach Familie beziehungsweise Kind.
|
||||
- Kategorienfilter und Umschaltung zwischen eigenen und allen Diensten; reguläre Kita-Termine bleiben auch im eigenen Filter sichtbar.
|
||||
- Kategorien: Kita-Fest, Schließtag, Teamtag, Mitmach-Tag, Elternabend, Mitgliederversammlung, Elterncafé, interner/externer Geburtstag und Sonstiges.
|
||||
- Terminanfrage mit Titel, Beschreibung, Kategorie, Anfang/Ende und Ganztagsangabe.
|
||||
- Eigene Anfragen werden als ausstehend angezeigt; Admins/Koordinatoren können ausstehende Anfragen bestätigen oder mit Begründung ablehnen.
|
||||
- Admins/Koordinatoren können bestätigte Termine direkt anlegen, bearbeiten und endgültig löschen.
|
||||
- Datumsprüfung: Beginn nicht in der Vergangenheit, Ende nicht vor Beginn.
|
||||
- Mitbringliste je Termin aktivieren/deaktivieren; Textbeiträge hinzufügen.
|
||||
- Eigene Beiträge löschen; Admins/Koordinatoren können auch fremde Beiträge löschen.
|
||||
- Verweise aus Dienst-/Notdienstterminen in die entsprechenden Verwaltungsseiten; Diensttausch über den Kalender.
|
||||
|
||||
**Einschränkungen:** Ausstehende Anfragen blockieren keinen Zeitraum durch eine serverseitige Kollisionsprüfung. Eine Anfrage ist für andere Eltern nicht allgemein als reservierter Slot sichtbar. Mitbringbeiträge lassen sich nicht direkt bearbeiten, sondern nur löschen und neu anlegen. Es wurde kein Kalenderabo, ICS-Export oder Serientermin-Ablauf gefunden.
|
||||
|
||||
Quellen: [Kalenderseite](../src/app/dashboard/kalender/page.tsx), [Kalenderansichten](../src/app/dashboard/kalender/_components/calendar-overview-client.tsx), [Terminaktionen](../src/app/dashboard/kalender/actions.ts), [Mitbringliste](../src/app/dashboard/kalender/_components/mitbringsel-list.tsx).
|
||||
|
||||
### 4.11 Event-Anmeldungen und RSVP
|
||||
|
||||
- Admins/Koordinatoren können Termine mit Teilnahmeabfrage, optionaler Rückmeldefrist und Hinweisen für Teilnehmende anlegen/bearbeiten.
|
||||
- Verwaltungsseite listet Termine samt Status und zeigt pro Event Zusagen, Absagen und ausstehende Antworten.
|
||||
- Kindbezogene Teilnehmerliste über alle aktiven Kinder der Kita.
|
||||
- Server Action für Zu-/Absage eines eigenen aktiven Kindes, inklusive Prüfung von Kita, Haushalt, bestätigtem Termin und Frist.
|
||||
- Fehlt eine eigene Frist, gilt der Eventbeginn als Rückmeldeschluss. Eine bestehende Antwort kann bis dahin überschrieben werden.
|
||||
- Teilnehmerhinweise werden im Kalender bei Admin/Koordinator und bei einem Haushalt mit mindestens einer Zusage angezeigt.
|
||||
|
||||
**Aktueller Stand:** Die Eltern-Eingabekomponente `RsvpActionCard` existiert, wird aber von keiner Seite verwendet. Eltern besitzen deshalb im aktuellen Seitenaufbau keinen eingebundenen Zu-/Absagedialog. Außerdem wird `participantInfo` bereits an den Kalender-Client übertragen und nur bei der Darstellung ausgeblendet; die Anzeigeprüfung ist keine serverseitige Zugriffsbeschränkung für diese Information.
|
||||
|
||||
Quellen: [RSVP-Aktionen](../src/actions/rsvp.ts), [Event-Verwaltung](../src/app/dashboard/admin/kalender/admin-calendar-manager.tsx), [ungebundene Elternkomponente](../src/app/dashboard/rsvp-action-card.tsx), [Kalenderdaten](../src/app/dashboard/kalender/page.tsx).
|
||||
|
||||
### 4.12 Krank- und Abwesenheitsmeldungen
|
||||
|
||||
- Eigenes aktives Kind aus dem Haushalt auswählen und für einen Tag oder Zeitraum abmelden.
|
||||
- Gründe: Krankheit, Urlaub und Sonstiges; optionale Notiz bis 500 Zeichen.
|
||||
- Ende darf nicht vor dem Beginn liegen; sich überschneidende Meldungen desselben Kindes werden abgewiesen.
|
||||
- Eigene laufende/bevorstehende Meldungen anzeigen und löschen.
|
||||
- Tagesübersicht für Admin, Koordinator und Erzieherrolle mit Kind, Familie, Grund, Zeitraum und Notiz.
|
||||
- Tagesnavigation zurück/vor/heute und freie Datumswahl.
|
||||
- Grundfilter, Kennzahlen und Hinweise zu Wochenende, Schließtag/Teamtag sowie Tagen mehr als 30 Tage in der Zukunft.
|
||||
- Abwesenheiten sind nicht als echte Anwesenheitserfassung mit Check-in/Check-out implementiert; „anwesend“ ist aus Kinderzahl minus Meldungen abgeleitet.
|
||||
|
||||
**Einschränkungen:** Gruppen „Krippe“ und „Elementar“ werden aus dem ersten Zeichen des Kindernamens künstlich abgeleitet (`mockGroup`). Das Datenmodell hat keine echte Gruppenzuordnung. Die Anwesenheitszahl dieser Ansicht verwendet die gefilterten Fehlmeldungen bei weiterhin gesamter Kinderzahl und kann deshalb bei aktivem Filter irreführend sein. Eine bestehende Abwesenheit kann nur gelöscht und neu gemeldet, nicht direkt bearbeitet werden.
|
||||
|
||||
Quellen: [Abwesenheitsaktionen](../src/actions/absences.ts), [Elternformular](../src/app/dashboard/absence-card.tsx), [Tagesübersicht](../src/app/dashboard/admin/abwesenheiten/page.tsx), [Filter](../src/app/dashboard/admin/abwesenheiten/_components/absences-client.tsx).
|
||||
|
||||
### 4.13 Wöchentlicher Dienstplan
|
||||
|
||||
- Separater Dienstplan für Haushalte, unabhängig von festen personenbezogenen Ämtern und Notdienst.
|
||||
- Diensttypen mit Name/Beschreibung anlegen und löschen.
|
||||
- Wochenraster mit wählbarem Beginn und Zeitraum; Standard zwölf Wochen, maximal 104 Wochen.
|
||||
- Haushalt je Diensttyp/Kalenderwoche auswählen oder Zuordnung entfernen; Änderungen werden direkt gespeichert.
|
||||
- Kita-Zugehörigkeit von Diensttyp und Familie wird geprüft.
|
||||
- Einteilungen erscheinen im gemeinsamen Kalender.
|
||||
- Manuelle Erinnerungsaktion für noch nicht erinnerte geplante Dienste, deren Beginn in der aktuellen Woche liegt.
|
||||
- Versand an verifizierte Benutzer der Familie; erfolgreicher Versand wird mit `reminderSentAt` gespeichert. Erneute Zuordnung setzt diesen Wert zurück.
|
||||
|
||||
**Einschränkungen:** Kein automatisch laufender Erinnerungsjob. Status `DONE` und `CANCELLED` existieren im Datenmodell, aber ein regulärer Abschluss-/Stornoablauf ist nicht eingebunden. Eine Änderung setzt den Dienst wieder auf `PLANNED`. Das Löschen eines Diensttyps löscht durch die Datenrelationen auch dessen Einteilungen.
|
||||
|
||||
Quellen: [Dienstplanseite](../src/app/dashboard/admin/dienste/page.tsx), [Raster](../src/app/dashboard/admin/dienste/duty-planner.tsx), [Dienstaktionen](../src/app/dashboard/admin/dienste/actions.ts).
|
||||
|
||||
### 4.14 Schwarzes Brett, E-Mail und Anhänge
|
||||
|
||||
- Admins können eine Ankündigung mit Titel und Markdown-Inhalt erstellen; Vorschau ist im Formular vorhanden.
|
||||
- PDF-, JPG- und PNG-Anhänge bis zehn MB je Datei.
|
||||
- Speicherung der Anhangdateien auf dem Server und der Metadaten in der Datenbank.
|
||||
- Abruf über geschützte API mit Prüfung auf angemeldeten Benutzer und dieselbe Kita; keine Auslieferung über ein öffentliches Uploadverzeichnis.
|
||||
- Optionaler E-Mail-Versand an verifizierte Haushaltskonten, Erzieherkonten oder beide Gruppen. Die Auswahl betrifft E-Mail-Empfänger und wird nicht als Sichtbarkeitsgruppe der Meldung gespeichert.
|
||||
- Adminseite zeigt die letzten acht veröffentlichten Meldungen mit Autor, Zeitpunkt und Anhanganzahl.
|
||||
- Bei Dateischreibfehlern werden die neue Meldung und angefangene Dateien zurückgenommen; bei Mailfehlern bleibt die Meldung gespeichert und eine Warnung wird zurückgegeben.
|
||||
- Lesestatusmodell und Aktion zum Markieren als gelesen sind vorhanden; `NewsTicker` enthält einen vorbereiteten Meldungsdialog.
|
||||
|
||||
**Aktueller Stand:** `NewsTicker` ist nicht in das Dashboard oder eine andere Seite eingebunden. Eltern können neue Meldungen daher derzeit über den optionalen Mailversand erhalten, besitzen aber keine eingebundene Meldungsliste in der App. Eine Adminauswertung der Lesebestätigungen sowie Bearbeiten/Löschen von Meldungen sind nicht umgesetzt.
|
||||
|
||||
Quellen: [Meldungsaktionen](../src/actions/announcements.ts), [Adminseite](../src/app/dashboard/admin/news/page.tsx), [Formular](../src/app/dashboard/admin/news/news-form.tsx), [ungebundener Ticker](../src/app/dashboard/news-ticker.tsx), [Anhang-API](../src/app/api/announcements/attachments/%5Bid%5D/route.ts).
|
||||
|
||||
### 4.15 Adressbuch
|
||||
|
||||
- Interne Übersicht der Haushalte, bei denen mindestens ein Benutzer der Kontaktfreigabe zugestimmt hat.
|
||||
- Anzeige von Haushaltsname, Kindern, Elternnamen und festen Ämtern; alphabetische Gruppierung nach Haushaltsnamen.
|
||||
- E-Mail, Telefon und Adresse werden nur für den jeweiligen Benutzer mit Opt-in geladen.
|
||||
- Suche nach Familienname, Eltern-/Kindernamen oder sichtbarer E-Mail.
|
||||
- Filter für Telefonnummer, Ämter und Haushalte mit Opt-in aller zugeordneten Benutzer.
|
||||
- Detaildialog je Familie; E-Mail- und Telefonlinks starten den jeweiligen Mail-/Telefonhandler.
|
||||
- Sichtbarkeitsdialog erklärt die Freigabe und führt zum eigenen Profil.
|
||||
- Optionales Geburtsdatum eines Kindes ist im Detaildialog sichtbar.
|
||||
|
||||
**Einschränkung:** Namen und Ämter eines zweiten Elternteils ohne Kontaktfreigabe sowie Kinderinformationen sind innerhalb eines sichtbaren Haushalts weiterhin sichtbar. Die Freigabe ist technisch kein Opt-in für sämtliche Haushaltsinformationen.
|
||||
|
||||
Quellen: [Adressbuchdaten](../src/app/dashboard/adressbuch/page.tsx), [Adressbuchoberfläche](../src/app/dashboard/adressbuch/_components/directory-client.tsx).
|
||||
|
||||
### 4.16 ErzieherInnen-Stammdaten
|
||||
|
||||
- Admins können Fachkräfte mit Vor- und Nachname anlegen, bearbeiten und löschen.
|
||||
- Aktivstatus wird gepflegt und dargestellt.
|
||||
- Diese Datensätze sind keine Login-Konten; die separate Benutzerrolle `ERZIEHER` ist hiervon unabhängig.
|
||||
- Referenz für Notdienstalarme im Datenmodell vorhanden, aber derzeit keine Fachkraftauswahl im Alarmauslöser.
|
||||
|
||||
Quellen: [Erzieheraktionen](../src/app/dashboard/erzieher/actions.ts), [Erzieheroberfläche](../src/app/dashboard/erzieher/_components/erzieher-list.tsx).
|
||||
|
||||
### 4.17 Systemweite Superadmin-Verwaltung
|
||||
|
||||
- Übersicht aller Kitas mit Slug, Modulstatus, Benutzer-/Familien-/Kinderzahlen, Terminen, Abwesenheiten, Meldungen, offenen Einladungen und letztem Login.
|
||||
- Hinweise auf Benutzer ohne Kita, Kontosperren, fehlende Datenschutzzustimmung und Familien ohne Benutzer.
|
||||
- Kita-Diagnose mit Benutzerliste, Rollen, Familien, Verifikationsstatus, Consent, Fehlversuchen und Sperren; zusätzlich Zähler für Konten ohne Passwort und abgelaufene Einladungen.
|
||||
- Diagnoseaufrufe werden im Audit-Protokoll gespeichert.
|
||||
- Kita endgültig löschen nach exakter Namensbestätigung, mit kaskadierender Löschung abhängiger Datenbankdatensätze.
|
||||
- Kita-gebundene Benutzer löschen nach Bestätigung ihrer E-Mail-Adresse; dabei zugehörige Verifikationstokens entfernen.
|
||||
- Benutzer für weitere Logins sperren, entsperren oder Fehlversuche zurücksetzen. Sperrung setzt `lockedUntil` weit in die Zukunft; Entsperren löscht die Sperre und setzt den Zähler zurück.
|
||||
- Eigene und andere Superadmin-Konten sowie Konten ohne Kita werden durch diese Support-Aktionen nicht verändert.
|
||||
- Auditansicht zeigt die neuesten 200 Einträge samt Akteur, Ziel, Zeitpunkt, Aktion und Metadaten. Protokolle können auch nach Löschung der Zielobjekte bestehen bleiben.
|
||||
|
||||
**Einschränkungen:** Offene und abgelaufene Einladungen werden über `Invitation` gezählt, während die tatsächlichen Familien-Einladungen `VerificationToken` nutzen. Eine Superadmin-Benutzerlöschung entfernt nicht automatisch einen nun leeren Haushalt. Eine Sperre wird bei der Anmeldung geprüft; das sofortige Beenden bereits bestehender Sessions ist nicht umgesetzt.
|
||||
|
||||
Quellen: [Adminübersicht und Diagnose-Daten](../src/app/admin/_lib/data.ts), [Support-Aktionen](../src/app/admin/actions.ts), [Auditseite](../src/app/admin/audit/page.tsx).
|
||||
|
||||
## 5. Datenmodell und technische Querschnittsfunktionen
|
||||
|
||||
- PostgreSQL mit Prisma. Eine Kita ist ein Mandant; fachliche Daten sind über `kitaId` zugeordnet.
|
||||
- Datenmodelle für Kitas, Benutzer, Haushalte, Kinder, Fachkräfte, feste Ämter, Diensttypen/-einteilungen, Abwesenheiten, Meldungen, Anhänge, Lesestatus, Termine, Teilnahme, Mitbringsel, Notdienstpläne/-verfügbarkeiten/-einteilungen/-alarme/-tausche sowie Auditprotokolle und Tokens.
|
||||
- Eindeutigkeitsregeln unter anderem für E-Mail, Kita-Slug, Monatsplan, Einteilung je Plantag, Verfügbarkeit je Kind/Tag, Teilnahme je Event/Kind und Amt je Benutzer.
|
||||
- Eingabeprüfung überwiegend mit Zod, Schreibabläufe teilweise als Datenbanktransaktionen, Aktualisierung von Seiten nach Änderungen.
|
||||
- Gemeinsamer E-Mail-Versand über Resend mit Fehlerbehandlung. Ohne API-Key werden E-Mails nur in der Entwicklungsumgebung simuliert; diese Simulation bestätigt keine Zustellung.
|
||||
- Vorlagen für Einladung, Alarm, Verfügbarkeitserinnerung, Einteilungsänderung, Diensttausch, Wochen-Diensterinnerung, News und Kontakt. Eine weitere Vorlage für erstmalige Notdienst-Zuteilung ist vorhanden, aber nicht an die Veröffentlichung angebunden.
|
||||
- Rollen-, Kita- und Consent-Prüfungen über gemeinsame Helfer. Geschützte Dashboard-/Adminlayouts, Seiten und Aktionen setzen Zugriffsprüfungen ein; nicht jede Aktion verwendet denselben Consent-Helfer.
|
||||
- Viele Datenbankrelationen löschen abhängige Datensätze mit. Das ist nicht gleichbedeutend mit vollständiger Bereinigung von Dateien, allgemeinen Verifikationstokens oder Audit-Snapshots.
|
||||
- Der Hilfsmodus `TEST_MODE=true` liefert in `requireRole` und `requireKitaSession` künstliche Sessions und umgeht dort die regulären Prüfungen. Er ist nicht auf die Entwicklungsumgebung begrenzt.
|
||||
|
||||
Quelle: [Schema](../prisma/schema.prisma), [Mail](../src/lib/mail.ts), [Auth-Helfer](../src/lib/auth-utils.ts).
|
||||
|
||||
## 6. Seiten- und Schnittstellenverzeichnis
|
||||
|
||||
| Route | Zweck/Zugriff |
|
||||
| --- | --- |
|
||||
| `/` | Öffentliche Startseite und Kontakt. |
|
||||
| `/features/notdienst-planer` | Öffentliche Notdienst-Beschreibung. |
|
||||
| `/features/kita-kalender` | Öffentliche Kalender-Beschreibung. |
|
||||
| `/features/eltern-adressbuch` | Öffentliche Adressbuch-Beschreibung. |
|
||||
| `/features/krankmeldung` | Öffentliche Abwesenheits-Beschreibung. |
|
||||
| `/features/schwarzes-brett` | Öffentliche Meldungs-Beschreibung. |
|
||||
| `/impressum`, `/datenschutz` | Öffentliche Informationsseiten mit noch offenen Platzhaltern. |
|
||||
| `/register`, `/login` | Gründerregistrierung und Anmeldung. |
|
||||
| `/onboarding` | Kita einrichten nach Anmeldung. |
|
||||
| `/onboarding/consent` | Hinweis bei fehlender Datenschutzannahme. |
|
||||
| `/invite/[token]` | Elternkonto über Einladungslink aktivieren. |
|
||||
| `/alert/[token]` | Notdienstalarm über Link bestätigen. |
|
||||
| `/forbidden` | Hinweis bei verweigertem Zugriff. |
|
||||
| `/dashboard` | Kita-Übersicht und eigene Abwesenheiten. |
|
||||
| `/dashboard/profil` | Eigener Haushalt, Kinder, Kontakte und Kontolöschung. |
|
||||
| `/dashboard/notdienst` | Verfügbarkeiten; Eltern, Admin, Koordinator. |
|
||||
| `/dashboard/notdienst/plan` | Planung; Admin, Koordinator. |
|
||||
| `/dashboard/kalender` | Kita-Kalender und Diensttausch. |
|
||||
| `/dashboard/adressbuch` | Internes Adressbuch. |
|
||||
| `/dashboard/families` | Familien und feste Ämter; Admin. |
|
||||
| `/dashboard/erzieher` | Fachkraft-Stammdaten; Admin. |
|
||||
| `/dashboard/admin/dienste` | Wochen-Dienstplan; Kita-Admin. Die Aktion erlaubt zusätzlich Superadmin mit Kita-Kontext. |
|
||||
| `/dashboard/admin/news` | Meldungen veröffentlichen; Admin. |
|
||||
| `/dashboard/admin/kalender` | Termine und Event-Anmeldungen; Admin, Koordinator. |
|
||||
| `/dashboard/admin/abwesenheiten` | Tagesübersicht; Admin, Koordinator, Erzieherrolle. |
|
||||
| `/admin` | Systemweite Kita-Übersicht; Superadmin. |
|
||||
| `/admin/kitas/[kitaId]` | Kita-Diagnose und Benutzersupport; Superadmin. |
|
||||
| `/admin/audit` | Systemweites Audit-Protokoll; Superadmin. |
|
||||
| `/api/auth/[...nextauth]` | Auth.js-Endpunkte. |
|
||||
| `/api/announcements/attachments/[id]` | Geschützter Anhangdownload. |
|
||||
| `/sitemap.xml`, `/robots.txt` | Suchmaschinensteuerung. |
|
||||
|
||||
## 7. Betrieb, Entwicklung und Prüfstand
|
||||
|
||||
- Stack laut `package.json`: Next.js 16.2.4, React 19.2.4, TypeScript, Prisma 6, Auth.js/NextAuth v5 beta, PostgreSQL, Tailwind CSS und Resend.
|
||||
- Lokaler Entwicklungsstart führt vor Next.js automatisch `prisma db push` und den Seed aus.
|
||||
- Seed legt Demonstrationsdaten mit mehreren Rollen und fachlichen Beispielen an; regulärer Seed ist bei bereits befüllter Datenbank zurückhaltend. Ein separater Reset-Modus existiert.
|
||||
- Docker-Build und Produktionsstart sind vorhanden; Produktionsstart wendet das Schema über `prisma db push --skip-generate` an. Versionierte Prisma-Migrationen liegen nicht im geprüften Dateibestand.
|
||||
- Lokale Compose-Datei stellt PostgreSQL bereit; Coolify-Compose enthält App, PostgreSQL, pgAdmin und persistente Datenbank-/Uploadvolumes.
|
||||
- Umgebungsvariablen-Vorlage und Produktionscheckliste sind vorhanden; ihre tatsächliche Erfüllung wurde nicht auf einem Server geprüft.
|
||||
- `UPLOAD_DIR` ist persistent vorgesehen. Die Dateihilfsfunktionen hängen noch `announcements` an diesen Pfad an; bei der aktuellen Compose-Konfiguration ergibt sich `/app/uploads/announcements/announcements`.
|
||||
- Kein Testskript und keine automatisierte Testdateisuite im geprüften Repository gefunden. Demo-Seed und manueller Live-Testplan ersetzen keine automatisierten Tests.
|
||||
|
||||
### Ausgeführte technische Prüfungen
|
||||
|
||||
Die Prüfungen liefen auf dem unveränderten Ausgangscheckout mit den dort bereits installierten Abhängigkeiten. Es wurden keine Bibliotheken installiert und keine Builds oder Datenbankoperationen ausgeführt.
|
||||
|
||||
| Prüfung | Ergebnis |
|
||||
| --- | --- |
|
||||
| `./node_modules/.bin/tsc --noEmit --incremental false` | Erfolgreich, Exit-Code 0. |
|
||||
| `npm run lint` | Fehlgeschlagen, Exit-Code 1: zwei Fehler und neun Warnungen. |
|
||||
| Quellenprüfung von Seiten, Aktionen, Komponenten und Schema | Durchführung für die oben beschriebenen Funktionsbereiche. |
|
||||
| Aktueller Browser-/End-to-End-Test | Nicht durchgeführt. |
|
||||
| Mailzustellung und produktives Deployment | Nicht geprüft. |
|
||||
|
||||
Beide ESLint-Fehler betreffen `as any` im Testmodus von `src/lib/auth-utils.ts` (Zeilen 57 und 91). Die Warnungen betreffen ungenutzte Variablen/Imports im Seed, Kalender, Adressbuch und Abwesenheiten. Diese Bestandsaufnahme ändert keinen Anwendungscode.
|
||||
|
||||
Der vorhandene [Live-Testplan](live-testplan.md) vom 17.05.2026 dokumentiert erfolgreiche Tests von Landingpage/Kontakt, Gründerregistrierung, Einrichtung, Dashboard, Familienanlage und Teilen des Login-Ablaufs. Eltern-Einladungsaktivierung und anschließende Bereinigung sind dort noch offen. Dies sind historische Einträge, keine aktuellen Testnachweise für den geprüften Commit. Die abgehakten Punkte in [UI-TODO.md](../UI-TODO.md) sind ebenfalls historische Angaben; viele der dort ehemals fehlenden Handler sind inzwischen im Code vorhanden.
|
||||
|
||||
Quellen: [Paket und Skripte](../package.json), [Deployment](../deploy/README.md), [Produktionscheckliste](../deploy/production-checklist.md), [Seed](../prisma/seed.ts), [Coolify-Compose](../docker-compose.coolify.yml).
|
||||
|
||||
## 8. Konkrete offene Punkte für die weitere Arbeit
|
||||
|
||||
Die Reihenfolge ist ein Vorschlag aus der Bestandsaufnahme; es wurde nichts davon umgesetzt.
|
||||
|
||||
| Reihenfolge | Thema | Konkreter nächster Schritt |
|
||||
| --- | --- | --- |
|
||||
| 1 | Fehlende Elternabläufe | NewsTicker und RSVP-Eingabe an eine erreichbare Seite anbinden; Daten dafür laden. |
|
||||
| 2 | Notdienstalarm | Auslösebutton, Fachkraftauswahl und Statusanzeige in den aktuellen Seitenaufbau integrieren. |
|
||||
| 3 | Moduldeaktivierung | Modulschalter in Navigation, Seiten und Server Actions einheitlich prüfen; spätere Einstellungen anbieten. |
|
||||
| 4 | Notdienstregeln | Wochenenden/Schließtage serverseitig ausschließen und Generierung um Schließtage ergänzen; Abwesenheitsregel festlegen. |
|
||||
| 5 | Erinnerungen | Automatische Ausführung ergänzen und unterscheiden, ob gar keine oder zu wenige Verfügbarkeiten vorliegen. |
|
||||
| 6 | Erstveröffentlichung | Zuteilungsmails beim Veröffentlichen anbinden und Mailfehler sichtbar behandeln. |
|
||||
| 7 | Einladungssupport | Tokenmodell und Superadmin-Zähler vereinheitlichen; erneuten Versand, Ablauf und Widerruf erreichbar machen. |
|
||||
| 8 | Rechtevergabe | Regulären Ablauf für Koordinator-/Erzieherkonten und Rollenänderungen ergänzen. |
|
||||
| 9 | Abwesenheitsgruppen | Echte Gruppenzuordnung einführen oder den künstlichen Gruppenfilter entfernen; Kennzahlen konsistent berechnen. |
|
||||
| 10 | Buchungskonflikte | Fachliche Reservierungsregeln definieren und Überschneidungen serverseitig prüfen. |
|
||||
| 11 | Selbstverwaltung | Passwort-Reset/-Änderung und direkte Bearbeitung von Abwesenheiten/Mitbringbeiträgen ergänzen. |
|
||||
| 12 | Löschabläufe | Kinderlöschung bei bestehenden Verpflichtungen prüfen; Anhangdateien und Tokens bei Kita-/Kontolöschungen bereinigen; Audit-Aufbewahrung gesondert definieren. |
|
||||
| 13 | Zugriffsprüfungen | Testmodus an Entwicklungsumgebung binden; Consent-/Session-Prüfungen und Teilnehmerhinweise serverseitig vereinheitlichen. |
|
||||
| 14 | Technischer Zustand | Zwei ESLint-Fehler beheben, Migrationsablauf einführen und aussagekräftige Tests für die zentralen Abläufe ergänzen. |
|
||||
| 15 | Aktuelle Abnahme | Rollenbasierte Browserprüfung, Mailzustellung, Elternaktivierung und Bereinigung anhand eines aktualisierten Testplans durchführen. |
|
||||
| 16 | Öffentliche Texte | Platzhalter in Impressum/Datenschutz mit den tatsächlichen Betriebsangaben vervollständigen und Marketingversprechen mit dem implementierten Verhalten abgleichen. |
|
||||
|
||||
## 9. Nicht als fertige Produktfunktion vorhanden
|
||||
|
||||
Im geprüften Code wurden kein Abonnement-/Zahlungsablauf, keine Import-/Exportverwaltung für Familien, kein externer Kalenderabgleich, keine Push-/SMS-Alarmierung, keine echte Kita-Gruppenverwaltung und keine Zeiterfassung/Check-in-Funktion gefunden. Vorhandene Planungs- oder Marketingtexte sind dafür kein Umsetzungsnachweis.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Notdienstalarm
|
||||
|
||||
Stand: 09.10.2026 · Umsetzung auf `codex/notdienstalarm`
|
||||
|
||||
## Bedienung
|
||||
|
||||
Kita-Admins und Notdienst-Koordination finden den Alarm in der Navigation und bei eingeteilten Tagen eines veröffentlichten Notdienstplans. Der Dialog zeigt die betroffene Familie und ihre Elternadressen vor dem Auslösen. Er umfasst einen ganzen Kita-Tag oder ein Zeitfenster, eine Antwortfrist (standardmäßig 30 Minuten), eine optionale interne Fachkraftzuordnung und eine interne Notiz.
|
||||
|
||||
Nur die eingeteilte Familie wird angefragt. Jedes Elternkonto erhält einen eigenen persönlichen Rückmeldelink und einen Hinweis in der App. Die Antwort „Wir übernehmen“ oder „Wir können nicht“ muss ausdrücklich gespeichert werden und gilt für den gesamten Haushalt. Das Öffnen des Links speichert keine Antwort. Die erste gespeicherte Antwort entscheidet auch bei gleichzeitigen Antworten; Änderungen dokumentiert die Koordination.
|
||||
|
||||
| Anzeige | Bedeutung / nächster Schritt |
|
||||
| --- | --- |
|
||||
| Antwort ausstehend | Haushalt hat noch nicht geantwortet. |
|
||||
| Übernahme bestätigt | Rechtzeitige Zusage oder dokumentierte Freigabe durch Koordination. |
|
||||
| Absage – Klärung erforderlich | Alle Admins und KoordinatorInnen der Kita erhalten sofort einen Hinweis. Ersatz manuell organisieren. |
|
||||
| Klärung erforderlich – Frist abgelaufen | Nach Ablauf informiert der Hintergrundjob einmalig die Admins und KoordinatorInnen. |
|
||||
| Verspätete Zusage | Muss durch Koordination freigegeben werden; keine automatische sichere Besetzung. |
|
||||
| Klärung erforderlich | Uneindeutige frühere Einzelantworten wurden übernommen. Haushalt kontaktieren. |
|
||||
| Storniert | Haushalt wird informiert; der Link kann keine Antwort mehr speichern. |
|
||||
|
||||
Telefonische Rückmeldungen, Korrekturen, verspätete Freigaben und Stornierungen benötigen eine interne Begründung. Interne Fachkraftdaten, Notizen und Verlauf erscheinen ausschließlich in der Verwaltungsansicht und werden nicht per E-Mail übermittelt. Eltern sehen ihre eigenen Alarme; andere Haushalte und Fachkräfte haben keinen Zugriff.
|
||||
|
||||
Aktive Alarme sperren manuelle Umzuordnung und Diensttausche. Für einen Ersatz zuerst den Alarm stornieren, dann die Einteilung ändern und neu alarmieren. Ein ausstehender E-Mail-Versand lässt sich erneut versuchen, ohne die Antwortfrist neu zu starten. Die Anzeige „vom Maildienst angenommen“ bestätigt die Annahme durch den Versanddienst, nicht die Zustellung im Postfach.
|
||||
|
||||
## Zeiten und Grenzen
|
||||
|
||||
- Kita-Zeitzone: `Europe/Berlin`, einschließlich Sommer-/Winterzeit. Nicht existierende Uhrzeiten bei der Zeitumstellung werden abgelehnt.
|
||||
- Ganze Tage enden um Mitternacht zum Folgetag. Zeitfenster müssen am selben Tag liegen; Ende nach Beginn.
|
||||
- Die Antwortfrist startet beim Auslösen, beträgt 1–1440 Minuten und endet spätestens mit dem Einsatz. Alarme sind für heute und zukünftige veröffentlichte Einteilungen möglich.
|
||||
- Nach Einsatzende können Rückmeldelinks keine Antwort mehr speichern. Der letzte Status bleibt lesbar.
|
||||
- Die Übersicht zeigt bis zu 100 Alarme seit 30 Tagen vor heute. Direkte Detail-Links öffnen auch ältere Vorgänge. Im Dialog stehen Einteilungen der nächsten 93 Tage zur Wahl; das Dashboard zeigt bis zu zehn aktuelle Alarme.
|
||||
- Kein SMS-/Push-Versand und keine automatische Alarmierung anderer Familien. Notdienstmails gehören zum Alarmablauf und verwenden nicht die persönliche Einstellung für Personal-Antragsmails.
|
||||
|
||||
## Betrieb und Hintergrundjob
|
||||
|
||||
Benötigt werden die vorhandene Resend-Konfiguration (`RESEND_API_KEY`, `EMAIL_FROM`), die korrekte öffentliche App-Adresse (`NEXT_PUBLIC_SITE_URL`, alternativ `AUTH_URL`/`NEXTAUTH_URL`) und neu `NOTDIENST_JOB_SECRET` mit mindestens 32 Zeichen. Ein zufälliges Secret kann beispielsweise mit `openssl rand -hex 32` erzeugt und in der Hosting-Konfiguration gespeichert werden. Das Secret gehört nicht in Git oder ins Browser-Frontend.
|
||||
|
||||
Der bestehende Docker-Produktionsstart startet `scripts/notdienst-alarm-worker.mjs`. Derselbe geschützte Job verarbeitet zusätzlich die gespeicherten Mitteilungs-Mails und deren Wiederholungen. Der Worker ruft nach fünf Sekunden und danach ungefähr jede Minute lokal `POST /api/jobs/notdienstalarme` auf. Fristmeldungen entstehen dadurch auch ohne geöffneten Browser, normalerweise innerhalb einer Minute nach Fristende, zuzüglich Laufzeit und möglicher Ausfälle. Bei fehlendem Secret startet der allgemeine Produktionsstart ohne Fristjob und die Verwaltungsseite zeigt einen Hinweis; die Coolify-Vorlage verlangt das Secret. Ein zu kurzes gesetztes Secret stoppt den Produktionsstart.
|
||||
|
||||
Bei einem anderen Hosting-Verfahren muss der Worker als eigener dauerhaft überwachter Prozess gestartet werden oder ein externer Scheduler die Route mindestens einmal pro Minute per POST und `Authorization: Bearer <NOTDIENST_JOB_SECRET>` aufrufen. Nur `npm run start` startet den Worker nicht. Der Scheduler/Host muss auch nach Neustarts laufen; ein gesetztes Secret allein belegt keine laufende Fristprüfung.
|
||||
|
||||
Der Job bearbeitet bis zu 100 Fristfälle und 100 E-Mail-Aufträge je Lauf. Wiederholte und gleichzeitige Aufrufe werden abgefangen: Fristmeldungen besitzen einen eindeutigen Ereignisschlüssel, Mailaufträge eine Versandreservierung und einen stabilen Idempotenzschlüssel. Versandfehler bleiben gespeichert und werden bei späteren Läufen erneut versucht; bereits angenommene Aufträge werden nicht erneut angefragt. Rollen- und Haushaltszuordnungen werden vor dem Versand erneut geprüft. Überholte Anfragen werden übersprungen. Provider-Garantien zur Idempotenz und tatsächliche Postfachzustellung bleiben Grenzen außerhalb der Anwendung.
|
||||
|
||||
HTTP 401 bedeutet fehlende/falsche Berechtigung, 503 fehlendes/zu kurzes Job-Secret, 500 einen fehlgeschlagenen Lauf. HTTP 200 enthält die Bearbeitungszahlen einschließlich fehlgeschlagener E-Mails; die Versandübersicht und Server-/Worker-Logs sind für die Betriebsprüfung heranzuziehen. Bei Ausfall wird die Fristüberschreitung schon anhand der Uhrzeit in der Oberfläche angezeigt; E-Mail-Eskalation und Reservierungsfreigabe benötigen den nächsten erfolgreichen Joblauf.
|
||||
|
||||
## Datenbank und vorhandene Alarme
|
||||
|
||||
Das Schema ergänzt einen gemeinsamen Alarmvorgang, einzelne Empfänger und Tokens, Hinweise, Mailaufträge und einen Verlauf. Die bisherigen `NotdienstAlert`-Datensätze bleiben erhalten. Ein aktiver Alarm reserviert seine Einteilung eindeutig; Transaktionen schützen konkurrierende Antworten und Planänderungen.
|
||||
|
||||
Aktuelle Releases verwenden versionierte Prisma-Migrationen über den separaten Initialisierungsdienst. Die Einrichtung beschreibt [deploy/README.md](../deploy/README.md); `prisma db push` und die historischen SQL-Upgrades sind kein aktueller Betriebsweg. Der bestehende Hintergrundjob übernimmt zusätzlich Verfügbarkeitserinnerungen und gespeicherte Veröffentlichungsmails.
|
||||
|
||||
Bisherige Einzelalarme werden beim Verwaltungsaufruf, Joblauf oder Aufruf eines alten Links in Gruppen pro Einteilung übernommen. Alte gültige Tokens bleiben nutzbar; Originalantworten bleiben gespeichert. Nur vollständig übereinstimmende gültige Antworten ergeben eine sichere gemeinsame Zusage. Widersprüche, fehlende/ungeklärte Empfänger oder doppelte Einzelalarme führen zu Klärungsbedarf. Bei doppelten Datensätzen eines Elternkontos wird ein Rückmeldetoken übernommen; die übrigen Originaldatensätze bleiben erhalten. Die Übernahme verschickt keine erneute Alarmanfrage.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Abschließender Prüfstand: 20 Alarmtests, 17 Personal-Service-Tests und neun Berechnungstests erfolgreich; TypeScript, ESLint der betroffenen Dateien und Produktionsbuild erfolgreich. Das additive SQL-Upgrade wurde auf einem separaten Schema der isolierten Datenbank angewandt und per Prisma mit dem Zielmodell verglichen: keine Abweichungen. Worker und Jobroute wurden lokal geprüft (401 ohne Secret, 200 mit Secret, Fristmeldung je zuständigem Konto einmalig). Die vorhandenen globalen ESLint-Fehler in `auth-utils.ts` liegen außerhalb dieser Änderung.
|
||||
|
||||
Automatisierte Alarmtests prüfen Rollen und Mandanten, gemeinsame und konkurrierende Antworten, Fristprüfung, Absagen, verspätete Freigaben, Stornierung, Tokens, Versandfehler/erneuten Versand, Altbestände sowie die Sperre vorhandener Plan- und Diensttausch-Aktionen. Sie benötigen ausdrücklich die isolierte lokale Testdatenbank `127.0.0.1:55439/kita_staff_test` und bereinigen nur ihre eigenen Test-Kitas.
|
||||
|
||||
```sh
|
||||
DATABASE_URL='postgresql://USER@127.0.0.1:55439/kita_staff_test' npm run test:alarms
|
||||
npm run test:staff
|
||||
```
|
||||
|
||||
Die Browserprüfung verwendet ausschließlich Demo-Daten und deaktivierten tatsächlichen E-Mail-Versand. Geprüft wurden Auslösen aus der Einteilung, Zeitfenster, Eltern-Dashboard-Hinweis, persönliche Rückmeldeseite mit expliziter Bestätigung und eine mobile Ansicht ohne horizontalen Überlauf. Produktive Zustellung und Deployment sind separat durchzuführen.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Notdienstalarm – abgestimmter Funktionsplan
|
||||
|
||||
Stand: 09.10.2026 · Branch `codex/notdienstalarm`
|
||||
|
||||
Status: Umgesetzt. Dieser Plan hält die abgestimmten Entscheidungen fest. Bedienung, Betriebsanforderungen und Prüfungen stehen in [NOTDIENSTALARM.md](NOTDIENSTALARM.md).
|
||||
|
||||
## Festgelegter Umfang
|
||||
|
||||
| Thema | Entscheidung |
|
||||
| --- | --- |
|
||||
| Zuständigkeit | Nur Kita-Admins und Notdienst-Koordination (`ADMIN`, `KOORDINATOR`) lösen Alarme aus und verwalten sie. |
|
||||
| Erste Empfänger | Ausschließlich die für das Datum im veröffentlichten Notdienstplan eingeteilte Familie. Alle Elternkonten dieses Haushalts werden einzeln angeschrieben. |
|
||||
| Rückmeldung | „Wir übernehmen“ oder „Wir können nicht“. Eine Antwort gilt für den gesamten Haushalt. |
|
||||
| Kanäle | E-Mail und ein deutlich sichtbarer Hinweis in der App. |
|
||||
| Antwortfrist | Standardmäßig 30 Minuten ab Auslösung; beim Auslösen anpassbar. |
|
||||
| Einsatzdauer | Wahl zwischen „Ganzer Kita-Tag“ und „Bestimmte Uhrzeiten“ mit Beginn und Ende. |
|
||||
| Fehlende Übernahme | Absage sofort, ausbleibende Antwort nach Fristablauf: „Klärung erforderlich“. Koordination informieren; Ersatz manuell organisieren. |
|
||||
|
||||
## Ablauf in der Oberfläche
|
||||
|
||||
### 1. Alarm auslösen
|
||||
|
||||
Im veröffentlichten Notdienstplan erhält die Einteilung eine Aktion **Notdienstalarm auslösen**. Eine aktuelle Alarmübersicht ist zusätzlich über das Dashboard erreichbar.
|
||||
|
||||
Der Dialog zeigt Datum, eingeteilte Familie und deren Empfängeradressen. Die auslösende Person wählt die Einsatzdauer, prüft die Antwortfrist und bestätigt den Versand. Bei **Bestimmte Uhrzeiten** sind Beginn und Ende Pflicht; Ende liegt nach Beginn. Bei **Ganzer Kita-Tag** sind keine künstlichen Uhrzeitangaben nötig. Die gewählte Dauer erscheint in der E-Mail und auf der Rückmeldeseite.
|
||||
|
||||
Ein Alarm ist nur für heutige oder zukünftige Einteilungen eines veröffentlichten Plans der eigenen Kita möglich. Notdienst muss für die Kita aktiviert sein. Fehlende Elternkonten werden vor der Auslösung deutlich angezeigt. Eine aktive Alarmierung verhindert einen zweiten Alarm für dieselbe Einteilung.
|
||||
|
||||
Optional kann die ausgefallene Fachkraft intern zugeordnet werden. Dies dient der Zuordnung und erzeugt keine automatische Krankheitsbuchung im Personalbereich. Private interne Notizen und Namen ausgefallener Fachkräfte gehören nicht in die Eltern-E-Mail.
|
||||
|
||||
### 2. Familie informieren und Antwort erfassen
|
||||
|
||||
Jedes Elternkonto erhält eine eigene E-Mail mit Kita, Datum, Einsatzdauer, Antwortfrist und persönlichem Rückmeldelink. Die Nachricht fragt eindeutig nach einer Übernahme. Alle Elternkonten des Haushalts sehen in der App denselben aktiven Alarm.
|
||||
|
||||
Die Rückmeldeseite funktioniert wie bisher ohne Login über einen persönlichen Token. Das Öffnen des Links löst keine Zusage aus. Erst die ausdrückliche Auswahl von **Wir übernehmen** oder **Wir können nicht** mit Bestätigung schreibt die Antwort.
|
||||
|
||||
Die Antwort wird am gemeinsamen Haushaltseinsatz gespeichert, einschließlich Zeitpunkt und antwortendem Elternkonto. Alle anderen Links zeigen anschließend die gemeinsame Antwort. Gleichzeitige gegensätzliche Antworten dürfen nicht beide wirksam werden. Eine bereits gespeicherte Antwort lässt sich nicht versehentlich durch einen zweiten Klick überschreiben.
|
||||
|
||||
### 3. Stand verfolgen und Ersatz klären
|
||||
|
||||
Die Alarmübersicht zeigt Datum, Dauer, Familie, Antwortfrist, gemeinsamen Rückmeldestatus und den Versandstand je Empfänger. Offene Alarme für heute werden besonders hervorgehoben.
|
||||
|
||||
| Anzeige | Bedeutung und nächste Aktion |
|
||||
| --- | --- |
|
||||
| Antwort ausstehend | Familie wurde angefragt; die Antwortfrist läuft. |
|
||||
| Übernahme bestätigt | Der Haushalt hat rechtzeitig zugesagt. Koordination und Admins sehen die Zusage. |
|
||||
| Klärung erforderlich – Absage | Der Haushalt kann nicht übernehmen. Koordination organisiert Ersatz. |
|
||||
| Klärung erforderlich – Frist abgelaufen | Es liegt keine Antwort vor. Koordination fasst nach oder organisiert Ersatz. |
|
||||
| Storniert | Einsatz entfällt; Rückmeldelinks erlauben keine weitere Antwort. |
|
||||
|
||||
Versandfehler sind ein eigener Zustand und dürfen nicht als Absage der Familie erscheinen. Eine fehlgeschlagene E-Mail wird deutlich angezeigt und kann erneut versendet werden; andere Empfänger werden trotzdem bedient.
|
||||
|
||||
Bei Absage oder Fristablauf erhalten zuständige Koordinations- und Admin-Konten derselben Kita einen Hinweis in der App und eine E-Mail. Die Fristmeldung wird je Alarm einmal erzeugt. Die Koordination organisiert Ersatz in dieser Version manuell; weitere Familien werden nicht automatisch alarmiert.
|
||||
|
||||
## Vorgeschlagene Ausgestaltung für Sonderfälle
|
||||
|
||||
Diese Details ergänzen die Nutzerentscheidungen als Umsetzungsvorschlag:
|
||||
|
||||
- **Verspätete Zusage:** Nach Fristablauf bleibt eine Rückmeldung möglich, solange der Einsatz läuft und der Alarm nicht storniert ist. Eine verspätete Zusage wird als solche angezeigt und muss von der Koordination bestätigt werden, damit sie nicht mit bereits organisiertem Ersatz kollidiert.
|
||||
- **Telefonische Rückmeldung:** Admin oder Koordination kann eine Zusage bzw. Absage für den Haushalt dokumentieren. Herkunft, handelndes Konto und Zeitpunkt bleiben nachvollziehbar.
|
||||
- **Antwort ändern:** Eine bereits dokumentierte Antwort wird nur durch Admin oder Koordination korrigiert. Eltern erhalten auf der Rückmeldeseite einen Hinweis, die Kita bei Änderungen zu kontaktieren.
|
||||
- **Stornieren:** Admin oder Koordination storniert mit einer internen Begründung. Alle angeschriebenen Elternkonten werden informiert; offene Links werden unwirksam. Eine neue Alarmierung derselben Einteilung ist danach als neuer Vorgang möglich.
|
||||
- **Token-Ende:** Für einen ganzen Kita-Tag endet die Rückmeldemöglichkeit spätestens mit dem Datum in Europe/Berlin; bei bestimmten Uhrzeiten mit dem Einsatzende. Die 30-Minuten-Frist für Eskalation ist davon unabhängig.
|
||||
- **Planänderungen und Tausch:** Aktive Alarme schützen die zugehörige Einteilung vor einem unbemerkten Familienwechsel oder Diensttausch. Ersatz darf nicht durch Änderung eines noch aktiven Alarms auf eine andere Familie übertragen werden.
|
||||
- **Erinnerung und erneuter Versand:** Eine Wiederholung startet die Antwortfrist nicht automatisch neu. E-Mail-Annahme durch den Versanddienst, Rückmeldung und tatsächliche Anwesenheit bleiben getrennte Informationen.
|
||||
|
||||
## Technische Umsetzung
|
||||
|
||||
Der bestehende `NotdienstAlert` speichert pro Elternkonto einen unabhängigen Status. Für die gemeinsame Haushaltsantwort wird ein übergeordneter Alarmvorgang benötigt; Empfänger, Tokens und Versandversuche sind untergeordnet. Bestehende Datensätze müssen bei der Schemaumstellung berücksichtigt werden, ohne widersprüchliche Einzelantworten als sichere Haushaltszusage zu übernehmen.
|
||||
|
||||
Am Vorgang werden Einteilung und Haushalt, auslösendes Konto, Zeitpunkt, Einsatzart, gegebenenfalls Uhrzeiten, Antwortfrist, gemeinsame Antwort, interne Zuordnung und Stornierungsdaten festgehalten. Empfängerdatensätze führen persönliche Tokens und deren Gültigkeit. Historie und Versandaufträge erlauben Wiederholung und nachvollziehbare Änderungen.
|
||||
|
||||
Alle Aktionen prüfen aktuelle Datenbankrolle, Kita-Zugehörigkeit und Zugriff serverseitig. Auslösung und gemeinsame Rückmeldung laufen transaktional. Eine Datenbanksicherung verhindert mehrere aktive Alarme für dieselbe Einteilung sowie widersprüchliche parallele Antworten.
|
||||
|
||||
Der Versand verwendet die vorhandene Resend-Anbindung mit persistenten Versandaufträgen. Elternmails enthalten ausschließlich notwendige Einsatzdaten. Interne Begründungen, Krankheitsdetails und Daten aus Personalzeitkonten werden nicht übernommen. Alarm-E-Mails sind unabhängig von der Abschalteinstellung für Personal-Entscheidungsmails.
|
||||
|
||||
Die Fristüberwachung braucht eine regelmäßig aufgerufene serverseitige Prüfung, auch wenn niemand die App geöffnet hat. Vorgesehen ist ein geschützter, einmal pro Minute aufgerufener Serverjob. Die Anzeige berechnet Fristablauf zusätzlich direkt aus der gespeicherten Antwortfrist. Die Benachrichtigung kann durch das Prüfintervall und den Mailtransport etwas nach Ablauf eintreffen. Für die Einführung muss der Job in der Hosting-Umgebung eingerichtet werden.
|
||||
|
||||
## Umsetzungsschritte
|
||||
|
||||
1. Gemeinsamen Alarmvorgang, Empfänger, Tokens, Verlauf und Schemaumstellung ergänzen.
|
||||
2. Auslösung, Haushaltsantwort, Korrektur, Stornierung und erneuten Versand absichern.
|
||||
3. E-Mail-Vorlagen, Versandaufträge und Benachrichtigung der Koordination anbinden.
|
||||
4. Alarmdialog, Rückmeldeseite, Dashboard-Hinweise und Statusübersicht umsetzen.
|
||||
5. Fristprüfung, Hosting-Konfiguration und Einführungsdokumentation ergänzen.
|
||||
6. Gemeinsame Haushaltsantworten, Fristen, Rollen, Versandfehler und Browserabläufe prüfen.
|
||||
|
||||
## Abnahmefälle
|
||||
|
||||
- Admin und Koordination dürfen auslösen; Eltern, Fachkräfte und Konten anderer Kitas dürfen es nicht.
|
||||
- Nur die im veröffentlichten Plan eingeteilte Familie wird zunächst angeschrieben.
|
||||
- Ganzer Kita-Tag und bestimmte Uhrzeiten erscheinen in E-Mail, App und Rückmeldeseite konsistent.
|
||||
- Eine Zusage oder Absage wirkt auf den gesamten Haushalt; weitere persönliche Links zeigen denselben Stand.
|
||||
- Zwei gleichzeitige Antworten, doppelte Auslösung und Fristprüfung erzeugen keine doppelten Vorgänge oder widersprüchlichen Zusagen.
|
||||
- Absage erzeugt sofort Klärungsbedarf; Fristablauf nach 30 Minuten erzeugt ihn auch bei geschlossener App.
|
||||
- Versandfehler verhindern keine Speicherung, unterbrechen nicht den Versand an weitere Empfänger und lassen sich gezielt wiederholen.
|
||||
- Verspätete Antwort, telefonisch dokumentierte Antwort und Stornierung bleiben nachvollziehbar.
|
||||
- Stornierte und zeitlich abgelaufene Links können keinen aktiven Einsatz zusagen.
|
||||
- Aktive Alarme schützen Einteilungen vor unbemerktem Tausch oder Familienwechsel.
|
||||
|
||||
## Vorhandene Grundlage
|
||||
|
||||
- `src/app/dashboard/actions.ts`: Auslösung und E-Mail-Versand, bisher ein separater Alarm pro Elternkonto.
|
||||
- `src/app/dashboard/alert-button.tsx`: Vorbereiteter, noch nicht eingebundener Auslösebutton.
|
||||
- `src/app/alert/[token]/`: Rückmeldeseite und Bestätigung, bisher nur Zusage und ohne zeitliche Token-Gültigkeit.
|
||||
- `src/app/dashboard/kalender/actions.ts`: Bestehender Tauschausschluss für aktive Alarmierungen.
|
||||
- `src/lib/mail.ts` und `src/lib/staff/email.ts`: Mailtransport sowie Vorlage für persistente, wiederholbare Versandaufträge.
|
||||
|
||||
Die Planung löst keine Alarme aus und versendet keine E-Mails.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Personalbereich: Arbeitszeit, Urlaub und Zeitkonto
|
||||
|
||||
Stand: 09.10.2026
|
||||
|
||||
## Einstieg und Zuständigkeiten
|
||||
|
||||
Der Bereich liegt unter `/dashboard/personal`. Kita-Admins (einschließlich Vorstand mit Rolle `ADMIN`) verwalten alle Personalprofile ihrer Kita. ErzieherInnen bearbeiten eigene Zeiten und Anträge. Eltern und Koordination haben keinen Zugriff. Der interne Teamkalender ist nur für Admins und ErzieherInnen zugänglich.
|
||||
|
||||
Neue Fachkräfte werden unter **ErzieherInnen → ErzieherIn anlegen** in einem Formular eingerichtet:
|
||||
|
||||
1. Vorname, Nachname und E-Mail-Adresse (Pflichtfelder).
|
||||
2. Tatsächlicher Beschäftigungsbeginn und separat der Start der Zeiterfassung (vorbelegt mit heute, änderbar). Die Erfassung darf nicht vor der Beschäftigung beginnen.
|
||||
3. **Fachkraft anlegen und einladen** speichert Stammdaten, Personalprofil, Accountverknüpfung und Mailauftrag gemeinsam in einer Transaktion. Unpassende oder schon verknüpfte E-Mail-Adressen verhindern die gesamte Anlage.
|
||||
4. Anschließend öffnet sich das Personalprofil. Dort werden datierte Netto-Sollstunden für Montag bis Sonntag, Jahresurlaub und ausdrücklich festgelegter Übertrag sowie vorhandene Überstunden eingerichtet. Der Anfangssaldo startet mit 0 Minuten und muss bei vorhandenen Überstunden vor der ersten Zeiterfassung angepasst werden.
|
||||
5. Die örtlichen Feiertage werden unter **Teamkalender** hinterlegt. Sie gelten separat von den Schließtagen des allgemeinen Kalenders.
|
||||
|
||||
Vorhandene Fachkräfte können ihre fehlende E-Mail und gegebenenfalls die beiden Startdaten direkt über **Bearbeiten** erhalten. Bereits eingerichtete Beschäftigungsdaten, Wochenmodelle und Konten bleiben dabei erhalten. Die E-Mail eines verknüpften Accounts wird angezeigt und kann im Stammdatendialog nicht geändert werden; Namensänderungen werden auch im verknüpften Fachkraft-Account übernommen.
|
||||
|
||||
Die Teamübersicht zeigt E-Mail und Zugangszustand: **Zugang fehlt**, **Einladung ausstehend**, **Account aktiviert** oder **Mailversand fehlgeschlagen**. Für offene Einladungen steht **Einladung erneut senden** zur Verfügung. Ein neuer Link ersetzt den alten. Ein Mailfehler entfernt keine Fachkraft: Er bleibt sichtbar, der Versand kann erneut angestoßen werden. Im Personalbereich kann außerdem der bestehende Mailauftrag erneut versucht werden.
|
||||
|
||||
Der Aktivierungsablauf verwendet die bestehende Passwort- und Consent-Einrichtung unter `/invite/[token]`. Neue Accounts erhalten einen persönlichen, einmal verwendbaren Aktivierungslink mit sieben Tagen Gültigkeit per E-Mail; im Personalprofil kann der Admin zusätzlich einen Link zum Kopieren erzeugen. Ein vorhandener verifizierter Fachkraft-Account derselben Kita ohne andere Profilverknüpfung wird verbunden, ohne eine Aktivierungsmail zu verschicken. Accounts anderer Kitas oder Rollen werden nicht übernommen.
|
||||
|
||||
Die gemeinsame Anlage benötigt keine zusätzlichen Datenbankfelder oder Umgebungsvariablen. Sie verwendet die bestehende Resend-Anbindung; siehe [Deployment und Mailversand](../deploy/DEPLOYMENT_UND_MAILVERSAND.md).
|
||||
|
||||
## Arbeitszeit und Überstunden
|
||||
|
||||
Pro Tag sind mehrere Arbeitsblöcke mit Beginn, Ende und Pause möglich. Zeitblöcke dürfen nicht überlappen. Die Pause wird in Minuten vom Block abgezogen. Zeiten werden für heutige oder vergangene Tage innerhalb der Beschäftigung und ab Erfassungsbeginn erfasst. Blöcke über Mitternacht werden auf zwei Tage aufgeteilt.
|
||||
|
||||
Die Tagesbilanz ist **Istzeit + Urlaub/Krankheit − Sollzeit**. Arbeit an einem freien Tag oder Feiertag zählt als zusätzliche Istzeit. Die Wochenansicht zeigt alle sieben Tage; in einer laufenden Woche fehlen zukünftige Arbeitszeiten noch, deshalb ist ihre Differenz vorläufig.
|
||||
|
||||
Die Fachkraft reicht ihre Woche nach dem letzten vorgesehenen Arbeitstag ein. Offene Abwesenheiten im Zeitraum müssen zuerst geklärt sein. Der Admin prüft die einzelnen Zeiten, genehmigt die Woche oder gibt sie mit Rückmeldung zurück. Eingereichte und genehmigte Wochen sperren Zeitänderungen. Eigene Wochen und eigene Anträge können nicht selbst freigegeben werden.
|
||||
|
||||
Beim Genehmigen wird die Tagesberechnung gespeichert. Nur genehmigte Wochen, der Anfangssaldo und wirksame manuelle Korrekturen zählen zum bestätigten Zeitkonto. Offene oder eingereichte Wochen wirken noch nicht auf dieses Konto. Eine Rückgabe entfernt die Wochenbuchung bis zur erneuten Freigabe. Fehler in manuellen Korrekturen werden durch eine begründete Gegenbuchung behoben.
|
||||
|
||||
## Feste Wochentage und seltene Wechsel
|
||||
|
||||
Wochenmodelle gelten ab ihrem Datum. Eine spätere Änderung überschreibt kein früheres Modell. Seltene Tausche werden über zwei Tagesausnahmen erfasst: der bisherige Arbeitstag erhält 0 Sollstunden, der Ersatztermin die vorgesehenen Stunden.
|
||||
|
||||
Genehmigte Abwesenheiten sowie eingereichte oder genehmigte Wochen schützen ihren Zeitraum auch vor Änderungen an Modellen, Ausnahmen und Feiertagen. Betroffene Freigaben müssen vor einer solchen Änderung zurückgegeben bzw. storniert werden. Grunddaten und Anfangssaldo werden nach der ersten Erfassung geschützt; das Beschäftigungsende bleibt über einen eigenen Ablauf verwaltbar, sofern bestehende Zeiten und Anträge nicht verletzt werden.
|
||||
|
||||
## Urlaub
|
||||
|
||||
Urlaub ist ganztägig oder für einen einzelnen halben Tag möglich. Nur vorgesehene Arbeitstage verbrauchen Urlaub. Wochenenden sind nicht pauschal frei: Entscheidend sind Wochenmodell und Tagesausnahmen. Feiertage verbrauchen keinen Urlaub und erzeugen keine Sollzeit. Ein halber Urlaubstag schreibt die Hälfte der Tages-Sollzeit gut; bei ungerader Minutenzahl wird auf eine ganze Minute gerundet.
|
||||
|
||||
Ein Jahreskonto führt Anspruch plus Übertrag, genommenen Urlaub, genehmigten geplanten Urlaub, offene Anträge und verfügbaren Rest. Offene Anträge werden bei weiteren Anträgen berücksichtigt. Abgelehnte und stornierte Anträge verbrauchen nichts. Zeiträume über den Jahreswechsel werden auf die jeweiligen Jahreskonten verteilt. Anspruch und Übertrag werden nicht automatisch anteilig berechnet oder zum Jahreswechsel übertragen.
|
||||
|
||||
Offene Anträge können geändert oder zurückgezogen werden. Bei genehmigten Anträgen wird eine Stornierung beantragt. Bis zur Entscheidung bleibt die Abwesenheit gebucht. Krankheit hinterlegt der Admin; sie entlastet die Sollzeit, ohne Urlaub zu verbrauchen. KollegInnen sehen Krankheit im Teamkalender nur als **Abwesend**, ohne persönliche Notizen oder Kontodaten.
|
||||
|
||||
## Freizeitausgleich
|
||||
|
||||
Freizeitausgleich wird für einen einzelnen vorgesehenen Arbeitstag in Stunden beantragt. Das bestätigte verfügbare Guthaben muss ausreichen; weitere offene Anträge werden dabei berücksichtigt.
|
||||
|
||||
Nach Genehmigung wird die Zeit reserviert. Freizeitausgleich erzeugt keine zusätzliche Istzeit oder Urlaubsgutschrift. Beispiel: 6 Stunden Soll, 5 Stunden Arbeit und 1 Stunde Freizeitausgleich ergeben eine Tagesbilanz von −1 Stunde. Mit der Wochenfreigabe wird diese Stunde im Zeitkonto gebucht und die Reservierung entfällt. Dadurch wird die Stunde genau einmal abgezogen. Bei offenen Stornierungen bleibt die Reservierung erhalten.
|
||||
|
||||
## Hinweise und Nachvollziehbarkeit
|
||||
|
||||
Neue Anträge und eingereichte Wochen erzeugen interne Hinweise für Kita-Admins. Entscheidungen und Rückgaben erzeugen Hinweise für die betroffene Fachkraft. Hinweise lassen sich als gelesen markieren; jede Person kann ausschließlich eigene Hinweise quittieren. Das Personalprofil zeigt die letzten 30 Änderungen. Im Datenmodell werden sämtliche Änderungen mit Zeitpunkt und handelnder User-ID gespeichert.
|
||||
|
||||
Parallele Änderungen laufen in serialisierbaren Datenbanktransaktionen mit Wiederholung bei Schreibkonflikten. Rollen, Accountstatus, Eigentümer und Kita-Zugehörigkeit werden serverseitig geprüft. Das Löschen von Fachkräften mit Personalprofil wird verhindert; sie können auf inaktiv gesetzt werden. Bei Accountlöschung bleiben Personalaufzeichnungen ohne Accountverknüpfung erhalten. Die Löschung einer Kita entfernt auch ihren Personalbereich.
|
||||
|
||||
## Einführung und Schema
|
||||
|
||||
Aktuelle Releases verwenden die versionierten Migrationen in `prisma/migrations` und einen separaten Initialisierungsdienst vor dem App-Start. Die [zentrale Betriebsanleitung](../deploy/README.md) beschreibt Installation, Updates, Baseline für bestehende Datenbanken und Runtime-Grants. `scripts/start-production.sh` startet die Anwendung; es verändert das Schema nicht. Kein `prisma db push` auf verwalteten Datenbanken verwenden.
|
||||
|
||||
Die historischen SQL-Dateien [Personalbereich](../deploy/sql/personalbereich.sql) und [Personalbereich-E-Mails](../deploy/sql/personalbereich-email.sql) dokumentieren ihre damaligen Ausgangsstände. Sie sind kein aktueller Deployment-Schritt und dürfen nicht zusätzlich zu den versionierten Migrationen ausgeführt werden. Vorhandene Personen erhalten weiterhin erst durch Einrichtung ein Beschäftigungsprofil.
|
||||
|
||||
Die reale Projekt- und Produktionsdatenbank wurde bei dieser Umsetzung nicht verändert. Alle DB- und Browserprüfungen liefen mit synthetischen Daten in einer separaten lokalen PostgreSQL-Datenbank.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
- `npm run test:staff`: Berechnungstests für Teilzeit, halbe Tage, Feiertage, Jahreswechsel, Statuswechsel, Pausen, Snapshots und Freizeitausgleich.
|
||||
- `npm run test:staff:integration`: echte Datenbankabläufe für Mandantentrennung, Rollen und Eigentümer, Anträge, Freigaben, parallele Änderungen, Sperren, Rückgaben, Aktivierungslinks und Beschäftigungsende.
|
||||
- Integrationstests akzeptieren ausschließlich `127.0.0.1:55439/kita_staff_test`. Beispiel: `DATABASE_URL=postgresql://USER@127.0.0.1:55439/kita_staff_test npm run test:staff:integration`. Die leere Testdatenbank vorher mit dem aktuellen Schema einrichten. Tests bereinigen nur ihre eigens erzeugten Testkitas.
|
||||
- Browserprüfung: Admin richtet Profil, 18-Stunden-Woche und 18 Urlaubstage ein; Erzieherin erfasst drei Blöcke mit Pausen, reicht eine 19-Stunden-Woche ein und beantragt einen Urlaubstag. Nach Adminfreigabe zeigt das Zeitkonto 11 Stunden (10 Stunden Anfangssaldo + 1 Stunde Wochenplus) und das Urlaubskonto 17 Tage. Der Urlaub erscheint im Teamkalender.
|
||||
|
||||
Ergebnisse der abschließenden Prüfung:
|
||||
|
||||
| Prüfung | Ergebnis |
|
||||
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Berechnungstests | 9 bestanden. |
|
||||
| Integrationstests | 25 bestanden; einschließlich gemeinsamer Anlage, vollständigem Rollback bei E-Mail-Konflikten, Bearbeitung bestehender Fachkräfte, Kontenschutz, Rollenprüfung und Empfänger, Opt-out, Versandfehler, Token-Erneuerung und paralleler Versandversuche. Testkitas danach bereinigt. |
|
||||
| TypeScript | Erfolgreich. |
|
||||
| ESLint für sämtliche geänderten Anwendungsteile und neue Tests | Erfolgreich, ohne Warnungen. |
|
||||
| Projektweites ESLint | Die bereits vorhandenen zwei `as any`-Fehler in `src/lib/auth-utils.ts` und neun Warnungen bestehen weiterhin. |
|
||||
| Endgültiger Produktionsbuild | Erfolgreich, einschließlich `/dashboard/personal`. |
|
||||
| Vollständiges und inkrementelles SQL-Skript | Erfolgreich in isolierten Prüfschemata; anschließend jeweils kein Unterschied zum Prisma-Schema. |
|
||||
| Browser | Admin- und ErzieherInnen-Ablauf einschließlich Freigaben erfolgreich. Mobile Ansicht und Arbeitszeitdialog ohne Seitenüberlauf geprüft. Abschalten der E-Mail-Hinweise im Demo-Profil gespeichert und nach Neuladen verifiziert; anschließend wieder eingeschaltet. |
|
||||
|
||||
Die vom bestehenden Build gemeldete Warnung zu mehreren Lockfiles bzw. dem Turbopack-Arbeitsverzeichnis und die bestehende Prisma-Konfigurationswarnung sind unabhängig vom Personalbereich. Eine produktive Bereitstellung oder Mailzustellung wurde nicht durchgeführt.
|
||||
|
||||
## Prüfung der gemeinsamen Fachkraft-Anlage (09.10.2026)
|
||||
|
||||
- 25 Personal-Integrationstests, TypeScript, ESLint der geänderten Anwendungsteile und Produktionsbuild erfolgreich.
|
||||
- Lokale Browserprüfung mit synthetischen Daten: Datum vor Beschäftigungsbeginn abgewiesen, Eingaben erhalten, korrigierte Anlage erfolgreich und unmittelbar zum Personalprofil weitergeleitet.
|
||||
- Resend im lokalen Produktionsbuild bewusst deaktiviert: gespeicherte Fachkraft und wiederholbarer Mailfehler in Personalbereich und Teamliste sichtbar; erneute Einladung aus der Teamliste erfolgreich ausgelöst.
|
||||
- Bestehende Fachkraft ohne Zugang zeigt E-Mail und fehlende Startdaten direkt im Bearbeitungsdialog. Mobilprüfung bei 390 × 844 Pixeln: Dialog ohne horizontalen Überlauf, längerer Inhalt innerhalb des Dialogs scrollbar.
|
||||
- Keine echten Aktivierungsmails versendet; keine Änderung an Produktionsdaten vorgenommen.
|
||||
|
||||
## E-Mail-Hinweise
|
||||
|
||||
Die E-Mail-Erweiterung wurde am 08.10.2026 ausdrücklich beauftragt. Sie verwendet die vorhandene Resend-Anbindung:
|
||||
|
||||
| Nachricht | Empfänger | Inhalt |
|
||||
| ------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Accountaktivierung | Vom Kita-Admin eingetragene E-Mail-Adresse der Fachkraft | Kita, Name, persönlicher Aktivierungslink mit sieben Tagen Gültigkeit. |
|
||||
| Antrag / Änderung / Stornierung oder Arbeitswoche eingereicht | Alle Kita-Admins derselben Kita, einzeln angeschrieben | Name, Zeitraum und Hinweis auf Prüfung im geschützten Personalbereich. |
|
||||
| Entscheidung / Rückgabe | Account-Adresse der betroffenen Fachkraft | Art, Zeitraum und Status mit Link in den geschützten Personalbereich. |
|
||||
|
||||
Fachkräfte können Entscheidungsmails unter **Mein Profil → E-Mail-Hinweise** abschalten. Aktivierungslinks und interne App-Hinweise bleiben davon unabhängig. Krankheitsbuchungen erzeugen keine E-Mail. Private Notizen, Entscheidungsbegründungen und Zeit- oder Urlaubskontostände werden nicht per E-Mail übertragen.
|
||||
|
||||
Versandaufträge werden innerhalb derselben Datenbanktransaktion wie die Personalaktion gespeichert. Der Versand erfolgt anschließend; ein Fehler macht den gespeicherten Antrag nicht rückgängig. Ausstehende Mails erscheinen im Personalbereich mit **Versand erneut versuchen**. Admins dürfen die Aufträge ihrer Kita wiederholen, Fachkräfte nur eigene. Pro Aktion werden bis zu 50 ausstehende Aufträge verarbeitet. Es gibt keinen zusätzlichen Hintergrundjob.
|
||||
|
||||
Vor jedem Versand werden Kita-Zugehörigkeit, aktuelle Rolle, Accountverknüpfung und gegebenenfalls die Abschalteinstellung geprüft. Ersetzte, abgelaufene oder bereits verwendete Aktivierungslinks werden übersprungen. Eine fünfminütige Versandsperre verhindert parallele Versuche für denselben Auftrag; ein stabiler Resend-Idempotenzschlüssel vermindert doppelte Zustellungen bei Wiederholungen. Eine Zustellbestätigung des Empfängerpostfachs wird nicht erfasst.
|
||||
|
||||
Benötigt werden `RESEND_API_KEY`, `EMAIL_FROM` und eine öffentliche App-Adresse über `NEXT_PUBLIC_SITE_URL`, alternativ `AUTH_URL` oder `NEXTAUTH_URL`. Ohne Konfiguration bleibt ein Produktionsversand ausstehend und wird als Warnung angezeigt. Im Entwicklungsmodus ohne Resend-Schlüssel wird der bestehende lokale Mailtransport simuliert. Die Tests verwenden ausschließlich einen eingesetzten Testtransport; es wurden keine echten E-Mails verschickt.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Elternabläufe: Mitteilungen und Teilnahmeantworten
|
||||
|
||||
## Verhalten
|
||||
|
||||
- Die Übersicht zeigt offene Rückmeldungen und drei aktuelle Mitteilungen. Das Archiv unter `/dashboard/mitteilungen` enthält alle für das eigene Konto sichtbaren Meldungen, zwölf pro Seite.
|
||||
- Termine können eine Antwort pro Kind oder pro Familie verlangen. Familienzusagen brauchen mindestens einen teilnehmenden Erwachsenen; Absagen speichern 0 Erwachsene. Beide Elternkonten bearbeiten dieselbe Antwort.
|
||||
- Die Terminart schlägt bei neuen Terminen eine Antwortart vor. Die Verwaltung kann sie ändern, solange keine Antworten vorliegen. Danach verhindert auch die Server Action einen Wechsel. Normale Terminänderungen erhalten die bisherigen Teilnahmefelder.
|
||||
- Kinderantworten werden für alle eigenen aktiven Kinder gemeinsam in einer Transaktion gespeichert. Eigene und fremde Kinder dürfen nicht gemischt werden. Frist, Terminstatus, Haushalt, Kita und aktiviertes Terminmodul werden serverseitig geprüft.
|
||||
- Ohne eigene Rückmeldefrist gilt der Terminbeginn. Nach Fristablauf bleiben die Antworten im Kalender lesbar. Die Eingabe ist in Monatsansicht, Liste und Agenda erreichbar.
|
||||
- Empfängergruppen sind Eltern, ErzieherInnen oder beide. Die Gruppe steuert App-Anzeige, Lesestatus-Aktion, Anhänge und optionalen E-Mail-Versand. Admins können sämtliche Meldungen der eigenen Kita zur Verwaltung sehen. Eltern mit Koordinationsrolle gehören mit ihrem Haushalt zur Elterngruppe; Erzieherkonten gehören zur Erziehergruppe.
|
||||
- Teilnehmerhinweise werden nur bei einer Zusage des Haushalts oder für Admin/Koordination an den Kalender-Client übertragen.
|
||||
|
||||
## Bestehende Daten und Schemaaktualisierung
|
||||
|
||||
Aktuelle Releases verwenden die versionierten Migrationen in `prisma/migrations`. Der separate Initialisierungsdienst wendet sie vor dem App-Start an und stellt die Runtime-Grants bereit. Einrichtung und Updates beschreibt [deploy/README.md](../deploy/README.md). Kein `prisma db push` auf verwalteten Datenbanken einsetzen: zusätzliche Tenant-Constraints ergänzen das Prisma-Modell.
|
||||
|
||||
`prisma/updates/20261007_parent_flows.sql` dokumentiert den historischen Ausgangsstand vor Einführung der Migrationshistorie. Es gehört nicht zum aktuellen Deployment und darf nicht zusätzlich zu bereits angewandten Migrationen ausgeführt werden. Die kompatiblen Defaults für bisherige Termine (`CHILD`) und Meldungen (`ALLE`) sind in den versionierten Migrationen enthalten.
|
||||
|
||||
## Verifikation
|
||||
|
||||
```sh
|
||||
npm run test:parent-flows
|
||||
TEST_DATABASE_URL=postgresql://USER@127.0.0.1:PORT/TESTDB npm run test:parent-flows:integration
|
||||
npx tsc --noEmit
|
||||
npm run build
|
||||
```
|
||||
|
||||
Die Integrationstests benötigen eine Node.js-Version mit experimentellem Modul-Mocking (hier geprüft mit Node.js 26), eine ausdrücklich über `TEST_DATABASE_URL` gesetzte lokale PostgreSQL-Testdatenbank und das aktuelle Schema. Sie legen eigene Kitas mit zufälligen IDs an und entfernen ausschließlich diese Testkitas wieder. Kein automatischer Zugriff auf `DATABASE_URL` ohne Testfreigabe.
|
||||
|
||||
Die Tests prüfen Erwachsenenanzahl, gemischte Kinderantworten, gemeinsame Familienantworten, Fristablauf, Terminabsage, Moduldeaktivierung, Kita-/Haushaltsgrenzen, gesperrte Antwortart einschließlich paralleler erster Rückmeldung, erhaltene Terminoptionen sowie verweigerte Team-Meldungen und Anhänge für Eltern. E-Mail-Empfänger werden anhand derselben Gruppenregeln geprüft; es werden keine echten E-Mails verschickt.
|
||||
|
||||
Zusätzlich im Browser geprüft: Familienzusage, unterschiedliche Antworten für zwei Kinder, verschwundene offene Hinweise, Lesen/Ändern der gemeinsamen Antwort aus einem zweiten Elternkonto, abgelaufene Eingabe, Kalender-Monats- und Agendaansicht, Lesestatus, Archivnavigation, Verwaltungsauswertung, gesperrte Antwortart und Eltern-/Erzieher-Empfängertrennung. Die Rückmeldeformulare und Übersicht wurden auch bei 390 Pixeln Breite visuell geprüft.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Produkt-Screenshots
|
||||
|
||||
Die Bilder in `public/landing/screenshots/` sind echte Browseraufnahmen der WebApp mit ausschließlich fiktiven Beispieldaten. Sie wurden am 13. Oktober 2026 aufgenommen. Keine Produktionsdaten, echten Kontaktadressen oder Zugangsdaten sind enthalten.
|
||||
|
||||
Die Metadaten und Bildbeschreibungen stehen in `src/lib/marketing/screenshots.ts`. Landingpage und Featureseiten verwenden dieselben Dateien. Die Vorschau reserviert den Bildbereich, lädt Bilder bedarfsgerecht über Next Image und bietet eine tastaturbedienbare Vollansicht mit Fensteranpassung, Originalgröße und Scrollbereich.
|
||||
|
||||
## Aufnahmen erneuern
|
||||
|
||||
1. Eine **leere**, ausschließlich lokale PostgreSQL-Datenbank namens `kita_screenshots_test` erstellen. Andere Datenbanknamen oder Hosts werden vom Fixture-Skript abgewiesen; bestehende Daten werden niemals gelöscht.
|
||||
2. `DATABASE_URL` nur für diese Sitzung auf diese Datenbank setzen und `npm run db:migrate:deploy` ausführen.
|
||||
3. Mit derselben URL `npx tsx scripts/seed-screenshots.ts` ausführen. Das Skript legt Kita Sonnenwinkel, sechs fiktive Familien und eine Fachkraft an. Die Zugangsdaten werden nur lokal ausgegeben. Alle E-Mail-Adressen verwenden die nicht zustellbare Domain `example.invalid`.
|
||||
4. Die App mit einer eigenen lokalen Auth-URL und einem Test-Secret starten. E-Mail-Versand und Analytics deaktivieren (`RESEND_API_KEY`, `NEXT_PUBLIC_UMAMI_SCRIPT_URL` und `NEXT_PUBLIC_UMAMI_WEBSITE_ID` leer; öffentliche Variablen schon beim Build leer setzen). Keine Jobs starten.
|
||||
5. Über die normale Anmeldung und Navigation die Ansichten aufnehmen. Der Fixture-Stand verwendet Oktober/November 2026. Bei späteren Aufnahmen die Termine, Auswahl und Arbeitswoche passend aktualisieren.
|
||||
6. Browseraufnahmen als JPEG speichern. Die Elternansichten wurden bei 1600 × 1000, die Arbeitszeitansicht bei 1600 × 1280 aufgenommen. Der Browser kann beim Ausschließen der Scrollbar leicht abweichende tatsächliche Bildmaße liefern; die Metadaten immer aus den **gespeicherten Dateien** lesen und aktualisieren.
|
||||
7. Notdienst, Kalender, Adressbuch und eigene Abwesenheiten zeigen die Elternansicht. Bei Mitteilungen die erste aktualisierte Meldung öffnen. Für Arbeitszeit mit dem Teamkonto anmelden und zur erfassten Woche vom 5. bis 11. Oktober wechseln.
|
||||
8. Bilder und Vergrößerung auf Desktop und Mobilgerät prüfen, dann Beispieldatenbank und lokalen Server entfernen.
|
||||
|
||||
Die sechs Screenshot-Files der Galerie sind zusammen unter 500 KiB groß. Die zusätzliche Aufnahme `termin-details.jpg` zeigt Teilnahme und Mitbringliste für das Erklärvideo. Das alte Bild `notdienst-calendar-linen.png` wird nicht mehr von der Landingpage verwendet.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Erklärvideo für Kita-Planer
|
||||
|
||||
Stand: 14. Oktober 2026. Das Video ist ein zweiminütiger, tonloser Rundgang durch die WebApp. Es verwendet echte App-Screenshots mit ausschließlich fiktiven Beispieldaten. Bildausschnitte und grüne Markierungen führen durch die Funktionen; es wird keine Bildschirmaufnahme von Klicks vorgetäuscht.
|
||||
|
||||
## Dateien
|
||||
|
||||
- `public/landing/video/kita-planer-rundgang.mp4`: teilbare Fassung mit fest eingeblendeten deutschen Untertiteln. H.264, 1920 × 1080, 25 Bilder/s, 2:00 Minuten, ohne Audiospur.
|
||||
- `public/landing/video/kita-planer-rundgang-web.mp4`: Website-Fassung ohne eingebrannte Untertitel. Der native Videoplayer lädt standardmäßig die deutschen WebVTT-Untertitel; sie passen sich dem Player an und lassen sich abschalten.
|
||||
- `kita-planer-untertitel.vtt`: synchronisierte deutsche Untertitel für beide Fassungen.
|
||||
- `kita-planer-kapitel.vtt`: acht Kapitel mit Zeitmarken.
|
||||
- `kita-planer-poster.jpg`: Vorschaubild für die Landingpage.
|
||||
- `kita-planer-transkript.txt`: vollständige Textfassung.
|
||||
|
||||
Alle Dateien liegen unter `public/landing/video/`. Der Player startet erst auf Wunsch und lädt das Video nicht beim Seitenaufruf (`preload="none"`). Er nutzt keine externen Videoanbieter. Unter dem Player gibt es Downloadlinks und eine aufklappbare Textfassung.
|
||||
|
||||
## Erneut rendern
|
||||
|
||||
Das Storyboard in `scripts/product-video/storyboard.json` enthält Dauer, Texte, Quellen, Bildausschnitte und Markierungen jeder Szene. Die Quellen liegen in `public/landing/screenshots/`; die Terminaufnahme entstand ebenfalls mit dem isolierten Screenshot-Fixture. Anleitung siehe `product-screenshots.md`.
|
||||
|
||||
Mit installierten Projektabhängigkeiten und lokalem `ffmpeg`:
|
||||
|
||||
```
|
||||
node scripts/product-video/render.mjs
|
||||
```
|
||||
|
||||
Der Renderer verwendet `sharp` aus den installierten Next.js-Abhängigkeiten für SVG-Titel und Video-Compositing. Unter macOS wird die vorhandene Schrift Avenir Next verwendet. Auf anderen Systemen diese Schrift oder einen geeigneten Ersatz bereitstellen und die Umbrüche visuell prüfen. Rendern ist ein lokaler Vorbereitungsschritt und kein Teil des Produktionsbuilds; das Deployment liefert die fertigen Dateien aus.
|
||||
|
||||
Bei Textänderungen beide Fassungen rendern, den Film vollständig auf Lesbarkeit und Synchronität prüfen und die Kapitelstarts mit dem Storyboard abgleichen. Bei Aktualisierung der App die echten Screenshots erneuern und Ausschnitte/Markierungen anpassen. Die temporären Renderdateien werden nach dem Lauf automatisch entfernt.
|
||||
@@ -1,12 +1,33 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
experimental: { serverActions: { bodySizeLimit: "26mb" } },
|
||||
async headers() {
|
||||
return [{ source: "/:path*", headers: [
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Referrer-Policy", value: "no-referrer" },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||
...(process.env.NODE_ENV === "production" ? [{ key: "Strict-Transport-Security", value: "max-age=31536000" }] : []),
|
||||
] }];
|
||||
},
|
||||
images: {
|
||||
localPatterns: [
|
||||
{ pathname: "/landing/**", search: "" },
|
||||
{ pathname: "/og-image.png", search: "" },
|
||||
{ pathname: "/_next/static/media/**", search: "" },
|
||||
],
|
||||
maximumDiskCacheSize: 128 * 1024 * 1024,
|
||||
maximumResponseBody: 10 * 1024 * 1024,
|
||||
maximumRedirects: 0,
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
pathname: "/**",
|
||||
port: "",
|
||||
pathname: "/photo-1503454537195-1dcabb73ffb9",
|
||||
search: "?auto=format&fit=crop&w=2400&q=86",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -3,23 +3,46 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development prisma db push && NODE_ENV=development prisma db seed && next dev",
|
||||
"build": "next build",
|
||||
"build:prod": "prisma generate && next build",
|
||||
"dev": "next dev",
|
||||
"build": "next build && node scripts/prepare-standalone.mjs",
|
||||
"build:prod": "prisma generate && next build && node scripts/prepare-standalone.mjs",
|
||||
"start": "next start",
|
||||
"start:prod": "next start",
|
||||
"start:prod": "sh .next/standalone/scripts/start-production.sh",
|
||||
"lint": "eslint",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:studio": "prisma studio",
|
||||
"db:seed": "NODE_ENV=development prisma db seed",
|
||||
"db:seed:reset": "NODE_ENV=development prisma db seed -- --reset"
|
||||
"db:seed": "prisma db seed",
|
||||
"db:seed:reset": "prisma db seed -- --reset",
|
||||
"db:migrate:deploy": "prisma migrate deploy",
|
||||
"test:security": "node --test tests/security.test.cjs",
|
||||
"db:provision-runtime": "node scripts/provision-runtime-role.mjs",
|
||||
"uploads:backfill": "tsx scripts/backfill-attachment-sizes.ts",
|
||||
"uploads:cleanup": "tsx scripts/cleanup-attachments.ts",
|
||||
"test:security:http": "node tests/security-http.cjs",
|
||||
"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:integration": "tsx --experimental-test-module-mocks --test tests/parent-flows.integration.test.ts",
|
||||
"test:notdienst": "tsx --test tests/notdienst-days.test.ts",
|
||||
"test:notdienst:integration": "node --test tests/notdienst-planning.integration.test.cjs",
|
||||
"test:calendar:integration": "node --test tests/calendar-actions.integration.test.cjs",
|
||||
"test:notdienst:notifications": "node --test tests/notdienst-notifications.integration.test.cjs",
|
||||
"test:announcements": "node --test tests/announcements.integration.test.cjs",
|
||||
"test:seo:http": "node --test tests/marketing-seo-http.cjs",
|
||||
"test:seo": "node --test tests/marketing-seo.test.cjs",
|
||||
"test:mail": "node --test tests/mail-transport.test.cjs",
|
||||
"test:account": "node --test tests/password-management.integration.test.cjs",
|
||||
"test:roles": "node --test tests/kita-roles.integration.test.cjs tests/notdienst-assignment-view.test.cjs",
|
||||
"test:runtime": "node --test tests/runtime-supervisor.test.cjs",
|
||||
"test:family-absence": "tsx --test tests/absence-policy.test.ts",
|
||||
"test:family-absence:integration": "tsx --experimental-test-module-mocks --test tests/family-absence-flows.integration.test.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
@@ -36,8 +59,8 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"next": "16.2.4",
|
||||
"next-auth": "^5.0.0-beta.31",
|
||||
"next": "^16.4.0",
|
||||
"next-auth": "^5.0.0-beta.32",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
@@ -57,10 +80,18 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.4",
|
||||
"eslint-config-next": "^16.4.0",
|
||||
"prisma": "^6.19.3",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"overrides": {
|
||||
"@prisma/config": {
|
||||
"deepmerge-ts": "8.0.0"
|
||||
},
|
||||
"tsx": {
|
||||
"esbuild": "^0.28.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
-- CreateSchema
|
||||
CREATE SCHEMA IF NOT EXISTS "public";
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserRole" AS ENUM ('SUPERADMIN', 'ADMIN', 'KOORDINATOR', 'ERZIEHER', 'ELTERN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "InvitationStatus" AS ENUM ('PENDING', 'ACCEPTED', 'EXPIRED', 'REVOKED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TerminType" AS ENUM ('KITA_FEST', 'SCHLIESSTAG', 'TEAMTAG', 'MITMACH_TAG', 'ELTERNABEND', 'MITGLIEDERVERSAMMLUNG', 'ELTERNCAFE', 'GEBURTSTAG_INTERN', 'GEBURTSTAG_EXTERN', 'SONSTIGES');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TerminStatus" AS ENUM ('PENDING', 'CONFIRMED', 'REJECTED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EventParticipationStatus" AS ENUM ('ATTENDING', 'NOT_ATTENDING');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AuditTargetType" AS ENUM ('KITA', 'USER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NotdienstPlanStatus" AS ENUM ('DRAFT', 'PUBLISHED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NotdienstAlertStatus" AS ENUM ('PENDING', 'CONFIRMED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NotdienstSwapStatus" AS ENUM ('PENDING', 'ACCEPTED', 'DECLINED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DutyAssignmentStatus" AS ENUM ('PLANNED', 'DONE', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AbsenceReason" AS ENUM ('ILLNESS', 'VACATION', 'OTHER');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "kitas" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"notdienstModuleEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"terminModuleEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"adressbuchModuleEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"notdienstMinPerChildPerMonth" INTEGER NOT NULL DEFAULT 2,
|
||||
"notdienstReminderDaysBefore" INTEGER NOT NULL DEFAULT 7,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "kitas_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "families" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "families_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT,
|
||||
"familyId" TEXT,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT NOT NULL,
|
||||
"role" "UserRole" NOT NULL DEFAULT 'ELTERN',
|
||||
"phone" TEXT,
|
||||
"street" TEXT,
|
||||
"postalCode" TEXT,
|
||||
"city" TEXT,
|
||||
"privacyPolicyAcceptedAt" TIMESTAMP(3),
|
||||
"privacyPolicyVersion" TEXT,
|
||||
"directoryOptInAt" TIMESTAMP(3),
|
||||
"emailVerifiedAt" TIMESTAMP(3),
|
||||
"lastLoginAt" TIMESTAMP(3),
|
||||
"failedLoginAttempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"lockedUntil" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "children" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"familyId" TEXT NOT NULL,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT NOT NULL,
|
||||
"dateOfBirth" TIMESTAMP(3),
|
||||
"notes" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "children_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "educators" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT NOT NULL,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "educators_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "parent_duties" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "parent_duties_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "parent_duty_assignments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"dutyId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "parent_duty_assignments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "duty_types" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "duty_types_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "duty_assignments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"familyId" TEXT NOT NULL,
|
||||
"dutyTypeId" TEXT NOT NULL,
|
||||
"startDate" DATE NOT NULL,
|
||||
"endDate" DATE NOT NULL,
|
||||
"status" "DutyAssignmentStatus" NOT NULL DEFAULT 'PLANNED',
|
||||
"reminderSentAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "duty_assignments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "absences" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"childId" TEXT NOT NULL,
|
||||
"startDate" DATE NOT NULL,
|
||||
"endDate" DATE NOT NULL,
|
||||
"reason" "AbsenceReason" NOT NULL,
|
||||
"note" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "absences_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "announcements" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"authorId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "announcements_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "attachments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"announcementId" TEXT NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"fileUrl" TEXT NOT NULL,
|
||||
"fileType" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "attachments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "announcement_reads" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"announcementId" TEXT NOT NULL,
|
||||
"readAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "announcement_reads_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "invitations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"role" "UserRole" NOT NULL DEFAULT 'ELTERN',
|
||||
"token" TEXT NOT NULL,
|
||||
"status" "InvitationStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"acceptedAt" TIMESTAMP(3),
|
||||
"invitedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "invitations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "termine" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"type" "TerminType" NOT NULL,
|
||||
"status" "TerminStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"startDate" TIMESTAMP(3) NOT NULL,
|
||||
"endDate" TIMESTAMP(3) NOT NULL,
|
||||
"allDay" BOOLEAN NOT NULL DEFAULT false,
|
||||
"mitbringselListEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"requiresRsvp" BOOLEAN NOT NULL DEFAULT false,
|
||||
"rsvpDeadline" TIMESTAMP(3),
|
||||
"participantInfo" TEXT,
|
||||
"createdById" TEXT,
|
||||
"approvedById" TEXT,
|
||||
"approvedAt" TIMESTAMP(3),
|
||||
"rejectionReason" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "termine_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "event_participations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"childId" TEXT NOT NULL,
|
||||
"status" "EventParticipationStatus" NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "event_participations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "mitbringsel_items" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"terminId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "mitbringsel_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_plans" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"year" INTEGER NOT NULL,
|
||||
"month" INTEGER NOT NULL,
|
||||
"status" "NotdienstPlanStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"createdById" TEXT,
|
||||
"publishedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "notdienst_plans_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_availabilities" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"childId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "notdienst_availabilities_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_assignments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"planId" TEXT NOT NULL,
|
||||
"childId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "notdienst_assignments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_swap_requests" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"requesterUserId" TEXT NOT NULL,
|
||||
"requesterFamilyId" TEXT NOT NULL,
|
||||
"targetAssignmentId" TEXT NOT NULL,
|
||||
"offeredAssignmentId" TEXT NOT NULL,
|
||||
"status" "NotdienstSwapStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"message" TEXT,
|
||||
"respondedById" TEXT,
|
||||
"respondedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "notdienst_swap_requests_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_alerts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"assignmentId" TEXT NOT NULL,
|
||||
"parentUserId" TEXT NOT NULL,
|
||||
"triggeredById" TEXT,
|
||||
"educatorId" TEXT,
|
||||
"status" "NotdienstAlertStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"confirmationToken" TEXT NOT NULL,
|
||||
"triggeredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"confirmedAt" TIMESTAMP(3),
|
||||
"notes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "notdienst_alerts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "audit_logs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"actorUserId" TEXT,
|
||||
"actorEmail" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"targetType" "AuditTargetType" NOT NULL,
|
||||
"targetId" TEXT NOT NULL,
|
||||
"targetLabel" TEXT NOT NULL,
|
||||
"targetKitaId" TEXT,
|
||||
"metadata" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "verification_tokens" (
|
||||
"identifier" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expires" TIMESTAMP(3) NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "kitas_slug_key" ON "kitas"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "families_kitaId_idx" ON "families"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "users_kitaId_idx" ON "users"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "users_familyId_idx" ON "users"("familyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "users_kitaId_role_idx" ON "users"("kitaId", "role");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "children_kitaId_idx" ON "children"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "children_familyId_idx" ON "children"("familyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "educators_kitaId_idx" ON "educators"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "parent_duties_kitaId_idx" ON "parent_duties"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "parent_duties_kitaId_name_key" ON "parent_duties"("kitaId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "parent_duty_assignments_kitaId_idx" ON "parent_duty_assignments"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "parent_duty_assignments_userId_idx" ON "parent_duty_assignments"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "parent_duty_assignments_dutyId_userId_key" ON "parent_duty_assignments"("dutyId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "duty_types_kitaId_idx" ON "duty_types"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "duty_types_kitaId_name_key" ON "duty_types"("kitaId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "duty_assignments_kitaId_startDate_idx" ON "duty_assignments"("kitaId", "startDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "duty_assignments_familyId_startDate_idx" ON "duty_assignments"("familyId", "startDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "duty_assignments_kitaId_dutyTypeId_startDate_key" ON "duty_assignments"("kitaId", "dutyTypeId", "startDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "absences_kitaId_startDate_endDate_idx" ON "absences"("kitaId", "startDate", "endDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "absences_childId_startDate_idx" ON "absences"("childId", "startDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "announcements_kitaId_createdAt_idx" ON "announcements"("kitaId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "announcements_authorId_idx" ON "announcements"("authorId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "attachments_announcementId_idx" ON "attachments"("announcementId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "announcement_reads_announcementId_idx" ON "announcement_reads"("announcementId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "announcement_reads_userId_announcementId_key" ON "announcement_reads"("userId", "announcementId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "invitations_token_key" ON "invitations"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "invitations_kitaId_idx" ON "invitations"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "invitations_kitaId_email_key" ON "invitations"("kitaId", "email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "termine_kitaId_startDate_idx" ON "termine"("kitaId", "startDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "termine_kitaId_status_idx" ON "termine"("kitaId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "event_participations_kitaId_idx" ON "event_participations"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "event_participations_childId_idx" ON "event_participations"("childId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "event_participations_eventId_childId_key" ON "event_participations"("eventId", "childId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "mitbringsel_items_kitaId_idx" ON "mitbringsel_items"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "mitbringsel_items_terminId_idx" ON "mitbringsel_items"("terminId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_plans_kitaId_idx" ON "notdienst_plans"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_plans_kitaId_year_month_key" ON "notdienst_plans"("kitaId", "year", "month");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_availabilities_kitaId_date_idx" ON "notdienst_availabilities"("kitaId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_availabilities_userId_idx" ON "notdienst_availabilities"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_availabilities_childId_date_key" ON "notdienst_availabilities"("childId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_assignments_kitaId_date_idx" ON "notdienst_assignments"("kitaId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_assignments_planId_date_key" ON "notdienst_assignments"("planId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_swap_requests_kitaId_status_idx" ON "notdienst_swap_requests"("kitaId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_swap_requests_requesterFamilyId_status_idx" ON "notdienst_swap_requests"("requesterFamilyId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_swap_requests_targetAssignmentId_status_idx" ON "notdienst_swap_requests"("targetAssignmentId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_swap_requests_offeredAssignmentId_status_idx" ON "notdienst_swap_requests"("offeredAssignmentId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_alerts_confirmationToken_key" ON "notdienst_alerts"("confirmationToken");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alerts_kitaId_idx" ON "notdienst_alerts"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alerts_assignmentId_idx" ON "notdienst_alerts"("assignmentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alerts_parentUserId_idx" ON "notdienst_alerts"("parentUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_actorUserId_idx" ON "audit_logs"("actorUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_targetType_targetId_idx" ON "audit_logs"("targetType", "targetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_targetKitaId_idx" ON "audit_logs"("targetKitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_createdAt_idx" ON "audit_logs"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "verification_tokens_token_key" ON "verification_tokens"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "verification_tokens_identifier_token_key" ON "verification_tokens"("identifier", "token");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "families" ADD CONSTRAINT "families_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "children" ADD CONSTRAINT "children_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "children" ADD CONSTRAINT "children_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "educators" ADD CONSTRAINT "educators_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "parent_duties" ADD CONSTRAINT "parent_duties_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "parent_duty_assignments" ADD CONSTRAINT "parent_duty_assignments_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "parent_duty_assignments" ADD CONSTRAINT "parent_duty_assignments_dutyId_fkey" FOREIGN KEY ("dutyId") REFERENCES "parent_duties"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "parent_duty_assignments" ADD CONSTRAINT "parent_duty_assignments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "duty_types" ADD CONSTRAINT "duty_types_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "duty_assignments" ADD CONSTRAINT "duty_assignments_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "duty_assignments" ADD CONSTRAINT "duty_assignments_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "duty_assignments" ADD CONSTRAINT "duty_assignments_dutyTypeId_fkey" FOREIGN KEY ("dutyTypeId") REFERENCES "duty_types"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "absences" ADD CONSTRAINT "absences_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "absences" ADD CONSTRAINT "absences_childId_fkey" FOREIGN KEY ("childId") REFERENCES "children"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "announcements" ADD CONSTRAINT "announcements_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "announcements" ADD CONSTRAINT "announcements_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_announcementId_fkey" FOREIGN KEY ("announcementId") REFERENCES "announcements"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "announcement_reads" ADD CONSTRAINT "announcement_reads_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "announcement_reads" ADD CONSTRAINT "announcement_reads_announcementId_fkey" FOREIGN KEY ("announcementId") REFERENCES "announcements"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "termine" ADD CONSTRAINT "termine_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "termine" ADD CONSTRAINT "termine_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "termine" ADD CONSTRAINT "termine_approvedById_fkey" FOREIGN KEY ("approvedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "event_participations" ADD CONSTRAINT "event_participations_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "event_participations" ADD CONSTRAINT "event_participations_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "termine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "event_participations" ADD CONSTRAINT "event_participations_childId_fkey" FOREIGN KEY ("childId") REFERENCES "children"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mitbringsel_items" ADD CONSTRAINT "mitbringsel_items_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mitbringsel_items" ADD CONSTRAINT "mitbringsel_items_terminId_fkey" FOREIGN KEY ("terminId") REFERENCES "termine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mitbringsel_items" ADD CONSTRAINT "mitbringsel_items_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_plans" ADD CONSTRAINT "notdienst_plans_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_plans" ADD CONSTRAINT "notdienst_plans_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_availabilities" ADD CONSTRAINT "notdienst_availabilities_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_availabilities" ADD CONSTRAINT "notdienst_availabilities_childId_fkey" FOREIGN KEY ("childId") REFERENCES "children"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_availabilities" ADD CONSTRAINT "notdienst_availabilities_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_assignments" ADD CONSTRAINT "notdienst_assignments_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_assignments" ADD CONSTRAINT "notdienst_assignments_planId_fkey" FOREIGN KEY ("planId") REFERENCES "notdienst_plans"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_assignments" ADD CONSTRAINT "notdienst_assignments_childId_fkey" FOREIGN KEY ("childId") REFERENCES "children"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_requesterUserId_fkey" FOREIGN KEY ("requesterUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_requesterFamilyId_fkey" FOREIGN KEY ("requesterFamilyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_targetAssignmentId_fkey" FOREIGN KEY ("targetAssignmentId") REFERENCES "notdienst_assignments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_offeredAssignmentId_fkey" FOREIGN KEY ("offeredAssignmentId") REFERENCES "notdienst_assignments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_respondedById_fkey" FOREIGN KEY ("respondedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "notdienst_assignments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_parentUserId_fkey" FOREIGN KEY ("parentUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_triggeredById_fkey" FOREIGN KEY ("triggeredById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_educatorId_fkey" FOREIGN KEY ("educatorId") REFERENCES "educators"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,102 @@
|
||||
BEGIN;
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "disabledAt" TIMESTAMP(3),
|
||||
ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "rate_limit_buckets" (
|
||||
"key" TEXT NOT NULL,
|
||||
"count" INTEGER NOT NULL,
|
||||
"resetAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "rate_limit_buckets_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "pending_registrations" (
|
||||
"email" TEXT NOT NULL,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "pending_registrations_pkey" PRIMARY KEY ("email")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "rate_limit_buckets_resetAt_idx" ON "rate_limit_buckets"("resetAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pending_registrations_tokenHash_key" ON "pending_registrations"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pending_registrations_expiresAt_idx" ON "pending_registrations"("expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "families_id_kitaId_key" ON "families"("id", "kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_id_kitaId_key" ON "users"("id", "kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "children_id_kitaId_key" ON "children"("id", "kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "educators_id_kitaId_key" ON "educators"("id", "kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "parent_duties_id_kitaId_key" ON "parent_duties"("id", "kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "duty_types_id_kitaId_key" ON "duty_types"("id", "kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "termine_id_kitaId_key" ON "termine"("id", "kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_plans_id_kitaId_key" ON "notdienst_plans"("id", "kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_assignments_id_kitaId_key" ON "notdienst_assignments"("id", "kitaId");
|
||||
|
||||
|
||||
-- Additional composite tenant foreign keys deliberately supplement the Prisma single-ID relations.
|
||||
-- NO ACTION preserves their existing Cascade/SetNull behavior; validation rejects inconsistent legacy rows.
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_familyId_tenant_fkey" FOREIGN KEY ("familyId", "kitaId") REFERENCES "families"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "children" ADD CONSTRAINT "children_familyId_tenant_fkey" FOREIGN KEY ("familyId", "kitaId") REFERENCES "families"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "parent_duty_assignments" ADD CONSTRAINT "parent_duty_assignments_dutyId_tenant_fkey" FOREIGN KEY ("dutyId", "kitaId") REFERENCES "parent_duties"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "parent_duty_assignments" ADD CONSTRAINT "parent_duty_assignments_userId_tenant_fkey" FOREIGN KEY ("userId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "duty_assignments" ADD CONSTRAINT "duty_assignments_familyId_tenant_fkey" FOREIGN KEY ("familyId", "kitaId") REFERENCES "families"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "duty_assignments" ADD CONSTRAINT "duty_assignments_dutyTypeId_tenant_fkey" FOREIGN KEY ("dutyTypeId", "kitaId") REFERENCES "duty_types"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "absences" ADD CONSTRAINT "absences_childId_tenant_fkey" FOREIGN KEY ("childId", "kitaId") REFERENCES "children"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "announcements" ADD CONSTRAINT "announcements_authorId_tenant_fkey" FOREIGN KEY ("authorId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_invitedById_tenant_fkey" FOREIGN KEY ("invitedById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "termine" ADD CONSTRAINT "termine_createdById_tenant_fkey" FOREIGN KEY ("createdById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "termine" ADD CONSTRAINT "termine_approvedById_tenant_fkey" FOREIGN KEY ("approvedById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "event_participations" ADD CONSTRAINT "event_participations_eventId_tenant_fkey" FOREIGN KEY ("eventId", "kitaId") REFERENCES "termine"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "event_participations" ADD CONSTRAINT "event_participations_childId_tenant_fkey" FOREIGN KEY ("childId", "kitaId") REFERENCES "children"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "mitbringsel_items" ADD CONSTRAINT "mitbringsel_items_terminId_tenant_fkey" FOREIGN KEY ("terminId", "kitaId") REFERENCES "termine"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "mitbringsel_items" ADD CONSTRAINT "mitbringsel_items_userId_tenant_fkey" FOREIGN KEY ("userId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_plans" ADD CONSTRAINT "notdienst_plans_createdById_tenant_fkey" FOREIGN KEY ("createdById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_availabilities" ADD CONSTRAINT "notdienst_availabilities_childId_tenant_fkey" FOREIGN KEY ("childId", "kitaId") REFERENCES "children"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_availabilities" ADD CONSTRAINT "notdienst_availabilities_userId_tenant_fkey" FOREIGN KEY ("userId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_assignments" ADD CONSTRAINT "notdienst_assignments_planId_tenant_fkey" FOREIGN KEY ("planId", "kitaId") REFERENCES "notdienst_plans"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_assignments" ADD CONSTRAINT "notdienst_assignments_childId_tenant_fkey" FOREIGN KEY ("childId", "kitaId") REFERENCES "children"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_requesterUserId_tenant_fkey" FOREIGN KEY ("requesterUserId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_requesterFamilyId_tenant_fkey" FOREIGN KEY ("requesterFamilyId", "kitaId") REFERENCES "families"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_targetAssignmentId_tenant_fkey" FOREIGN KEY ("targetAssignmentId", "kitaId") REFERENCES "notdienst_assignments"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_offeredAssignmentId_tenant_fkey" FOREIGN KEY ("offeredAssignmentId", "kitaId") REFERENCES "notdienst_assignments"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_respondedById_tenant_fkey" FOREIGN KEY ("respondedById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_assignmentId_tenant_fkey" FOREIGN KEY ("assignmentId", "kitaId") REFERENCES "notdienst_assignments"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_parentUserId_tenant_fkey" FOREIGN KEY ("parentUserId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_triggeredById_tenant_fkey" FOREIGN KEY ("triggeredById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_educatorId_tenant_fkey" FOREIGN KEY ("educatorId", "kitaId") REFERENCES "educators"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- Existing founder registrations previously set this timestamp without verification.
|
||||
UPDATE users SET "emailVerifiedAt" = NULL, "sessionVersion" = "sessionVersion" + 1
|
||||
WHERE role = 'ADMIN' AND "kitaId" IS NULL;
|
||||
-- Preserve existing administrative account locks separately from temporary brute-force lockouts.
|
||||
UPDATE users SET "disabledAt" = NOW(), "sessionVersion" = "sessionVersion" + 1
|
||||
WHERE "lockedUntil" >= TIMESTAMP '2099-01-01';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,20 @@
|
||||
BEGIN;
|
||||
-- AlterTable
|
||||
ALTER TABLE "announcement_reads" ADD COLUMN "kitaId" TEXT;
|
||||
UPDATE announcement_reads AS r SET "kitaId" = a."kitaId" FROM announcements AS a WHERE a.id = r."announcementId";
|
||||
ALTER TABLE announcement_reads ALTER COLUMN "kitaId" SET NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "announcements_id_kitaId_key" ON "announcements"("id", "kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "announcement_reads_kitaId_idx" ON "announcement_reads"("kitaId");
|
||||
|
||||
|
||||
ALTER TABLE users ADD CONSTRAINT users_family_requires_tenant CHECK ("familyId" IS NULL OR "kitaId" IS NOT NULL);
|
||||
ALTER TABLE announcement_reads ADD CONSTRAINT announcement_reads_user_tenant_fkey
|
||||
FOREIGN KEY ("userId", "kitaId") REFERENCES users(id, "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE announcement_reads ADD CONSTRAINT announcement_reads_announcement_tenant_fkey
|
||||
FOREIGN KEY ("announcementId", "kitaId") REFERENCES announcements(id, "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "attachments" ADD COLUMN "sizeBytes" INTEGER;
|
||||
@@ -0,0 +1,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,13 @@
|
||||
-- Family invitation delivery status; applied before the restricted runtime starts.
|
||||
ALTER TABLE "invitations"
|
||||
ADD COLUMN "userId" TEXT,
|
||||
ADD COLUMN "sentAt" TIMESTAMP(3),
|
||||
ADD COLUMN "lastAttemptAt" TIMESTAMP(3),
|
||||
ADD COLUMN "leaseUntil" TIMESTAMP(3),
|
||||
ADD COLUMN "attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "failed" BOOLEAN NOT NULL DEFAULT false;
|
||||
CREATE UNIQUE INDEX "invitations_userId_key" ON "invitations"("userId");
|
||||
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_userId_tenant_fkey"
|
||||
FOREIGN KEY ("userId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
@@ -0,0 +1,47 @@
|
||||
BEGIN;
|
||||
CREATE TABLE "notdienst_emails" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"familyId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"planId" TEXT,
|
||||
"year" INTEGER NOT NULL,
|
||||
"month" INTEGER NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"eventKey" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"skippedAt" TIMESTAMP(3),
|
||||
"leaseUntil" TIMESTAMP(3),
|
||||
"claimToken" TEXT,
|
||||
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"failed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastError" TEXT,
|
||||
CONSTRAINT "notdienst_emails_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "notdienst_emails_month_check" CHECK ("month" BETWEEN 1 AND 12),
|
||||
CONSTRAINT "notdienst_emails_kind_check" CHECK ("kind" IN ('REMINDER','PUBLICATION')),
|
||||
CONSTRAINT "notdienst_emails_plan_check" CHECK (("kind" = 'PUBLICATION' AND "planId" IS NOT NULL) OR ("kind" = 'REMINDER' AND "planId" IS NULL))
|
||||
);
|
||||
CREATE UNIQUE INDEX "notdienst_emails_kitaId_userId_kind_year_month_eventKey_key" ON "notdienst_emails"("kitaId","userId","kind","year","month","eventKey");
|
||||
CREATE INDEX "notdienst_emails_sentAt_skippedAt_nextAttemptAt_idx" ON "notdienst_emails"("sentAt","skippedAt","nextAttemptAt");
|
||||
CREATE INDEX "notdienst_emails_kitaId_year_month_idx" ON "notdienst_emails"("kitaId","year","month");
|
||||
ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_planId_fkey" FOREIGN KEY ("planId") REFERENCES "notdienst_plans"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_familyId_tenant_fkey" FOREIGN KEY ("familyId","kitaId") REFERENCES "families"("id","kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_userId_tenant_fkey" FOREIGN KEY ("userId","kitaId") REFERENCES "users"("id","kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_planId_tenant_fkey" FOREIGN KEY ("planId","kitaId") REFERENCES "notdienst_plans"("id","kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
CREATE TABLE "notdienst_reminder_runs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"year" INTEGER NOT NULL,
|
||||
"month" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "notdienst_reminder_runs_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "notdienst_reminder_runs_month_check" CHECK ("month" BETWEEN 1 AND 12)
|
||||
);
|
||||
CREATE UNIQUE INDEX "notdienst_reminder_runs_kitaId_year_month_key" ON "notdienst_reminder_runs"("kitaId","year","month");
|
||||
ALTER TABLE "notdienst_reminder_runs" ADD CONSTRAINT "notdienst_reminder_runs_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,23 @@
|
||||
BEGIN;
|
||||
ALTER TABLE "announcements" ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN "editedAt" TIMESTAMP(3), ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, ADD COLUMN "emailReady" BOOLEAN NOT NULL DEFAULT true;
|
||||
UPDATE "announcements" SET "updatedAt" = "createdAt";
|
||||
CREATE INDEX "announcements_kitaId_updatedAt_idx" ON "announcements"("kitaId", "updatedAt");
|
||||
CREATE TABLE "announcement_emails" (
|
||||
"id" TEXT NOT NULL, "kitaId" TEXT NOT NULL, "announcementId" TEXT NOT NULL, "userId" TEXT NOT NULL,
|
||||
"revision" INTEGER NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"leaseUntil" TIMESTAMP(3), "claimToken" TEXT, "sentAt" TIMESTAMP(3), "skippedAt" TIMESTAMP(3),
|
||||
"failed" BOOLEAN NOT NULL DEFAULT false, "lastError" TEXT,
|
||||
CONSTRAINT "announcement_emails_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "announcement_emails_revision_check" CHECK ("revision" > 0)
|
||||
);
|
||||
CREATE UNIQUE INDEX "announcement_emails_announcementId_revision_userId_key" ON "announcement_emails"("announcementId", "revision", "userId");
|
||||
CREATE INDEX "announcement_emails_kitaId_announcementId_revision_idx" ON "announcement_emails"("kitaId", "announcementId", "revision");
|
||||
CREATE INDEX "announcement_emails_sentAt_skippedAt_nextAttemptAt_idx" ON "announcement_emails"("sentAt", "skippedAt", "nextAttemptAt");
|
||||
ALTER TABLE "announcement_emails" ADD CONSTRAINT "announcement_emails_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "announcement_emails" ADD CONSTRAINT "announcement_emails_announcementId_fkey" FOREIGN KEY ("announcementId") REFERENCES "announcements"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "announcement_emails" ADD CONSTRAINT "announcement_emails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "announcement_emails" ADD CONSTRAINT "announcement_emails_announcementId_tenant_fkey" FOREIGN KEY ("announcementId", "kitaId") REFERENCES "announcements"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "announcement_emails" ADD CONSTRAINT "announcement_emails_userId_tenant_fkey" FOREIGN KEY ("userId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
COMMIT;
|
||||
@@ -0,0 +1 @@
|
||||
provider = "postgresql"
|
||||
@@ -63,6 +63,17 @@ enum TerminStatus {
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum RsvpMode {
|
||||
CHILD
|
||||
FAMILY
|
||||
}
|
||||
|
||||
enum AnnouncementAudience {
|
||||
ELTERN
|
||||
ERZIEHER
|
||||
ALLE
|
||||
}
|
||||
|
||||
enum EventParticipationStatus {
|
||||
ATTENDING
|
||||
NOT_ATTENDING
|
||||
@@ -84,6 +95,15 @@ enum NotdienstAlertStatus {
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum NotdienstAlarmStatus {
|
||||
PENDING
|
||||
CONFIRMED
|
||||
DECLINED
|
||||
LATE_CONFIRMATION
|
||||
REVIEW_REQUIRED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum NotdienstSwapStatus {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
@@ -135,15 +155,22 @@ model Kita {
|
||||
dutyAssignments DutyAssignment[]
|
||||
absences Absence[]
|
||||
announcements Announcement[]
|
||||
announcementEmails AnnouncementEmail[]
|
||||
invitations Invitation[]
|
||||
termine Termin[]
|
||||
mitbringselItems MitbringselItem[]
|
||||
eventParticipations EventParticipation[]
|
||||
notdienstPlans NotdienstPlan[]
|
||||
notdienstEmails NotdienstEmail[]
|
||||
notdienstReminderRuns NotdienstReminderRun[]
|
||||
notdienstPlans NotdienstPlan[]
|
||||
notdienstAvailabilities NotdienstAvailability[]
|
||||
notdienstAssignments NotdienstAssignment[]
|
||||
notdienstAlerts NotdienstAlert[]
|
||||
notdienstAlarms NotdienstAlarm[]
|
||||
notdienstSwapRequests NotdienstSwapRequest[]
|
||||
staffProfiles StaffProfile[]
|
||||
staffHolidays StaffHoliday[]
|
||||
familyEventParticipations FamilyEventParticipation[]
|
||||
|
||||
@@map("kitas")
|
||||
}
|
||||
@@ -158,18 +185,22 @@ model Family {
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
|
||||
name String
|
||||
name String
|
||||
notdienstAlarms NotdienstAlarm[]
|
||||
notdienstEmails NotdienstEmail[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
users User[]
|
||||
children Child[]
|
||||
dutyAssignments DutyAssignment[]
|
||||
users User[]
|
||||
children Child[]
|
||||
dutyAssignments DutyAssignment[]
|
||||
notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequesterFamily")
|
||||
familyEventParticipations FamilyEventParticipation[]
|
||||
|
||||
@@index([kitaId])
|
||||
@@map("families")
|
||||
@@unique([id, kitaId])
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -211,12 +242,15 @@ model User {
|
||||
privacyPolicyVersion String?
|
||||
/// Zeitstempel des Opt-Ins für das interne Kita-Adressbuch (Modul 3).
|
||||
directoryOptInAt DateTime?
|
||||
staffDecisionEmails Boolean @default(true)
|
||||
|
||||
// Auth-Bookkeeping
|
||||
emailVerifiedAt DateTime?
|
||||
lastLoginAt DateTime?
|
||||
failedLoginAttempts Int @default(0)
|
||||
lockedUntil DateTime?
|
||||
disabledAt DateTime?
|
||||
sessionVersion Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -224,26 +258,36 @@ model User {
|
||||
// Relations
|
||||
dutyAssignments ParentDutyAssignment[]
|
||||
|
||||
notdienstAvailabilities NotdienstAvailability[]
|
||||
notdienstAlertsAssigned NotdienstAlert[] @relation("NotdienstAlertParent")
|
||||
notdienstAlertsTriggered NotdienstAlert[] @relation("NotdienstAlertTrigger")
|
||||
notdienstPlansCreated NotdienstPlan[] @relation("NotdienstPlanCreator")
|
||||
notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequester")
|
||||
notdienstSwapRequestsResponded NotdienstSwapRequest[] @relation("NotdienstSwapResponder")
|
||||
announcementsAuthored Announcement[] @relation("AnnouncementAuthor")
|
||||
announcementReads AnnouncementRead[]
|
||||
notdienstAvailabilities NotdienstAvailability[]
|
||||
notdienstAlertsAssigned NotdienstAlert[] @relation("NotdienstAlertParent")
|
||||
notdienstAlertsTriggered NotdienstAlert[] @relation("NotdienstAlertTrigger")
|
||||
notdienstPlansCreated NotdienstPlan[] @relation("NotdienstPlanCreator")
|
||||
notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequester")
|
||||
notdienstSwapRequestsResponded NotdienstSwapRequest[] @relation("NotdienstSwapResponder")
|
||||
announcementsAuthored Announcement[] @relation("AnnouncementAuthor")
|
||||
announcementReads AnnouncementRead[]
|
||||
|
||||
termineCreated Termin[] @relation("TerminCreator")
|
||||
termineApproved Termin[] @relation("TerminApprover")
|
||||
|
||||
mitbringselItems MitbringselItem[]
|
||||
invitationsCreated Invitation[] @relation("InvitationCreator")
|
||||
auditLogsAuthored AuditLog[] @relation("AuditLogActor")
|
||||
invitationsCreated Invitation[] @relation("InvitationCreator")
|
||||
familyInvitation Invitation? @relation("InvitationRecipient")
|
||||
auditLogsAuthored AuditLog[] @relation("AuditLogActor")
|
||||
staffProfile StaffProfile?
|
||||
staffNotifications StaffNotification[]
|
||||
staffEmails StaffEmail[]
|
||||
alarmRecipients NotdienstAlarmRecipient[]
|
||||
alarmNotifications NotdienstAlarmNotification[]
|
||||
alarmEmails NotdienstAlarmEmail[]
|
||||
notdienstEmails NotdienstEmail[]
|
||||
announcementEmails AnnouncementEmail[]
|
||||
|
||||
@@index([kitaId])
|
||||
@@index([familyId])
|
||||
@@index([kitaId, role])
|
||||
@@map("users")
|
||||
@@unique([id, kitaId])
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -276,6 +320,7 @@ model Child {
|
||||
@@index([kitaId])
|
||||
@@index([familyId])
|
||||
@@map("children")
|
||||
@@unique([id, kitaId])
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -295,9 +340,236 @@ model Educator {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
notdienstAlerts NotdienstAlert[]
|
||||
staffProfile StaffProfile?
|
||||
|
||||
@@index([kitaId])
|
||||
@@map("educators")
|
||||
@@unique([id, kitaId])
|
||||
}
|
||||
|
||||
// Personalbereich: Tage als UTC-Datum, Zeiten als Minuten, Anspruch in halben Tagen.
|
||||
enum StaffRequestStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
CANCEL_REQUESTED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum StaffLeaveKind {
|
||||
VACATION
|
||||
TIME_OFF
|
||||
SICKNESS
|
||||
}
|
||||
|
||||
enum StaffWeekStatus {
|
||||
DRAFT
|
||||
SUBMITTED
|
||||
APPROVED
|
||||
}
|
||||
|
||||
model StaffProfile {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
educatorId String @unique
|
||||
educator Educator @relation(fields: [educatorId], references: [id], onDelete: NoAction)
|
||||
userId String? @unique
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
employmentStart DateTime @db.Date
|
||||
employmentEnd DateTime? @db.Date
|
||||
trackingStart DateTime @db.Date
|
||||
openingMinutes Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
workPatterns StaffWorkPattern[]
|
||||
dayOverrides StaffDayOverride[]
|
||||
leaveAccounts StaffLeaveAccount[]
|
||||
leaves StaffLeave[]
|
||||
timeEntries StaffTimeEntry[]
|
||||
weeks StaffWeek[]
|
||||
adjustments StaffAdjustment[]
|
||||
history StaffHistory[]
|
||||
notifications StaffNotification[]
|
||||
emails StaffEmail[]
|
||||
|
||||
@@index([kitaId])
|
||||
@@map("staff_profiles")
|
||||
}
|
||||
|
||||
// Persisted in the same transaction as the staff action; delivered after commit.
|
||||
model StaffEmail {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
toAdmins Boolean @default(false)
|
||||
subject String
|
||||
message String
|
||||
path String
|
||||
inviteToken String?
|
||||
createdAt DateTime @default(now())
|
||||
sentAt DateTime?
|
||||
skippedAt DateTime?
|
||||
leaseUntil DateTime?
|
||||
attempts Int @default(0)
|
||||
failed Boolean @default(false)
|
||||
|
||||
@@index([profileId, sentAt, skippedAt])
|
||||
@@map("staff_emails")
|
||||
}
|
||||
|
||||
model StaffWorkPattern {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
effectiveFrom DateTime @db.Date
|
||||
// Montag bis Sonntag; 0 bedeutet arbeitsfrei.
|
||||
dailyMinutes Int[]
|
||||
|
||||
@@unique([profileId, effectiveFrom])
|
||||
@@map("staff_work_patterns")
|
||||
}
|
||||
|
||||
model StaffDayOverride {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
date DateTime @db.Date
|
||||
minutes Int
|
||||
reason String
|
||||
|
||||
@@unique([profileId, date])
|
||||
@@map("staff_day_overrides")
|
||||
}
|
||||
|
||||
model StaffHoliday {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
date DateTime @db.Date
|
||||
name String
|
||||
|
||||
@@unique([kitaId, date])
|
||||
@@map("staff_holidays")
|
||||
}
|
||||
|
||||
model StaffLeaveAccount {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
year Int
|
||||
entitlementUnits Int
|
||||
carryUnits Int @default(0)
|
||||
|
||||
@@unique([profileId, year])
|
||||
@@map("staff_leave_accounts")
|
||||
}
|
||||
|
||||
model StaffLeave {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
kind StaffLeaveKind
|
||||
status StaffRequestStatus @default(PENDING)
|
||||
startDate DateTime @db.Date
|
||||
endDate DateTime @db.Date
|
||||
halfDay Boolean @default(false)
|
||||
minutes Int?
|
||||
note String?
|
||||
decisionNote String?
|
||||
requestedById String
|
||||
reviewedById String?
|
||||
reviewedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
days StaffLeaveDay[]
|
||||
|
||||
@@index([profileId, status, startDate])
|
||||
@@map("staff_leaves")
|
||||
}
|
||||
|
||||
model StaffLeaveDay {
|
||||
id String @id @default(cuid())
|
||||
leaveId String
|
||||
leave StaffLeave @relation(fields: [leaveId], references: [id], onDelete: Cascade)
|
||||
date DateTime @db.Date
|
||||
vacationUnits Int @default(0)
|
||||
creditMinutes Int
|
||||
|
||||
@@unique([leaveId, date])
|
||||
@@map("staff_leave_days")
|
||||
}
|
||||
|
||||
model StaffTimeEntry {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
date DateTime @db.Date
|
||||
startMinute Int
|
||||
endMinute Int
|
||||
breakMinutes Int @default(0)
|
||||
note String?
|
||||
|
||||
@@index([profileId, date])
|
||||
@@map("staff_time_entries")
|
||||
}
|
||||
|
||||
model StaffWeek {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
weekStart DateTime @db.Date
|
||||
status StaffWeekStatus @default(DRAFT)
|
||||
submittedAt DateTime?
|
||||
reviewedAt DateTime?
|
||||
reviewedById String?
|
||||
decisionNote String?
|
||||
// Snapshot von Soll-, Ist- und Abwesenheitsminuten bei Genehmigung.
|
||||
dailySnapshot Json?
|
||||
|
||||
@@unique([profileId, weekStart])
|
||||
@@map("staff_weeks")
|
||||
}
|
||||
|
||||
model StaffAdjustment {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
date DateTime @db.Date
|
||||
minutes Int
|
||||
reason String
|
||||
actorId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([profileId, date])
|
||||
@@map("staff_adjustments")
|
||||
}
|
||||
|
||||
model StaffHistory {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
actorId String
|
||||
action String
|
||||
details String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([profileId, createdAt])
|
||||
@@map("staff_history")
|
||||
}
|
||||
|
||||
model StaffNotification {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
message String
|
||||
readAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, readAt, createdAt])
|
||||
@@map("staff_notifications")
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -321,6 +593,7 @@ model ParentDuty {
|
||||
@@unique([kitaId, name])
|
||||
@@index([kitaId])
|
||||
@@map("parent_duties")
|
||||
@@unique([id, kitaId])
|
||||
}
|
||||
|
||||
model ParentDutyAssignment {
|
||||
@@ -360,6 +633,7 @@ model DutyType {
|
||||
@@unique([kitaId, name])
|
||||
@@index([kitaId])
|
||||
@@map("duty_types")
|
||||
@@unique([id, kitaId])
|
||||
}
|
||||
|
||||
model DutyAssignment {
|
||||
@@ -422,8 +696,13 @@ model Announcement {
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
|
||||
title String
|
||||
content String
|
||||
title String
|
||||
content String
|
||||
audience AnnouncementAudience @default(ALLE)
|
||||
revision Int @default(1)
|
||||
editedAt DateTime?
|
||||
updatedAt DateTime @default(now())
|
||||
emailReady Boolean @default(true)
|
||||
|
||||
authorId String
|
||||
author User @relation("AnnouncementAuthor", fields: [authorId], references: [id], onDelete: Cascade)
|
||||
@@ -432,12 +711,40 @@ model Announcement {
|
||||
|
||||
attachments Attachment[]
|
||||
reads AnnouncementRead[]
|
||||
emails AnnouncementEmail[]
|
||||
|
||||
@@index([kitaId, createdAt])
|
||||
@@index([kitaId, updatedAt])
|
||||
@@index([authorId])
|
||||
@@unique([id, kitaId])
|
||||
@@map("announcements")
|
||||
}
|
||||
|
||||
model AnnouncementEmail {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
announcementId String
|
||||
announcement Announcement @relation(fields: [announcementId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
revision Int
|
||||
createdAt DateTime @default(now())
|
||||
nextAttemptAt DateTime @default(now())
|
||||
attempts Int @default(0)
|
||||
leaseUntil DateTime?
|
||||
claimToken String?
|
||||
sentAt DateTime?
|
||||
skippedAt DateTime?
|
||||
failed Boolean @default(false)
|
||||
lastError String?
|
||||
|
||||
@@unique([announcementId, revision, userId])
|
||||
@@index([kitaId, announcementId, revision])
|
||||
@@index([sentAt, skippedAt, nextAttemptAt])
|
||||
@@map("announcement_emails")
|
||||
}
|
||||
|
||||
model Attachment {
|
||||
id String @id @default(cuid())
|
||||
announcementId String
|
||||
@@ -446,12 +753,14 @@ model Attachment {
|
||||
fileName String
|
||||
fileUrl String
|
||||
fileType String
|
||||
sizeBytes Int?
|
||||
|
||||
@@index([announcementId])
|
||||
@@map("attachments")
|
||||
}
|
||||
|
||||
model AnnouncementRead {
|
||||
kitaId String
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@ -462,6 +771,7 @@ model AnnouncementRead {
|
||||
|
||||
@@unique([userId, announcementId])
|
||||
@@index([announcementId])
|
||||
@@index([kitaId])
|
||||
@@map("announcement_reads")
|
||||
}
|
||||
|
||||
@@ -477,10 +787,19 @@ model Invitation {
|
||||
email String
|
||||
role UserRole @default(ELTERN)
|
||||
|
||||
// Nullable for older, unlinked invitations. Family invitations belong to a user.
|
||||
userId String? @unique
|
||||
user User? @relation("InvitationRecipient", fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
token String @unique
|
||||
status InvitationStatus @default(PENDING)
|
||||
expiresAt DateTime
|
||||
acceptedAt DateTime?
|
||||
sentAt DateTime?
|
||||
lastAttemptAt DateTime?
|
||||
leaseUntil DateTime?
|
||||
attempts Int @default(0)
|
||||
failed Boolean @default(false)
|
||||
|
||||
invitedById String?
|
||||
invitedBy User? @relation("InvitationCreator", fields: [invitedById], references: [id], onDelete: SetNull)
|
||||
@@ -516,6 +835,7 @@ model Termin {
|
||||
|
||||
/// Teilnahme-Abfrage für Ausflüge und besondere Events.
|
||||
requiresRsvp Boolean @default(false)
|
||||
rsvpMode RsvpMode @default(CHILD)
|
||||
rsvpDeadline DateTime?
|
||||
participantInfo String?
|
||||
|
||||
@@ -530,12 +850,14 @@ model Termin {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
mitbringselItems MitbringselItem[]
|
||||
participations EventParticipation[]
|
||||
mitbringselItems MitbringselItem[]
|
||||
participations EventParticipation[]
|
||||
familyParticipations FamilyEventParticipation[]
|
||||
|
||||
@@index([kitaId, startDate])
|
||||
@@index([kitaId, status])
|
||||
@@map("termine")
|
||||
@@unique([id, kitaId])
|
||||
}
|
||||
|
||||
model EventParticipation {
|
||||
@@ -560,6 +882,25 @@ model EventParticipation {
|
||||
@@map("event_participations")
|
||||
}
|
||||
|
||||
model FamilyEventParticipation {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
eventId String
|
||||
event Termin @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
familyId String
|
||||
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||
status EventParticipationStatus
|
||||
adultCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([eventId, familyId])
|
||||
@@index([kitaId])
|
||||
@@index([familyId])
|
||||
@@map("family_event_participations")
|
||||
}
|
||||
|
||||
model MitbringselItem {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
@@ -608,10 +949,12 @@ model NotdienstPlan {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
assignments NotdienstAssignment[]
|
||||
emails NotdienstEmail[]
|
||||
|
||||
@@unique([kitaId, year, month])
|
||||
@@index([kitaId])
|
||||
@@map("notdienst_plans")
|
||||
@@unique([id, kitaId])
|
||||
}
|
||||
|
||||
/// Verfügbarkeits-Eintrag eines Elternteils für ein Kind an einem Tag.
|
||||
@@ -657,13 +1000,15 @@ model NotdienstAssignment {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
alerts NotdienstAlert[]
|
||||
alerts NotdienstAlert[]
|
||||
alarms NotdienstAlarm[]
|
||||
swapRequestsAsTarget NotdienstSwapRequest[] @relation("NotdienstSwapTargetAssignment")
|
||||
swapRequestsAsOffered NotdienstSwapRequest[] @relation("NotdienstSwapOfferedAssignment")
|
||||
|
||||
@@unique([planId, date])
|
||||
@@index([kitaId, date])
|
||||
@@map("notdienst_assignments")
|
||||
@@unique([id, kitaId])
|
||||
}
|
||||
|
||||
/// Tauschanfrage zwischen Eltern fuer zwei bereits veroeffentlichte
|
||||
@@ -685,11 +1030,11 @@ model NotdienstSwapRequest {
|
||||
offeredAssignmentId String
|
||||
offeredAssignment NotdienstAssignment @relation("NotdienstSwapOfferedAssignment", fields: [offeredAssignmentId], references: [id], onDelete: Cascade)
|
||||
|
||||
status NotdienstSwapStatus @default(PENDING)
|
||||
status NotdienstSwapStatus @default(PENDING)
|
||||
message String?
|
||||
|
||||
respondedById String?
|
||||
respondedBy User? @relation("NotdienstSwapResponder", fields: [respondedById], references: [id], onDelete: SetNull)
|
||||
respondedBy User? @relation("NotdienstSwapResponder", fields: [respondedById], references: [id], onDelete: SetNull)
|
||||
respondedAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
@@ -704,10 +1049,117 @@ model NotdienstSwapRequest {
|
||||
|
||||
/// Aktive Alarmierung — wird bei Krankmeldung einer Fachkraft
|
||||
/// vom Koordinator ausgelöst. Bestätigung via Magic-Link (Token).
|
||||
model NotdienstAlarm {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
assignmentId String?
|
||||
assignment NotdienstAssignment? @relation(fields: [assignmentId], references: [id], onDelete: SetNull)
|
||||
// Nullable unique reservation: cancelled or ended alarms release the assignment.
|
||||
activeAssignmentId String? @unique
|
||||
familyId String
|
||||
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||
familyName String
|
||||
childName String
|
||||
date DateTime @db.Date
|
||||
wholeDay Boolean @default(true)
|
||||
startMinute Int?
|
||||
endMinute Int?
|
||||
expiresAt DateTime
|
||||
deadline DateTime
|
||||
status NotdienstAlarmStatus @default(PENDING)
|
||||
triggeredById String?
|
||||
triggeredAt DateTime @default(now())
|
||||
respondedById String?
|
||||
respondedAt DateTime?
|
||||
responseSource String?
|
||||
escalatedAt DateTime?
|
||||
educatorId String?
|
||||
notes String?
|
||||
cancelledAt DateTime?
|
||||
cancelReason String?
|
||||
recipients NotdienstAlarmRecipient[]
|
||||
notifications NotdienstAlarmNotification[]
|
||||
emails NotdienstAlarmEmail[]
|
||||
history NotdienstAlarmHistory[]
|
||||
legacyAlerts NotdienstAlert[]
|
||||
|
||||
@@index([kitaId, date])
|
||||
@@index([status, deadline])
|
||||
@@map("notdienst_alarms")
|
||||
}
|
||||
|
||||
model NotdienstAlarmRecipient {
|
||||
id String @id @default(cuid())
|
||||
alarmId String
|
||||
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
token String @unique
|
||||
sessionVersion Int @default(0)
|
||||
|
||||
@@unique([alarmId, userId])
|
||||
@@map("notdienst_alarm_recipients")
|
||||
}
|
||||
|
||||
model NotdienstAlarmNotification {
|
||||
id String @id @default(cuid())
|
||||
alarmId String
|
||||
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
eventKey String
|
||||
message String
|
||||
createdAt DateTime @default(now())
|
||||
readAt DateTime?
|
||||
|
||||
@@unique([alarmId, userId, eventKey])
|
||||
@@index([userId, readAt])
|
||||
@@map("notdienst_alarm_notifications")
|
||||
}
|
||||
|
||||
model NotdienstAlarmEmail {
|
||||
id String @id @default(cuid())
|
||||
alarmId String
|
||||
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
eventKey String
|
||||
kind String
|
||||
subject String
|
||||
message String
|
||||
path String
|
||||
createdAt DateTime @default(now())
|
||||
sentAt DateTime?
|
||||
skippedAt DateTime?
|
||||
leaseUntil DateTime?
|
||||
failed Boolean @default(false)
|
||||
attempts Int @default(0)
|
||||
|
||||
@@unique([alarmId, userId, eventKey])
|
||||
@@index([sentAt, skippedAt, createdAt])
|
||||
@@map("notdienst_alarm_emails")
|
||||
}
|
||||
|
||||
model NotdienstAlarmHistory {
|
||||
id String @id @default(cuid())
|
||||
alarmId String
|
||||
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
|
||||
actorId String?
|
||||
action String
|
||||
details String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([alarmId, createdAt])
|
||||
@@map("notdienst_alarm_history")
|
||||
}
|
||||
|
||||
model NotdienstAlert {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
id String @id @default(cuid())
|
||||
alarmId String?
|
||||
alarm NotdienstAlarm? @relation(fields: [alarmId], references: [id], onDelete: SetNull)
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
|
||||
assignmentId String
|
||||
assignment NotdienstAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||||
@@ -756,13 +1208,13 @@ model AuditLog {
|
||||
actorUser User? @relation("AuditLogActor", fields: [actorUserId], references: [id], onDelete: SetNull)
|
||||
actorEmail String
|
||||
|
||||
action String
|
||||
targetType AuditTargetType
|
||||
targetId String
|
||||
targetLabel String
|
||||
action String
|
||||
targetType AuditTargetType
|
||||
targetId String
|
||||
targetLabel String
|
||||
targetKitaId String?
|
||||
|
||||
metadata Json?
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([actorUserId])
|
||||
@@ -786,3 +1238,64 @@ model VerificationToken {
|
||||
@@unique([identifier, token])
|
||||
@@map("verification_tokens")
|
||||
}
|
||||
|
||||
model RateLimitBucket {
|
||||
key String @id
|
||||
count Int
|
||||
resetAt DateTime
|
||||
@@index([resetAt])
|
||||
@@map("rate_limit_buckets")
|
||||
}
|
||||
|
||||
model PendingRegistration {
|
||||
email String @id
|
||||
firstName String
|
||||
lastName String
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
@@index([expiresAt])
|
||||
@@map("pending_registrations")
|
||||
}
|
||||
|
||||
/// Durable notification jobs. No bearer tokens or personal response links.
|
||||
model NotdienstEmail {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
familyId String
|
||||
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
planId String?
|
||||
plan NotdienstPlan? @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
year Int
|
||||
month Int
|
||||
kind String
|
||||
eventKey String
|
||||
createdAt DateTime @default(now())
|
||||
sentAt DateTime?
|
||||
skippedAt DateTime?
|
||||
leaseUntil DateTime?
|
||||
claimToken String?
|
||||
nextAttemptAt DateTime @default(now())
|
||||
failed Boolean @default(false)
|
||||
attempts Int @default(0)
|
||||
lastError String?
|
||||
|
||||
@@unique([kitaId, userId, kind, year, month, eventKey])
|
||||
@@index([sentAt, skippedAt, nextAttemptAt])
|
||||
@@index([kitaId, year, month])
|
||||
@@map("notdienst_emails")
|
||||
}
|
||||
|
||||
model NotdienstReminderRun {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
year Int
|
||||
month Int
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([kitaId, year, month])
|
||||
@@map("notdienst_reminder_runs")
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { hash } from "bcryptjs";
|
||||
// noch keine Kita und keine User enthaelt.
|
||||
//
|
||||
// Reset: `npm run db:seed:reset` loescht gezielt die Demo-Daten und baut
|
||||
// sie neu auf. Production wird nur mit ALLOW_DATABASE_SEED=true erlaubt.
|
||||
// sie neu auf. Production-Seeding ist grundsätzlich deaktiviert.
|
||||
// =====================================================================
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
@@ -101,16 +101,8 @@ function getWorkingDays(year: number, month: number, limit?: number) {
|
||||
}
|
||||
|
||||
function isSeedAllowed() {
|
||||
if (
|
||||
process.env.NODE_ENV === "development" ||
|
||||
process.env.ALLOW_DATABASE_SEED === "true"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Seed uebersprungen: NODE_ENV ist nicht development. Setze ALLOW_DATABASE_SEED=true, wenn das bewusst gewuenscht ist.",
|
||||
);
|
||||
if (process.env.NODE_ENV !== "production" && process.env.ALLOW_DATABASE_SEED === "true") return true;
|
||||
console.log("Seed deaktiviert. Nur außerhalb der Produktion mit ALLOW_DATABASE_SEED=true erlaubt.");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -688,10 +680,10 @@ async function createAnnouncements(context: SeedContext) {
|
||||
|
||||
await prisma.announcementRead.createMany({
|
||||
data: [
|
||||
{ userId: koordinator.id, announcementId: sommerfest.id },
|
||||
{ userId: elternSchmidt.id, announcementId: sommerfest.id },
|
||||
{ userId: elternYilmaz.id, announcementId: sommerfest.id },
|
||||
{ userId: elternFischer.id, announcementId: garderobe.id },
|
||||
{ kitaId: kita.id, userId: koordinator.id, announcementId: sommerfest.id },
|
||||
{ kitaId: kita.id, userId: elternSchmidt.id, announcementId: sommerfest.id },
|
||||
{ kitaId: kita.id, userId: elternYilmaz.id, announcementId: sommerfest.id },
|
||||
{ kitaId: kita.id, userId: elternFischer.id, announcementId: garderobe.id },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- HISTORISCHER UPGRADEPFAD: aktuelle Releases verwenden prisma/migrations und deploy/README.md.
|
||||
-- Nicht zusätzlich zu versionierten Migrationen oder auf bereits aktualisierten Datenbanken ausführen.
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RsvpMode" AS ENUM ('CHILD', 'FAMILY');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AnnouncementAudience" AS ENUM ('ELTERN', 'ERZIEHER', 'ALLE');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "announcements" ADD COLUMN "audience" "AnnouncementAudience" NOT NULL DEFAULT 'ALLE';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "termine" ADD COLUMN "rsvpMode" "RsvpMode" NOT NULL DEFAULT 'CHILD';
|
||||
|
||||
-- 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")
|
||||
);
|
||||
|
||||
-- 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");
|
||||
|
||||
-- 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;
|
||||
|
||||
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 83 KiB |
@@ -0,0 +1,33 @@
|
||||
WEBVTT
|
||||
|
||||
1
|
||||
00:00:00.000 --> 00:00:10.000
|
||||
Willkommen im Kita-Planer
|
||||
|
||||
2
|
||||
00:00:10.000 --> 00:00:32.000
|
||||
Notdienst gemeinsam planen
|
||||
|
||||
3
|
||||
00:00:32.000 --> 00:00:52.000
|
||||
Termine und Feste im Blick
|
||||
|
||||
4
|
||||
00:00:52.000 --> 00:01:06.000
|
||||
Freigegebene Kontakte
|
||||
|
||||
5
|
||||
00:01:06.000 --> 00:01:20.000
|
||||
Abwesenheiten verwalten
|
||||
|
||||
6
|
||||
00:01:20.000 --> 00:01:34.000
|
||||
Offizielle Mitteilungen
|
||||
|
||||
7
|
||||
00:01:34.000 --> 00:01:50.000
|
||||
Arbeitszeit für das Team
|
||||
|
||||
8
|
||||
00:01:50.000 --> 00:02:00.000
|
||||
Kita-Planer kennenlernen
|
||||
|
After Width: | Height: | Size: 145 KiB |
@@ -0,0 +1,50 @@
|
||||
Kita-Planer – Ein Rundgang durch die Anwendung
|
||||
Dauer: 120 Sekunden. Ohne Ton. App-Ansichten mit fiktiven Beispieldaten.
|
||||
|
||||
Willkommen im Kita-Planer
|
||||
Kita-Planer bringt Dienste, Termine und offizielle Informationen an einen gemeinsamen Ort – für Eltern, Vorstand und Kita-Team.
|
||||
|
||||
Notdienst gemeinsam planen
|
||||
Eltern markieren die Tage, an denen ihr Haushalt für den Notdienst verfügbar ist.
|
||||
|
||||
Notdienst gemeinsam planen
|
||||
Feste, Elternabende und gesperrte Teamtage stehen direkt im Kalender. So lässt sich die Auswahl mit dem Kita-Alltag abstimmen.
|
||||
|
||||
Notdienst gemeinsam planen
|
||||
Die Anzeige zeigt, ob genug Tage ausgewählt sind. Mit Speichern wird die Verfügbarkeit für die Planung übernommen.
|
||||
|
||||
Termine und Feste im Blick
|
||||
Im Monatskalender stehen Ausflüge, Elternabende, Feste und Schließtage. Alle sehen die Termine im selben Zusammenhang.
|
||||
|
||||
Termine und Feste im Blick
|
||||
Für dieses Herbstfest antwortet ihr pro Familie und gebt an, wie viele Erwachsene teilnehmen.
|
||||
|
||||
Termine und Feste im Blick
|
||||
In der Mitbringliste seht ihr, wer schon etwas beisteuert. Eigene Beiträge könnt ihr bearbeiten oder löschen.
|
||||
|
||||
Freigegebene Kontakte
|
||||
Das interne Adressbuch verbindet Haushalte, Kinder und die freigegebenen Kontakte der Eltern.
|
||||
|
||||
Freigegebene Kontakte
|
||||
Jeder Elternteil entscheidet selbst über die Freigabe seiner Kontaktdaten. Im Adressbuch erscheinen nur ausdrücklich freigegebene Angaben.
|
||||
|
||||
Abwesenheiten verwalten
|
||||
Eltern melden Abwesenheiten digital. Die Kita sieht die Meldung in ihrer Übersicht.
|
||||
|
||||
Abwesenheiten verwalten
|
||||
Zukünftige Abwesenheiten können Eltern bearbeiten oder zurücknehmen. Sobald eine Meldung begonnen hat, übernimmt die Kita die Änderung.
|
||||
|
||||
Offizielle Mitteilungen
|
||||
Mitteilungen bündeln offizielle Informationen an einem Ort. Formatierter Text und der Aktualisierungshinweis machen Änderungen sichtbar.
|
||||
|
||||
Offizielle Mitteilungen
|
||||
Kita-Admins können Beiträge korrigieren; Anhänge bleiben geschützt abrufbar. Beim optionalen E-Mail-Versand bleibt der Status je Empfänger nachvollziehbar.
|
||||
|
||||
Arbeitszeit für das Team
|
||||
Fachkräfte erfassen ihre Arbeitszeit mit Pausen und reichen vollständige Wochen zur Prüfung ein.
|
||||
|
||||
Arbeitszeit für das Team
|
||||
Das bestätigte Zeitkonto und der verfügbare Urlaub bleiben sichtbar. Urlaub und Freizeitausgleich lassen sich im Personalbereich beantragen.
|
||||
|
||||
Kita-Planer kennenlernen
|
||||
Lernt Kita-Planer in Ruhe kennen und entdeckt die passenden Funktionen. Alle gezeigten App-Ansichten verwenden ausschließlich Beispieldaten.
|
||||
@@ -0,0 +1,81 @@
|
||||
WEBVTT
|
||||
|
||||
1
|
||||
00:00:00.000 --> 00:00:10.000
|
||||
Kita-Planer bringt Dienste, Termine und offizielle Informationen
|
||||
an einen gemeinsamen Ort – für Eltern, Vorstand und Kita-Team.
|
||||
|
||||
2
|
||||
00:00:10.000 --> 00:00:17.000
|
||||
Eltern markieren die Tage, an denen ihr Haushalt
|
||||
für den Notdienst verfügbar ist.
|
||||
|
||||
3
|
||||
00:00:17.000 --> 00:00:24.000
|
||||
Feste, Elternabende und gesperrte Teamtage stehen direkt im Kalender.
|
||||
So lässt sich die Auswahl mit dem Kita-Alltag abstimmen.
|
||||
|
||||
4
|
||||
00:00:24.000 --> 00:00:32.000
|
||||
Die Anzeige zeigt, ob genug Tage ausgewählt sind.
|
||||
Mit Speichern wird die Verfügbarkeit für die Planung übernommen.
|
||||
|
||||
5
|
||||
00:00:32.000 --> 00:00:41.000
|
||||
Im Monatskalender stehen Ausflüge, Elternabende, Feste und Schließtage.
|
||||
Alle sehen die Termine im selben Zusammenhang.
|
||||
|
||||
6
|
||||
00:00:41.000 --> 00:00:46.000
|
||||
Für dieses Herbstfest antwortet ihr pro Familie
|
||||
und gebt an, wie viele Erwachsene teilnehmen.
|
||||
|
||||
7
|
||||
00:00:46.000 --> 00:00:52.000
|
||||
In der Mitbringliste seht ihr, wer schon etwas beisteuert.
|
||||
Eigene Beiträge könnt ihr bearbeiten oder löschen.
|
||||
|
||||
8
|
||||
00:00:52.000 --> 00:00:59.000
|
||||
Das interne Adressbuch verbindet Haushalte, Kinder
|
||||
und die freigegebenen Kontakte der Eltern.
|
||||
|
||||
9
|
||||
00:00:59.000 --> 00:01:06.000
|
||||
Jeder Elternteil entscheidet selbst über die Freigabe seiner Kontaktdaten.
|
||||
Im Adressbuch erscheinen nur ausdrücklich freigegebene Angaben.
|
||||
|
||||
10
|
||||
00:01:06.000 --> 00:01:13.000
|
||||
Eltern melden Abwesenheiten digital.
|
||||
Die Kita sieht die Meldung in ihrer Übersicht.
|
||||
|
||||
11
|
||||
00:01:13.000 --> 00:01:20.000
|
||||
Zukünftige Abwesenheiten können Eltern bearbeiten oder zurücknehmen.
|
||||
Sobald eine Meldung begonnen hat, übernimmt die Kita die Änderung.
|
||||
|
||||
12
|
||||
00:01:20.000 --> 00:01:27.000
|
||||
Mitteilungen bündeln offizielle Informationen an einem Ort.
|
||||
Formatierter Text und der Aktualisierungshinweis machen Änderungen sichtbar.
|
||||
|
||||
13
|
||||
00:01:27.000 --> 00:01:34.000
|
||||
Kita-Admins können Beiträge korrigieren; Anhänge bleiben geschützt abrufbar.
|
||||
Beim optionalen E-Mail-Versand bleibt der Status je Empfänger nachvollziehbar.
|
||||
|
||||
14
|
||||
00:01:34.000 --> 00:01:42.000
|
||||
Fachkräfte erfassen ihre Arbeitszeit mit Pausen
|
||||
und reichen vollständige Wochen zur Prüfung ein.
|
||||
|
||||
15
|
||||
00:01:42.000 --> 00:01:50.000
|
||||
Das bestätigte Zeitkonto und der verfügbare Urlaub bleiben sichtbar.
|
||||
Urlaub und Freizeitausgleich lassen sich im Personalbereich beantragen.
|
||||
|
||||
16
|
||||
00:01:50.000 --> 00:02:00.000
|
||||
Lernt Kita-Planer in Ruhe kennen und entdeckt die passenden Funktionen.
|
||||
Alle gezeigten App-Ansichten verwenden ausschließlich Beispieldaten.
|
||||
@@ -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,73 @@
|
||||
-- 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"
|
||||
|
||||
UNION ALL
|
||||
SELECT 'notdienst_emails.familyId' AS relation, count(*) AS invalid_rows FROM "notdienst_emails" c JOIN "families" p ON c."familyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'notdienst_emails.userId' AS relation, count(*) AS invalid_rows FROM "notdienst_emails" c JOIN "users" p ON c."userId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'notdienst_emails.planId' AS relation, count(*) AS invalid_rows FROM "notdienst_emails" c JOIN "notdienst_plans" p ON c."planId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'announcement_emails.userId' AS relation, count(*) AS invalid_rows FROM "announcement_emails" c JOIN "users" p ON c."userId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||
UNION ALL
|
||||
SELECT 'announcement_emails.announcementId' AS relation, count(*) AS invalid_rows FROM "announcement_emails" c JOIN "announcements" p ON c."announcementId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."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,42 @@
|
||||
// Runs inside the application container. No external cron service or browser is required.
|
||||
const secret = process.env.NOTDIENST_JOB_SECRET;
|
||||
if (!secret || secret.length < 32) {
|
||||
console.error(
|
||||
"Notdienst-Hintergrundjob deaktiviert: NOTDIENST_JOB_SECRET muss mindestens 32 Zeichen haben.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) process.exit(1);
|
||||
let stopped = false;
|
||||
let timer;
|
||||
async function tick() {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/api/jobs/notdienstalarme`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${secret}` },
|
||||
signal: AbortSignal.timeout(50000),
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
console.error(
|
||||
`Notdienst-Hintergrundjob: HTTP ${response.status}; erneuter Versuch in einer Minute.`,
|
||||
);
|
||||
} catch {
|
||||
console.error(
|
||||
"Notdienst-Hintergrundjob momentan nicht erreichbar; erneuter Versuch in einer Minute.",
|
||||
);
|
||||
}
|
||||
if (!stopped)
|
||||
timer = setTimeout(tick, Math.max(1000, 60000 - (Date.now() - started)));
|
||||
}
|
||||
for (const signal of ["SIGINT", "SIGTERM"])
|
||||
process.on(signal, () => {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
process.exit(0);
|
||||
});
|
||||
timer = setTimeout(tick, 5000);
|
||||
@@ -0,0 +1,10 @@
|
||||
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');
|
||||
cpSync('scripts/verify-runtime-isolation.mjs', '.next/standalone/scripts/verify-runtime-isolation.mjs');
|
||||
|
||||
cpSync('scripts/start-runtime.mjs', '.next/standalone/scripts/start-runtime.mjs');
|
||||
@@ -0,0 +1,82 @@
|
||||
/** Render the local, silent product tour from real app captures and SVG titles. */
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const storyboard = JSON.parse(await fs.readFile(path.join(root, 'scripts/product-video/storyboard.json'), 'utf8'));
|
||||
const out = path.join(root, 'public/landing/video');
|
||||
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'kita-product-video-'));
|
||||
await fs.mkdir(out, { recursive: true });
|
||||
const { width: W, height: H, fps } = storyboard;
|
||||
const colors = { paper: '#f7f4ed', ink: '#302820', brown: '#7b492e', green: '#3f745a', muted: '#696358' };
|
||||
const esc = text => String(text).replace(/[<>&"']/g, c => ({ '<':'<', '>':'>', '&':'&', '"':'"', "'":''' }[c]));
|
||||
const text = (x,y,value,size=30,color=colors.ink,weight=400) => `<text x="${x}" y="${y}" font-family="Avenir Next, sans-serif" font-size="${size}" font-weight="${weight}" fill="${color}">${esc(value)}</text>`;
|
||||
const lines = (x,y,values,size=30,step=44,color=colors.ink,weight=400) => values.map((v,i)=>text(x,y+i*step,v,size,color,weight)).join('');
|
||||
const imageStage = { x:600, y:162, width:1240, height:714 };
|
||||
const total = storyboard.scenes.reduce((sum,scene)=>sum+scene.duration,0);
|
||||
const timestamp = seconds => `${String(Math.floor(seconds/3600)).padStart(2,'0')}:${String(Math.floor(seconds/60)%60).padStart(2,'0')}:${String(seconds%60).padStart(2,'0')}.000`;
|
||||
let time = 0;
|
||||
const cues = [];
|
||||
const assets = [];
|
||||
const webAssets = [];
|
||||
|
||||
function ffmpeg(args) {
|
||||
const result = spawnSync('ffmpeg', ['-hide_banner','-loglevel','error','-y',...args], { encoding:'utf8', maxBuffer:8*1024*1024 });
|
||||
if (result.status !== 0) throw new Error(result.stderr || 'ffmpeg failed');
|
||||
}
|
||||
|
||||
try {
|
||||
for (const [index,scene] of storyboard.scenes.entries()) {
|
||||
const [left,top,width,height] = scene.crop;
|
||||
const shot = await sharp(path.join(root, 'public/landing/screenshots',scene.image))
|
||||
.extract({left,top,width,height}).resize(imageStage.width,imageStage.height,{fit:'contain',background:colors.paper}).png().toBuffer();
|
||||
const scale = Math.min(imageStage.width/width,imageStage.height/height);
|
||||
const offsetX = imageStage.x+(imageStage.width-width*scale)/2;
|
||||
const offsetY = imageStage.y+(imageStage.height-height*scale)/2;
|
||||
let focus = '';
|
||||
if (scene.focus) {
|
||||
const [fx,fy,fw,fh] = scene.focus;
|
||||
focus = `<rect x="${offsetX+(fx-left)*scale}" y="${offsetY+(fy-top)*scale}" width="${fw*scale}" height="${fh*scale}" rx="12" fill="none" stroke="${colors.green}" stroke-width="5"/><circle cx="${offsetX+(fx-left)*scale+12}" cy="${offsetY+(fy-top)*scale-10}" r="18" fill="${colors.green}"/>`;
|
||||
}
|
||||
const chapter = storyboard.chapters[scene.chapter];
|
||||
const title = lines(74,290,scene.title,56,72,colors.ink,600);
|
||||
const hintWords = scene.hint.split(' '); const hintLines = []; let line = '';
|
||||
for (const word of hintWords) { if ((line+' '+word).trim().length > 28) { hintLines.push(line); line=word; } else line=(line+' '+word).trim(); } if(line)hintLines.push(line);
|
||||
const header = `<rect width="${W}" height="${H}" fill="${colors.paper}"/><rect x="72" y="44" width="54" height="54" rx="16" fill="${colors.brown}"/>${text(88,82,'K',30,'white',600)}${text(144,80,'Kita-Planer',32,colors.ink,600)}${text(144,111,'Ein Rundgang durch die Anwendung',19,colors.muted)}${text(1490,80,`${String(scene.chapter+1).padStart(2,'0')} / 08`,24,colors.green,600)}${text(1490,111,'App-Ansichten mit Beispieldaten',18,colors.muted)}<line x1="72" y1="132" x2="1840" y2="132" stroke="#dcd6cb"/>${text(74,208,scene.chapter===0?'WILLKOMMEN':scene.chapter===7?'GEMEINSAM STARTEN':`BEREICH ${String(scene.chapter).padStart(2,'0')}`,20,colors.green,600)}${title}<rect x="74" y="466" width="4" height="${Math.max(90,hintLines.length*40)}" rx="2" fill="${colors.green}"/>${lines(96,498,hintLines,28,40,colors.muted)}${text(74,775,'Für Eltern, Vorstand',24,colors.muted)}${text(74,811,'und das Kita-Team.',24,colors.muted)}<rect x="598" y="160" width="1244" height="718" rx="16" fill="white" stroke="#d9d3c7" stroke-width="2"/>`;
|
||||
const footer = `<rect x="0" y="920" width="1920" height="160" fill="#ebe7dc"/><rect x="72" y="923" width="1768" height="4" rx="2" fill="#d8d1c2"/><rect x="72" y="923" width="${1768*(time+scene.duration)/total}" height="4" rx="2" fill="${colors.green}"/>${text(74,899,chapter.title,20,colors.muted)}${text(1470,899,'mein-kitaplaner.de',20,colors.brown,600)}`;
|
||||
const base = await sharp(Buffer.from(`<svg width="${W}" height="${H}">${header}${footer}</svg>`)).png().toBuffer();
|
||||
const captions = `<rect x="210" y="951" width="1500" height="100" rx="18" fill="#302820"/>${scene.subtitle.map((value,i)=>`<text x="960" y="${989+i*42}" text-anchor="middle" font-family="Avenir Next, sans-serif" font-size="34" font-weight="500" fill="#ffffff">${esc(value)}</text>`).join('')}`;
|
||||
const foreground = Buffer.from(`<svg width="${W}" height="${H}">${focus}${captions}</svg>`);
|
||||
const cleanFrame = await sharp(base).composite([{input:shot,left:imageStage.x,top:imageStage.y},{input:Buffer.from(`<svg width="${W}" height="${H}">${focus}</svg>`),left:0,top:0}]).png().toBuffer();
|
||||
const cleanPath = path.join(temp,`web-frame-${index}.png`);
|
||||
await fs.writeFile(cleanPath,cleanFrame);
|
||||
const frame = await sharp(base).composite([{input:shot,left:imageStage.x,top:imageStage.y},{input:foreground,left:0,top:0}]).png().toBuffer();
|
||||
const framePath = path.join(temp,`frame-${index}.png`);
|
||||
await fs.writeFile(framePath,frame);
|
||||
if(index===0) await sharp(frame).jpeg({quality:88}).toFile(path.join(out,'kita-planer-poster.jpg'));
|
||||
const clip = path.join(temp,`clip-${index}.mp4`);
|
||||
// Gentle transitions leave the UI and all captions still and legible.
|
||||
ffmpeg(['-loop','1','-framerate',String(fps),'-i',framePath,'-t',String(scene.duration),'-vf',`fade=t=in:st=0:d=0.25:color=0xf7f4ed,fade=t=out:st=${scene.duration-0.25}:d=0.25:color=0xf7f4ed,format=yuv420p`,'-c:v','libx264','-preset','fast','-crf','20','-threads','2','-an',clip]);
|
||||
assets.push(clip);
|
||||
const webClip = path.join(temp,`web-clip-${index}.mp4`);
|
||||
ffmpeg(['-loop','1','-framerate',String(fps),'-i',cleanPath,'-t',String(scene.duration),'-vf',`fade=t=in:st=0:d=0.25:color=0xf7f4ed,fade=t=out:st=${scene.duration-0.25}:d=0.25:color=0xf7f4ed,format=yuv420p`,'-c:v','libx264','-preset','fast','-crf','20','-threads','2','-an',webClip]);
|
||||
webAssets.push(webClip);
|
||||
cues.push(`${index+1}\n${timestamp(time)} --> ${timestamp(time+scene.duration)}\n${scene.subtitle.join('\n')}`);
|
||||
time+=scene.duration;
|
||||
console.log(`Rendered scene ${index+1}/${storyboard.scenes.length}: ${chapter.title}`);
|
||||
}
|
||||
await fs.writeFile(path.join(temp,'concat.txt'),assets.map(p=>`file '${p}'`).join('\n'));
|
||||
ffmpeg(['-f','concat','-safe','0','-i',path.join(temp,'concat.txt'),'-c','copy','-movflags','+faststart','-metadata','title=Kita-Planer – Ein Rundgang durch die Anwendung','-metadata','comment=Echte App-Ansichten mit fiktiven Beispieldaten. Deutsche Untertitel und visuelle Hinweise. Ohne Ton.',path.join(out,'kita-planer-rundgang.mp4')]);
|
||||
await fs.writeFile(path.join(temp,'web-concat.txt'),webAssets.map(p=>`file '${p}'`).join('\n'));
|
||||
ffmpeg(['-f','concat','-safe','0','-i',path.join(temp,'web-concat.txt'),'-c','copy','-movflags','+faststart',path.join(out,'kita-planer-rundgang-web.mp4')]);
|
||||
await fs.writeFile(path.join(out,'kita-planer-untertitel.vtt'),`WEBVTT\n\n${cues.join('\n\n')}\n`);
|
||||
await fs.writeFile(path.join(out,'kita-planer-kapitel.vtt'),`WEBVTT\n\n${storyboard.chapters.map((chapter,index)=>`${index+1}\n${timestamp(chapter.start)} --> ${timestamp(storyboard.chapters[index+1]?.start??total)}\n${chapter.title}`).join('\n\n')}\n`);
|
||||
await fs.writeFile(path.join(out,'kita-planer-transkript.txt'),`Kita-Planer – Ein Rundgang durch die Anwendung\nDauer: ${total} Sekunden. Ohne Ton. App-Ansichten mit fiktiven Beispieldaten.\n\n${storyboard.scenes.map(scene=>`${storyboard.chapters[scene.chapter].title}\n${scene.subtitle.join(' ')}`).join('\n\n')}\n`);
|
||||
console.log(`Finished ${total}s video in ${out}`);
|
||||
} finally {
|
||||
await fs.rm(temp,{recursive:true,force:true});
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
{
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fps": 25,
|
||||
"chapters": [
|
||||
{
|
||||
"start": 0,
|
||||
"title": "Willkommen im Kita-Planer"
|
||||
},
|
||||
{
|
||||
"start": 10,
|
||||
"title": "Notdienst gemeinsam planen"
|
||||
},
|
||||
{
|
||||
"start": 32,
|
||||
"title": "Termine und Feste im Blick"
|
||||
},
|
||||
{
|
||||
"start": 52,
|
||||
"title": "Freigegebene Kontakte"
|
||||
},
|
||||
{
|
||||
"start": 66,
|
||||
"title": "Abwesenheiten verwalten"
|
||||
},
|
||||
{
|
||||
"start": 80,
|
||||
"title": "Offizielle Mitteilungen"
|
||||
},
|
||||
{
|
||||
"start": 94,
|
||||
"title": "Arbeitszeit für das Team"
|
||||
},
|
||||
{
|
||||
"start": 110,
|
||||
"title": "Kita-Planer kennenlernen"
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"duration": 10,
|
||||
"chapter": 0,
|
||||
"title": [
|
||||
"Mehr Ruhe im",
|
||||
"Kita-Alltag."
|
||||
],
|
||||
"hint": "Für Elterninitiativen und Kita-Vereine",
|
||||
"image": "notdienst.jpg",
|
||||
"crop": [
|
||||
0,
|
||||
0,
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"subtitle": [
|
||||
"Kita-Planer bringt Dienste, Termine und offizielle Informationen",
|
||||
"an einen gemeinsamen Ort – für Eltern, Vorstand und Kita-Team."
|
||||
]
|
||||
},
|
||||
{
|
||||
"duration": 7,
|
||||
"chapter": 1,
|
||||
"title": [
|
||||
"Wann könnt",
|
||||
"ihr helfen?"
|
||||
],
|
||||
"hint": "Verfügbarkeiten gemeinsam eintragen",
|
||||
"image": "notdienst.jpg",
|
||||
"crop": [
|
||||
310,
|
||||
45,
|
||||
1230,
|
||||
870
|
||||
],
|
||||
"focus": [
|
||||
325,
|
||||
365,
|
||||
394,
|
||||
200
|
||||
],
|
||||
"subtitle": [
|
||||
"Eltern markieren die Tage, an denen ihr Haushalt",
|
||||
"für den Notdienst verfügbar ist."
|
||||
]
|
||||
},
|
||||
{
|
||||
"duration": 7,
|
||||
"chapter": 1,
|
||||
"title": [
|
||||
"Kita-Termine",
|
||||
"gleich sehen."
|
||||
],
|
||||
"hint": "Teamtage sind für die Auswahl gesperrt",
|
||||
"image": "notdienst.jpg",
|
||||
"crop": [
|
||||
330,
|
||||
170,
|
||||
1200,
|
||||
720
|
||||
],
|
||||
"focus": [
|
||||
355,
|
||||
670,
|
||||
335,
|
||||
105
|
||||
],
|
||||
"subtitle": [
|
||||
"Feste, Elternabende und gesperrte Teamtage stehen direkt im Kalender.",
|
||||
"So lässt sich die Auswahl mit dem Kita-Alltag abstimmen."
|
||||
]
|
||||
},
|
||||
{
|
||||
"duration": 8,
|
||||
"chapter": 1,
|
||||
"title": [
|
||||
"Auswahl prüfen.",
|
||||
"Dann speichern."
|
||||
],
|
||||
"hint": "Der Fortschritt bleibt sichtbar",
|
||||
"image": "notdienst.jpg",
|
||||
"crop": [
|
||||
310,
|
||||
45,
|
||||
1230,
|
||||
870
|
||||
],
|
||||
"focus": [
|
||||
1050,
|
||||
80,
|
||||
450,
|
||||
65
|
||||
],
|
||||
"subtitle": [
|
||||
"Die Anzeige zeigt, ob genug Tage ausgewählt sind.",
|
||||
"Mit Speichern wird die Verfügbarkeit für die Planung übernommen."
|
||||
]
|
||||
},
|
||||
{
|
||||
"duration": 9,
|
||||
"chapter": 2,
|
||||
"title": [
|
||||
"Was steht",
|
||||
"als Nächstes an?"
|
||||
],
|
||||
"hint": "Ein gemeinsamer Kalender",
|
||||
"image": "kalender.jpg",
|
||||
"crop": [
|
||||
300,
|
||||
35,
|
||||
1240,
|
||||
920
|
||||
],
|
||||
"focus": [
|
||||
830,
|
||||
660,
|
||||
415,
|
||||
155
|
||||
],
|
||||
"subtitle": [
|
||||
"Im Monatskalender stehen Ausflüge, Elternabende, Feste und Schließtage.",
|
||||
"Alle sehen die Termine im selben Zusammenhang."
|
||||
]
|
||||
},
|
||||
{
|
||||
"duration": 5,
|
||||
"chapter": 2,
|
||||
"title": [
|
||||
"Zusagen pro",
|
||||
"Familie."
|
||||
],
|
||||
"hint": "Anzahl der Erwachsenen angeben",
|
||||
"image": "termin-details.jpg",
|
||||
"crop": [
|
||||
543,
|
||||
335,
|
||||
501,
|
||||
370
|
||||
],
|
||||
"focus": [
|
||||
561,
|
||||
444,
|
||||
470,
|
||||
150
|
||||
],
|
||||
"subtitle": [
|
||||
"Für dieses Herbstfest antwortet ihr pro Familie",
|
||||
"und gebt an, wie viele Erwachsene teilnehmen."
|
||||
]
|
||||
},
|
||||
{
|
||||
"duration": 6,
|
||||
"chapter": 2,
|
||||
"title": [
|
||||
"Wer bringt",
|
||||
"was mit?"
|
||||
],
|
||||
"hint": "Eigene Beiträge bearbeiten oder löschen",
|
||||
"image": "termin-details.jpg",
|
||||
"crop": [
|
||||
543,
|
||||
690,
|
||||
501,
|
||||
318
|
||||
],
|
||||
"focus": [
|
||||
575,
|
||||
748,
|
||||
440,
|
||||
62
|
||||
],
|
||||
"subtitle": [
|
||||
"In der Mitbringliste seht ihr, wer schon etwas beisteuert.",
|
||||
"Eigene Beiträge könnt ihr bearbeiten oder löschen."
|
||||
]
|
||||
},
|
||||
{
|
||||
"duration": 7,
|
||||
"chapter": 3,
|
||||
"title": [
|
||||
"Familien und",
|
||||
"Kontakte finden."
|
||||
],
|
||||
"hint": "Haushalte und Kinder zusammen sehen",
|
||||
"image": "adressbuch.jpg",
|
||||
"crop": [
|
||||
295,
|
||||
35,
|
||||
1220,
|
||||
700
|
||||
],
|
||||
"focus": [
|
||||
315,
|
||||
325,
|
||||
1080,
|
||||
120
|
||||
],
|
||||
"subtitle": [
|
||||
"Das interne Adressbuch verbindet Haushalte, Kinder",
|
||||
"und die freigegebenen Kontakte der Eltern."
|
||||
]
|
||||
},
|
||||
{
|
||||
"duration": 7,
|
||||
"chapter": 3,
|
||||
"title": [
|
||||
"Freigabe bleibt",
|
||||
"eine Entscheidung."
|
||||
],
|
||||
"hint": "Kontaktdaten erscheinen nach Opt-in",
|
||||
"image": "adressbuch.jpg",
|
||||
"crop": [
|
||||
580,
|
||||
290,
|
||||
900,
|
||||
310
|
||||
],
|
||||
"focus": [
|
||||
638,
|
||||
330,
|
||||
420,
|
||||
95
|
||||
],
|
||||
"subtitle": [
|
||||
"Jeder Elternteil entscheidet selbst über die Freigabe seiner Kontaktdaten.",
|
||||
"Im Adressbuch erscheinen nur ausdrücklich freigegebene Angaben."
|
||||
]
|
||||
},
|
||||
{
|
||||
"duration": 7,
|
||||
"chapter": 4,
|
||||
"title": [
|
||||
"Kind abmelden.",
|
||||
"Kita informieren."
|
||||
],
|
||||
"hint": "Krankheit, Urlaub und andere Gründe",
|
||||
"image": "abwesenheiten.jpg",
|
||||
"crop": [
|
||||
395,
|
||||
25,
|
||||
1060,
|
||||
640
|
||||
],
|
||||
"focus": [
|
||||
420,
|
||||
320,
|
||||
1010,
|
||||
130
|
||||
],
|
||||
"subtitle": [
|
||||
"Eltern melden Abwesenheiten digital.",
|
||||
"Die Kita sieht die Meldung in ihrer Übersicht."
|
||||
]
|
||||
},
|
||||
{
|
||||
"duration": 7,
|
||||
"chapter": 4,
|
||||
"title": [
|
||||
"Pläne dürfen",
|
||||
"sich ändern."
|
||||
],
|
||||
"hint": "Zukünftige Meldungen korrigieren",
|
||||
"image": "abwesenheiten.jpg",
|
||||
"crop": [
|
||||
415,
|
||||
305,
|
||||
1020,
|
||||
340
|
||||
],
|
||||
"focus": [
|
||||
446,
|
||||
565,
|
||||
320,
|
||||
45
|
||||
],
|
||||
"subtitle": [
|
||||
"Zukünftige Abwesenheiten können Eltern bearbeiten oder zurücknehmen.",
|
||||
"Sobald eine Meldung begonnen hat, übernimmt die Kita die Änderung."
|
||||
]
|
||||
},
|
||||
{
|
||||
"duration": 7,
|
||||
"chapter": 5,
|
||||
"title": [
|
||||
"Offizielle Infos",
|
||||
"bleiben auffindbar."
|
||||
],
|
||||
"hint": "Das digitale Schwarze Brett",
|
||||
"image": "mitteilungen.jpg",
|
||||
"crop": [
|
||||
410,
|
||||
275,
|
||||
780,
|
||||
450
|
||||
],
|
||||
"focus": [
|
||||
484,
|
||||
367,
|
||||
630,
|
||||
80
|
||||
],
|
||||
"subtitle": [
|
||||
"Mitteilungen bündeln offizielle Informationen an einem Ort.",
|
||||
"Formatierter Text und der Aktualisierungshinweis machen Änderungen sichtbar."
|
||||
]
|
||||
},
|
||||
{
|
||||
"duration": 7,
|
||||
"chapter": 5,
|
||||
"title": [
|
||||
"Auch nach einer",
|
||||
"Korrektur klar."
|
||||
],
|
||||
"hint": "Geschützte Anhänge und optionale E-Mails",
|
||||
"image": "mitteilungen.jpg",
|
||||
"crop": [
|
||||
450,
|
||||
330,
|
||||
700,
|
||||
335
|
||||
],
|
||||
"focus": [
|
||||
485,
|
||||
580,
|
||||
625,
|
||||
53
|
||||
],
|
||||
"subtitle": [
|
||||
"Kita-Admins können Beiträge korrigieren; Anhänge bleiben geschützt abrufbar.",
|
||||
"Beim optionalen E-Mail-Versand bleibt der Status je Empfänger nachvollziehbar."
|
||||
]
|
||||
},
|
||||
{
|
||||
"duration": 8,
|
||||
"chapter": 6,
|
||||
"title": [
|
||||
"Arbeitszeit für",
|
||||
"das Kita-Team."
|
||||
],
|
||||
"hint": "Stunden erfassen und Wochen einreichen",
|
||||
"image": "personal.jpg",
|
||||
"crop": [
|
||||
315,
|
||||
445,
|
||||
1210,
|
||||
780
|
||||
],
|
||||
"focus": [
|
||||
345,
|
||||
1105,
|
||||
780,
|
||||
105
|
||||
],
|
||||
"subtitle": [
|
||||
"Fachkräfte erfassen ihre Arbeitszeit mit Pausen",
|
||||
"und reichen vollständige Wochen zur Prüfung ein."
|
||||
]
|
||||
},
|
||||
{
|
||||
"duration": 8,
|
||||
"chapter": 6,
|
||||
"title": [
|
||||
"Zeitkonto und",
|
||||
"Urlaub im Blick."
|
||||
],
|
||||
"hint": "Bestätigte Zeit von vorläufiger unterscheiden",
|
||||
"image": "personal.jpg",
|
||||
"crop": [
|
||||
308,
|
||||
235,
|
||||
1210,
|
||||
200
|
||||
],
|
||||
"focus": [
|
||||
315,
|
||||
275,
|
||||
1185,
|
||||
135
|
||||
],
|
||||
"subtitle": [
|
||||
"Das bestätigte Zeitkonto und der verfügbare Urlaub bleiben sichtbar.",
|
||||
"Urlaub und Freizeitausgleich lassen sich im Personalbereich beantragen."
|
||||
]
|
||||
},
|
||||
{
|
||||
"duration": 10,
|
||||
"chapter": 7,
|
||||
"title": [
|
||||
"Gemeinsam",
|
||||
"besser planen."
|
||||
],
|
||||
"hint": "Startet mit den Abläufen, die euch entlasten",
|
||||
"image": "kalender.jpg",
|
||||
"crop": [
|
||||
300,
|
||||
35,
|
||||
1240,
|
||||
920
|
||||
],
|
||||
"subtitle": [
|
||||
"Lernt Kita-Planer in Ruhe kennen und entdeckt die passenden Funktionen.",
|
||||
"Alle gezeigten App-Ansichten verwenden ausschließlich Beispieldaten."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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());
|
||||
@@ -0,0 +1,92 @@
|
||||
/** Synthetic fixture for public product screenshots. Never run against real data. */
|
||||
import { PrismaClient, UserRole, TerminType, type User, type Child } from "@prisma/client";
|
||||
import { hash } from "bcryptjs";
|
||||
|
||||
const url = new URL(process.env.DATABASE_URL ?? "postgresql://invalid");
|
||||
if (!['localhost', '127.0.0.1'].includes(url.hostname) ||
|
||||
url.pathname !== '/kita_screenshots_test') {
|
||||
throw new Error('Screenshot fixtures require the isolated local kita_screenshots_test database.');
|
||||
}
|
||||
const prisma = new PrismaClient();
|
||||
const date = (value: string) => new Date(`${value}T00:00:00Z`);
|
||||
|
||||
async function main() {
|
||||
if (await prisma.kita.count() || await prisma.user.count()) {
|
||||
throw new Error('Database must be empty; existing data will never be reset.');
|
||||
}
|
||||
const passwordHash = await hash('demo-screenshots-only', 10);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const kita = await tx.kita.create({ data: { name: 'Kita Sonnenwinkel', slug: 'sonnenwinkel-demo' } });
|
||||
const kitaId = kita.id;
|
||||
const common = { kitaId, passwordHash, emailVerifiedAt: date('2026-09-01'),
|
||||
privacyPolicyAcceptedAt: date('2026-09-01'), privacyPolicyVersion: '2026-05-01' };
|
||||
const admin = await tx.user.create({ data: { ...common, email: 'admin@example.invalid', firstName: 'Mara', lastName: 'Winter', role: UserRole.ADMIN } });
|
||||
const households = [
|
||||
['Berger', 'Lena', 'Jonas', 'Emma', 'Leo'],
|
||||
['Hoffmann', 'Anna', 'Tim', 'Mila'],
|
||||
['Schneider', 'Clara', 'Ben', 'Paul'],
|
||||
['Neumann', 'Nora', 'Felix', 'Lotta'],
|
||||
['Weber', 'Sarah', 'David', 'Finn'],
|
||||
['Koch', 'Julia', 'Max', 'Ella'],
|
||||
];
|
||||
const parents: User[] = [];
|
||||
const children: Child[] = [];
|
||||
for (const [lastName, firstName, partner, ...childNames] of households) {
|
||||
const family = await tx.family.create({ data: { kitaId, name: `Familie ${lastName}` } });
|
||||
for (const name of [firstName, partner]) {
|
||||
parents.push(await tx.user.create({ data: { ...common, familyId: family.id, firstName: name, lastName,
|
||||
email: `${name.toLowerCase()}@example.invalid`, role: UserRole.ELTERN,
|
||||
directoryOptInAt: date('2026-09-01'), city: 'Musterstadt' } }));
|
||||
}
|
||||
for (const name of childNames) {
|
||||
children.push(await tx.child.create({ data: { kitaId, familyId: family.id, firstName: name, lastName } }));
|
||||
}
|
||||
}
|
||||
await tx.notdienstAvailability.createMany({ data: [2, 4, 6, 10, 12, 17, 20, 25].map(day => ({
|
||||
kitaId, childId: children[0].id, userId: parents[0].id,
|
||||
date: date(`2026-11-${String(day).padStart(2, '0')}`),
|
||||
})) });
|
||||
await tx.absence.createMany({ data: [
|
||||
{ kitaId, childId: children[0].id, startDate: date('2026-10-12'), endDate: date('2026-10-14'), reason: 'ILLNESS' },
|
||||
{ kitaId, childId: children[1].id, startDate: date('2026-10-26'), endDate: date('2026-10-30'), reason: 'VACATION', note: 'Familienurlaub in den Herbstferien' },
|
||||
] });
|
||||
const events: [string, TerminType, string, string, boolean][] = [
|
||||
['Elterncafé', 'ELTERNCAFE', '2026-10-08T13:00:00Z', '2026-10-08T14:30:00Z', false],
|
||||
['Ausflug in den Stadtwald', 'SONSTIGES', '2026-10-15T07:00:00Z', '2026-10-15T11:00:00Z', false],
|
||||
['Elternabend', 'ELTERNABEND', '2026-10-20T16:00:00Z', '2026-10-20T18:00:00Z', false],
|
||||
['Herbstfest im Garten', 'KITA_FEST', '2026-10-23T13:00:00Z', '2026-10-23T16:00:00Z', false],
|
||||
['Teamtag · Kita geschlossen', 'TEAMTAG', '2026-10-30T00:00:00Z', '2026-10-30T23:59:00Z', true],
|
||||
['Elterncafé', 'ELTERNCAFE', '2026-11-05T14:00:00Z', '2026-11-05T15:30:00Z', false],
|
||||
['Laternenfest', 'KITA_FEST', '2026-11-11T15:00:00Z', '2026-11-11T17:00:00Z', false],
|
||||
['Gartenaktion', 'MITMACH_TAG', '2026-11-14T09:00:00Z', '2026-11-14T12:00:00Z', false],
|
||||
['Teamtag · Kita geschlossen', 'TEAMTAG', '2026-11-23T00:00:00Z', '2026-11-23T23:59:00Z', true],
|
||||
];
|
||||
for (const [title, type, start, end, allDay] of events) {
|
||||
const event = await tx.termin.create({ data: { kitaId, title, type, status: 'CONFIRMED', startDate: new Date(start), endDate: new Date(end), allDay,
|
||||
description: title.includes('Herbstfest') ? 'Wir feiern gemeinsam mit allen Familien. Für Snacks und Getränke gibt es eine Mitbringliste.' : 'Gemeinsam planen wir unseren Kita-Alltag.',
|
||||
createdById: admin.id, approvedById: admin.id, approvedAt: date('2026-10-01'),
|
||||
requiresRsvp: type === 'KITA_FEST', rsvpMode: 'FAMILY', mitbringselListEnabled: type === 'KITA_FEST' } });
|
||||
if (type === 'KITA_FEST') {
|
||||
await tx.familyEventParticipation.create({ data: { kitaId, eventId: event.id, familyId: parents[0].familyId!, status: 'ATTENDING', adultCount: 2 } });
|
||||
await tx.mitbringselItem.createMany({ data: parents.slice(0, 3).map((parent, index) => ({ kitaId, terminId: event.id, userId: parent.id, content: ['Apfelkuchen', 'Gemüsesticks', 'Eine Kanne Tee'][index] })) });
|
||||
}
|
||||
}
|
||||
for (const [title, content, created, updated] of [
|
||||
['Herbstfest: Wir freuen uns auf euch!', 'Am **Freitag, 23. Oktober**, feiern wir ab 15 Uhr im Kita-Garten.\n\nBitte tragt euch im Kalender für die Teilnahme und in die Mitbringliste ein. Gemeinsam sorgen wir für ein buntes Buffet.\n\n**Aktualisierung:** Bei Regen feiern wir im Gruppenraum.', '2026-10-09', '2026-10-13'],
|
||||
['Elternabend am 20. Oktober', 'Wir treffen uns um **18 Uhr** im großen Gruppenraum.\n\nAuf der Agenda stehen der Kita-Alltag, die nächsten Ausflüge und eure Fragen. Wir freuen uns auf den Austausch!', '2026-10-08', '2026-10-08'],
|
||||
['Gemeinsam unseren Garten vorbereiten', 'Am 14. November machen wir unseren Garten winterfest. Kleine und große Helfer sind willkommen!\n\nHandschuhe und gute Laune reichen. Alle Details findet ihr im Kalender.', '2026-10-06', '2026-10-06'],
|
||||
]) {
|
||||
await tx.announcement.create({ data: { kitaId, authorId: admin.id, title, content, audience: 'ALLE',
|
||||
createdAt: date(created), updatedAt: date(updated), revision: created === updated ? 1 : 2, editedAt: created === updated ? null : date(updated) } });
|
||||
}
|
||||
const staff = await tx.user.create({ data: { ...common, email: 'team@example.invalid', firstName: 'Sophie', lastName: 'Lind', role: UserRole.ERZIEHER } });
|
||||
const educator = await tx.educator.create({ data: { kitaId, firstName: staff.firstName, lastName: staff.lastName } });
|
||||
const profile = await tx.staffProfile.create({ data: { kitaId, educatorId: educator.id, userId: staff.id,
|
||||
employmentStart: date('2026-01-01'), trackingStart: date('2026-10-01'), openingMinutes: 120,
|
||||
workPatterns: { create: { effectiveFrom: date('2026-01-01'), dailyMinutes: [420,420,420,420,420,0,0] } },
|
||||
leaveAccounts: { create: { year: 2026, entitlementUnits: 60, carryUnits: 0 } } } });
|
||||
await tx.staffTimeEntry.createMany({ data: [5,6,7,8,9].map(day => ({ profileId: profile.id, date: date(`2026-10-${String(day).padStart(2,'0')}`), startMinute: 480, endMinute: 930, breakMinutes: 30 })) });
|
||||
}, { timeout: 30000 });
|
||||
console.log('Synthetic screenshot data ready. Logins: lena@example.invalid / team@example.invalid. Password: demo-screenshots-only.');
|
||||
}
|
||||
main().finally(() => prisma.$disconnect());
|
||||
@@ -1,8 +1,14 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
echo "Applying Prisma schema..."
|
||||
npx prisma db push --skip-generate
|
||||
|
||||
echo "Starting Kita-Planer..."
|
||||
exec npm run start:prod
|
||||
cd "$(dirname "$0")/.."
|
||||
if [ -n "${APP_DATABASE_PASSWORD_FILE:-}" ]; then
|
||||
DATABASE_URL="$(node scripts/runtime-database-url.mjs)"
|
||||
export DATABASE_URL
|
||||
fi
|
||||
unset POSTGRES_PASSWORD PGADMIN_DEFAULT_PASSWORD APP_DATABASE_PASSWORD MIGRATION_DATABASE_URL
|
||||
# Schema changes are applied by the maintenance image before runtime startup.
|
||||
if [ -n "${NOTDIENST_JOB_SECRET:-}" ] && [ "${#NOTDIENST_JOB_SECRET}" -lt 32 ]; then
|
||||
echo "NOTDIENST_JOB_SECRET muss mindestens 32 Zeichen haben." >&2
|
||||
exit 1
|
||||
fi
|
||||
exec node scripts/start-runtime.mjs
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { spawn } from "node:child_process";
|
||||
let stopping = false,
|
||||
worker,
|
||||
restartTimer,
|
||||
stopTimer;
|
||||
const server = spawn(process.execPath, ["server.js"], { stdio: "inherit" });
|
||||
function shutdown(code = 0) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
process.exitCode = code;
|
||||
clearTimeout(restartTimer);
|
||||
server.kill("SIGTERM");
|
||||
worker?.kill("SIGTERM");
|
||||
stopTimer = setTimeout(() => {
|
||||
server.kill("SIGKILL");
|
||||
worker?.kill("SIGKILL");
|
||||
process.exit(code);
|
||||
}, 4000);
|
||||
stopTimer.unref();
|
||||
Promise.all(
|
||||
[server, worker]
|
||||
.filter(Boolean)
|
||||
.map((child) =>
|
||||
child.exitCode !== null || child.signalCode !== null
|
||||
? Promise.resolve()
|
||||
: new Promise((resolve) => child.once("close", resolve)),
|
||||
),
|
||||
).then(() => {
|
||||
clearTimeout(stopTimer);
|
||||
process.exit(code);
|
||||
});
|
||||
}
|
||||
function startWorker() {
|
||||
if (stopping) return;
|
||||
worker = spawn(process.execPath, ["scripts/notdienst-alarm-worker.mjs"], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
worker.on("close", () => {
|
||||
if (stopping) return;
|
||||
console.error(
|
||||
"Notdienst-Hintergrundjob beendet; Neustart in fünf Sekunden.",
|
||||
);
|
||||
restartTimer = setTimeout(startWorker, 5000);
|
||||
});
|
||||
worker.on("error", () => {
|
||||
if (!stopping)
|
||||
console.error("Notdienst-Hintergrundjob konnte nicht starten.");
|
||||
});
|
||||
}
|
||||
server.on("exit", (code) => shutdown(code ?? 1));
|
||||
server.on("error", () => shutdown(1));
|
||||
for (const signal of ["SIGTERM", "SIGINT"])
|
||||
process.on(signal, () => shutdown(0));
|
||||
if ((process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32) startWorker();
|
||||
else
|
||||
console.warn(
|
||||
"Automatische Notdienst-Jobs deaktiviert: gültiges NOTDIENST_JOB_SECRET fehlt.",
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
let prisma;
|
||||
try {
|
||||
const main = Object.fromEntries(readFileSync('/proc/1/environ', 'utf8').split('\0').filter(Boolean).map(line => {
|
||||
const index = line.indexOf('='); return [line.slice(0, index), line.slice(index + 1)];
|
||||
}));
|
||||
for (const key of ['POSTGRES_PASSWORD', 'PGADMIN_DEFAULT_PASSWORD', 'APP_DATABASE_PASSWORD', 'MIGRATION_DATABASE_URL']) {
|
||||
if ((process.env[key] && process.env[key] !== 'unavailable-in-app') || main[key]) throw new Error('Administrative credential reached the app.');
|
||||
}
|
||||
// Configured global/legacy URLs must not expose an administrative login either.
|
||||
if (process.env.DATABASE_URL && process.env.DATABASE_URL !== 'unavailable-in-app') throw new Error('Hosting injected a legacy database URL.');
|
||||
if (new URL(main.DATABASE_URL).username !== 'kita_runtime') throw new Error('Wrong application database role.');
|
||||
prisma = new PrismaClient({ datasourceUrl: main.DATABASE_URL });
|
||||
const [row] = await prisma.$queryRawUnsafe('SELECT current_user');
|
||||
if (row.current_user !== 'kita_runtime') throw new Error('Wrong effective database role.');
|
||||
console.log('Runtime isolation verified: no administrative credentials in container/app environment; current_user=kita_runtime.');
|
||||
} catch {
|
||||
console.error('Runtime isolation verification failed. Credential values were not logged.');
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await prisma?.$disconnect();
|
||||
}
|
||||
@@ -1,23 +1,33 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AbsenceReason, UserRole } from "@prisma/client";
|
||||
import { AbsenceReason, Prisma, UserRole } from "@prisma/client";
|
||||
import { dateOnlySchema } from "@/lib/date-schema";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
absenceDate,
|
||||
absenceToday,
|
||||
canManageKitaAbsences,
|
||||
} from "@/lib/absence-policy";
|
||||
|
||||
const reportAbsenceSchema = z.object({
|
||||
childId: z.string().min(1),
|
||||
startDate: z.string().min(1),
|
||||
endDate: z.string().min(1),
|
||||
const dateSchema = dateOnlySchema;
|
||||
const fieldsSchema = z.object({
|
||||
startDate: dateSchema,
|
||||
endDate: dateSchema,
|
||||
reason: z.nativeEnum(AbsenceReason),
|
||||
note: z.string().trim().max(500).optional(),
|
||||
});
|
||||
|
||||
const dailyAbsenceSchema = z.object({
|
||||
date: z.string().min(1),
|
||||
const reportSchema = fieldsSchema.extend({
|
||||
childId: z.string().min(1).max(128),
|
||||
});
|
||||
const identitySchema = z.object({
|
||||
id: z.string().min(1).max(128),
|
||||
updatedAt: z.string().datetime({ offset: true }),
|
||||
});
|
||||
const updateSchema = fieldsSchema.extend(identitySchema.shape);
|
||||
type MutationResult = { success?: boolean; error?: string };
|
||||
|
||||
export type DailyAbsenceDto = {
|
||||
id: string;
|
||||
@@ -27,112 +37,216 @@ export type DailyAbsenceDto = {
|
||||
note: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function parseDateOnly(value: string) {
|
||||
const date = new Date(`${value.slice(0, 10)}T00:00:00`);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new Error("Ungueltiges Datum.");
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
function toDateKey(date: Date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export async function reportAbsence(rawPayload: unknown) {
|
||||
const session = await requireKitaSession();
|
||||
const parsed = reportAbsenceSchema.safeParse(rawPayload);
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
if (!session.user.familyId) {
|
||||
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
||||
}
|
||||
|
||||
const startDate = parseDateOnly(parsed.data.startDate);
|
||||
const endDate = parseDateOnly(parsed.data.endDate);
|
||||
|
||||
if (endDate < startDate) {
|
||||
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
|
||||
}
|
||||
|
||||
const child = await prisma.child.findFirst({
|
||||
where: {
|
||||
id: parsed.data.childId,
|
||||
kitaId: session.user.kitaId,
|
||||
familyId: session.user.familyId,
|
||||
active: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!child) {
|
||||
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
|
||||
}
|
||||
|
||||
const overlappingAbsence = await prisma.absence.findFirst({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
childId: child.id,
|
||||
startDate: { lte: endDate },
|
||||
endDate: { gte: startDate },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (overlappingAbsence) {
|
||||
return { error: "Für diesen Zeitraum existiert bereits eine Abmeldung." };
|
||||
}
|
||||
|
||||
await prisma.absence.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId,
|
||||
childId: child.id,
|
||||
startDate,
|
||||
endDate,
|
||||
reason: parsed.data.reason,
|
||||
note: parsed.data.note || null,
|
||||
},
|
||||
});
|
||||
|
||||
function refreshAbsences() {
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/abwesenheiten");
|
||||
revalidatePath("/dashboard/admin/abwesenheiten");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteAbsence(id: string) {
|
||||
const session = await requireKitaSession();
|
||||
|
||||
if (!session.user.familyId) {
|
||||
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
||||
}
|
||||
|
||||
const absence = await prisma.absence.findFirst({
|
||||
async function currentActor(
|
||||
tx: Prisma.TransactionClient,
|
||||
id: string,
|
||||
kitaId: string,
|
||||
) {
|
||||
return tx.user.findFirst({
|
||||
where: {
|
||||
id,
|
||||
kitaId: session.user.kitaId,
|
||||
child: {
|
||||
familyId: session.user.familyId,
|
||||
kitaId,
|
||||
role: {
|
||||
in: [
|
||||
UserRole.ELTERN,
|
||||
UserRole.ADMIN,
|
||||
UserRole.KOORDINATOR,
|
||||
UserRole.ERZIEHER,
|
||||
],
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
select: { id: true, role: true, familyId: true },
|
||||
});
|
||||
}
|
||||
|
||||
if (!absence) {
|
||||
return { error: "Diese Abmeldung gehört nicht zu deinem Haushalt." };
|
||||
}
|
||||
async function lockChild(
|
||||
tx: Prisma.TransactionClient,
|
||||
childId: string,
|
||||
kitaId: string,
|
||||
) {
|
||||
// Serializes overlapping create/edit/delete operations for this child.
|
||||
await tx.$queryRaw`SELECT "id" FROM "children" WHERE "id" = ${childId} AND "kitaId" = ${kitaId} FOR UPDATE`;
|
||||
}
|
||||
|
||||
await prisma.absence.delete({
|
||||
where: { id: absence.id },
|
||||
export async function reportAbsence(
|
||||
rawPayload: unknown,
|
||||
): Promise<MutationResult> {
|
||||
const session = await requireKitaSession();
|
||||
const parsed = reportSchema.safeParse(rawPayload);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
error:
|
||||
"Bitte gültige Daten und einen Grund angeben. Die Bemerkung darf höchstens 500 Zeichen haben.",
|
||||
};
|
||||
const { childId, startDate, endDate, reason, note } = parsed.data;
|
||||
if (endDate < startDate)
|
||||
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const actor = await currentActor(tx, session.user.id, session.user.kitaId);
|
||||
if (!actor?.familyId)
|
||||
return { error: "Dein Account ist keinem Haushalt zugeordnet." };
|
||||
if (!canManageKitaAbsences(actor.role) && startDate < absenceToday())
|
||||
return {
|
||||
error:
|
||||
"Vergangene Tage kann nur die Kita nachtragen. Bitte melde dich dort.",
|
||||
};
|
||||
await lockChild(tx, childId, session.user.kitaId);
|
||||
const child = await tx.child.findFirst({
|
||||
where: {
|
||||
id: childId,
|
||||
kitaId: session.user.kitaId,
|
||||
familyId: actor.familyId,
|
||||
active: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!child)
|
||||
return {
|
||||
error:
|
||||
"Dieses Kind gehört nicht zu deinem Haushalt oder ist nicht mehr aktiv.",
|
||||
};
|
||||
const start = absenceDate(startDate),
|
||||
end = absenceDate(endDate);
|
||||
const overlap = await tx.absence.findFirst({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
childId,
|
||||
startDate: { lte: end },
|
||||
endDate: { gte: start },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (overlap)
|
||||
return { error: "Für diesen Zeitraum existiert bereits eine Abmeldung." };
|
||||
await tx.absence.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId,
|
||||
childId,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
reason,
|
||||
note: note || null,
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
});
|
||||
if (result.success) refreshAbsences();
|
||||
return result;
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/admin/abwesenheiten");
|
||||
return { success: true };
|
||||
async function changeAbsence(
|
||||
rawPayload: unknown,
|
||||
remove: boolean,
|
||||
): Promise<MutationResult> {
|
||||
const session = await requireKitaSession();
|
||||
const parsed = remove
|
||||
? identitySchema.safeParse(rawPayload)
|
||||
: updateSchema.safeParse(rawPayload);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
error:
|
||||
"Ungültige Eingabedaten. Bitte die Ansicht aktualisieren und gültige Daten angeben.",
|
||||
};
|
||||
const input = parsed.data;
|
||||
const fields = remove ? null : updateSchema.parse(rawPayload);
|
||||
if (fields && fields.endDate < fields.startDate)
|
||||
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const actor = await currentActor(tx, session.user.id, session.user.kitaId);
|
||||
if (!actor) return { error: "Keine Berechtigung für diese Meldung." };
|
||||
const team = canManageKitaAbsences(actor.role);
|
||||
if (!team && !actor.familyId)
|
||||
return { error: "Dein Account ist keinem Haushalt zugeordnet." };
|
||||
const where: Prisma.AbsenceWhereInput = {
|
||||
id: input.id,
|
||||
kitaId: session.user.kitaId,
|
||||
child: {
|
||||
kitaId: session.user.kitaId,
|
||||
...(team ? {} : { familyId: actor.familyId! }),
|
||||
},
|
||||
};
|
||||
const candidate = await tx.absence.findFirst({
|
||||
where,
|
||||
select: { childId: true },
|
||||
});
|
||||
if (!candidate)
|
||||
return {
|
||||
error:
|
||||
"Meldung wurde nicht gefunden oder gehört nicht zu deinem Haushalt.",
|
||||
};
|
||||
await lockChild(tx, candidate.childId, session.user.kitaId);
|
||||
const absence = await tx.absence.findFirst({ where });
|
||||
if (!absence)
|
||||
return {
|
||||
error:
|
||||
"Die Meldung ist nicht mehr vorhanden. Bitte die Ansicht aktualisieren.",
|
||||
};
|
||||
if (
|
||||
absence.updatedAt.toISOString() !==
|
||||
new Date(input.updatedAt).toISOString()
|
||||
)
|
||||
return {
|
||||
error:
|
||||
"Die Meldung wurde inzwischen geändert. Bitte die Ansicht aktualisieren.",
|
||||
};
|
||||
const today = absenceToday();
|
||||
if (
|
||||
!team &&
|
||||
(absence.startDate.toISOString().slice(0, 10) <= today ||
|
||||
(fields && fields.startDate <= today))
|
||||
)
|
||||
return {
|
||||
error:
|
||||
"Bereits begonnene Meldungen und laufende Tage verwaltet die Kita. Bitte melde dich dort.",
|
||||
};
|
||||
if (remove) await tx.absence.delete({ where: { id: absence.id } });
|
||||
else if (fields) {
|
||||
const startDate = absenceDate(fields.startDate),
|
||||
endDate = absenceDate(fields.endDate);
|
||||
const overlap = await tx.absence.findFirst({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
childId: absence.childId,
|
||||
id: { not: absence.id },
|
||||
startDate: { lte: endDate },
|
||||
endDate: { gte: startDate },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (overlap)
|
||||
return {
|
||||
error: "Für diesen Zeitraum existiert bereits eine andere Abmeldung.",
|
||||
};
|
||||
await tx.absence.update({
|
||||
where: { id: absence.id },
|
||||
data: {
|
||||
startDate,
|
||||
endDate,
|
||||
reason: fields.reason,
|
||||
note: fields.note || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
if (result.success) refreshAbsences();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateAbsence(rawPayload: unknown) {
|
||||
return changeAbsence(rawPayload, false);
|
||||
}
|
||||
export async function deleteAbsence(rawPayload: unknown) {
|
||||
return changeAbsence(rawPayload, true);
|
||||
}
|
||||
|
||||
export async function getDailyAbsences(
|
||||
@@ -143,17 +257,10 @@ export async function getDailyAbsences(
|
||||
UserRole.KOORDINATOR,
|
||||
UserRole.ERZIEHER,
|
||||
]);
|
||||
|
||||
if (!session.user.kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
const parsed = dailyAbsenceSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültiges Datum." };
|
||||
}
|
||||
|
||||
const date = parseDateOnly(parsed.data.date);
|
||||
if (!session.user.kitaId) return { error: "Kein Mandant zugeordnet." };
|
||||
const parsed = z.object({ date: dateSchema }).safeParse(rawPayload);
|
||||
if (!parsed.success) return { error: "Ungültiges Datum." };
|
||||
const date = absenceDate(parsed.data.date);
|
||||
const rows = await prisma.absence.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
@@ -166,24 +273,17 @@ export async function getDailyAbsences(
|
||||
note: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
updatedAt: true,
|
||||
child: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
family: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
family: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ child: { lastName: "asc" } },
|
||||
{ child: { firstName: "asc" } },
|
||||
],
|
||||
orderBy: [{ child: { lastName: "asc" } }, { child: { firstName: "asc" } }],
|
||||
});
|
||||
|
||||
return {
|
||||
absences: rows.map((absence) => ({
|
||||
id: absence.id,
|
||||
@@ -191,8 +291,9 @@ export async function getDailyAbsences(
|
||||
familyName: absence.child.family.name,
|
||||
reason: absence.reason,
|
||||
note: absence.note,
|
||||
startDate: toDateKey(absence.startDate),
|
||||
endDate: toDateKey(absence.endDate),
|
||||
startDate: absence.startDate.toISOString().slice(0, 10),
|
||||
endDate: absence.endDate.toISOString().slice(0, 10),
|
||||
updatedAt: absence.updatedAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,25 +2,50 @@
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import { mkdir, rm, writeFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { createElement } from "react";
|
||||
import {
|
||||
attachmentDirectory,
|
||||
attachmentPath,
|
||||
MAX_ATTACHMENT_SIZE,
|
||||
detectAttachmentType,
|
||||
attachmentExtensionMatches,
|
||||
} from "@/lib/attachments";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { after } from "next/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { Prisma, UserRole } from "@prisma/client";
|
||||
import { UserRole } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { NewsEmail } from "@/emails/NewsEmail";
|
||||
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { visibleAnnouncementAudiences } 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"],
|
||||
]);
|
||||
import {
|
||||
assertAnnouncementAdmin,
|
||||
assertAnnouncementEditable,
|
||||
deliverDueAnnouncementEmails,
|
||||
lockAnnouncement,
|
||||
queueAnnouncementEmails,
|
||||
} from "@/lib/announcement-delivery";
|
||||
import { publicActionError, UserFacingError } from "@/lib/action-error";
|
||||
|
||||
const ALLOWED_EXTENSIONS = new Set([".pdf", ".jpg", ".jpeg", ".png"]);
|
||||
function revalidateAnnouncements() {
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/admin/news");
|
||||
revalidatePath("/dashboard/mitteilungen");
|
||||
}
|
||||
function startAnnouncementDelivery(kitaId: string, announcementId: string) {
|
||||
after(async () => {
|
||||
try {
|
||||
await deliverDueAnnouncementEmails({ kitaId, announcementId });
|
||||
} catch {
|
||||
console.error(
|
||||
"Mitteilungsversand wird im nächsten Hintergrundlauf erneut versucht.",
|
||||
);
|
||||
}
|
||||
revalidateAnnouncements();
|
||||
});
|
||||
}
|
||||
|
||||
const audienceSchema = z.enum(["ELTERN", "ERZIEHER", "ALLE"]);
|
||||
|
||||
@@ -31,58 +56,10 @@ const announcementSchema = z.object({
|
||||
audience: audienceSchema,
|
||||
});
|
||||
|
||||
function getUploadDir() {
|
||||
return path.resolve(process.env.UPLOAD_DIR ?? "uploads", "announcements");
|
||||
}
|
||||
|
||||
function getSafeFileName(name: string) {
|
||||
return name.replace(/[^\w.\- äöüÄÖÜß]/g, "_").slice(0, 160);
|
||||
}
|
||||
|
||||
function validateAttachment(file: File) {
|
||||
const extension = path.extname(file.name).toLowerCase();
|
||||
const typeAllowed = ALLOWED_TYPES.has(file.type);
|
||||
const extensionAllowed = ALLOWED_EXTENSIONS.has(extension);
|
||||
|
||||
if (!typeAllowed && !extensionAllowed) {
|
||||
return "Nur PDF, JPG und PNG sind erlaubt.";
|
||||
}
|
||||
|
||||
if (file.size > MAX_ATTACHMENT_SIZE) {
|
||||
return "Anhänge dürfen maximal 10 MB groß sein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getRecipients(kitaId: string, audience: z.infer<typeof audienceSchema>) {
|
||||
const where: Prisma.UserWhereInput =
|
||||
audience === "ELTERN"
|
||||
? {
|
||||
kitaId,
|
||||
familyId: { not: null },
|
||||
emailVerifiedAt: { not: null },
|
||||
}
|
||||
: audience === "ERZIEHER"
|
||||
? {
|
||||
kitaId,
|
||||
role: UserRole.ERZIEHER,
|
||||
emailVerifiedAt: { not: null },
|
||||
}
|
||||
: {
|
||||
kitaId,
|
||||
emailVerifiedAt: { not: null },
|
||||
OR: [{ familyId: { not: null } }, { role: UserRole.ERZIEHER }],
|
||||
};
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where,
|
||||
select: { email: true },
|
||||
});
|
||||
|
||||
return Array.from(new Set(users.map((user) => user.email)));
|
||||
}
|
||||
|
||||
export async function createAnnouncement(formData: FormData) {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
const kitaId = session.user.kitaId;
|
||||
@@ -106,140 +83,349 @@ export async function createAnnouncement(formData: FormData) {
|
||||
.getAll("attachments")
|
||||
.filter((value): value is File => value instanceof File && value.size > 0);
|
||||
|
||||
if (
|
||||
attachmentFiles.length > 5 ||
|
||||
attachmentFiles.some((file) => file.size > MAX_ATTACHMENT_SIZE) ||
|
||||
attachmentFiles.reduce((total, file) => total + file.size, 0) >
|
||||
25 * 1024 * 1024
|
||||
) {
|
||||
return {
|
||||
error: "Maximal 5 Anhänge, je 10 MB und insgesamt 25 MB sind erlaubt.",
|
||||
};
|
||||
}
|
||||
if (
|
||||
!(await rateLimit(`announcements:${kitaId}:${session.user.id}`, 20, 3600))
|
||||
.allowed
|
||||
) {
|
||||
return {
|
||||
error: "Zu viele Ankündigungen. Bitte versuche es später erneut.",
|
||||
};
|
||||
}
|
||||
const attachmentInputs: {
|
||||
id: string;
|
||||
bytes: Buffer;
|
||||
fileName: string;
|
||||
fileType: string;
|
||||
fileUrl: string;
|
||||
}[] = [];
|
||||
for (const file of attachmentFiles) {
|
||||
const error = validateAttachment(file);
|
||||
if (error) {
|
||||
return { error };
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
const fileType = detectAttachmentType(bytes);
|
||||
if (
|
||||
!fileType ||
|
||||
!attachmentExtensionMatches(file.name, fileType) ||
|
||||
(file.type && file.type !== fileType)
|
||||
) {
|
||||
return {
|
||||
error:
|
||||
"Nur PDF-, JPG- und PNG-Dateien mit passenden Dateiinhalten sind erlaubt.",
|
||||
};
|
||||
}
|
||||
const id = randomUUID();
|
||||
attachmentInputs.push({
|
||||
id,
|
||||
bytes,
|
||||
fileName: getSafeFileName(file.name),
|
||||
fileType,
|
||||
fileUrl: `/api/announcements/attachments/${id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const attachmentInputs = attachmentFiles.map((file) => {
|
||||
const id = randomUUID();
|
||||
return {
|
||||
id,
|
||||
file,
|
||||
fileName: getSafeFileName(file.name),
|
||||
fileType: file.type || "application/octet-stream",
|
||||
fileUrl: `/api/announcements/attachments/${id}`,
|
||||
};
|
||||
});
|
||||
|
||||
const announcement = await prisma.announcement.create({
|
||||
data: {
|
||||
kitaId,
|
||||
title: parsed.data.title,
|
||||
content: parsed.data.content,
|
||||
authorId: session.user.id,
|
||||
attachments: {
|
||||
create: attachmentInputs.map((attachment) => ({
|
||||
id: attachment.id,
|
||||
fileName: attachment.fileName,
|
||||
fileType: attachment.fileType,
|
||||
fileUrl: attachment.fileUrl,
|
||||
})),
|
||||
const announcement = await serializableTransaction(async (tx) => {
|
||||
if (attachmentInputs.length) {
|
||||
const pending = await tx.attachment.count({ where: { sizeBytes: null } });
|
||||
if (pending) return null;
|
||||
const [tenantUsage, globalUsage] = await Promise.all([
|
||||
tx.attachment.aggregate({
|
||||
where: { announcement: { kitaId } },
|
||||
_sum: { sizeBytes: true },
|
||||
}),
|
||||
tx.attachment.aggregate({ _sum: { sizeBytes: true } }),
|
||||
]);
|
||||
const bytes = attachmentInputs.reduce(
|
||||
(total, item) => total + item.bytes.length,
|
||||
0,
|
||||
);
|
||||
const quota = (value: string | undefined, fallback: number) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
};
|
||||
if (
|
||||
(tenantUsage._sum.sizeBytes ?? 0) + bytes >
|
||||
quota(process.env.UPLOAD_TENANT_QUOTA_BYTES, 256 * 1024 ** 2) ||
|
||||
(globalUsage._sum.sizeBytes ?? 0) + bytes >
|
||||
quota(process.env.UPLOAD_GLOBAL_QUOTA_BYTES, 10 * 1024 ** 3)
|
||||
)
|
||||
return null;
|
||||
}
|
||||
const row = await tx.announcement.create({
|
||||
data: {
|
||||
kitaId,
|
||||
title: parsed.data.title,
|
||||
content: parsed.data.content,
|
||||
audience: parsed.data.audience,
|
||||
authorId: session.user.id,
|
||||
emailReady: attachmentInputs.length === 0,
|
||||
attachments: {
|
||||
create: attachmentInputs.map((attachment) => ({
|
||||
id: attachment.id,
|
||||
fileName: attachment.fileName,
|
||||
fileType: attachment.fileType,
|
||||
sizeBytes: attachment.bytes.length,
|
||||
fileUrl: attachment.fileUrl,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
kitaId: true,
|
||||
audience: true,
|
||||
revision: true,
|
||||
},
|
||||
});
|
||||
if (parsed.data.sendEmail) await queueAnnouncementEmails(tx, row);
|
||||
return row;
|
||||
});
|
||||
if (!announcement)
|
||||
return {
|
||||
error:
|
||||
"Anhänge können momentan nicht gespeichert werden. Bitte wende dich an die Administration.",
|
||||
};
|
||||
|
||||
if (attachmentInputs.length > 0) {
|
||||
const uploadDir = getUploadDir();
|
||||
const uploadDir = attachmentDirectory();
|
||||
try {
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
await Promise.all(
|
||||
attachmentInputs.map(async (attachment) => {
|
||||
const arrayBuffer = await attachment.file.arrayBuffer();
|
||||
await writeFile(
|
||||
path.join(uploadDir, attachment.id),
|
||||
Buffer.from(arrayBuffer),
|
||||
);
|
||||
await writeFile(attachmentPath(attachment.id), attachment.bytes, {
|
||||
flag: "wx",
|
||||
mode: 0o600,
|
||||
});
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
await prisma.announcement.delete({ where: { id: announcement.id } });
|
||||
await Promise.all(
|
||||
attachmentInputs.map((attachment) =>
|
||||
rm(path.join(uploadDir, attachment.id), { force: true }),
|
||||
rm(attachmentPath(attachment.id), { force: true }),
|
||||
),
|
||||
);
|
||||
console.error("Ankündigungs-Anhang konnte nicht gespeichert werden:", error);
|
||||
console.error(
|
||||
"Ankündigungs-Anhang konnte nicht gespeichert werden:",
|
||||
error,
|
||||
);
|
||||
return { error: "Anhang konnte nicht gespeichert werden." };
|
||||
}
|
||||
}
|
||||
|
||||
let emailError: string | null = null;
|
||||
if (parsed.data.sendEmail) {
|
||||
const configError = getAppEmailConfigError();
|
||||
if (configError) {
|
||||
emailError = configError;
|
||||
} else {
|
||||
const recipients = await getRecipients(kitaId, parsed.data.audience);
|
||||
if (recipients.length > 0) {
|
||||
const result = await sendAppEmail({
|
||||
to: recipients,
|
||||
subject: `Neue Kita-Ankündigung: ${announcement.title}`,
|
||||
react: createElement(NewsEmail, {
|
||||
title: announcement.title,
|
||||
content: announcement.content,
|
||||
dashboardUrl: `${process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000"}/dashboard`,
|
||||
}),
|
||||
});
|
||||
if (attachmentInputs.length)
|
||||
await prisma.announcement.update({
|
||||
where: { id: announcement.id, kitaId },
|
||||
data: { emailReady: true },
|
||||
});
|
||||
if (parsed.data.sendEmail) startAnnouncementDelivery(kitaId, announcement.id);
|
||||
revalidateAnnouncements();
|
||||
return { success: true, warning: undefined };
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
emailError = result.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const editSchema = z.object({
|
||||
id: z.string().min(1).max(64),
|
||||
revision: z.coerce.number().int().positive(),
|
||||
title: announcementSchema.shape.title,
|
||||
content: announcementSchema.shape.content,
|
||||
sendEmail: z.boolean(),
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/admin/news");
|
||||
|
||||
if (emailError) {
|
||||
export async function updateAnnouncement(formData: FormData) {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
const input = editSchema.safeParse({
|
||||
id: formData.get("id"),
|
||||
revision: formData.get("revision"),
|
||||
title: formData.get("title"),
|
||||
content: formData.get("content"),
|
||||
sendEmail: formData.get("sendEmail") === "on",
|
||||
});
|
||||
if (!input.success)
|
||||
return {
|
||||
success: true,
|
||||
warning: `Ankündigung gespeichert, E-Mail-Versand fehlgeschlagen: ${emailError}`,
|
||||
error:
|
||||
"Bitte Titel (3–160 Zeichen) und Inhalt (3–20.000 Zeichen) prüfen.",
|
||||
};
|
||||
const actor = {
|
||||
id: session.user.id,
|
||||
kitaId: session.user.kitaId!,
|
||||
sessionVersion: session.user.sessionVersion,
|
||||
};
|
||||
if (
|
||||
!(
|
||||
await rateLimit(`announcement-edit:${actor.kitaId}:${actor.id}`, 30, 3600)
|
||||
).allowed
|
||||
)
|
||||
return { error: "Zu viele Änderungen. Bitte versuche es später erneut." };
|
||||
try {
|
||||
const result = await serializableTransaction(async (tx) => {
|
||||
await assertAnnouncementAdmin(tx, actor);
|
||||
const previous = await assertAnnouncementEditable(
|
||||
tx,
|
||||
actor.kitaId,
|
||||
input.data.id,
|
||||
input.data.revision,
|
||||
);
|
||||
const changed =
|
||||
previous.title !== input.data.title ||
|
||||
previous.content !== input.data.content;
|
||||
const row = changed
|
||||
? await tx.announcement.update({
|
||||
where: { id: previous.id, kitaId: actor.kitaId },
|
||||
data: {
|
||||
title: input.data.title,
|
||||
content: input.data.content,
|
||||
editedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
revision: { increment: 1 },
|
||||
},
|
||||
})
|
||||
: previous;
|
||||
if (changed) {
|
||||
await tx.announcementRead.deleteMany({
|
||||
where: { kitaId: actor.kitaId, announcementId: row.id },
|
||||
});
|
||||
await tx.announcementEmail.updateMany({
|
||||
where: {
|
||||
kitaId: actor.kitaId,
|
||||
announcementId: row.id,
|
||||
sentAt: null,
|
||||
skippedAt: null,
|
||||
},
|
||||
data: {
|
||||
skippedAt: new Date(),
|
||||
failed: false,
|
||||
lastError: "Durch eine neuere Beitragsversion ersetzt.",
|
||||
claimToken: null,
|
||||
leaseUntil: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
const queued = input.data.sendEmail
|
||||
? await queueAnnouncementEmails(tx, row)
|
||||
: 0;
|
||||
return { changed, queued };
|
||||
});
|
||||
if (result.queued) startAnnouncementDelivery(actor.kitaId, input.data.id);
|
||||
revalidateAnnouncements();
|
||||
return { success: true, ...result };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: publicActionError(
|
||||
error,
|
||||
"Mitteilung konnte nicht gespeichert werden. Bitte versuche es erneut.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function markAnnouncementRead(announcementId: string) {
|
||||
export async function retryAnnouncementEmails(id: string, revision: number) {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
if (
|
||||
!z.string().min(1).max(64).safeParse(id).success ||
|
||||
!Number.isSafeInteger(revision) ||
|
||||
revision < 1
|
||||
)
|
||||
return { error: "Ungültiger Beitrag." };
|
||||
const actor = {
|
||||
id: session.user.id,
|
||||
kitaId: session.user.kitaId!,
|
||||
sessionVersion: session.user.sessionVersion,
|
||||
};
|
||||
if (
|
||||
!(
|
||||
await rateLimit(
|
||||
`announcement-retry:${actor.kitaId}:${actor.id}`,
|
||||
20,
|
||||
3600,
|
||||
)
|
||||
).allowed
|
||||
)
|
||||
return {
|
||||
error: "Zu viele Versandversuche. Bitte versuche es später erneut.",
|
||||
};
|
||||
try {
|
||||
const queued = await serializableTransaction(async (tx) => {
|
||||
await assertAnnouncementAdmin(tx, actor);
|
||||
await assertAnnouncementEditable(tx, actor.kitaId, id, revision);
|
||||
return (
|
||||
await tx.announcementEmail.updateMany({
|
||||
where: {
|
||||
kitaId: actor.kitaId,
|
||||
announcementId: id,
|
||||
revision,
|
||||
sentAt: null,
|
||||
skippedAt: null,
|
||||
failed: true,
|
||||
},
|
||||
data: { nextAttemptAt: new Date(), failed: false },
|
||||
})
|
||||
).count;
|
||||
});
|
||||
if (queued) startAnnouncementDelivery(actor.kitaId, id);
|
||||
revalidateAnnouncements();
|
||||
return { success: true, queued };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: publicActionError(
|
||||
error,
|
||||
"Versand konnte nicht erneut gestartet werden.",
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function markAnnouncementRead(
|
||||
announcementId: string,
|
||||
revision = 1,
|
||||
) {
|
||||
const session = await requireKitaSession();
|
||||
|
||||
const announcement = await prisma.announcement.findFirst({
|
||||
where: {
|
||||
id: announcementId,
|
||||
kitaId: session.user.kitaId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!announcement) {
|
||||
return { error: "Ankündigung wurde nicht gefunden." };
|
||||
if (!Number.isSafeInteger(revision) || revision < 1)
|
||||
return { error: "Ungültige Beitragsversion." };
|
||||
try {
|
||||
await serializableTransaction(async (tx) => {
|
||||
await lockAnnouncement(tx, session.user.kitaId, announcementId);
|
||||
const announcement = await tx.announcement.findFirst({
|
||||
where: {
|
||||
id: announcementId,
|
||||
revision,
|
||||
kitaId: session.user.kitaId,
|
||||
audience: { in: visibleAnnouncementAudiences(session.user) },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!announcement)
|
||||
throw new UserFacingError(
|
||||
"Der Beitrag wurde geändert oder ist nicht verfügbar. Bitte lade die Seite neu.",
|
||||
);
|
||||
await tx.announcementRead.upsert({
|
||||
where: {
|
||||
userId_announcementId: { userId: session.user.id, announcementId },
|
||||
},
|
||||
create: {
|
||||
kitaId: session.user.kitaId,
|
||||
userId: session.user.id,
|
||||
announcementId,
|
||||
},
|
||||
update: { readAt: new Date() },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
error: publicActionError(
|
||||
error,
|
||||
"Lesestatus konnte nicht gespeichert werden.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.announcementRead.upsert({
|
||||
where: {
|
||||
userId_announcementId: {
|
||||
userId: session.user.id,
|
||||
announcementId: announcement.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: session.user.id,
|
||||
announcementId: announcement.id,
|
||||
},
|
||||
update: {
|
||||
readAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
revalidateAnnouncements();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { createElement } from "react";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
|
||||
import { ContactConfirmationEmail } from "@/emails/ContactConfirmationEmail";
|
||||
import { ContactRequestEmail } from "@/emails/ContactRequestEmail";
|
||||
@@ -11,39 +11,17 @@ import {
|
||||
type ContactSubmissionPayload,
|
||||
} from "@/lib/contact-schema";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { limitPublicAction } from "@/lib/rate-limit";
|
||||
|
||||
const DEFAULT_ADMIN_EMAIL = "kontakt@kita-planer.local";
|
||||
const RATE_LIMIT_MAX = 5;
|
||||
const RATE_LIMIT_WINDOW_SECONDS = 60 * 60;
|
||||
|
||||
async function resolveClientIp(): Promise<string> {
|
||||
const h = await headers();
|
||||
const forwarded = h.get("x-forwarded-for");
|
||||
if (forwarded) {
|
||||
return forwarded.split(",")[0]?.trim() || "unknown";
|
||||
}
|
||||
return h.get("x-real-ip") ?? "unknown";
|
||||
}
|
||||
|
||||
export async function submitContactRequest(data: ContactSubmissionPayload) {
|
||||
// Honeypot: silently absorb bot submissions without informing them.
|
||||
if (data.website && data.website.trim().length > 0) {
|
||||
if (typeof data?.website === "string" && data.website.trim().length > 0) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const ip = await resolveClientIp();
|
||||
const limit = rateLimit(
|
||||
`contact:${ip}`,
|
||||
RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_SECONDS,
|
||||
);
|
||||
if (!limit.allowed) {
|
||||
const minutes = Math.max(1, Math.ceil(limit.retryAfterSeconds / 60));
|
||||
return {
|
||||
success: false,
|
||||
error: `Zu viele Anfragen. Bitte versuche es in ${minutes} Minuten erneut.`,
|
||||
};
|
||||
if (!(await limitPublicAction("contact", String(data.email ?? "").trim().toLowerCase()))) {
|
||||
return { success: false, error: "Zu viele Anfragen. Bitte versuche es später erneut." };
|
||||
}
|
||||
|
||||
const parsed = contactRequestSchema.safeParse(data);
|
||||
|
||||
@@ -3,20 +3,16 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
EventParticipationStatus,
|
||||
RsvpMode,
|
||||
TerminStatus,
|
||||
UserRole,
|
||||
} from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { rsvpSubmissionSchema } from "@/lib/rsvp";
|
||||
import { lockEvent } from "@/lib/event-lock";
|
||||
|
||||
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const submitRsvpSchema = z.object({
|
||||
eventId: z.string().min(1),
|
||||
childId: z.string().min(1),
|
||||
status: z.nativeEnum(EventParticipationStatus),
|
||||
});
|
||||
|
||||
export type EventParticipantDto = {
|
||||
childId: string;
|
||||
childName: string;
|
||||
@@ -24,79 +20,120 @@ export type EventParticipantDto = {
|
||||
status: EventParticipationStatus | null;
|
||||
};
|
||||
|
||||
export async function submitEventRsvp(rawPayload: unknown) {
|
||||
export async function submitEventRsvp(
|
||||
rawPayload: unknown,
|
||||
): Promise<{ error?: string; success?: boolean }> {
|
||||
const session = await requireKitaSession();
|
||||
const parsed = submitRsvpSchema.safeParse(rawPayload);
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
const parsed = rsvpSubmissionSchema.safeParse(rawPayload);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
error:
|
||||
"Bitte alle Antworten und bei Familienzusagen die Anzahl Erwachsener angeben.",
|
||||
};
|
||||
if (!session.user.familyId || session.user.role === UserRole.ERZIEHER) {
|
||||
return {
|
||||
error: "Für Rückmeldungen ist ein Elternkonto mit Haushalt erforderlich.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!session.user.familyId) {
|
||||
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
||||
const kitaId = session.user.kitaId;
|
||||
const familyId = session.user.familyId;
|
||||
const payload = parsed.data;
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
await lockEvent(tx, payload.eventId, kitaId);
|
||||
const event = await tx.termin.findFirst({
|
||||
where: { id: payload.eventId, kitaId },
|
||||
select: {
|
||||
status: true,
|
||||
requiresRsvp: true,
|
||||
rsvpMode: true,
|
||||
rsvpDeadline: true,
|
||||
startDate: true,
|
||||
kita: { select: { terminModuleEnabled: true } },
|
||||
},
|
||||
});
|
||||
if (
|
||||
!event?.kita.terminModuleEnabled ||
|
||||
!event.requiresRsvp ||
|
||||
event.status !== TerminStatus.CONFIRMED
|
||||
) {
|
||||
return { error: "Für diesen Termin ist keine Rückmeldung aktiv." };
|
||||
}
|
||||
if (event.rsvpMode !== payload.mode)
|
||||
return {
|
||||
error: "Die Antwortart wurde geändert. Bitte lade den Termin erneut.",
|
||||
};
|
||||
if (payload.mode === RsvpMode.FAMILY) {
|
||||
const family = await tx.family.findFirst({
|
||||
where: { id: familyId, kitaId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!family) return { error: "Haushalt wurde nicht gefunden." };
|
||||
if (
|
||||
(event.rsvpDeadline ?? event.startDate) <= new Date() ||
|
||||
event.startDate <= new Date()
|
||||
) {
|
||||
return { error: "Die Rückmeldefrist ist bereits abgelaufen." };
|
||||
}
|
||||
await tx.familyEventParticipation.upsert({
|
||||
where: { eventId_familyId: { eventId: payload.eventId, familyId } },
|
||||
create: {
|
||||
kitaId,
|
||||
eventId: payload.eventId,
|
||||
familyId,
|
||||
status: payload.status,
|
||||
adultCount: payload.adultCount,
|
||||
},
|
||||
update: { status: payload.status, adultCount: payload.adultCount },
|
||||
});
|
||||
} else {
|
||||
const children = await tx.child.findMany({
|
||||
where: { kitaId, familyId, active: true },
|
||||
select: { id: true },
|
||||
});
|
||||
const ids = new Set(children.map((child) => child.id));
|
||||
if (
|
||||
payload.responses.length !== ids.size ||
|
||||
payload.responses.some((row) => !ids.has(row.childId))
|
||||
) {
|
||||
return {
|
||||
error:
|
||||
"Bitte antworte für alle eigenen aktiven Kinder. Lade den Termin bei Änderungen erneut.",
|
||||
};
|
||||
}
|
||||
if (
|
||||
(event.rsvpDeadline ?? event.startDate) <= new Date() ||
|
||||
event.startDate <= new Date()
|
||||
) {
|
||||
return { error: "Die Rückmeldefrist ist bereits abgelaufen." };
|
||||
}
|
||||
for (const response of payload.responses) {
|
||||
await tx.eventParticipation.upsert({
|
||||
where: {
|
||||
eventId_childId: {
|
||||
eventId: payload.eventId,
|
||||
childId: response.childId,
|
||||
},
|
||||
},
|
||||
create: { kitaId, eventId: payload.eventId, ...response },
|
||||
update: { status: response.status },
|
||||
});
|
||||
}
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
if (result.error) return result;
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/kalender");
|
||||
revalidatePath("/dashboard/admin/kalender");
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Rückmeldung konnte nicht gespeichert werden:", error);
|
||||
return {
|
||||
error:
|
||||
"Rückmeldung konnte nicht gespeichert werden. Bitte versuche es erneut.",
|
||||
};
|
||||
}
|
||||
|
||||
const { eventId, childId, status } = parsed.data;
|
||||
|
||||
const event = await prisma.termin.findFirst({
|
||||
where: {
|
||||
id: eventId,
|
||||
kitaId: session.user.kitaId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
requiresRsvp: true,
|
||||
rsvpDeadline: true,
|
||||
startDate: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!event || !event.requiresRsvp || event.status !== TerminStatus.CONFIRMED) {
|
||||
return { error: "Für diesen Termin ist keine Rückmeldung aktiv." };
|
||||
}
|
||||
|
||||
const deadline = event.rsvpDeadline ?? event.startDate;
|
||||
if (deadline < new Date()) {
|
||||
return { error: "Die Rückmeldefrist ist bereits abgelaufen." };
|
||||
}
|
||||
|
||||
const child = await prisma.child.findFirst({
|
||||
where: {
|
||||
id: childId,
|
||||
kitaId: session.user.kitaId,
|
||||
familyId: session.user.familyId,
|
||||
active: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!child) {
|
||||
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
|
||||
}
|
||||
|
||||
await prisma.eventParticipation.upsert({
|
||||
where: {
|
||||
eventId_childId: {
|
||||
eventId,
|
||||
childId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
kitaId: session.user.kitaId,
|
||||
eventId,
|
||||
childId,
|
||||
status,
|
||||
},
|
||||
update: {
|
||||
status,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/kalender");
|
||||
revalidatePath("/dashboard/admin/kalender");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getEventParticipants(
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { removeAttachmentFiles } from "@/lib/attachments";
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AuditTargetType, UserRole } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import {
|
||||
assertKitaAdminRemains,
|
||||
lockKitaAccounts,
|
||||
} from "@/lib/kita-admin-guard";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { UserFacingError } from "@/lib/action-error";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const deleteKitaSchema = z.object({
|
||||
@@ -26,6 +34,8 @@ type ActionResult = {
|
||||
|
||||
async function requireSuperadminActor() {
|
||||
const session = await requireRole([UserRole.SUPERADMIN]);
|
||||
if (Date.now() - session.user.authenticatedAt > 15 * 60_000)
|
||||
redirect("/login?reauth=1&callbackUrl=/admin");
|
||||
return {
|
||||
id: session.user.id,
|
||||
email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
|
||||
@@ -92,7 +102,11 @@ export async function deleteKita(
|
||||
|
||||
const counts = await getKitaDeleteCounts(kita.id);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const attachmentIds = await prisma.$transaction(async (tx) => {
|
||||
const attachments = await tx.attachment.findMany({
|
||||
where: { announcement: { kitaId: kita.id } },
|
||||
select: { id: true },
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
actorUserId: actor.id,
|
||||
@@ -107,8 +121,10 @@ export async function deleteKita(
|
||||
});
|
||||
|
||||
await tx.kita.delete({ where: { id: kita.id } });
|
||||
return attachments.map((item) => item.id);
|
||||
});
|
||||
|
||||
await removeAttachmentFiles(attachmentIds);
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/admin/audit");
|
||||
return { ok: true, redirectTo: "/admin" };
|
||||
@@ -150,13 +166,19 @@ async function findMutableTenantUser(userId: string, actorId: string) {
|
||||
return { error: "Benutzer wurde nicht gefunden." } as const;
|
||||
}
|
||||
if (user.id === actorId) {
|
||||
return { error: "Das eigene Superadmin-Konto kann hier nicht geändert werden." } as const;
|
||||
return {
|
||||
error: "Das eigene Superadmin-Konto kann hier nicht geändert werden.",
|
||||
} as const;
|
||||
}
|
||||
if (user.role === UserRole.SUPERADMIN) {
|
||||
return { error: "Andere Superadmins werden in V1 nicht verwaltet." } as const;
|
||||
return {
|
||||
error: "Andere Superadmins werden in V1 nicht verwaltet.",
|
||||
} as const;
|
||||
}
|
||||
if (!user.kitaId) {
|
||||
return { error: "Nur tenant-gebundene Benutzer können verwaltet werden." } as const;
|
||||
return {
|
||||
error: "Nur tenant-gebundene Benutzer können verwaltet werden.",
|
||||
} as const;
|
||||
}
|
||||
|
||||
return { user } as const;
|
||||
@@ -182,32 +204,45 @@ export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.verificationToken.deleteMany({
|
||||
where: { identifier: user.id },
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
actorUserId: actor.id,
|
||||
actorEmail: actor.email,
|
||||
action: "USER_DELETE",
|
||||
targetType: AuditTargetType.USER,
|
||||
targetId: user.id,
|
||||
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
|
||||
targetKitaId: user.kitaId,
|
||||
metadata: {
|
||||
role: user.role,
|
||||
kitaName: user.kita?.name ?? null,
|
||||
kitaSlug: user.kita?.slug ?? null,
|
||||
familyId: user.familyId,
|
||||
familyName: user.family?.name ?? null,
|
||||
familyUserCountBeforeDelete: user.family?._count.users ?? null,
|
||||
let attachmentIds: string[];
|
||||
try {
|
||||
attachmentIds = await serializableTransaction(async (tx) => {
|
||||
await lockKitaAccounts(tx, user.kitaId!);
|
||||
await assertKitaAdminRemains(tx, user.kitaId!, [user.id]);
|
||||
const attachments = await tx.attachment.findMany({
|
||||
where: { announcement: { authorId: user.id } },
|
||||
select: { id: true },
|
||||
});
|
||||
await tx.verificationToken.deleteMany({
|
||||
where: { identifier: user.id },
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
actorUserId: actor.id,
|
||||
actorEmail: actor.email,
|
||||
action: "USER_DELETE",
|
||||
targetType: AuditTargetType.USER,
|
||||
targetId: user.id,
|
||||
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
|
||||
targetKitaId: user.kitaId,
|
||||
metadata: {
|
||||
role: user.role,
|
||||
kitaName: user.kita?.name ?? null,
|
||||
kitaSlug: user.kita?.slug ?? null,
|
||||
familyId: user.familyId,
|
||||
familyName: user.family?.name ?? null,
|
||||
familyUserCountBeforeDelete: user.family?._count.users ?? null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await tx.user.delete({ where: { id: user.id } });
|
||||
return attachments.map((item) => item.id);
|
||||
});
|
||||
await tx.user.delete({ where: { id: user.id } });
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (!(error instanceof UserFacingError)) throw error;
|
||||
return { ok: false, error: error.message };
|
||||
}
|
||||
await removeAttachmentFiles(attachmentIds);
|
||||
revalidatePath("/admin");
|
||||
revalidatePath(`/admin/kitas/${user.kitaId}`);
|
||||
revalidatePath("/admin/audit");
|
||||
@@ -219,6 +254,7 @@ async function updateUserSupportState(
|
||||
action: "USER_LOCK" | "USER_UNLOCK" | "USER_FAILED_LOGIN_RESET",
|
||||
data: {
|
||||
lockedUntil?: Date | null;
|
||||
disabledAt?: Date | null;
|
||||
failedLoginAttempts?: number;
|
||||
},
|
||||
): Promise<ActionResult> {
|
||||
@@ -235,29 +271,41 @@ async function updateUserSupportState(
|
||||
|
||||
const { user } = lookup;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data,
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
actorUserId: actor.id,
|
||||
actorEmail: actor.email,
|
||||
action,
|
||||
targetType: AuditTargetType.USER,
|
||||
targetId: user.id,
|
||||
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
|
||||
targetKitaId: user.kitaId,
|
||||
metadata: {
|
||||
role: user.role,
|
||||
previousLockedUntil: user.lockedUntil?.toISOString() ?? null,
|
||||
previousFailedLoginAttempts: user.failedLoginAttempts,
|
||||
try {
|
||||
await serializableTransaction(async (tx) => {
|
||||
await lockKitaAccounts(tx, user.kitaId!);
|
||||
if (action === "USER_LOCK")
|
||||
await assertKitaAdminRemains(tx, user.kitaId!, [user.id]);
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
...data,
|
||||
...(action === "USER_LOCK"
|
||||
? { sessionVersion: { increment: 1 } }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
actorUserId: actor.id,
|
||||
actorEmail: actor.email,
|
||||
action,
|
||||
targetType: AuditTargetType.USER,
|
||||
targetId: user.id,
|
||||
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
|
||||
targetKitaId: user.kitaId,
|
||||
metadata: {
|
||||
role: user.role,
|
||||
previousLockedUntil: user.lockedUntil?.toISOString() ?? null,
|
||||
previousFailedLoginAttempts: user.failedLoginAttempts,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (!(error instanceof UserFacingError)) throw error;
|
||||
return { ok: false, error: error.message };
|
||||
}
|
||||
revalidatePath("/admin");
|
||||
revalidatePath(`/admin/kitas/${user.kitaId}`);
|
||||
revalidatePath("/admin/audit");
|
||||
@@ -266,12 +314,14 @@ async function updateUserSupportState(
|
||||
|
||||
export async function lockUser(userId: string): Promise<ActionResult> {
|
||||
return updateUserSupportState(userId, "USER_LOCK", {
|
||||
disabledAt: new Date(),
|
||||
lockedUntil: new Date("2099-12-31T23:59:59.000Z"),
|
||||
});
|
||||
}
|
||||
|
||||
export async function unlockUser(userId: string): Promise<ActionResult> {
|
||||
return updateUserSupportState(userId, "USER_UNLOCK", {
|
||||
disabledAt: null,
|
||||
lockedUntil: null,
|
||||
failedLoginAttempts: 0,
|
||||
});
|
||||
@@ -284,4 +334,3 @@ export async function resetFailedLoginAttempts(
|
||||
failedLoginAttempts: 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
"use server";
|
||||
|
||||
import { publicActionError } from "@/lib/action-error";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { NotdienstAlertStatus } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { respondToAlarm } from "@/lib/alarms/service";
|
||||
import { deliverAlarmEmails } from "@/lib/alarms/email";
|
||||
import { limitPublicAction } from "@/lib/rate-limit";
|
||||
import { hashToken, isPublicToken } from "@/lib/tokens";
|
||||
|
||||
export async function confirmAlertAction(token: string) {
|
||||
export async function respondAlertAction(token: string, approve: boolean) {
|
||||
if (!isPublicToken(token) || typeof approve !== "boolean")
|
||||
return { error: "Ungültiger Rückmeldelink." };
|
||||
if (!(await limitPublicAction("alarm-response", hashToken(token), 12, 60)))
|
||||
return { error: "Bitte kurz warten und erneut versuchen." };
|
||||
try {
|
||||
const alert = await prisma.notdienstAlert.findUnique({
|
||||
where: { confirmationToken: token },
|
||||
});
|
||||
|
||||
if (!alert) {
|
||||
return { error: "Ungültiger Bestätigungs-Link." };
|
||||
const result = await respondToAlarm(token, approve);
|
||||
// The household response is already committed; transport failure must not undo it.
|
||||
try {
|
||||
await deliverAlarmEmails(result.emailIds);
|
||||
} catch {
|
||||
/* Persistent jobs will retry. */
|
||||
}
|
||||
|
||||
if (alert.status !== NotdienstAlertStatus.PENDING) {
|
||||
return { error: "Dieser Alarm wurde bereits bestätigt oder abgebrochen." };
|
||||
}
|
||||
|
||||
await prisma.notdienstAlert.update({
|
||||
where: { id: alert.id },
|
||||
data: {
|
||||
status: NotdienstAlertStatus.CONFIRMED,
|
||||
confirmedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/alert/[token]", "page");
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/notdienst/alarm");
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { error: "Fehler bei der Bestätigung." };
|
||||
} catch (error) {
|
||||
const message = publicActionError(error, "");
|
||||
if (message) return { error: message };
|
||||
return {
|
||||
error:
|
||||
"Die Rückmeldung konnte nicht gespeichert werden. Bitte den aktuellen Stand prüfen.",
|
||||
};
|
||||
}
|
||||
}
|
||||
export async function confirmAlertAction(token: string) {
|
||||
return respondAlertAction(token, true);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { confirmAlertAction } from "./actions";
|
||||
import { respondAlertAction } from "./actions";
|
||||
|
||||
export function AlertConfirmForm({ token }: { token: string }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleConfirm = () => {
|
||||
startTransition(async () => {
|
||||
const result = await confirmAlertAction(token);
|
||||
if ("error" in result && result.error) {
|
||||
toast.error(result.error as string);
|
||||
} else {
|
||||
toast.success("Erfolgreich bestätigt!");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const [choice, setChoice] = useState<boolean | null>(null);
|
||||
const [pending, start] = useTransition();
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full sm:w-auto font-semibold text-lg"
|
||||
onClick={handleConfirm}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : null}
|
||||
Ich bestätige! (Bin auf dem Weg)
|
||||
</Button>
|
||||
<div className="grid gap-4">
|
||||
{choice === null ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Button onClick={() => setChoice(true)}>Wir übernehmen</Button>
|
||||
<Button variant="outline" onClick={() => setChoice(false)}>
|
||||
Wir können nicht
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 rounded-xl border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
|
||||
<p className="text-sm">
|
||||
{choice
|
||||
? "Wir sagen für unseren Haushalt zu."
|
||||
: "Unser Haushalt kann diesen Einsatz nicht übernehmen."}{" "}
|
||||
Diese Antwort gilt für alle Elternkonten des Haushalts.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
start(async () => {
|
||||
setError("");
|
||||
const result = await respondAlertAction(token, choice);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
router.refresh();
|
||||
} else router.refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
{pending ? "Wird gespeichert …" : "Antwort verbindlich speichern"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
setChoice(null);
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-[var(--danger)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,127 +1,114 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { BellRing, ShieldCheck, XCircle } from "lucide-react";
|
||||
import { NotdienstAlertStatus } from "@prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BellRing, CheckCircle2 } from "lucide-react";
|
||||
import { getPublicAlarm } from "@/lib/alarms/service";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
alarmLabel,
|
||||
dateKey,
|
||||
durationLabel,
|
||||
instantLabel,
|
||||
} from "@/lib/alarms/format";
|
||||
import { AlertConfirmForm } from "./alert-confirm-form";
|
||||
|
||||
export const metadata = { title: "Notdienst Alarm · Kita-Planer" };
|
||||
|
||||
export const metadata = {
|
||||
title: "Notdienstalarm · Kita-Planer",
|
||||
robots: { index: false, follow: false },
|
||||
referrer: "no-referrer" as const,
|
||||
};
|
||||
export const dynamic = "force-dynamic";
|
||||
export default async function AlertPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
}) {
|
||||
const { token } = await params;
|
||||
|
||||
const alert = await prisma.notdienstAlert.findUnique({
|
||||
where: { confirmationToken: token },
|
||||
select: {
|
||||
status: true,
|
||||
parentUser: {
|
||||
select: {
|
||||
firstName: true,
|
||||
},
|
||||
},
|
||||
kita: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
assignment: {
|
||||
select: {
|
||||
date: true,
|
||||
child: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!alert) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isPending = alert.status === NotdienstAlertStatus.PENDING;
|
||||
const isConfirmed = alert.status === NotdienstAlertStatus.CONFIRMED;
|
||||
|
||||
const alarm = await getPublicAlarm(token);
|
||||
if (!alarm) notFound();
|
||||
const now = new Date(),
|
||||
expired = alarm.expiresAt <= now;
|
||||
const canRespond = alarm.status === "PENDING" && !expired;
|
||||
const overdue = alarm.deadline <= now;
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/30 px-4 py-12">
|
||||
<Card className="w-full max-w-md shadow-lg border-primary/20">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
{isPending ? (
|
||||
<BellRing className="h-8 w-8 animate-pulse text-destructive" />
|
||||
) : isConfirmed ? (
|
||||
<ShieldCheck className="h-8 w-8 text-success" />
|
||||
) : (
|
||||
<XCircle className="h-8 w-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-2xl">
|
||||
{isPending
|
||||
? "Notdienst Einsatz!"
|
||||
: isConfirmed
|
||||
? "Einsatz bestätigt"
|
||||
: "Alarm abgebrochen"}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base mt-2">
|
||||
Kita {alert.kita.name}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pt-4">
|
||||
<div className="rounded-lg bg-background border p-4 text-center space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Datum</p>
|
||||
<p className="font-semibold text-lg">
|
||||
{format(alert.assignment.date, "EEEE, dd. MMMM yyyy", {
|
||||
locale: de,
|
||||
})}
|
||||
</p>
|
||||
<div className="h-2" />
|
||||
<p className="text-sm text-muted-foreground">Eingeteiltes Kind</p>
|
||||
<p className="font-medium">
|
||||
{alert.assignment.child.firstName}{" "}
|
||||
{alert.assignment.child.lastName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
{isPending ? (
|
||||
<>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
Hallo {alert.parentUser.firstName}, eine Fachkraft hat sich
|
||||
krankgemeldet und du bist heute für den Notdienst eingeteilt.
|
||||
Bitte bestätige deinen Einsatz schnellstmöglich!
|
||||
<main className="flex min-h-screen items-center justify-center bg-[var(--bg)] px-5 py-12">
|
||||
<section className="w-full max-w-xl overflow-hidden rounded-2xl border border-[var(--hairline)] bg-[var(--surface)] shadow-sm">
|
||||
<header
|
||||
className={`border-b border-[var(--hairline)] p-6 sm:p-8 ${alarm.status === "CONFIRMED" ? "bg-[var(--accent-soft)]" : "bg-[var(--surface-2)]"}`}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--accent-deep)]">
|
||||
Kita-Planer · {alarm.kitaName}
|
||||
</p>
|
||||
{alarm.status === "CONFIRMED" ? (
|
||||
<CheckCircle2 className="mt-6 h-7 w-7 text-[var(--accent-deep)]" />
|
||||
) : (
|
||||
<BellRing className="mt-6 h-7 w-7 text-[var(--danger)]" />
|
||||
)}
|
||||
<h1 className="mt-3 text-3xl text-[var(--ink)]">
|
||||
{alarm.status === "PENDING"
|
||||
? "Könnt ihr den Notdienst übernehmen?"
|
||||
: alarmLabel(alarm)}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-[var(--ink-soft)]">
|
||||
Hallo {alarm.firstName}, diese Rückmeldung gilt für euren gesamten
|
||||
Haushalt.
|
||||
</p>
|
||||
</header>
|
||||
<div className="grid gap-6 p-6 sm:p-8">
|
||||
<dl className="grid grid-cols-2 gap-5 text-sm">
|
||||
<div>
|
||||
<dt className="text-[var(--ink-muted)]">Datum</dt>
|
||||
<dd className="mt-1 font-semibold">
|
||||
{dateKey(alarm.date).split("-").reverse().join(".")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--ink-muted)]">Einsatz</dt>
|
||||
<dd className="mt-1 font-semibold">{durationLabel(alarm)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--ink-muted)]">Haushalt</dt>
|
||||
<dd className="mt-1 font-semibold">{alarm.familyName}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--ink-muted)]">Antwortfrist</dt>
|
||||
<dd className="mt-1 font-semibold">
|
||||
{instantLabel(alarm.deadline)} Uhr
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{canRespond ? (
|
||||
<>
|
||||
{overdue && (
|
||||
<p className="rounded-lg bg-[var(--surface-2)] p-4 text-sm leading-6">
|
||||
Die Antwortfrist ist abgelaufen. Ihr könnt noch antworten.
|
||||
Eine verspätete Zusage muss die Koordination freigeben, da
|
||||
möglicherweise bereits Ersatz organisiert wird.
|
||||
</p>
|
||||
<AlertConfirmForm token={token} />
|
||||
</>
|
||||
) : isConfirmed ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Danke, {alert.parentUser.firstName}! Dein Einsatz wurde
|
||||
erfolgreich bestätigt. Die Kita rechnet mit dir.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dieser Einsatz wurde vom Koordinator abgebrochen. Du musst nicht
|
||||
einspringen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
<AlertConfirmForm token={token} />
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-xl border border-[var(--hairline)] p-4 text-sm leading-6"
|
||||
>
|
||||
{alarm.status === "CANCELLED"
|
||||
? "Der Einsatz wurde storniert. Ihr müsst für diesen Alarm nicht einspringen."
|
||||
: alarm.status === "CONFIRMED"
|
||||
? "Euer Haushalt hat zugesagt. Die Kita rechnet mit euch. Bei einer Änderung kontaktiert bitte die Koordination."
|
||||
: alarm.status === "LATE_CONFIRMATION"
|
||||
? "Eure verspätete Zusage ist gespeichert. Wartet bitte auf die Freigabe durch die Koordination."
|
||||
: alarm.status === "DECLINED"
|
||||
? "Die Absage eures Haushalts ist gespeichert. Die Koordination kümmert sich um Ersatz."
|
||||
: alarm.status === "REVIEW_REQUIRED"
|
||||
? "Die bisherigen Rückmeldungen müssen von der Koordination geprüft werden. Bitte kontaktiert die Kita."
|
||||
: "Der Einsatz ist beendet. Dieser Rückmeldelink ist abgelaufen."}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs leading-5 text-[var(--ink-muted)]">
|
||||
Den persönlichen Link bitte innerhalb eures Haushalts behalten.
|
||||
Änderungen einer gespeicherten Antwort klärt ihr mit der Kita.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { open } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { attachmentPath, attachmentDisposition, detectAttachmentType, MAX_ATTACHMENT_SIZE } from "@/lib/attachments";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { getKitaSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { visibleAnnouncementAudiences } from "@/lib/announcement-access";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function getUploadPath(attachmentId: string) {
|
||||
const uploadDir = path.resolve(
|
||||
process.env.UPLOAD_DIR ?? "uploads",
|
||||
"announcements",
|
||||
);
|
||||
return path.join(uploadDir, attachmentId);
|
||||
}
|
||||
|
||||
function contentDisposition(fileName: string) {
|
||||
const fallback = fileName.replace(/[^\w.\-]/g, "_");
|
||||
return `inline; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth();
|
||||
const session = await getKitaSession();
|
||||
const user = session?.user;
|
||||
|
||||
if (!user?.id || !user.kitaId) {
|
||||
@@ -32,11 +21,13 @@ export async function GET(
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
try { attachmentPath(id); } catch { return NextResponse.json({ error: "Not found" }, { status: 404 }); }
|
||||
const attachment = await prisma.attachment.findFirst({
|
||||
where: {
|
||||
id,
|
||||
announcement: {
|
||||
kitaId: user.kitaId,
|
||||
audience: { in: visibleAnnouncementAudiences(user) },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
@@ -51,12 +42,22 @@ export async function GET(
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await readFile(getUploadPath(attachment.id));
|
||||
const filePath = attachmentPath(attachment.id);
|
||||
const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
let file: Buffer;
|
||||
try {
|
||||
const info = await handle.stat();
|
||||
if (!info.isFile() || info.size > MAX_ATTACHMENT_SIZE) throw new Error("Invalid attachment");
|
||||
file = await handle.readFile();
|
||||
} finally { await handle.close(); }
|
||||
return new Response(new Uint8Array(file), {
|
||||
headers: {
|
||||
"Content-Type": attachment.fileType,
|
||||
"Content-Disposition": contentDisposition(attachment.fileName),
|
||||
"Cache-Control": "private, max-age=300",
|
||||
"Content-Type": detectAttachmentType(file) ?? "application/octet-stream",
|
||||
"Content-Disposition": attachmentDisposition(attachment.fileName),
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { authorizeAlarmJob, runAlarmJob } from "@/lib/alarms/job";
|
||||
|
||||
import { runNotdienstNotificationJob } from "@/lib/notdienst-notifications";
|
||||
|
||||
import { deliverDueAnnouncementEmails } from "@/lib/announcement-delivery";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export async function POST(request: Request) {
|
||||
const status = authorizeAlarmJob(request.headers.get("authorization"));
|
||||
if (status !== 200)
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
status === 503
|
||||
? "Fristprüfung ist nicht konfiguriert."
|
||||
: "Nicht autorisiert.",
|
||||
},
|
||||
{ status },
|
||||
);
|
||||
try {
|
||||
const [alarm, planning, announcements] = await Promise.allSettled([
|
||||
runAlarmJob(),
|
||||
runNotdienstNotificationJob(),
|
||||
deliverDueAnnouncementEmails(),
|
||||
]);
|
||||
if (
|
||||
alarm.status === "rejected" ||
|
||||
planning.status === "rejected" ||
|
||||
announcements.status === "rejected"
|
||||
)
|
||||
throw new Error("Ein Hintergrundlauf ist fehlgeschlagen.");
|
||||
const result = {
|
||||
...alarm.value,
|
||||
planning: planning.value,
|
||||
announcements: announcements.value,
|
||||
};
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/notdienst/alarm");
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
revalidatePath("/dashboard/admin/news");
|
||||
return Response.json(result);
|
||||
} catch {
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
"Fristprüfung fehlgeschlagen; nächster Aufruf versucht es erneut.",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { AbsenceReason } from "@prisma/client";
|
||||
import { Baby, CalendarX2, Check, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { reasonColor, reasonLabel } from "@/lib/absence-utils";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { reportAbsence } from "@/actions/absences";
|
||||
import { AbsenceManagementActions } from "@/components/absence-management-actions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -40,6 +41,7 @@ type AbsenceListItem = {
|
||||
note: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function AbsenceCard({
|
||||
@@ -47,10 +49,11 @@ export function AbsenceCard({
|
||||
today,
|
||||
}: {
|
||||
childOptions: ChildOption[];
|
||||
absences: AbsenceListItem[];
|
||||
today: string;
|
||||
}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [startDate, setStartDate] = useState(today);
|
||||
const [endDate, setEndDate] = useState(today);
|
||||
const defaultChildId = childOptions[0]?.id;
|
||||
const hasChildren = childOptions.length > 0;
|
||||
|
||||
@@ -92,92 +95,113 @@ export function AbsenceCard({
|
||||
|
||||
<CardContent>
|
||||
{hasChildren ? (
|
||||
<form action={handleSubmit}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FieldLabel label="Welches Kind?">
|
||||
<Select name="childId" defaultValue={defaultChildId} required>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Kind auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{childOptions.map((child) => (
|
||||
<SelectItem key={child.id} value={child.id}>
|
||||
{child.name}
|
||||
<form
|
||||
onReset={() => {
|
||||
setStartDate(today);
|
||||
setEndDate(today);
|
||||
}}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleSubmit(new FormData(event.currentTarget));
|
||||
}}
|
||||
>
|
||||
<fieldset disabled={isPending}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FieldLabel label="Welches Kind?">
|
||||
<Select name="childId" defaultValue={defaultChildId} required>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Kind auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{childOptions.map((child) => (
|
||||
<SelectItem key={child.id} value={child.id}>
|
||||
{child.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldLabel>
|
||||
|
||||
<FieldLabel label="Grund">
|
||||
<Select
|
||||
name="reason"
|
||||
defaultValue={AbsenceReason.ILLNESS}
|
||||
required
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Grund auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={AbsenceReason.ILLNESS}>
|
||||
Krank
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldLabel>
|
||||
<SelectItem value={AbsenceReason.VACATION}>
|
||||
Urlaub
|
||||
</SelectItem>
|
||||
<SelectItem value={AbsenceReason.OTHER}>
|
||||
Sonstiges
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldLabel>
|
||||
|
||||
<FieldLabel label="Grund">
|
||||
<Select
|
||||
name="reason"
|
||||
defaultValue={AbsenceReason.ILLNESS}
|
||||
required
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Grund auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={AbsenceReason.ILLNESS}>Krank</SelectItem>
|
||||
<SelectItem value={AbsenceReason.VACATION}>
|
||||
Urlaub
|
||||
</SelectItem>
|
||||
<SelectItem value={AbsenceReason.OTHER}>
|
||||
Sonstiges
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldLabel>
|
||||
<FieldLabel label="Von" htmlFor="absence-startDate">
|
||||
<Input
|
||||
id="absence-startDate"
|
||||
name="startDate"
|
||||
type="date"
|
||||
value={startDate}
|
||||
min={today}
|
||||
onChange={(event) => {
|
||||
const date = event.target.value;
|
||||
setStartDate(date);
|
||||
if (date > endDate) setEndDate(date);
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</FieldLabel>
|
||||
|
||||
<FieldLabel label="Von" htmlFor="absence-startDate">
|
||||
<Input
|
||||
id="absence-startDate"
|
||||
name="startDate"
|
||||
type="date"
|
||||
defaultValue={today}
|
||||
required
|
||||
/>
|
||||
</FieldLabel>
|
||||
<FieldLabel label="Bis" htmlFor="absence-endDate">
|
||||
<Input
|
||||
id="absence-endDate"
|
||||
name="endDate"
|
||||
type="date"
|
||||
value={endDate}
|
||||
min={startDate}
|
||||
onChange={(event) => setEndDate(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</FieldLabel>
|
||||
|
||||
<FieldLabel label="Bis" htmlFor="absence-endDate">
|
||||
<Input
|
||||
id="absence-endDate"
|
||||
name="endDate"
|
||||
type="date"
|
||||
defaultValue={today}
|
||||
required
|
||||
/>
|
||||
</FieldLabel>
|
||||
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label
|
||||
htmlFor="absence-note"
|
||||
className="text-xs font-semibold text-[var(--ink-soft)]"
|
||||
>
|
||||
Bemerkung{" "}
|
||||
<span className="font-medium text-[var(--ink-faint)]">
|
||||
optional
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="absence-note"
|
||||
name="note"
|
||||
rows={4}
|
||||
placeholder="z. B. voraussichtlich wieder da am Freitag."
|
||||
/>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label
|
||||
htmlFor="absence-note"
|
||||
className="text-xs font-semibold text-[var(--ink-soft)]"
|
||||
>
|
||||
Bemerkung{" "}
|
||||
<span className="font-medium text-[var(--ink-faint)]">
|
||||
optional
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="absence-note"
|
||||
name="note"
|
||||
rows={4}
|
||||
placeholder="z. B. voraussichtlich wieder da am Freitag."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-7 flex items-center justify-between border-t border-[var(--hairline-soft)] pt-5">
|
||||
<Button type="reset" variant="ghost">
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? <Loader2 className="animate-spin" /> : <Check />}
|
||||
Abwesenheit melden
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-7 flex items-center justify-between border-t border-[var(--hairline-soft)] pt-5">
|
||||
<Button type="reset" variant="ghost">
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? <Loader2 className="animate-spin" /> : <Check />}
|
||||
Abwesenheit melden
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex min-h-72 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-6 text-center">
|
||||
@@ -201,8 +225,12 @@ export function AbsenceCard({
|
||||
|
||||
export function UpcomingAbsencesCard({
|
||||
absences,
|
||||
today,
|
||||
team = false,
|
||||
}: {
|
||||
absences: AbsenceListItem[];
|
||||
today: string;
|
||||
team?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -233,16 +261,21 @@ export function UpcomingAbsencesCard({
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--hairline-soft)]">
|
||||
{absences.map((absence) => (
|
||||
<AbsenceRow key={absence.id} absence={absence} />
|
||||
<AbsenceRow
|
||||
key={`${absence.id}-${absence.updatedAt}`}
|
||||
absence={absence}
|
||||
today={today}
|
||||
team={team}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/dashboard/admin/abwesenheiten"
|
||||
href="/dashboard/abwesenheiten"
|
||||
className="mt-4 inline-flex items-center gap-1 text-[13px] font-semibold text-[var(--accent-deep)] transition hover:gap-2 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
>
|
||||
Alle Abwesenheiten
|
||||
Meine Abwesenheiten
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
@@ -272,7 +305,15 @@ function FieldLabel({
|
||||
);
|
||||
}
|
||||
|
||||
function AbsenceRow({ absence }: { absence: AbsenceListItem }) {
|
||||
function AbsenceRow({
|
||||
absence,
|
||||
today,
|
||||
team,
|
||||
}: {
|
||||
absence: AbsenceListItem;
|
||||
today: string;
|
||||
team: boolean;
|
||||
}) {
|
||||
const { day, month, detail } = useMemo(() => {
|
||||
const start = new Date(`${absence.startDate}T00:00:00`);
|
||||
const end = new Date(`${absence.endDate}T00:00:00`);
|
||||
@@ -327,9 +368,8 @@ function AbsenceRow({ absence }: { absence: AbsenceListItem }) {
|
||||
{absence.note}
|
||||
</p>
|
||||
) : null}
|
||||
<AbsenceManagementActions absence={absence} today={today} team={team} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import Link from "next/link";
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
absenceDate,
|
||||
absenceToday,
|
||||
canManageKitaAbsences,
|
||||
} from "@/lib/absence-policy";
|
||||
import { reasonLabel } from "@/lib/absence-utils";
|
||||
import { AbsenceManagementActions } from "@/components/absence-management-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const metadata = { title: "Meine Abwesenheiten · Kita-Planer" };
|
||||
|
||||
export default async function OwnAbsencesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ view?: string; page?: string }>;
|
||||
}) {
|
||||
const session = await requireKitaSession();
|
||||
const actor = await prisma.user.findFirstOrThrow({
|
||||
where: { id: session.user.id, kitaId: session.user.kitaId },
|
||||
select: { familyId: true, role: true },
|
||||
});
|
||||
const query = await searchParams;
|
||||
const past = query.view === "past";
|
||||
const parsedPage = Number(query.page ?? 1);
|
||||
const requestedPage =
|
||||
Number.isSafeInteger(parsedPage) && parsedPage > 0 ? parsedPage : 1;
|
||||
const today = absenceToday();
|
||||
const where = {
|
||||
kitaId: session.user.kitaId,
|
||||
child: { kitaId: session.user.kitaId, familyId: actor.familyId ?? "" },
|
||||
endDate: past ? { lt: absenceDate(today) } : { gte: absenceDate(today) },
|
||||
};
|
||||
const count = actor.familyId ? await prisma.absence.count({ where }) : 0;
|
||||
const pages = Math.max(1, Math.ceil(count / 20));
|
||||
const page = Math.min(requestedPage, pages);
|
||||
const absences = actor.familyId
|
||||
? await prisma.absence.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
reason: true,
|
||||
note: true,
|
||||
updatedAt: true,
|
||||
child: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: [{ startDate: past ? "desc" : "asc" }, { id: "asc" }],
|
||||
take: 20,
|
||||
skip: (page - 1) * 20,
|
||||
})
|
||||
: [];
|
||||
const href = (nextPage: number) =>
|
||||
`/dashboard/abwesenheiten?view=${past ? "past" : "current"}&page=${nextPage}`;
|
||||
const dateLabel = (date: Date) =>
|
||||
new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "medium",
|
||||
timeZone: "UTC",
|
||||
}).format(date);
|
||||
return (
|
||||
<div className="mx-auto max-w-[1100px] px-11 py-9 max-[760px]:px-5">
|
||||
<header className="mb-7">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-[var(--accent-deep)]">
|
||||
MEIN HAUSHALT
|
||||
</p>
|
||||
<h1 className="mt-2 text-4xl tracking-tight">
|
||||
Meine{" "}
|
||||
<em className="font-normal text-[var(--accent-deep)]">
|
||||
Abwesenheiten
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--ink-soft)]">
|
||||
Zukünftige Meldungen deiner Kinder kannst du ändern oder zurücknehmen.
|
||||
Sobald eine Meldung begonnen hat, übernimmt die Kita die gesamte
|
||||
Meldung.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/dashboard">Kind abmelden</Link>
|
||||
</Button>
|
||||
{canManageKitaAbsences(actor.role) && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/dashboard/admin/abwesenheiten">
|
||||
Kita-Tagesübersicht
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{!actor.familyId ? (
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<p className="mb-3">
|
||||
Deinem Konto ist noch kein Haushalt zugeordnet.
|
||||
</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/dashboard/profil">Profil öffnen</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<nav aria-label="Abwesenheitszeitraum" className="mb-5 flex gap-2">
|
||||
<Button asChild size="sm" variant={!past ? "default" : "outline"}>
|
||||
<Link href="/dashboard/abwesenheiten">Aktuell & zukünftig</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant={past ? "default" : "outline"}>
|
||||
<Link href="/dashboard/abwesenheiten?view=past">Vergangen</Link>
|
||||
</Button>
|
||||
</nav>
|
||||
<p className="mb-3 text-sm text-[var(--ink-muted)]">
|
||||
{count} Meldung{count === 1 ? "" : "en"}
|
||||
</p>
|
||||
<div className="grid gap-4">
|
||||
{absences.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-sm text-[var(--ink-soft)]">
|
||||
Für diesen Zeitraum sind keine Abwesenheiten gemeldet.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{absences.map((absence) => {
|
||||
const startDate = absence.startDate.toISOString().slice(0, 10);
|
||||
return (
|
||||
<Card key={absence.id}>
|
||||
<CardContent className="py-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold">
|
||||
{absence.child.firstName} {absence.child.lastName}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
{dateLabel(absence.startDate)}
|
||||
{absence.endDate.getTime() !==
|
||||
absence.startDate.getTime()
|
||||
? ` – ${dateLabel(absence.endDate)}`
|
||||
: ""}{" "}
|
||||
· {reasonLabel(absence.reason)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{past
|
||||
? "Vergangen"
|
||||
: startDate <= today
|
||||
? "Laufend"
|
||||
: "Zukünftig"}
|
||||
</Badge>
|
||||
</div>
|
||||
{absence.note && (
|
||||
<p className="mt-3 whitespace-pre-wrap break-words text-sm text-[var(--ink-soft)]">
|
||||
{absence.note}
|
||||
</p>
|
||||
)}
|
||||
<AbsenceManagementActions
|
||||
key={`${absence.id}-${absence.updatedAt.toISOString()}`}
|
||||
today={today}
|
||||
team={canManageKitaAbsences(actor.role)}
|
||||
absence={{
|
||||
id: absence.id,
|
||||
reason: absence.reason,
|
||||
note: absence.note,
|
||||
childName: `${absence.child.firstName} ${absence.child.lastName}`,
|
||||
startDate,
|
||||
endDate: absence.endDate.toISOString().slice(0, 10),
|
||||
updatedAt: absence.updatedAt.toISOString(),
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{pages > 1 && (
|
||||
<div className="mt-5 flex items-center justify-between gap-3">
|
||||
{page > 1 ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={href(page - 1)}>Zurück</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<span className="text-sm text-[var(--ink-muted)]">
|
||||
Seite {page} von {pages}
|
||||
</span>
|
||||
{page < pages ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={href(page + 1)}>Weiter</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,123 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import crypto from "crypto";
|
||||
import { createElement } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { NotdienstAlertStatus, UserRole } from "@prisma/client";
|
||||
|
||||
import { AlertEmail } from "@/emails/AlertEmail";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const BASE_URL =
|
||||
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
||||
|
||||
import { alarmAction } from "./notdienst/alarm/actions";
|
||||
// Existing callers use the shared household workflow and the same guards.
|
||||
export async function triggerAlertAction(assignmentId: string) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
|
||||
try {
|
||||
const [existing, assignment] = await Promise.all([
|
||||
// Prüfen ob es schon einen aktiven Alert für dieses Assignment gibt
|
||||
prisma.notdienstAlert.findFirst({
|
||||
where: { assignmentId, kitaId },
|
||||
}),
|
||||
prisma.notdienstAssignment.findFirst({
|
||||
where: { id: assignmentId, kitaId },
|
||||
include: {
|
||||
child: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
family: {
|
||||
select: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (existing) {
|
||||
return { error: "Für diese Einteilung wurde bereits ein Alarm ausgelöst." };
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
return { error: "Notdienst-Einteilung wurde nicht gefunden." };
|
||||
}
|
||||
|
||||
const parents = assignment.child.family.users;
|
||||
|
||||
if (parents.length === 0) {
|
||||
return { error: "Kein Elternteil für diesen Notdienst hinterlegt." };
|
||||
}
|
||||
|
||||
const mailConfigError = getAppEmailConfigError();
|
||||
if (mailConfigError) {
|
||||
return { error: mailConfigError };
|
||||
}
|
||||
|
||||
const dateLabel = format(assignment.date, "EEEE, dd. MMMM yyyy", {
|
||||
locale: de,
|
||||
});
|
||||
const childName = `${assignment.child.firstName} ${assignment.child.lastName}`;
|
||||
const alertJobs: { email: string; token: string }[] = [];
|
||||
|
||||
await prisma.$transaction(
|
||||
parents.map((parent) => {
|
||||
const token = crypto.randomUUID();
|
||||
alertJobs.push({ email: parent.email, token });
|
||||
return prisma.notdienstAlert.create({
|
||||
data: {
|
||||
kitaId,
|
||||
assignmentId,
|
||||
parentUserId: parent.id,
|
||||
triggeredById: session.user.id,
|
||||
status: NotdienstAlertStatus.PENDING,
|
||||
confirmationToken: token,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
for (const job of alertJobs) {
|
||||
const emailResult = await sendAppEmail({
|
||||
to: job.email,
|
||||
subject: `Dringender Notdienst-Alarm für ${dateLabel}`,
|
||||
react: createElement(AlertEmail, {
|
||||
date: dateLabel,
|
||||
childName,
|
||||
confirmLink: `${BASE_URL}/alert/${job.token}`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!emailResult.success) {
|
||||
revalidatePath("/dashboard");
|
||||
return {
|
||||
error: `Alarm wurde angelegt, aber mindestens eine E-Mail konnte nicht versendet werden: ${emailResult.error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { error: "Fehler beim Auslösen des Alarms." };
|
||||
}
|
||||
return alarmAction("create", {
|
||||
assignmentId,
|
||||
wholeDay: true,
|
||||
replyMinutes: 30,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { addDays, format, isWeekend, parseISO } from "date-fns";
|
||||
import { addDays, format, parseISO } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -55,34 +55,23 @@ export function AbsencesClient({
|
||||
const today = parseISO(todayStr);
|
||||
|
||||
// Filters State
|
||||
const [selectedGroup, setSelectedGroup] = useState<"all" | "Krippe" | "Elementar">("all");
|
||||
const [selectedReason, setSelectedReason] = useState<"all" | AbsenceReason>("all");
|
||||
|
||||
// Dropdown UI state
|
||||
const [isGroupOpen, setIsGroupOpen] = useState(false);
|
||||
const [isReasonOpen, setIsReasonOpen] = useState(false);
|
||||
|
||||
// Date input state
|
||||
const [inputDate, setInputDate] = useState(dateKey);
|
||||
|
||||
// Group resolver function
|
||||
const mockGroup = (childName: string) => {
|
||||
const charCode = childName.charCodeAt(0);
|
||||
return charCode % 2 === 0 ? "Krippe" : "Elementar";
|
||||
};
|
||||
|
||||
// Filter computation
|
||||
const filteredAbsences = useMemo(() => {
|
||||
return initialAbsences.filter((item) => {
|
||||
if (selectedGroup !== "all" && mockGroup(item.childName) !== selectedGroup) {
|
||||
return false;
|
||||
}
|
||||
if (selectedReason !== "all" && item.reason !== selectedReason) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [initialAbsences, selectedGroup, selectedReason]);
|
||||
}, [initialAbsences, selectedReason]);
|
||||
|
||||
// Recalculate reasons count based on filtered absences
|
||||
const countsByReason = useMemo(() => {
|
||||
@@ -113,9 +102,9 @@ export function AbsencesClient({
|
||||
TAGESÜBERSICHT
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||
Heute fehlen{" "}
|
||||
{isToday ? "Heute" : "An diesem Tag"}{filteredAbsences.length === 1 ? " fehlt " : " fehlen "}
|
||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
||||
{filteredAbsences.length} Kinder
|
||||
{filteredAbsences.length} {filteredAbsences.length === 1 ? "Kind" : "Kinder"}
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-2 text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
@@ -126,7 +115,7 @@ export function AbsencesClient({
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
<Stethoscope className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
|
||||
{childCount - filteredAbsences.length} anwesend
|
||||
{Math.max(0, childCount - initialAbsences.length)} anwesend
|
||||
</span>
|
||||
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
|
||||
<button
|
||||
@@ -167,71 +156,6 @@ export function AbsencesClient({
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Group Dropdown Filter */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsGroupOpen(!isGroupOpen)}
|
||||
className={`inline-flex h-9 items-center gap-2 rounded-full border px-4 text-[13px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] ${
|
||||
selectedGroup !== "all"
|
||||
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
|
||||
: "border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
|
||||
}`}
|
||||
>
|
||||
{selectedGroup === "all" ? "Alle Gruppen" : selectedGroup}
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{isGroupOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setIsGroupOpen(false)} />
|
||||
<div className="absolute left-0 mt-1.5 w-44 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] py-1.5 shadow-lg z-20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedGroup("all");
|
||||
setIsGroupOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
|
||||
selectedGroup === "all"
|
||||
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
|
||||
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
|
||||
}`}
|
||||
>
|
||||
Alle Gruppen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedGroup("Krippe");
|
||||
setIsGroupOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
|
||||
selectedGroup === "Krippe"
|
||||
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
|
||||
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
|
||||
}`}
|
||||
>
|
||||
Krippe (Mock)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedGroup("Elementar");
|
||||
setIsGroupOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
|
||||
selectedGroup === "Elementar"
|
||||
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
|
||||
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
|
||||
}`}
|
||||
>
|
||||
Elementar (Mock)
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reason Dropdown Filter */}
|
||||
<div className="relative">
|
||||
<button
|
||||
@@ -361,7 +285,7 @@ export function AbsencesClient({
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AbsenceTable absences={filteredAbsences} />
|
||||
<AbsenceTable absences={filteredAbsences} today={todayStr} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isTooFarFuture ? (
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState } from "react";
|
||||
import { AbsenceReason } from "@prisma/client";
|
||||
import { CalendarDays, Circle, MessageSquareText, UserRound } from "lucide-react";
|
||||
import { reasonColor, reasonLabel } from "@/lib/absence-utils";
|
||||
import { AbsenceManagementActions } from "@/components/absence-management-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -30,12 +32,12 @@ export type AbsenceTableItem = {
|
||||
note: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
|
||||
const [activeAbsence, setActiveAbsence] = useState<AbsenceTableItem | null>(
|
||||
null,
|
||||
);
|
||||
export function AbsenceTable({ absences, today }: { absences: AbsenceTableItem[]; today: string }) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const activeAbsence = absences.find((absence) => absence.id === activeId) ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -47,6 +49,7 @@ export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
|
||||
<TableHead>Grund</TableHead>
|
||||
<TableHead>Bemerkung</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead><span className="sr-only">Aktionen</span></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -54,7 +57,7 @@ export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
|
||||
<TableRow
|
||||
key={absence.id}
|
||||
className="cursor-pointer border-[var(--hairline-soft)] hover:bg-[var(--surface-2)]"
|
||||
onClick={() => setActiveAbsence(absence)}
|
||||
onClick={() => setActiveId(absence.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -89,6 +92,9 @@ export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
|
||||
<TableCell>
|
||||
<Badge variant="success">Bestätigt</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={(event) => { event.stopPropagation(); setActiveId(absence.id); }} aria-label={`Abwesenheit öffnen: ${absence.childName}`}>Details & bearbeiten</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -96,9 +102,9 @@ export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
|
||||
|
||||
<Dialog
|
||||
open={!!activeAbsence}
|
||||
onOpenChange={(open) => !open && setActiveAbsence(null)}
|
||||
onOpenChange={(open) => !open && setActiveId(null)}
|
||||
>
|
||||
<DialogContent className="left-auto right-0 top-0 h-full max-w-md translate-x-0 translate-y-0 rounded-none border-y-0 border-r-0 border-[var(--hairline)] bg-[var(--surface)] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right">
|
||||
<DialogContent className="left-auto right-0 top-0 h-full max-w-md overflow-y-auto translate-x-0 translate-y-0 rounded-none border-y-0 border-r-0 border-[var(--hairline)] bg-[var(--surface)] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right">
|
||||
{activeAbsence ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
@@ -129,6 +135,7 @@ export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
|
||||
value={activeAbsence.note || "Keine Bemerkung hinterlegt."}
|
||||
/>
|
||||
<DetailLine icon={UserRound} label="Status" value="Bestätigt" />
|
||||
<AbsenceManagementActions key={activeAbsence.updatedAt} absence={activeAbsence} today={today} team onChanged={() => setActiveId(null)} />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { differenceInCalendarDays, format, isWeekend } from "date-fns";
|
||||
import { TerminStatus, TerminType, UserRole } from "@prisma/client";
|
||||
|
||||
import { getDailyAbsences } from "@/actions/absences";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { absenceDate, absenceToday, isAbsenceDate } from "@/lib/absence-policy";
|
||||
import { AbsencesClient } from "./_components/absences-client";
|
||||
import { type AbsenceTableItem } from "./absence-table";
|
||||
|
||||
@@ -21,9 +21,10 @@ export default async function AdminAbwesenheitenPage({
|
||||
]);
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const kitaId = session.user.kitaId!;
|
||||
const today = startOfLocalDay(new Date());
|
||||
const todayKey = absenceToday();
|
||||
const today = absenceDate(todayKey);
|
||||
const selectedDate = parseDateParam(resolvedSearchParams.date, today);
|
||||
const dateKey = format(selectedDate, "yyyy-MM-dd");
|
||||
const dateKey = selectedDate.toISOString().slice(0, 10);
|
||||
const result = await getDailyAbsences({ date: dateKey });
|
||||
const absences = result.absences ?? [];
|
||||
const childCount = await prisma.child.count({
|
||||
@@ -46,9 +47,9 @@ export default async function AdminAbwesenheitenPage({
|
||||
select: { title: true, type: true },
|
||||
orderBy: { startDate: "asc" },
|
||||
});
|
||||
const isToday = dateKey === format(today, "yyyy-MM-dd");
|
||||
const isTooFarFuture = differenceInCalendarDays(selectedDate, today) > 30;
|
||||
const isClosedDay = isWeekend(selectedDate) || !!closure;
|
||||
const isToday = dateKey === todayKey;
|
||||
const isTooFarFuture = (selectedDate.getTime() - today.getTime()) / 86_400_000 > 30;
|
||||
const isClosedDay = [0, 6].includes(selectedDate.getUTCDay()) || !!closure;
|
||||
const tableItems: AbsenceTableItem[] = absences.map((absence) => ({
|
||||
id: absence.id,
|
||||
childName: absence.childName,
|
||||
@@ -57,6 +58,7 @@ export default async function AdminAbwesenheitenPage({
|
||||
note: absence.note,
|
||||
startDate: absence.startDate,
|
||||
endDate: absence.endDate,
|
||||
updatedAt: absence.updatedAt,
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -67,11 +69,11 @@ export default async function AdminAbwesenheitenPage({
|
||||
upcomingAbsenceCount={upcomingAbsenceCount}
|
||||
closure={closure}
|
||||
dateKey={dateKey}
|
||||
selectedDateStr={selectedDate.toISOString()}
|
||||
selectedDateStr={dateKey}
|
||||
isToday={isToday}
|
||||
isTooFarFuture={isTooFarFuture}
|
||||
isClosedDay={isClosedDay}
|
||||
todayStr={today.toISOString()}
|
||||
todayStr={todayKey}
|
||||
error={result.error ?? null}
|
||||
/>
|
||||
</div>
|
||||
@@ -79,13 +81,5 @@ export default async function AdminAbwesenheitenPage({
|
||||
}
|
||||
|
||||
function parseDateParam(value: string | undefined, fallback: Date) {
|
||||
if (!value) return fallback;
|
||||
const parsed = new Date(`${value}T00:00:00`);
|
||||
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
|
||||
}
|
||||
|
||||
function startOfLocalDay(date: Date) {
|
||||
const nextDate = new Date(date);
|
||||
nextDate.setHours(0, 0, 0, 0);
|
||||
return nextDate;
|
||||
return value && isAbsenceDate(value) ? absenceDate(value) : fallback;
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import { revalidatePath } from "next/cache";
|
||||
import { DutyAssignmentStatus, UserRole } from "@prisma/client";
|
||||
import { endOfWeek, format, startOfDay, startOfWeek } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { dateOnlySchema } from "@/lib/date-schema";
|
||||
import { z } from "zod";
|
||||
|
||||
import { DutyReminderEmail } from "@/emails/DutyReminderEmail";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const dutyTypeSchema = z.object({
|
||||
@@ -20,8 +22,8 @@ const dutyTypeSchema = z.object({
|
||||
const assignmentSchema = z.object({
|
||||
dutyTypeId: z.string().min(1),
|
||||
familyId: z.string().nullable(),
|
||||
startDate: z.string().min(1),
|
||||
endDate: z.string().min(1),
|
||||
startDate: dateOnlySchema,
|
||||
endDate: dateOnlySchema,
|
||||
});
|
||||
|
||||
async function requireDutyAdmin() {
|
||||
@@ -160,6 +162,7 @@ export async function upsertDutyAssignment(rawPayload: unknown) {
|
||||
|
||||
export async function checkAndSendDutyReminders() {
|
||||
const { kitaId } = await requireDutyAdmin();
|
||||
if (!(await rateLimit(`duty-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
|
||||
const configError = getAppEmailConfigError();
|
||||
|
||||
if (configError) {
|
||||
@@ -195,6 +198,8 @@ export async function checkAndSendDutyReminders() {
|
||||
users: {
|
||||
where: {
|
||||
emailVerifiedAt: { not: null },
|
||||
disabledAt: null,
|
||||
privacyPolicyAcceptedAt: { not: null },
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import {
|
||||
EventParticipationStatus,
|
||||
RsvpMode,
|
||||
TerminStatus,
|
||||
TerminType,
|
||||
} from "@prisma/client";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { suggestedRsvpMode } from "@/lib/rsvp";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -48,10 +50,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
createTerminAdmin,
|
||||
updateTerminAdmin,
|
||||
} from "../../kalender/actions";
|
||||
import { createTerminAdmin, updateTerminAdmin } from "../../kalender/actions";
|
||||
|
||||
type AdminTerminDto = {
|
||||
id: string;
|
||||
@@ -64,6 +63,12 @@ type AdminTerminDto = {
|
||||
allDay: boolean;
|
||||
mitbringselListEnabled: boolean;
|
||||
requiresRsvp: boolean;
|
||||
rsvpMode: RsvpMode;
|
||||
familyParticipations: {
|
||||
familyId: string;
|
||||
status: EventParticipationStatus;
|
||||
adultCount: number;
|
||||
}[];
|
||||
rsvpDeadline: string | null;
|
||||
participantInfo: string | null;
|
||||
participations: {
|
||||
@@ -81,9 +86,11 @@ type ChildDto = {
|
||||
export function AdminCalendarManager({
|
||||
termine,
|
||||
childRows,
|
||||
familyRows,
|
||||
}: {
|
||||
termine: AdminTerminDto[];
|
||||
childRows: ChildDto[];
|
||||
familyRows: { id: string; name: string }[];
|
||||
}) {
|
||||
const rsvpCount = termine.filter((termin) => termin.requiresRsvp).length;
|
||||
const pendingCount = termine.filter(
|
||||
@@ -104,8 +111,8 @@ export function AdminCalendarManager({
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Termine verwalten, RSVP aktivieren und den Rücklauf pro Kind
|
||||
verfolgen.
|
||||
Termine verwalten, RSVP aktivieren und den Rücklauf pro Familie oder
|
||||
Kind verfolgen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
@@ -134,7 +141,7 @@ export function AdminCalendarManager({
|
||||
Noch keine Termine
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
|
||||
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -145,6 +152,7 @@ export function AdminCalendarManager({
|
||||
key={termin.id}
|
||||
termin={termin}
|
||||
childRows={childRows}
|
||||
familyRows={familyRows}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -156,11 +164,25 @@ export function AdminCalendarManager({
|
||||
function AdminTerminCard({
|
||||
termin,
|
||||
childRows,
|
||||
familyRows,
|
||||
}: {
|
||||
termin: AdminTerminDto;
|
||||
childRows: ChildDto[];
|
||||
familyRows: { id: string; name: string }[];
|
||||
}) {
|
||||
const stats = useMemo(() => getStats(termin, childRows), [termin, childRows]);
|
||||
const isFamily = termin.rsvpMode === RsvpMode.FAMILY;
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
isFamily
|
||||
? familyRows.map((family) => ({
|
||||
id: family.id,
|
||||
name: family.name,
|
||||
familyName: family.name,
|
||||
}))
|
||||
: childRows,
|
||||
[isFamily, familyRows, childRows],
|
||||
);
|
||||
const stats = useMemo(() => getStats(termin, rows), [termin, rows]);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
@@ -175,7 +197,9 @@ function AdminTerminCard({
|
||||
{getStatusLabel(termin.status)}
|
||||
</Badge>
|
||||
{termin.requiresRsvp && (
|
||||
<Badge variant="warning">RSVP aktiv</Badge>
|
||||
<Badge variant="warning">
|
||||
Antwort pro {isFamily ? "Familie" : "Kind"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-lg">{termin.title}</CardTitle>
|
||||
@@ -217,14 +241,19 @@ function AdminTerminCard({
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
|
||||
{stats.pending} Ausstehend
|
||||
{stats.pending} Ausstehend{" "}
|
||||
{isFamily ? `· ${stats.adultCount} Erwachsene` : ""}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Frist:{" "}
|
||||
{termin.rsvpDeadline
|
||||
? format(new Date(termin.rsvpDeadline), "dd.MM.yyyy HH:mm", {
|
||||
locale: de,
|
||||
})
|
||||
? format(
|
||||
new Date(termin.rsvpDeadline),
|
||||
"dd.MM.yyyy HH:mm",
|
||||
{
|
||||
locale: de,
|
||||
},
|
||||
)
|
||||
: "bis Terminbeginn"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -241,18 +270,24 @@ function AdminTerminCard({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kind</TableHead>
|
||||
<TableHead>Familie</TableHead>
|
||||
<TableHead>{isFamily ? "Familie" : "Kind"}</TableHead>
|
||||
<TableHead>{isFamily ? "Erwachsene" : "Familie"}</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{childRows.map((child) => {
|
||||
{rows.map((child) => {
|
||||
const status = stats.statusByChildId.get(child.id) ?? null;
|
||||
return (
|
||||
<TableRow key={child.id}>
|
||||
<TableCell className="font-medium">{child.name}</TableCell>
|
||||
<TableCell>{child.familyName}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{child.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isFamily
|
||||
? (stats.adultCounts.get(child.id) ?? "–")
|
||||
: child.familyName}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RsvpStatusBadge status={status} />
|
||||
</TableCell>
|
||||
@@ -283,6 +318,13 @@ function TerminFormDialog({
|
||||
const [requiresRsvp, setRequiresRsvp] = useState(
|
||||
termin?.requiresRsvp ?? false,
|
||||
);
|
||||
const [rsvpMode, setRsvpMode] = useState(
|
||||
termin?.rsvpMode ?? suggestedRsvpMode(TerminType.KITA_FEST),
|
||||
);
|
||||
const [modeTouched, setModeTouched] = useState(false);
|
||||
const modeLocked =
|
||||
!!termin &&
|
||||
termin.participations.length + termin.familyParticipations.length > 0;
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleAction(formData: FormData) {
|
||||
@@ -300,6 +342,7 @@ function TerminFormDialog({
|
||||
allDay: formData.get("allDay") === "on",
|
||||
mitbringselListEnabled: formData.get("mitbringselListEnabled") === "on",
|
||||
requiresRsvp: hasRsvp,
|
||||
rsvpMode,
|
||||
rsvpDeadline:
|
||||
hasRsvp && deadlineRaw ? new Date(deadlineRaw).toISOString() : null,
|
||||
participantInfo: String(formData.get("participantInfo") ?? ""),
|
||||
@@ -330,6 +373,10 @@ function TerminFormDialog({
|
||||
setOpen(nextOpen);
|
||||
if (nextOpen) {
|
||||
setRequiresRsvp(termin?.requiresRsvp ?? false);
|
||||
setRsvpMode(
|
||||
termin?.rsvpMode ?? suggestedRsvpMode(TerminType.KITA_FEST),
|
||||
);
|
||||
setModeTouched(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -380,6 +427,12 @@ function TerminFormDialog({
|
||||
<select
|
||||
id={`${mode}-type`}
|
||||
name="type"
|
||||
onChange={(event) => {
|
||||
if (!termin && !modeLocked && !modeTouched)
|
||||
setRsvpMode(
|
||||
suggestedRsvpMode(event.target.value as TerminType),
|
||||
);
|
||||
}}
|
||||
defaultValue={termin?.type ?? TerminType.KITA_FEST}
|
||||
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:bg-[oklch(0.99_0.005_75)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
required
|
||||
@@ -453,9 +506,33 @@ function TerminFormDialog({
|
||||
{requiresRsvp && (
|
||||
<div className="grid gap-4 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${mode}-rsvpDeadline`}>
|
||||
Rückmeldefrist
|
||||
</Label>
|
||||
<Label htmlFor={`${mode}-rsvpMode`}>Antwortart</Label>
|
||||
<select
|
||||
id={`${mode}-rsvpMode`}
|
||||
value={rsvpMode}
|
||||
disabled={modeLocked}
|
||||
onChange={(e) => {
|
||||
setRsvpMode(e.target.value as RsvpMode);
|
||||
setModeTouched(true);
|
||||
}}
|
||||
className="h-10 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 text-sm disabled:opacity-60"
|
||||
>
|
||||
<option value="FAMILY">
|
||||
Pro Familie · mit Anzahl Erwachsener
|
||||
</option>
|
||||
<option value="CHILD">
|
||||
Pro Kind · einzelne Zu-/Absage
|
||||
</option>
|
||||
</select>
|
||||
{modeLocked ? (
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Die Antwortart ist gesperrt, da bereits Rückmeldungen
|
||||
vorliegen.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${mode}-rsvpDeadline`}>Rückmeldefrist</Label>
|
||||
<Input
|
||||
id={`${mode}-rsvpDeadline`}
|
||||
name="rsvpDeadline"
|
||||
@@ -532,28 +609,34 @@ function RsvpStatusBadge({
|
||||
);
|
||||
}
|
||||
|
||||
function getStats(termin: AdminTerminDto, children: ChildDto[]) {
|
||||
const statusByChildId = new Map(
|
||||
termin.participations.map((participation) => [
|
||||
participation.childId,
|
||||
participation.status,
|
||||
]),
|
||||
function getStats(termin: AdminTerminDto, rows: ChildDto[]) {
|
||||
const isFamily = termin.rsvpMode === RsvpMode.FAMILY;
|
||||
const statusByChildId = new Map<string, EventParticipationStatus>(
|
||||
isFamily
|
||||
? termin.familyParticipations.map((row) => [row.familyId, row.status])
|
||||
: termin.participations.map((row) => [row.childId, row.status]),
|
||||
);
|
||||
const adultCounts = new Map(
|
||||
termin.familyParticipations.map((row) => [row.familyId, row.adultCount]),
|
||||
);
|
||||
|
||||
let attending = 0;
|
||||
let notAttending = 0;
|
||||
|
||||
children.forEach((child) => {
|
||||
const status = statusByChildId.get(child.id);
|
||||
if (status === EventParticipationStatus.ATTENDING) attending += 1;
|
||||
let adultCount = 0;
|
||||
for (const row of rows) {
|
||||
const status = statusByChildId.get(row.id);
|
||||
if (status === EventParticipationStatus.ATTENDING) {
|
||||
attending += 1;
|
||||
adultCount += adultCounts.get(row.id) ?? 0;
|
||||
}
|
||||
if (status === EventParticipationStatus.NOT_ATTENDING) notAttending += 1;
|
||||
});
|
||||
|
||||
}
|
||||
return {
|
||||
attending,
|
||||
notAttending,
|
||||
pending: Math.max(children.length - attending - notAttending, 0),
|
||||
pending: rows.length - attending - notAttending,
|
||||
statusByChildId,
|
||||
adultCounts,
|
||||
adultCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export default async function AdminKalenderPage() {
|
||||
return <div className="p-8">Kein Mandant zugeordnet.</div>;
|
||||
}
|
||||
|
||||
const [termine, children] = await Promise.all([
|
||||
const [termine, children, families] = await Promise.all([
|
||||
prisma.termin.findMany({
|
||||
where: { kitaId },
|
||||
select: {
|
||||
@@ -28,6 +28,10 @@ export default async function AdminKalenderPage() {
|
||||
allDay: true,
|
||||
mitbringselListEnabled: true,
|
||||
requiresRsvp: true,
|
||||
rsvpMode: true,
|
||||
familyParticipations: {
|
||||
select: { familyId: true, status: true, adultCount: true },
|
||||
},
|
||||
rsvpDeadline: true,
|
||||
participantInfo: true,
|
||||
participations: {
|
||||
@@ -60,6 +64,11 @@ export default async function AdminKalenderPage() {
|
||||
{ firstName: "asc" },
|
||||
],
|
||||
}),
|
||||
prisma.family.findMany({
|
||||
where: { kitaId },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -70,6 +79,7 @@ export default async function AdminKalenderPage() {
|
||||
endDate: termin.endDate.toISOString(),
|
||||
rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null,
|
||||
}))}
|
||||
familyRows={families}
|
||||
childRows={children.map((child) => ({
|
||||
id: child.id,
|
||||
name: `${child.firstName} ${child.lastName}`,
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
import { ArrowLeft, FileText, Mail } from "lucide-react";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { announcementAudienceLabels } from "@/lib/announcement-access";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { EditNewsForm, RetryNewsEmails } from "../edit-news-form";
|
||||
|
||||
export const metadata = { title: "Mitteilung bearbeiten · Kita-Planer" };
|
||||
export default async function EditNewsPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}) {
|
||||
const session = await requireRole(["ADMIN"]);
|
||||
const { id } = await params;
|
||||
const announcement = await prisma.announcement.findFirst({
|
||||
where: { id, kitaId: session.user.kitaId! },
|
||||
include: {
|
||||
attachments: { select: { id: true, fileName: true, fileUrl: true } },
|
||||
},
|
||||
});
|
||||
if (!announcement) notFound();
|
||||
const rawPage = Number((await searchParams).page ?? 1);
|
||||
const page =
|
||||
Number.isSafeInteger(rawPage) && rawPage > 0
|
||||
? Math.min(rawPage, 100000)
|
||||
: 1;
|
||||
const where = {
|
||||
kitaId: session.user.kitaId!,
|
||||
announcementId: id,
|
||||
revision: announcement.revision,
|
||||
};
|
||||
const [statuses, emails, older] = await Promise.all([
|
||||
prisma.announcementEmail.findMany({
|
||||
where,
|
||||
select: { sentAt: true, skippedAt: true, failed: true },
|
||||
}),
|
||||
prisma.announcementEmail.findMany({
|
||||
where,
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
take: 51,
|
||||
skip: (page - 1) * 50,
|
||||
select: {
|
||||
id: true,
|
||||
sentAt: true,
|
||||
skippedAt: true,
|
||||
failed: true,
|
||||
attempts: true,
|
||||
leaseUntil: true,
|
||||
lastError: true,
|
||||
user: { select: { firstName: true, lastName: true, email: true } },
|
||||
},
|
||||
}),
|
||||
prisma.announcementEmail.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId!,
|
||||
announcementId: id,
|
||||
revision: { lt: announcement.revision },
|
||||
},
|
||||
select: { revision: true, sentAt: true, skippedAt: true, failed: true },
|
||||
}),
|
||||
]);
|
||||
const history = new Map<
|
||||
number,
|
||||
{ sent: number; skipped: number; failed: number }
|
||||
>();
|
||||
for (const job of older) {
|
||||
const summary = history.get(job.revision) ?? {
|
||||
sent: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
};
|
||||
if (job.sentAt) summary.sent++;
|
||||
else if (job.skippedAt) summary.skipped++;
|
||||
else if (job.failed) summary.failed++;
|
||||
history.set(job.revision, summary);
|
||||
}
|
||||
const backgroundConfigured =
|
||||
(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32;
|
||||
const sent = statuses.filter((job) => job.sentAt).length;
|
||||
const skipped = statuses.filter((job) => job.skippedAt).length;
|
||||
const failed = statuses.filter(
|
||||
(job) => !job.sentAt && !job.skippedAt && job.failed,
|
||||
).length;
|
||||
const pending = statuses.length - sent - skipped - failed;
|
||||
return (
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<Button asChild variant="ghost" size="sm" className="mb-5">
|
||||
<Link href="/dashboard/admin/news#beitraege">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Alle Beiträge
|
||||
</Link>
|
||||
</Button>
|
||||
<header className="mb-7">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-deep)]">
|
||||
SCHWARZES BRETT · BEITRAG
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] tracking-[-0.025em]">
|
||||
Mitteilung{" "}
|
||||
<em className="font-normal text-[var(--accent-deep)]">bearbeiten</em>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-[var(--ink-soft)]">
|
||||
Veröffentlicht am {format(announcement.createdAt, "dd.MM.yyyy HH:mm")}
|
||||
{announcement.editedAt &&
|
||||
` · Aktualisiert am ${format(announcement.editedAt, "dd.MM.yyyy HH:mm")}`}
|
||||
</p>
|
||||
</header>
|
||||
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Inhalt korrigieren</CardTitle>
|
||||
<CardDescription>
|
||||
Empfängergruppe und bestehende Anhänge bleiben bei einer Korrektur
|
||||
erhalten.
|
||||
</CardDescription>
|
||||
<Badge variant="outline" className="mt-2 w-fit">
|
||||
{announcementAudienceLabels[announcement.audience]}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EditNewsForm
|
||||
key={announcement.revision}
|
||||
announcement={{
|
||||
id,
|
||||
revision: announcement.revision,
|
||||
title: announcement.title,
|
||||
content: announcement.content,
|
||||
}}
|
||||
/>
|
||||
{announcement.attachments.length > 0 && (
|
||||
<div className="mt-6 border-t border-[var(--hairline)] pt-4">
|
||||
<h2 className="mb-2 text-sm font-medium">Anhänge</h2>
|
||||
<div className="grid gap-2">
|
||||
{announcement.attachments.map((a) => (
|
||||
<a
|
||||
key={a.id}
|
||||
href={a.fileUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 break-all text-sm text-[var(--accent-deep)] underline"
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0" />
|
||||
{a.fileName}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card id="versand">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
E-Mail-Versand
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Status der aktuellen Fassung. „Gesendet“ bedeutet, dass der
|
||||
Maildienst die Nachricht angenommen hat.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="success">{sent} gesendet</Badge>
|
||||
<Badge variant="secondary">{pending} ausstehend</Badge>
|
||||
<Badge variant="outline">{failed} fehlgeschlagen</Badge>
|
||||
{skipped > 0 && (
|
||||
<Badge variant="outline">{skipped} übersprungen</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!backgroundConfigured && (
|
||||
<p className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3 text-sm leading-6">
|
||||
Automatische Wiederholungen sind noch nicht eingerichtet. Bitte
|
||||
wende dich an die Hosting-Administration; fehlgeschlagene
|
||||
E-Mails kannst du hier selbst erneut versuchen.
|
||||
</p>
|
||||
)}
|
||||
{!announcement.emailReady && (
|
||||
<p className="text-sm text-[var(--ink-soft)]">
|
||||
Die Anhänge werden noch vorbereitet. Der Versand beginnt erst,
|
||||
wenn alle Dateien gespeichert sind.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||
Bei eingerichteter Automatik werden fehlgeschlagene Sendungen
|
||||
erneut versucht. Deaktivierte Konten und Personen, die nicht mehr
|
||||
zur Empfängergruppe gehören, werden übersprungen.
|
||||
</p>
|
||||
{failed > 0 && (
|
||||
<div className="[&_button]:h-auto [&_button]:whitespace-normal">
|
||||
<RetryNewsEmails
|
||||
id={id}
|
||||
revision={announcement.revision}
|
||||
failed={failed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-self-start"
|
||||
>
|
||||
<Link href={`/dashboard/admin/news/${id}?page=${page}#versand`}>
|
||||
Status aktualisieren
|
||||
</Link>
|
||||
</Button>
|
||||
{statuses.length === 0 && (
|
||||
<p className="rounded-lg bg-[var(--surface-2)] p-4 text-sm leading-6 text-[var(--ink-soft)]">
|
||||
Für diese Fassung sind keine E-Mails vorgemerkt. Bei Beiträgen
|
||||
aus der Zeit vor Einführung dieser Übersicht liegt kein
|
||||
damaliger Versandstatus vor. Über das Formular kannst du die
|
||||
aktuelle Fassung einmalig versenden.
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
{emails.slice(0, 50).map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
className="min-w-0 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
{job.user.firstName} {job.user.lastName}
|
||||
</p>
|
||||
<Badge variant={job.sentAt ? "success" : "outline"}>
|
||||
{job.sentAt
|
||||
? "Gesendet"
|
||||
: job.skippedAt
|
||||
? "Übersprungen"
|
||||
: job.failed
|
||||
? "Fehlgeschlagen"
|
||||
: job.leaseUntil && job.leaseUntil > new Date()
|
||||
? "Wird versendet"
|
||||
: "Ausstehend"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 break-all text-xs text-[var(--ink-muted)]">
|
||||
{job.user.email}
|
||||
</p>
|
||||
{job.sentAt && (
|
||||
<p className="mt-1 text-xs text-[var(--ink-muted)]">
|
||||
{format(job.sentAt, "dd.MM.yyyy HH:mm")}
|
||||
</p>
|
||||
)}
|
||||
{job.lastError && (
|
||||
<p className="mt-2 text-xs leading-5 text-[var(--ink-soft)]">
|
||||
{job.lastError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{history.size > 0 && (
|
||||
<details className="rounded-lg border border-[var(--hairline)] p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Versand früherer Fassungen
|
||||
</summary>
|
||||
<div className="mt-3 grid gap-2">
|
||||
{[...history]
|
||||
.sort(([a], [b]) => b - a)
|
||||
.map(([revision, summary]) => (
|
||||
<p
|
||||
key={revision}
|
||||
className="text-xs leading-5 text-[var(--ink-soft)]"
|
||||
>
|
||||
Fassung {revision}: {summary.sent} gesendet ·{" "}
|
||||
{summary.skipped} übersprungen · {summary.failed}{" "}
|
||||
fehlgeschlagen
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-xs leading-5 text-[var(--ink-muted)]">
|
||||
Ungesendete E-Mails älterer Fassungen wurden durch die
|
||||
Korrektur ersetzt.
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
{(page > 1 || emails.length > 50) && (
|
||||
<nav
|
||||
aria-label="Empfänger durchblättern"
|
||||
className="flex flex-wrap justify-between gap-3"
|
||||
>
|
||||
<div>
|
||||
{page > 1 && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`?page=${page - 1}#versand`}>Zurück</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm">Seite {page}</span>
|
||||
<div>
|
||||
{emails.length > 50 && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`?page=${page + 1}#versand`}>Weitere</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, RotateCw, Save } from "lucide-react";
|
||||
import {
|
||||
updateAnnouncement,
|
||||
retryAnnouncementEmails,
|
||||
} from "@/actions/announcements";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { MarkdownContent } from "@/components/markdown-content";
|
||||
|
||||
export function EditNewsForm({
|
||||
announcement,
|
||||
}: {
|
||||
announcement: {
|
||||
id: string;
|
||||
revision: number;
|
||||
title: string;
|
||||
content: string;
|
||||
};
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [content, setContent] = useState(announcement.content);
|
||||
const [sendEmail, setSendEmail] = useState(false);
|
||||
function submit(formData: FormData) {
|
||||
formData.set("id", announcement.id);
|
||||
formData.set("revision", String(announcement.revision));
|
||||
formData.set("sendEmail", sendEmail ? "on" : "off");
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await updateAnnouncement(formData);
|
||||
if (!("success" in result)) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
result.queued
|
||||
? `Mitteilung gespeichert. ${result.queued} E-Mails sind zum Versand vorgemerkt.`
|
||||
: result.changed
|
||||
? "Mitteilung aktualisiert."
|
||||
: "Keine Änderungen vorhanden.",
|
||||
);
|
||||
setSendEmail(false);
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error(
|
||||
"Mitteilung konnte nicht gespeichert werden. Bitte versuche es erneut.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return (
|
||||
<form action={submit} className="grid gap-5">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-title">Titel</Label>
|
||||
<Input
|
||||
id="edit-title"
|
||||
name="title"
|
||||
defaultValue={announcement.title}
|
||||
minLength={3}
|
||||
maxLength={160}
|
||||
required
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-content">Text</Label>
|
||||
<Textarea
|
||||
id="edit-content"
|
||||
name="content"
|
||||
rows={10}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
minLength={3}
|
||||
maxLength={20_000}
|
||||
required
|
||||
disabled={pending}
|
||||
/>
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
Markdown ist aktiv. HTML wird nicht interpretiert.
|
||||
</p>
|
||||
</div>
|
||||
<details className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Vorschau ansehen
|
||||
</summary>
|
||||
<div className="mt-4 text-sm">
|
||||
<MarkdownContent content={content} />
|
||||
</div>
|
||||
</details>
|
||||
<div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="correction-email"
|
||||
checked={sendEmail}
|
||||
disabled={pending}
|
||||
onCheckedChange={(checked) => setSendEmail(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="correction-email"
|
||||
className="cursor-pointer leading-5"
|
||||
>
|
||||
Aktuelle Fassung auch per E-Mail senden
|
||||
</Label>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-[var(--ink-soft)]">
|
||||
Bei einer Textänderung erscheint der Beitrag für alle wieder als
|
||||
ungelesen. Jede Fassung wird je Person höchstens einmal zum Versand
|
||||
vorgemerkt.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" disabled={pending} className="justify-self-end">
|
||||
{pending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
Änderungen speichern
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function RetryNewsEmails({
|
||||
id,
|
||||
revision,
|
||||
failed,
|
||||
}: {
|
||||
id: string;
|
||||
revision: number;
|
||||
failed: number;
|
||||
}) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={pending || failed === 0}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await retryAnnouncementEmails(id, revision);
|
||||
if (!("success" in result)) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
result.queued
|
||||
? `${result.queued} fehlgeschlagene E-Mails werden erneut versucht.`
|
||||
: "Keine fehlgeschlagenen Sendungen vorhanden.",
|
||||
);
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error("Versand konnte nicht gestartet werden.");
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="h-4 w-4" />
|
||||
)}
|
||||
Fehlgeschlagene erneut versuchen
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { Loader2, Send, UploadCloud } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { MarkdownContent } from "@/components/markdown-content";
|
||||
|
||||
export function NewsForm() {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [sendEmail, setSendEmail] = useState(false);
|
||||
const [content, setContent] = useState("");
|
||||
@@ -30,27 +31,43 @@ export function NewsForm() {
|
||||
formData.set("sendEmail", "on");
|
||||
}
|
||||
|
||||
const result = await createAnnouncement(formData);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await createAnnouncement(formData);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.warning) {
|
||||
toast.warning(result.warning);
|
||||
} else {
|
||||
toast.success("Ankündigung veröffentlicht.");
|
||||
if (result.warning) {
|
||||
toast.warning(result.warning);
|
||||
} else {
|
||||
toast.success(
|
||||
sendEmail
|
||||
? "Ankündigung veröffentlicht. E-Mails sind zum Versand vorgemerkt."
|
||||
: "Ankündigung veröffentlicht.",
|
||||
);
|
||||
}
|
||||
formRef.current?.reset();
|
||||
setContent("");
|
||||
setSendEmail(false);
|
||||
} catch {
|
||||
toast.error(
|
||||
"Ankündigung konnte nicht veröffentlicht werden. Bitte versuche es erneut.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={handleSubmit} className="grid gap-5">
|
||||
<form ref={formRef} action={handleSubmit} className="grid gap-5">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="news-title">Titel</Label>
|
||||
<Input
|
||||
id="news-title"
|
||||
name="title"
|
||||
minLength={3}
|
||||
maxLength={160}
|
||||
disabled={isPending}
|
||||
placeholder="Sommerfest: Aufbau und Mitbringliste"
|
||||
required
|
||||
/>
|
||||
@@ -62,9 +79,14 @@ export function NewsForm() {
|
||||
id="news-content"
|
||||
name="content"
|
||||
rows={10}
|
||||
minLength={3}
|
||||
maxLength={20_000}
|
||||
disabled={isPending}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder={"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."}
|
||||
placeholder={
|
||||
"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."
|
||||
}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
@@ -93,7 +115,7 @@ export function NewsForm() {
|
||||
/>
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-[var(--ink-muted)]">
|
||||
<UploadCloud className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
PDF, JPG und PNG bis 10 MB pro Datei.
|
||||
PDF, JPG und PNG: maximal 5 Dateien, je 10 MB und insgesamt 25 MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,21 +132,23 @@ export function NewsForm() {
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{sendEmail && (
|
||||
<div className="mt-4 grid gap-2">
|
||||
<Label>Empfänger</Label>
|
||||
<Select name="audience" defaultValue="ELTERN">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Empfänger auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ELTERN">Eltern</SelectItem>
|
||||
<SelectItem value="ERZIEHER">ErzieherInnen</SelectItem>
|
||||
<SelectItem value="ALLE">Alle</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 grid gap-2">
|
||||
<Label htmlFor="news-audience">Empfängergruppe</Label>
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Bestimmt, wer die Meldung und Anhänge in der App sehen darf und
|
||||
optional die E-Mail erhält.
|
||||
</p>
|
||||
<Select name="audience" defaultValue="ELTERN">
|
||||
<SelectTrigger id="news-audience">
|
||||
<SelectValue placeholder="Empfänger auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ELTERN">Eltern</SelectItem>
|
||||
<SelectItem value="ERZIEHER">ErzieherInnen</SelectItem>
|
||||
<SelectItem value="ALLE">Eltern und ErzieherInnen</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { FileText, Megaphone, Send } from "lucide-react";
|
||||
@@ -13,11 +17,16 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { announcementAudienceLabels } from "@/lib/announcement-access";
|
||||
import { NewsForm } from "./news-form";
|
||||
|
||||
export const metadata = { title: "Schwarzes Brett · Kita-Planer" };
|
||||
|
||||
export default async function AdminNewsPage() {
|
||||
export default async function AdminNewsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; page?: string }>;
|
||||
}) {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
const kitaId = session.user.kitaId;
|
||||
|
||||
@@ -25,28 +34,46 @@ export default async function AdminNewsPage() {
|
||||
throw new Error("Kein Mandant zugeordnet.");
|
||||
}
|
||||
|
||||
const announcements = await prisma.announcement.findMany({
|
||||
where: { kitaId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
attachments: {
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
},
|
||||
const params = await searchParams;
|
||||
const q = typeof params.q === "string" ? params.q.trim().slice(0, 160) : "";
|
||||
const parsedPage = Number(params.page ?? 1);
|
||||
const page =
|
||||
Number.isSafeInteger(parsedPage) && parsedPage > 0
|
||||
? Math.min(parsedPage, 100000)
|
||||
: 1;
|
||||
const pageSize = 12;
|
||||
const where = {
|
||||
kitaId,
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: q, mode: "insensitive" as const } },
|
||||
{ content: { contains: q, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const [announcements, total] = await Promise.all([
|
||||
prisma.announcement.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
audience: true,
|
||||
createdAt: true,
|
||||
editedAt: true,
|
||||
revision: true,
|
||||
_count: { select: { attachments: true } },
|
||||
author: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
author: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 8,
|
||||
});
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.announcement.count({ where }),
|
||||
]);
|
||||
const href = (p: number) =>
|
||||
`/dashboard/admin/news?${new URLSearchParams({ ...(q ? { q } : {}), page: String(p) })}#beitraege`;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
@@ -58,53 +85,88 @@ export default async function AdminNewsPage() {
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||
Meldungen{" "}
|
||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
||||
veröffentlichen
|
||||
verwalten
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge
|
||||
werden geschützt gespeichert und nur für eingeloggte Nutzer
|
||||
derselben Kita ausgeliefert.
|
||||
werden geschützt gespeichert und nur an die gewählte Empfängergruppe
|
||||
deiner Kita ausgeliefert.
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
|
||||
<Megaphone className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
|
||||
{announcements.length} zuletzt
|
||||
{total} {total === 1 ? "Beitrag" : "Beiträge"}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||
<Card>
|
||||
<div className="grid gap-6">
|
||||
<details
|
||||
open={!q && page === 1}
|
||||
className="rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-5"
|
||||
>
|
||||
<summary className="cursor-pointer text-lg font-semibold">
|
||||
Neue Ankündigung veröffentlichen
|
||||
</summary>
|
||||
<Card className="mt-4 border-0 shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Send className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
|
||||
Neue Ankündigung
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Markdown wird sicher gerendert; PDF, JPG und PNG können als
|
||||
geschützte Anhänge hinzugefügt werden.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<NewsForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</details>
|
||||
|
||||
<Card id="beitraege" className="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Send className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
|
||||
Neue Ankündigung
|
||||
<CardTitle className="text-base">
|
||||
Veröffentlichte Beiträge
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Markdown wird sicher gerendert; PDF, JPG und PNG können als
|
||||
geschützte Anhänge hinzugefügt werden.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<NewsForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Zuletzt veröffentlicht</CardTitle>
|
||||
<CardDescription>
|
||||
Die neuesten Meldungen auf dem Schwarzen Brett.
|
||||
Korrigiere auch ältere Beiträge und prüfe den E-Mail-Versand.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
<form
|
||||
className="mb-4 flex flex-wrap items-end gap-3"
|
||||
action="/dashboard/admin/news#beitraege"
|
||||
>
|
||||
<div className="grid flex-1 gap-2">
|
||||
<Label htmlFor="news-search">Beiträge durchsuchen</Label>
|
||||
<Input
|
||||
id="news-search"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
maxLength={160}
|
||||
placeholder="Titel oder Text"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="outline">
|
||||
Suchen
|
||||
</Button>
|
||||
{q && (
|
||||
<Button asChild variant="ghost">
|
||||
<Link href="/dashboard/admin/news#beitraege">
|
||||
Alle Beiträge
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
{announcements.length === 0 ? (
|
||||
<div className="flex max-h-[280px] min-h-[220px] flex-col items-center justify-center rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-6 text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Megaphone className="h-6 w-6 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[var(--ink)]">
|
||||
Noch keine Ankündigungen
|
||||
Keine Beiträge gefunden
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Veröffentlichte Meldungen erscheinen hier.
|
||||
@@ -117,16 +179,19 @@ export default async function AdminNewsPage() {
|
||||
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<p className="font-medium leading-snug text-[var(--ink)]">
|
||||
<p className="min-w-0 break-words font-medium leading-snug text-[var(--ink)]">
|
||||
{announcement.title}
|
||||
</p>
|
||||
{announcement.attachments.length > 0 && (
|
||||
{announcement._count.attachments > 0 && (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
<FileText className="h-3 w-3 [stroke-width:1.7]" />
|
||||
{announcement.attachments.length}
|
||||
{announcement._count.attachments}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline" className="mb-2">
|
||||
{announcementAudienceLabels[announcement.audience]}
|
||||
</Badge>
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
{format(announcement.createdAt, "dd.MM.yyyy HH:mm", {
|
||||
locale: de,
|
||||
@@ -134,9 +199,42 @@ export default async function AdminNewsPage() {
|
||||
· {announcement.author.firstName}{" "}
|
||||
{announcement.author.lastName}
|
||||
</p>
|
||||
{announcement.editedAt && (
|
||||
<p className="mt-1 text-xs text-[var(--accent-deep)]">
|
||||
Aktualisiert am{" "}
|
||||
{format(announcement.editedAt, "dd.MM.yyyy HH:mm")}
|
||||
</p>
|
||||
)}
|
||||
<Button asChild variant="outline" size="sm" className="mt-3">
|
||||
<Link href={`/dashboard/admin/news/${announcement.id}`}>
|
||||
Bearbeiten & Versand
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<nav
|
||||
aria-label="Beiträge durchblättern"
|
||||
className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-[var(--hairline)] pt-4"
|
||||
>
|
||||
<div>
|
||||
{page > 1 && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={href(page - 1)}>Neuere Beiträge</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-[var(--ink-soft)]">
|
||||
Seite {page} · {total} {total === 1 ? "Beitrag" : "Beiträge"}
|
||||
</span>
|
||||
<div>
|
||||
{page * pageSize < total && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={href(page + 1)}>Ältere Beiträge</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RoleChangeButton,
|
||||
type RolePerson,
|
||||
} from "../../zugriffsrechte/role-change-button";
|
||||
import { useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, Pencil, Search, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -29,18 +35,115 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
import { createEducator, updateEducator, deleteEducator } from "../actions";
|
||||
import {
|
||||
createEducator,
|
||||
updateEducator,
|
||||
deleteEducator,
|
||||
resendEducatorInvitation,
|
||||
} from "../actions";
|
||||
|
||||
type EducatorDto = {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
active: boolean;
|
||||
hasProfile: boolean;
|
||||
email: string | null;
|
||||
verified: boolean;
|
||||
inviteFailed: boolean;
|
||||
account: RolePerson | null;
|
||||
};
|
||||
|
||||
export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
function AccessFields({
|
||||
prefix,
|
||||
today,
|
||||
educator,
|
||||
}: {
|
||||
prefix: string;
|
||||
today: string;
|
||||
educator?: EducatorDto;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${prefix}-email`}>E-Mail-Adresse</Label>
|
||||
<Input
|
||||
id={`${prefix}-email`}
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
defaultValue={educator?.email ?? ""}
|
||||
readOnly={!!educator?.email}
|
||||
required
|
||||
maxLength={254}
|
||||
/>
|
||||
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||
{educator?.email
|
||||
? "Diese Adresse gehört zum verknüpften Account."
|
||||
: "An diese Adresse senden wir den persönlichen Aktivierungslink."}
|
||||
</p>
|
||||
</div>
|
||||
{!educator?.hasProfile && (
|
||||
<div className="grid gap-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${prefix}-employmentStart`}>
|
||||
Beschäftigungsbeginn
|
||||
</Label>
|
||||
<Input
|
||||
id={`${prefix}-employmentStart`}
|
||||
name="employmentStart"
|
||||
type="date"
|
||||
required
|
||||
min="2000-01-01"
|
||||
max="2100-12-31"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${prefix}-trackingStart`}>
|
||||
Start der Zeiterfassung
|
||||
</Label>
|
||||
<Input
|
||||
id={`${prefix}-trackingStart`}
|
||||
name="trackingStart"
|
||||
type="date"
|
||||
defaultValue={today}
|
||||
required
|
||||
min="2000-01-01"
|
||||
max="2100-12-31"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-[var(--ink-soft)] sm:col-span-2">
|
||||
Ab diesem Datum werden Arbeitszeiten im Kita-Planer erfasst.
|
||||
Arbeitstage, Urlaub und vorhandene Überstunden richtest du
|
||||
anschließend im Personalprofil ein.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErzieherList({
|
||||
currentUserId,
|
||||
educators,
|
||||
today,
|
||||
}: {
|
||||
currentUserId: string;
|
||||
educators: EducatorDto[];
|
||||
today: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [search, setSearch] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
const filtered = educators.filter((ed) =>
|
||||
`${ed.firstName} ${ed.lastName} ${ed.email ?? ""}`
|
||||
.toLocaleLowerCase("de")
|
||||
.includes(search.toLocaleLowerCase("de")),
|
||||
);
|
||||
const [openCreate, setOpenCreate] = useState(false);
|
||||
const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(null);
|
||||
const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(
|
||||
null,
|
||||
);
|
||||
const [educatorToDelete, setEducatorToDelete] = useState<EducatorDto | null>(
|
||||
null,
|
||||
);
|
||||
@@ -51,15 +154,24 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
firstName: formData.get("firstName") as string,
|
||||
lastName: formData.get("lastName") as string,
|
||||
active: formData.get("active") === "on",
|
||||
email: formData.get("email"),
|
||||
employmentStart: formData.get("employmentStart"),
|
||||
trackingStart: formData.get("trackingStart"),
|
||||
};
|
||||
|
||||
setFormError("");
|
||||
startTransition(async () => {
|
||||
const res = await createEducator(data);
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
setFormError(res.error);
|
||||
} else {
|
||||
toast.success("ErzieherIn angelegt.");
|
||||
toast.success(res.message ?? "Fachkraft und Personalprofil angelegt.");
|
||||
if (res.warning) toast.warning(res.warning, { duration: 12000 });
|
||||
setOpenCreate(false);
|
||||
if (res.educatorId)
|
||||
router.push(
|
||||
`/dashboard/personal?tab=verwaltung&educator=${res.educatorId}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -70,19 +182,35 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
firstName: formData.get("firstName") as string,
|
||||
lastName: formData.get("lastName") as string,
|
||||
active: formData.get("active") === "on",
|
||||
email: formData.get("email"),
|
||||
employmentStart: formData.get("employmentStart"),
|
||||
trackingStart: formData.get("trackingStart"),
|
||||
};
|
||||
|
||||
setFormError("");
|
||||
startTransition(async () => {
|
||||
const res = await updateEducator(editingEducator.id, data);
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
setFormError(res.error);
|
||||
} else {
|
||||
toast.success("Daten aktualisiert.");
|
||||
toast.success(res.message ?? "Daten aktualisiert.");
|
||||
if (res.warning) toast.warning(res.warning, { duration: 12000 });
|
||||
setEditingEducator(null);
|
||||
router.refresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleResend = (educator: EducatorDto) => {
|
||||
startTransition(async () => {
|
||||
const res = await resendEducatorInvitation(educator.id);
|
||||
if (res.error) toast.error(res.error);
|
||||
else if (res.warning) toast.warning(res.warning, { duration: 12000 });
|
||||
else toast.success(res.message ?? "Einladung erneuert.");
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (educator: EducatorDto) => {
|
||||
startTransition(async () => {
|
||||
const res = await deleteEducator(educator.id);
|
||||
@@ -100,24 +228,44 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
|
||||
<Input placeholder="Name suchen" className="pl-9" />
|
||||
<Input
|
||||
placeholder="Name oder E-Mail suchen"
|
||||
aria-label="Team durchsuchen"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Dialog open={openCreate} onOpenChange={setOpenCreate}>
|
||||
<Dialog
|
||||
open={openCreate}
|
||||
onOpenChange={(open) => {
|
||||
if (!isPending) {
|
||||
setOpenCreate(open);
|
||||
setFormError("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4 [stroke-width:1.7]" />
|
||||
ErzieherIn anlegen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form action={handleCreate}>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleCreate(new FormData(event.currentTarget));
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neue(n) ErzieherIn anlegen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Erfasse die Stammdaten des Kita-Personals.
|
||||
Lege Stammdaten, Personalprofil und App-Zugang gemeinsam an.
|
||||
Die Fachkraft erhält ihren Aktivierungslink per E-Mail.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<fieldset disabled={isPending} className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="firstName">Vorname</Label>
|
||||
<Input id="firstName" name="firstName" required />
|
||||
@@ -126,14 +274,31 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
<Label htmlFor="lastName">Nachname</Label>
|
||||
<Input id="lastName" name="lastName" required />
|
||||
</div>
|
||||
<AccessFields prefix="create" today={today} />
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch id="active" name="active" defaultChecked />
|
||||
<Label htmlFor="active">Aktiv (Arbeitet aktuell hier)</Label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
{formError && (
|
||||
<p role="alert" className="mb-4 text-sm text-[var(--danger)]">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpenCreate(false)}>Abbrechen</Button>
|
||||
<Button type="submit" disabled={isPending}>Speichern</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isPending}
|
||||
onClick={() => setOpenCreate(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending
|
||||
? "Wird angelegt …"
|
||||
: "Fachkraft anlegen und einladen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
@@ -153,91 +318,196 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 pb-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-6">Vorname</TableHead>
|
||||
<TableHead>Nachname</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="pr-6 text-right">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{educators.length === 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-32 text-center">
|
||||
<div className="flex max-h-[240px] min-h-[200px] flex-col items-center justify-center">
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Plus className="h-6 w-6 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[var(--ink)]">
|
||||
Keine Einträge gefunden
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Lege die erste Person im Team an.
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableHead className="pl-6">Vorname</TableHead>
|
||||
<TableHead>Nachname</TableHead>
|
||||
<TableHead>E-Mail / App-Zugang</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="pr-6 text-right">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
educators.map((ed) => (
|
||||
<TableRow key={ed.id}>
|
||||
<TableCell className="pl-6 font-medium">{ed.firstName}</TableCell>
|
||||
<TableCell>{ed.lastName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ed.active ? "success" : "secondary"}>
|
||||
{ed.active ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="pr-6 text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => setEditingEducator(ed)}>
|
||||
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||
onClick={() => setEducatorToDelete(ed)}
|
||||
disabled={isPending}
|
||||
aria-label={`${ed.firstName} ${ed.lastName} löschen`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-32 text-center">
|
||||
<div className="flex max-h-[240px] min-h-[200px] flex-col items-center justify-center">
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Plus className="h-6 w-6 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[var(--ink)]">
|
||||
Keine Einträge gefunden
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Lege die erste Person im Team an.
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
filtered.map((ed) => (
|
||||
<TableRow key={ed.id}>
|
||||
<TableCell className="pl-6 font-medium">
|
||||
{ed.firstName}
|
||||
</TableCell>
|
||||
<TableCell>{ed.lastName}</TableCell>
|
||||
<TableCell>
|
||||
<p className="mb-1 text-sm">
|
||||
{ed.email ?? "Noch keine E-Mail hinterlegt"}
|
||||
</p>
|
||||
<Badge variant={ed.verified ? "success" : "secondary"}>
|
||||
{ed.verified
|
||||
? "Account aktiviert"
|
||||
: ed.inviteFailed
|
||||
? "Mailversand fehlgeschlagen"
|
||||
: ed.email
|
||||
? "Einladung ausstehend"
|
||||
: "Zugang fehlt"}
|
||||
</Badge>
|
||||
{ed.email && !ed.verified && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={() => handleResend(ed)}
|
||||
aria-label={`Einladung für ${ed.firstName} ${ed.lastName} erneut senden`}
|
||||
className="mt-1 block"
|
||||
>
|
||||
Einladung erneut senden
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ed.active ? "success" : "secondary"}>
|
||||
{ed.active ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="pr-6 text-right">
|
||||
{ed.account ? (
|
||||
<span className="mr-2 inline-block">
|
||||
<RoleChangeButton
|
||||
person={ed.account}
|
||||
ownAccount={ed.account.id === currentUserId}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="mr-2"
|
||||
>
|
||||
<Link
|
||||
href={`/dashboard/personal?tab=verwaltung&educator=${ed.id}`}
|
||||
>
|
||||
Personalprofil
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setFormError("");
|
||||
setEditingEducator(ed);
|
||||
}}
|
||||
disabled={isPending}
|
||||
aria-label={`${ed.firstName} ${ed.lastName} bearbeiten`}
|
||||
>
|
||||
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||
onClick={() => setEducatorToDelete(ed)}
|
||||
disabled={isPending}
|
||||
aria-label={`${ed.firstName} ${ed.lastName} löschen`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={!!editingEducator} onOpenChange={(open) => !open && setEditingEducator(null)}>
|
||||
<DialogContent>
|
||||
<form action={handleUpdate}>
|
||||
<Dialog
|
||||
open={!!editingEducator}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isPending) setEditingEducator(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleUpdate(new FormData(event.currentTarget));
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>ErzieherIn bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editingEducator && (
|
||||
<div className="grid gap-4 py-4">
|
||||
<fieldset disabled={isPending} className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-firstName">Vorname</Label>
|
||||
<Input id="edit-firstName" name="firstName" defaultValue={editingEducator.firstName} required />
|
||||
<Input
|
||||
id="edit-firstName"
|
||||
name="firstName"
|
||||
defaultValue={editingEducator.firstName}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-lastName">Nachname</Label>
|
||||
<Input id="edit-lastName" name="lastName" defaultValue={editingEducator.lastName} required />
|
||||
<Input
|
||||
id="edit-lastName"
|
||||
name="lastName"
|
||||
defaultValue={editingEducator.lastName}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<AccessFields
|
||||
prefix="edit"
|
||||
today={today}
|
||||
educator={editingEducator}
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch id="edit-active" name="active" defaultChecked={editingEducator.active} />
|
||||
<Switch
|
||||
id="edit-active"
|
||||
name="active"
|
||||
defaultChecked={editingEducator.active}
|
||||
/>
|
||||
<Label htmlFor="edit-active">Aktiv</Label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
{formError && (
|
||||
<p role="alert" className="mb-4 text-sm text-[var(--danger)]">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setEditingEducator(null)}>Abbrechen</Button>
|
||||
<Button type="submit" disabled={isPending}>Aktualisieren</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isPending}
|
||||
onClick={() => setEditingEducator(null)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending
|
||||
? "Wird gespeichert …"
|
||||
: editingEducator?.email
|
||||
? "Aktualisieren"
|
||||
: "Speichern und einladen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -6,74 +6,23 @@ import { z } from "zod";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { staffAction } from "../personal/actions";
|
||||
|
||||
const educatorSchema = z.object({
|
||||
firstName: z.string().min(1, "Vorname ist erforderlich").max(100).trim(),
|
||||
lastName: z.string().min(1, "Nachname ist erforderlich").max(100).trim(),
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
// The staff service creates the person, profile, account and mail job in one transaction.
|
||||
export async function createEducator(rawPayload: unknown) {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
const parsed = educatorSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.educator.create({
|
||||
data: {
|
||||
kitaId,
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
active: parsed.data.active,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/erzieher");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Anlegen:", error);
|
||||
return { error: "Ein Fehler ist aufgetreten." };
|
||||
}
|
||||
const parsed = z.record(z.string(), z.unknown()).safeParse(rawPayload);
|
||||
if (!parsed.success) return { error: "Ungültige Eingabedaten." };
|
||||
return staffAction("educatorCreate", parsed.data);
|
||||
}
|
||||
|
||||
export async function updateEducator(id: string, rawPayload: unknown) {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
const parsed = educatorSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.educator.update({
|
||||
where: {
|
||||
id,
|
||||
kitaId,
|
||||
},
|
||||
data: {
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
active: parsed.data.active,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/erzieher");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Aktualisieren:", error);
|
||||
return { error: "Ein Fehler ist aufgetreten." };
|
||||
}
|
||||
const parsed = z.record(z.string(), z.unknown()).safeParse(rawPayload);
|
||||
if (!parsed.success) return { error: "Ungültige Eingabedaten." };
|
||||
return staffAction("educatorUpdate", { ...parsed.data, educatorId: id });
|
||||
}
|
||||
|
||||
export async function resendEducatorInvitation(educatorId: string) {
|
||||
return staffAction("educatorInvite", { educatorId });
|
||||
}
|
||||
|
||||
export async function deleteEducator(id: string) {
|
||||
@@ -82,15 +31,23 @@ export async function deleteEducator(id: string) {
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const profile = await prisma.staffProfile.findFirst({
|
||||
where: { educatorId: id, kitaId },
|
||||
});
|
||||
if (profile)
|
||||
return {
|
||||
error:
|
||||
"Diese Fachkraft hat ein Personalprofil. Bitte auf inaktiv setzen, damit Zeiten und Konten erhalten bleiben.",
|
||||
};
|
||||
await prisma.educator.delete({
|
||||
where: {
|
||||
id,
|
||||
kitaId,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
revalidatePath("/dashboard/erzieher");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { UserRole } from "@prisma/client";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { todayKey } from "@/lib/staff/calculations";
|
||||
import { ErzieherList } from "./_components/erzieher-list";
|
||||
|
||||
export const metadata = { title: "ErzieherInnen · Kita-Planer" };
|
||||
@@ -17,6 +18,26 @@ export default async function ErzieherPage() {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
active: true,
|
||||
staffProfile: {
|
||||
select: {
|
||||
id: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
familyId: true,
|
||||
email: true,
|
||||
emailVerifiedAt: true,
|
||||
},
|
||||
},
|
||||
emails: {
|
||||
where: { inviteToken: { not: null }, skippedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
select: { failed: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||
});
|
||||
@@ -36,8 +57,8 @@ export default async function ErzieherPage() {
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Stammdaten des Kita-Personals für interne Planung und spätere
|
||||
Notdienst-Alarmierung.
|
||||
Stammdaten des Teams und Zugang zu Beschäftigungsprofilen,
|
||||
Urlaubsansprüchen und Arbeitszeitmodellen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
@@ -51,7 +72,26 @@ export default async function ErzieherPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ErzieherList educators={educators} />
|
||||
<ErzieherList
|
||||
currentUserId={session.user.id}
|
||||
today={todayKey()}
|
||||
educators={educators.map(({ staffProfile, ...educator }) => ({
|
||||
...educator,
|
||||
hasProfile: !!staffProfile,
|
||||
account: staffProfile?.user
|
||||
? {
|
||||
id: staffProfile.user.id,
|
||||
name: `${educator.firstName} ${educator.lastName}`,
|
||||
role: staffProfile.user.role,
|
||||
familyId: staffProfile.user.familyId,
|
||||
hasStaffProfile: true,
|
||||
}
|
||||
: null,
|
||||
email: staffProfile?.user?.email ?? null,
|
||||
verified: !!staffProfile?.user?.emailVerifiedAt,
|
||||
inviteFailed: !!staffProfile?.emails[0]?.failed,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { UserRole } from "@prisma/client";
|
||||
import { RoleChangeButton } from "../../zugriffsrechte/role-change-button";
|
||||
import { RoleBadge } from "@/components/ui/role-badge";
|
||||
import { useState, useTransition } from "react";
|
||||
import { LayoutGrid, Mail, Search, Table, Users } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { resendParentInvite } from "../actions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -17,6 +22,8 @@ import {
|
||||
import { AddFamilyDialog } from "../add-family-dialog";
|
||||
import { DutyManager } from "../duty-manager";
|
||||
import { EditFamilyDialog } from "../edit-family-dialog";
|
||||
import { InvitationStatus } from "../invitation-status";
|
||||
import type { FamilyInvitationView } from "@/lib/family-invitation-view";
|
||||
|
||||
interface FamilyUser {
|
||||
id: string;
|
||||
@@ -24,6 +31,9 @@ interface FamilyUser {
|
||||
lastName: string;
|
||||
email: string;
|
||||
emailVerifiedAt: Date | null;
|
||||
invitation: FamilyInvitationView;
|
||||
role: UserRole;
|
||||
staffProfile: { id: string } | null;
|
||||
dutyAssignments: {
|
||||
id: string;
|
||||
dutyId: string;
|
||||
@@ -55,12 +65,14 @@ interface DutyData {
|
||||
}
|
||||
|
||||
interface FamiliesListClientProps {
|
||||
currentUserId: string;
|
||||
initialFamilies: FamilyData[];
|
||||
allDuties: DutyData[];
|
||||
canManageDuties: boolean;
|
||||
}
|
||||
|
||||
export function FamiliesListClient({
|
||||
currentUserId,
|
||||
initialFamilies,
|
||||
allDuties,
|
||||
canManageDuties,
|
||||
@@ -80,7 +92,7 @@ export function FamiliesListClient({
|
||||
(user) =>
|
||||
user.firstName.toLowerCase().includes(q) ||
|
||||
user.lastName.toLowerCase().includes(q) ||
|
||||
user.email.toLowerCase().includes(q)
|
||||
user.email.toLowerCase().includes(q),
|
||||
);
|
||||
if (matchesParent) return true;
|
||||
|
||||
@@ -88,20 +100,20 @@ export function FamiliesListClient({
|
||||
const matchesChild = family.children.some(
|
||||
(child) =>
|
||||
child.firstName.toLowerCase().includes(q) ||
|
||||
child.lastName.toLowerCase().includes(q)
|
||||
child.lastName.toLowerCase().includes(q),
|
||||
);
|
||||
return matchesChild;
|
||||
});
|
||||
|
||||
const childCount = filteredFamilies.reduce(
|
||||
(acc, family) => acc + family.children.length,
|
||||
0
|
||||
0,
|
||||
);
|
||||
|
||||
const pendingInviteCount = filteredFamilies.reduce(
|
||||
(acc, family) =>
|
||||
acc + family.users.filter((user) => !user.emailVerifiedAt).length,
|
||||
0
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -118,14 +130,17 @@ export function FamiliesListClient({
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort pflegen.
|
||||
Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort
|
||||
pflegen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setViewMode(viewMode === "table" ? "cards" : "table")}
|
||||
onClick={() =>
|
||||
setViewMode(viewMode === "table" ? "cards" : "table")
|
||||
}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{viewMode === "table" ? (
|
||||
@@ -147,7 +162,9 @@ export function FamiliesListClient({
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
<span className="text-[var(--ink)] tabular-nums">{filteredFamilies.length}</span>
|
||||
<span className="text-[var(--ink)] tabular-nums">
|
||||
{filteredFamilies.length}
|
||||
</span>
|
||||
Familien
|
||||
</span>
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
@@ -155,7 +172,9 @@ export function FamiliesListClient({
|
||||
Kinder
|
||||
</span>
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
<span className="text-[var(--ink)] tabular-nums">{pendingInviteCount}</span>
|
||||
<span className="text-[var(--ink)] tabular-nums">
|
||||
{pendingInviteCount}
|
||||
</span>
|
||||
Offene Einladungen
|
||||
</span>
|
||||
</div>
|
||||
@@ -191,7 +210,8 @@ export function FamiliesListClient({
|
||||
<div>
|
||||
<CardTitle>Haushalte</CardTitle>
|
||||
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
||||
{filteredFamilies.length} {filteredFamilies.length === 1 ? "Familie" : "Familien"} ·{" "}
|
||||
{filteredFamilies.length}{" "}
|
||||
{filteredFamilies.length === 1 ? "Familie" : "Familien"} ·{" "}
|
||||
{childCount} Kinder · {pendingInviteCount} offene Einladungen
|
||||
</p>
|
||||
</div>
|
||||
@@ -213,7 +233,7 @@ export function FamiliesListClient({
|
||||
<TableBody>
|
||||
{filteredFamilies.map((family) => {
|
||||
const hasPendingInvite = family.users.some(
|
||||
(user) => !user.emailVerifiedAt
|
||||
(user) => !user.emailVerifiedAt,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -245,8 +265,29 @@ export function FamiliesListClient({
|
||||
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
|
||||
<span className="break-words [overflow-wrap:anywhere]">
|
||||
{parent.email}
|
||||
{!parent.emailVerifiedAt && (
|
||||
<ResendInviteButton userId={parent.id} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<InvitationStatus
|
||||
userId={parent.id}
|
||||
name={`${parent.firstName} ${parent.lastName}`}
|
||||
invitation={parent.invitation}
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<RoleBadge role={parent.role} />
|
||||
<RoleChangeButton
|
||||
ownAccount={parent.id === currentUserId}
|
||||
person={{
|
||||
id: parent.id,
|
||||
name: `${parent.firstName} ${parent.lastName}`,
|
||||
role: parent.role,
|
||||
familyId: family.id,
|
||||
hasStaffProfile: !!parent.staffProfile,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -270,7 +311,9 @@ export function FamiliesListClient({
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={hasPendingInvite ? "warning" : "success"}>
|
||||
<Badge
|
||||
variant={hasPendingInvite ? "warning" : "success"}
|
||||
>
|
||||
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
@@ -285,6 +328,7 @@ export function FamiliesListClient({
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
email: parent.email,
|
||||
invitation: parent.invitation,
|
||||
})),
|
||||
children: family.children,
|
||||
}}
|
||||
@@ -315,7 +359,7 @@ export function FamiliesListClient({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredFamilies.map((family) => {
|
||||
const hasPendingInvite = family.users.some(
|
||||
(user) => !user.emailVerifiedAt
|
||||
(user) => !user.emailVerifiedAt,
|
||||
);
|
||||
return (
|
||||
<Card key={family.id} className="flex flex-col h-full">
|
||||
@@ -325,9 +369,12 @@ export function FamiliesListClient({
|
||||
{family.name.slice(0, 1)}
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg font-semibold">{family.name}</CardTitle>
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
{family.name}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
{family.users.length} Elternteil{family.users.length === 1 ? "" : "e"}
|
||||
{family.users.length} Elternteil
|
||||
{family.users.length === 1 ? "" : "e"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -351,8 +398,29 @@ export function FamiliesListClient({
|
||||
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
|
||||
<span className="break-words [overflow-wrap:anywhere]">
|
||||
{parent.email}
|
||||
{!parent.emailVerifiedAt && (
|
||||
<ResendInviteButton userId={parent.id} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<InvitationStatus
|
||||
userId={parent.id}
|
||||
name={`${parent.firstName} ${parent.lastName}`}
|
||||
invitation={parent.invitation}
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<RoleBadge role={parent.role} />
|
||||
<RoleChangeButton
|
||||
ownAccount={parent.id === currentUserId}
|
||||
person={{
|
||||
id: parent.id,
|
||||
name: `${parent.firstName} ${parent.lastName}`,
|
||||
role: parent.role,
|
||||
familyId: family.id,
|
||||
hasStaffProfile: !!parent.staffProfile,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -389,6 +457,7 @@ export function FamiliesListClient({
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
email: parent.email,
|
||||
invitation: parent.invitation,
|
||||
})),
|
||||
children: family.children,
|
||||
}}
|
||||
@@ -416,3 +485,24 @@ export function FamiliesListClient({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResendInviteButton({ userId }: { userId: string }) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
const result = await resendParentInvite(userId);
|
||||
if (result.error) toast.error(result.error);
|
||||
else toast.success("Einladung erneut gesendet.");
|
||||
})
|
||||
}
|
||||
>
|
||||
{pending ? "Wird gesendet…" : "Einladung erneut senden"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,60 @@
|
||||
"use server";
|
||||
|
||||
import crypto from "crypto";
|
||||
import { createElement } from "react";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { Prisma, UserRole } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { InviteEmail } from "@/emails/InviteEmail";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
deliverFamilyInvitations,
|
||||
queueFamilyInvitation,
|
||||
resendFamilyInvitation,
|
||||
} from "@/lib/family-invitations";
|
||||
import {
|
||||
assertKitaAdminRemains,
|
||||
lockKitaAccounts,
|
||||
} from "@/lib/kita-admin-guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const INVITE_TOKEN_TTL_DAYS = 7;
|
||||
const BASE_URL =
|
||||
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
||||
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
|
||||
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||
|
||||
const childSchema = z.object({
|
||||
firstName: z.string().min(1, "Vorname des Kindes fehlt.").max(100).trim(),
|
||||
lastName: z.string().min(1, "Nachname des Kindes fehlt.").max(100).trim(),
|
||||
firstName: z.string().trim().min(1, "Vorname des Kindes fehlt.").max(100),
|
||||
lastName: z.string().trim().min(1, "Nachname des Kindes fehlt.").max(100),
|
||||
});
|
||||
|
||||
const parentInputSchema = z.object({
|
||||
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
|
||||
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
|
||||
firstName: z.string().trim().min(1, "Vorname ist erforderlich.").max(100),
|
||||
lastName: z.string().trim().min(1, "Nachname ist erforderlich.").max(100),
|
||||
email: z
|
||||
.string()
|
||||
.email("Bitte eine gültige E-Mail-Adresse angeben.")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.trim(),
|
||||
.email("Bitte eine gültige E-Mail-Adresse angeben."),
|
||||
});
|
||||
|
||||
const addFamilySchema = z.object({
|
||||
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
|
||||
parent1FirstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
|
||||
parent1LastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
|
||||
familyName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Familienname ist erforderlich.")
|
||||
.max(120),
|
||||
parent1FirstName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Vorname ist erforderlich.")
|
||||
.max(100),
|
||||
parent1LastName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Nachname ist erforderlich.")
|
||||
.max(100),
|
||||
parent1Email: z
|
||||
.string()
|
||||
.email("Bitte eine gültige E-Mail-Adresse angeben.")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.trim(),
|
||||
.email("Bitte eine gültige E-Mail-Adresse angeben."),
|
||||
parent2FirstName: z.string().max(100).trim().optional(),
|
||||
parent2LastName: z.string().max(100).trim().optional(),
|
||||
parent2Email: z.string().trim().optional(),
|
||||
@@ -51,12 +67,19 @@ const addFamilySchema = z.object({
|
||||
|
||||
const updateFamilySchema = z.object({
|
||||
familyId: z.string().min(1),
|
||||
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
|
||||
parents: z.array(
|
||||
parentInputSchema.extend({
|
||||
id: z.string().min(1),
|
||||
}),
|
||||
).min(1).max(2),
|
||||
familyName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Familienname ist erforderlich.")
|
||||
.max(120),
|
||||
parents: z
|
||||
.array(
|
||||
parentInputSchema.extend({
|
||||
id: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(2),
|
||||
newParents: z.array(parentInputSchema).max(1).default([]),
|
||||
children: z
|
||||
.array(
|
||||
@@ -82,12 +105,7 @@ export type AddFamilyState = {
|
||||
_form?: string[];
|
||||
};
|
||||
success?: boolean;
|
||||
};
|
||||
|
||||
type InviteJob = {
|
||||
to: string;
|
||||
parentName: string;
|
||||
inviteUrl: string;
|
||||
warning?: string;
|
||||
};
|
||||
|
||||
function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
|
||||
@@ -114,32 +132,14 @@ function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
|
||||
return { ok: true as const, parent: parsed.data };
|
||||
}
|
||||
|
||||
function createInviteToken() {
|
||||
const token = crypto.randomUUID();
|
||||
const expires = new Date(
|
||||
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000,
|
||||
);
|
||||
return { token, expires, inviteUrl: `${BASE_URL}/invite/${token}` };
|
||||
}
|
||||
|
||||
async function sendInviteJobs(kitaName: string, jobs: InviteJob[]) {
|
||||
for (const job of jobs) {
|
||||
const result = await sendAppEmail({
|
||||
to: job.to,
|
||||
subject: `Einladung zu ${kitaName} im Kita-Planer`,
|
||||
react: createElement(InviteEmail, {
|
||||
parentName: job.parentName,
|
||||
kitaName,
|
||||
inviteLink: job.inviteUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return result.error;
|
||||
}
|
||||
async function sendInviteJobs(ids: string[]) {
|
||||
try {
|
||||
const result = await deliverFamilyInvitations(ids);
|
||||
if (!result.failed) return undefined;
|
||||
} catch {
|
||||
// A committed family remains successful even if delivery/status storage fails.
|
||||
}
|
||||
|
||||
return null;
|
||||
return "Familie gespeichert. Mindestens eine Einladung konnte nicht versendet werden. Den Versand kannst du beim jeweiligen Elternteil erneut versuchen.";
|
||||
}
|
||||
|
||||
export async function addFamilyAction(
|
||||
@@ -153,6 +153,13 @@ export async function addFamilyAction(
|
||||
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
|
||||
}
|
||||
|
||||
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed)
|
||||
return {
|
||||
errors: {
|
||||
_form: ["Zu viele Einladungen. Bitte versuche es später erneut."],
|
||||
},
|
||||
};
|
||||
|
||||
const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData));
|
||||
if (!parsedFamily.success) {
|
||||
return { errors: parsedFamily.error.flatten().fieldErrors };
|
||||
@@ -182,11 +189,6 @@ export async function addFamilyAction(
|
||||
childrenRaw.push(parsed.data);
|
||||
}
|
||||
|
||||
const mailConfigError = getAppEmailConfigError();
|
||||
if (mailConfigError) {
|
||||
return { errors: { _form: [mailConfigError] } };
|
||||
}
|
||||
|
||||
const kita = await prisma.kita.findUnique({
|
||||
where: { id: kitaId },
|
||||
select: { name: true },
|
||||
@@ -208,12 +210,14 @@ export async function addFamilyAction(
|
||||
if (emailSet.size !== parents.length) {
|
||||
return {
|
||||
errors: {
|
||||
_form: ["Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein."],
|
||||
_form: [
|
||||
"Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein.",
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const inviteJobs: InviteJob[] = [];
|
||||
const inviteJobs: string[] = [];
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
@@ -236,19 +240,12 @@ export async function addFamilyAction(
|
||||
role: UserRole.ELTERN,
|
||||
},
|
||||
});
|
||||
const invite = createInviteToken();
|
||||
inviteJobs.push({
|
||||
to: parent.email,
|
||||
parentName: `${parent.firstName} ${parent.lastName}`,
|
||||
inviteUrl: invite.inviteUrl,
|
||||
});
|
||||
await tx.verificationToken.create({
|
||||
data: {
|
||||
identifier: createdParent.id,
|
||||
token: invite.token,
|
||||
expires: invite.expires,
|
||||
},
|
||||
});
|
||||
const invite = await queueFamilyInvitation(
|
||||
tx,
|
||||
createdParent.id,
|
||||
session.user.id,
|
||||
);
|
||||
inviteJobs.push(invite.id);
|
||||
}
|
||||
|
||||
await tx.child.createMany({
|
||||
@@ -271,26 +268,25 @@ export async function addFamilyAction(
|
||||
},
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const emailError = await sendInviteJobs(kita.name, inviteJobs);
|
||||
if (emailError) {
|
||||
console.error("Familie konnte nicht angelegt werden:", err);
|
||||
return {
|
||||
errors: {
|
||||
_form: [
|
||||
`Familie wurde angelegt, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`,
|
||||
"Familie konnte nicht angelegt werden. Bitte erneut versuchen.",
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const warning = await sendInviteJobs(inviteJobs);
|
||||
revalidatePath("/dashboard/families");
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
return { success: true, warning };
|
||||
}
|
||||
|
||||
export async function updateFamilyAction(rawPayload: unknown) {
|
||||
export async function updateFamilyAction(
|
||||
rawPayload: unknown,
|
||||
): Promise<{ success?: boolean; error?: string; warning?: string }> {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
const kitaId = session.user.kitaId;
|
||||
|
||||
@@ -313,24 +309,25 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
removedChildIds,
|
||||
} = parsed.data;
|
||||
|
||||
const parentEmails = [...parents, ...newParents].map((parent) => parent.email);
|
||||
const parentEmails = [...parents, ...newParents].map(
|
||||
(parent) => parent.email,
|
||||
);
|
||||
if (new Set(parentEmails).size !== parentEmails.length) {
|
||||
return { error: "Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein." };
|
||||
return {
|
||||
error: "Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein.",
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
return { error: "Eine Familie benötigt mindestens ein Kind." };
|
||||
}
|
||||
|
||||
if (newParents.length > 0) {
|
||||
const mailConfigError = getAppEmailConfigError();
|
||||
if (mailConfigError) {
|
||||
return { error: mailConfigError };
|
||||
}
|
||||
}
|
||||
|
||||
const kita = await prisma.kita.findUnique({
|
||||
where: { id: kitaId },
|
||||
select: { name: true },
|
||||
@@ -340,14 +337,14 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
return { error: "Kita wurde nicht gefunden." };
|
||||
}
|
||||
|
||||
const inviteJobs: InviteJob[] = [];
|
||||
const inviteJobs: string[] = [];
|
||||
|
||||
try {
|
||||
const existingFamily = await prisma.family.findFirst({
|
||||
where: { id: familyId, kitaId },
|
||||
select: {
|
||||
id: true,
|
||||
users: { select: { id: true } },
|
||||
users: { select: { id: true, email: true } },
|
||||
children: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
@@ -356,11 +353,31 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
return { error: "Familie wurde nicht gefunden." };
|
||||
}
|
||||
|
||||
const existingParentIds = new Set(existingFamily.users.map((user) => user.id));
|
||||
const existingChildIds = new Set(existingFamily.children.map((child) => child.id));
|
||||
const needsEmail =
|
||||
newParents.length > 0 ||
|
||||
parents.some(
|
||||
(parent) =>
|
||||
existingFamily.users.find((user) => user.id === parent.id)?.email !==
|
||||
parent.email,
|
||||
);
|
||||
if (needsEmail) {
|
||||
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed)
|
||||
return {
|
||||
error: "Zu viele Einladungen. Bitte versuche es später erneut.",
|
||||
};
|
||||
}
|
||||
|
||||
const existingParentIds = new Set(
|
||||
existingFamily.users.map((user) => user.id),
|
||||
);
|
||||
const existingChildIds = new Set(
|
||||
existingFamily.children.map((child) => child.id),
|
||||
);
|
||||
|
||||
if (!parents.every((parent) => existingParentIds.has(parent.id))) {
|
||||
return { error: "Mindestens ein Elternteil gehört nicht zu dieser Familie." };
|
||||
return {
|
||||
error: "Mindestens ein Elternteil gehört nicht zu dieser Familie.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!children.every((child) => existingChildIds.has(child.id))) {
|
||||
@@ -375,75 +392,109 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
return { error: "Pro Familie sind maximal zwei Elternteile vorgesehen." };
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.family.update({
|
||||
where: { id: familyId },
|
||||
data: { name: familyName },
|
||||
});
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await lockKitaAccounts(tx, kitaId);
|
||||
const changedEmails = parents
|
||||
.filter(
|
||||
(parent) =>
|
||||
existingFamily.users.find((user) => user.id === parent.id)
|
||||
?.email !== parent.email,
|
||||
)
|
||||
.map((parent) => parent.id);
|
||||
await assertKitaAdminRemains(tx, kitaId, changedEmails);
|
||||
await assertChildrenRemovable(tx, kitaId, familyId, removedChildIds);
|
||||
const removed = await tx.child.count({
|
||||
where: { kitaId, familyId, id: { in: removedChildIds } },
|
||||
});
|
||||
if (removed !== removedChildIds.length)
|
||||
throw new UserFacingError(
|
||||
"Mindestens ein Kind gehört nicht zu dieser Familie.",
|
||||
);
|
||||
await tx.family.update({
|
||||
where: { id: familyId },
|
||||
data: { name: familyName },
|
||||
});
|
||||
|
||||
for (const parent of parents) {
|
||||
await tx.user.update({
|
||||
where: { id: parent.id },
|
||||
data: {
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
email: parent.email,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const parent of parents) {
|
||||
const current = await tx.user.findFirst({
|
||||
where: { id: parent.id, kitaId, familyId },
|
||||
select: { email: true },
|
||||
});
|
||||
if (!current) throw new Error("Parent assignment changed");
|
||||
const emailChanged = current.email !== parent.email;
|
||||
await tx.user.update({
|
||||
where: { id: parent.id, kitaId, familyId },
|
||||
data: {
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
email: parent.email,
|
||||
...(emailChanged
|
||||
? {
|
||||
passwordHash: "",
|
||||
emailVerifiedAt: null,
|
||||
sessionVersion: { increment: 1 },
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
if (emailChanged) {
|
||||
const invite = await queueFamilyInvitation(
|
||||
tx,
|
||||
parent.id,
|
||||
session.user.id,
|
||||
);
|
||||
inviteJobs.push(invite.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const parent of newParents) {
|
||||
const createdParent = await tx.user.create({
|
||||
data: {
|
||||
kitaId,
|
||||
familyId,
|
||||
email: parent.email,
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
passwordHash: "",
|
||||
role: UserRole.ELTERN,
|
||||
},
|
||||
});
|
||||
const invite = createInviteToken();
|
||||
inviteJobs.push({
|
||||
to: parent.email,
|
||||
parentName: `${parent.firstName} ${parent.lastName}`,
|
||||
inviteUrl: invite.inviteUrl,
|
||||
});
|
||||
await tx.verificationToken.create({
|
||||
data: {
|
||||
identifier: createdParent.id,
|
||||
token: invite.token,
|
||||
expires: invite.expires,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const parent of newParents) {
|
||||
const createdParent = await tx.user.create({
|
||||
data: {
|
||||
kitaId,
|
||||
familyId,
|
||||
email: parent.email,
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
passwordHash: "",
|
||||
role: UserRole.ELTERN,
|
||||
},
|
||||
});
|
||||
const invite = await queueFamilyInvitation(
|
||||
tx,
|
||||
createdParent.id,
|
||||
session.user.id,
|
||||
);
|
||||
inviteJobs.push(invite.id);
|
||||
}
|
||||
|
||||
for (const child of activeChildren) {
|
||||
await tx.child.update({
|
||||
where: { id: child.id },
|
||||
data: {
|
||||
firstName: child.firstName,
|
||||
lastName: child.lastName,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const child of activeChildren) {
|
||||
await tx.child.update({
|
||||
where: { id: child.id, kitaId, familyId },
|
||||
data: {
|
||||
firstName: child.firstName,
|
||||
lastName: child.lastName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const childId of removedChildIds) {
|
||||
await tx.child.delete({ where: { id: childId } });
|
||||
}
|
||||
for (const childId of removedChildIds) {
|
||||
await tx.child.delete({ where: { id: childId, kitaId, familyId } });
|
||||
}
|
||||
|
||||
for (const child of newChildren) {
|
||||
await tx.child.create({
|
||||
data: {
|
||||
kitaId,
|
||||
familyId,
|
||||
firstName: child.firstName,
|
||||
lastName: child.lastName,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
for (const child of newChildren) {
|
||||
await tx.child.create({
|
||||
data: {
|
||||
kitaId,
|
||||
familyId,
|
||||
firstName: child.firstName,
|
||||
lastName: child.lastName,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
@@ -452,20 +503,76 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
return { error: "Mindestens eine E-Mail-Adresse existiert bereits." };
|
||||
}
|
||||
|
||||
console.error("Fehler beim Aktualisieren der Familie:", error);
|
||||
return { error: "Familie konnte nicht aktualisiert werden." };
|
||||
}
|
||||
|
||||
const emailError = await sendInviteJobs(kita.name, inviteJobs);
|
||||
if (emailError) {
|
||||
if (!(error instanceof UserFacingError))
|
||||
console.error("Fehler beim Aktualisieren der Familie:", error);
|
||||
return {
|
||||
error: `Familie wurde aktualisiert, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`,
|
||||
error: publicActionError(
|
||||
error,
|
||||
"Familie konnte nicht aktualisiert werden.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const warning = await sendInviteJobs(inviteJobs);
|
||||
revalidatePath("/dashboard/families");
|
||||
revalidatePath("/dashboard/profil");
|
||||
revalidatePath("/dashboard/notdienst");
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
return { success: true, warning };
|
||||
}
|
||||
|
||||
export async function resendFamilyInvitationAction(
|
||||
userId: string,
|
||||
): Promise<{ success?: boolean; error?: string; warning?: string }> {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
if (
|
||||
!session.user.kitaId ||
|
||||
typeof userId !== "string" ||
|
||||
!userId ||
|
||||
userId.length > 64
|
||||
)
|
||||
return { error: "Ungültiges Elternkonto." };
|
||||
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 recipient = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
kitaId: session.user.kitaId,
|
||||
familyId: { not: null },
|
||||
emailVerifiedAt: null,
|
||||
passwordHash: "",
|
||||
disabledAt: null,
|
||||
},
|
||||
select: { email: true },
|
||||
});
|
||||
if (!recipient) return { error: "Keine offene Einladung gefunden." };
|
||||
let result;
|
||||
try {
|
||||
result = await resendFamilyInvitation(
|
||||
session.user.kitaId,
|
||||
userId,
|
||||
session.user.id,
|
||||
recipient.email,
|
||||
);
|
||||
} catch {
|
||||
result = {
|
||||
error:
|
||||
"Einladung konnte nicht erneut versendet werden. Bitte den Status prüfen und erneut versuchen.",
|
||||
};
|
||||
}
|
||||
revalidatePath("/dashboard/families");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function resendParentInvite(userId: string) {
|
||||
return resendFamilyInvitationAction(userId);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -26,28 +27,24 @@ const initialState: AddFamilyState = {};
|
||||
// • Dialog-Open/Close-State
|
||||
// • Dynamische Kinderliste (min. 1, max. 10)
|
||||
// • Ein oder zwei Elternteile pro Haushalt
|
||||
// • useActionState → Server-Action-Fehler anzeigen
|
||||
// • Bei Erfolg (state.success) Dialog automatisch schließen
|
||||
// • Eingaben bei Validierungsfehlern behalten
|
||||
// • Nach erfolgreichem Speichern schließen, auch bei einer Versandwarnung
|
||||
// =====================================================================
|
||||
|
||||
export function AddFamilyDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [childCount, setChildCount] = useState(1);
|
||||
const [state, formAction, pending] = useActionState(
|
||||
addFamilyAction,
|
||||
initialState,
|
||||
);
|
||||
|
||||
// Dialog schließen, wenn die Action erfolgreich war
|
||||
if (state.success && open) {
|
||||
setOpen(false);
|
||||
}
|
||||
const [childIds, setChildIds] = useState(["initial"]);
|
||||
const childCount = childIds.length;
|
||||
const [state, setState] = useState<AddFamilyState>(initialState);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (pending) return;
|
||||
if (nextOpen) {
|
||||
setState(initialState);
|
||||
setChildIds([crypto.randomUUID()]);
|
||||
}
|
||||
setOpen(nextOpen);
|
||||
// State beim Schließen zurücksetzen würde useActionState erfordern —
|
||||
// stattdessen schließen wir einfach und der State bleibt bis zur nächsten
|
||||
// Aktion erhalten (unkritisch).
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -63,166 +60,185 @@ export function AddFamilyDialog() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Familie hinzufügen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Lege einen Haushalt mit Elternteilen und Kindern an. Die
|
||||
Elternteile erhalten Einladungslinks per E-Mail.
|
||||
Lege einen Haushalt mit Elternteilen und Kindern an. Die Elternteile
|
||||
erhalten Einladungslinks per E-Mail.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form action={formAction} className="space-y-6">
|
||||
{/* Anzahl Kinder als verstecktes Feld für die Server Action */}
|
||||
<input type="hidden" name="childCount" value={childCount} />
|
||||
|
||||
<FormField
|
||||
id="familyName"
|
||||
name="familyName"
|
||||
label="Familienname"
|
||||
placeholder="Familie Schmidt"
|
||||
error={state.errors?.familyName?.[0]}
|
||||
/>
|
||||
|
||||
{/* ── Elternteile ────────────────────────────────────────── */}
|
||||
<fieldset className="space-y-4">
|
||||
<legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Elternteil 1
|
||||
</legend>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
id="parent1FirstName"
|
||||
name="parent1FirstName"
|
||||
label="Vorname"
|
||||
autoComplete="given-name"
|
||||
error={state.errors?.parent1FirstName?.[0]}
|
||||
/>
|
||||
<FormField
|
||||
id="parent1LastName"
|
||||
name="parent1LastName"
|
||||
label="Nachname"
|
||||
autoComplete="family-name"
|
||||
error={state.errors?.parent1LastName?.[0]}
|
||||
/>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
startTransition(async () => {
|
||||
const result = await addFamilyAction(initialState, data);
|
||||
setState(result);
|
||||
if (result.success) {
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
else toast.success("Familie angelegt. Einladungen versendet.");
|
||||
setOpen(false);
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="space-y-6"
|
||||
>
|
||||
<fieldset disabled={pending} className="space-y-6">
|
||||
{/* Anzahl Kinder als verstecktes Feld für die Server Action */}
|
||||
<input type="hidden" name="childCount" value={childCount} />
|
||||
|
||||
<FormField
|
||||
id="parent1Email"
|
||||
name="parent1Email"
|
||||
type="email"
|
||||
label="E-Mail-Adresse"
|
||||
autoComplete="email"
|
||||
error={state.errors?.parent1Email?.[0]}
|
||||
id="familyName"
|
||||
name="familyName"
|
||||
label="Familienname"
|
||||
placeholder="Familie Schmidt"
|
||||
error={state.errors?.familyName?.[0]}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="space-y-4">
|
||||
<legend>
|
||||
<span className="text-[11px] uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Elternteil 2
|
||||
</span>{" "}
|
||||
<span className="font-normal italic text-[var(--ink-faint)]">
|
||||
optional
|
||||
</span>
|
||||
</legend>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
id="parent2FirstName"
|
||||
name="parent2FirstName"
|
||||
label="Vorname"
|
||||
autoComplete="given-name"
|
||||
required={false}
|
||||
error={state.errors?.parent2FirstName?.[0]}
|
||||
/>
|
||||
<FormField
|
||||
id="parent2LastName"
|
||||
name="parent2LastName"
|
||||
label="Nachname"
|
||||
autoComplete="family-name"
|
||||
required={false}
|
||||
error={state.errors?.parent2LastName?.[0]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
id="parent2Email"
|
||||
name="parent2Email"
|
||||
type="email"
|
||||
label="E-Mail-Adresse"
|
||||
autoComplete="email"
|
||||
required={false}
|
||||
error={state.errors?.parent2Email?.[0]}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{/* ── Kinder ─────────────────────────────────────────────── */}
|
||||
<fieldset className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* ── Elternteile ────────────────────────────────────────── */}
|
||||
<fieldset className="space-y-4">
|
||||
<legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Kinder ({childCount})
|
||||
Elternteil 1
|
||||
</legend>
|
||||
{childCount < 10 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setChildCount((c) => c + 1)}
|
||||
>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Kind hinzufügen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{Array.from({ length: childCount }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
|
||||
>
|
||||
{childCount > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Kind entfernen — childCount reduzieren reicht, da die
|
||||
// Felder nach Index benannt sind und der letzte entfernt wird.
|
||||
setChildCount((c) => c - 1);
|
||||
}}
|
||||
className="absolute right-3 top-3 rounded-md p-1 text-[var(--ink-muted)] transition-colors hover:bg-[var(--danger-soft)] hover:text-[var(--danger)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-2)]"
|
||||
aria-label={`Kind ${i + 1} entfernen`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<p className="mb-3 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Kind {i + 1}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
id={`childFirstName_${i}`}
|
||||
name={`childFirstName_${i}`}
|
||||
label="Vorname"
|
||||
/>
|
||||
<FormField
|
||||
id={`childLastName_${i}`}
|
||||
name={`childLastName_${i}`}
|
||||
label="Nachname"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
id="parent1FirstName"
|
||||
name="parent1FirstName"
|
||||
label="Vorname"
|
||||
autoComplete="given-name"
|
||||
error={state.errors?.parent1FirstName?.[0]}
|
||||
/>
|
||||
<FormField
|
||||
id="parent1LastName"
|
||||
name="parent1LastName"
|
||||
label="Nachname"
|
||||
autoComplete="family-name"
|
||||
error={state.errors?.parent1LastName?.[0]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{state.errors?.children?.[0] && (
|
||||
<p className="text-xs text-[var(--danger)]">
|
||||
{state.errors.children[0]}
|
||||
<FormField
|
||||
id="parent1Email"
|
||||
name="parent1Email"
|
||||
type="email"
|
||||
label="E-Mail-Adresse"
|
||||
autoComplete="email"
|
||||
error={state.errors?.parent1Email?.[0]}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="space-y-4">
|
||||
<legend>
|
||||
<span className="text-[11px] uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Elternteil 2
|
||||
</span>{" "}
|
||||
<span className="font-normal italic text-[var(--ink-faint)]">
|
||||
optional
|
||||
</span>
|
||||
</legend>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
id="parent2FirstName"
|
||||
name="parent2FirstName"
|
||||
label="Vorname"
|
||||
autoComplete="given-name"
|
||||
required={false}
|
||||
error={state.errors?.parent2FirstName?.[0]}
|
||||
/>
|
||||
<FormField
|
||||
id="parent2LastName"
|
||||
name="parent2LastName"
|
||||
label="Nachname"
|
||||
autoComplete="family-name"
|
||||
required={false}
|
||||
error={state.errors?.parent2LastName?.[0]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
id="parent2Email"
|
||||
name="parent2Email"
|
||||
type="email"
|
||||
label="E-Mail-Adresse"
|
||||
autoComplete="email"
|
||||
required={false}
|
||||
error={state.errors?.parent2Email?.[0]}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{/* ── Kinder ─────────────────────────────────────────────── */}
|
||||
<fieldset className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Kinder ({childCount})
|
||||
</legend>
|
||||
{childCount < 10 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setChildIds((ids) => [...ids, crypto.randomUUID()])
|
||||
}
|
||||
>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Kind hinzufügen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{childIds.map((childId, i) => (
|
||||
<div
|
||||
key={childId}
|
||||
className="relative rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
|
||||
>
|
||||
{childCount > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChildIds((ids) =>
|
||||
ids.filter((id) => id !== childId),
|
||||
);
|
||||
}}
|
||||
className="absolute right-3 top-3 rounded-md p-1 text-[var(--ink-muted)] transition-colors hover:bg-[var(--danger-soft)] hover:text-[var(--danger)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-2)]"
|
||||
aria-label={`Kind ${i + 1} entfernen`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<p className="mb-3 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Kind {i + 1}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
id={`childFirstName_${i}`}
|
||||
name={`childFirstName_${i}`}
|
||||
label="Vorname"
|
||||
/>
|
||||
<FormField
|
||||
id={`childLastName_${i}`}
|
||||
name={`childLastName_${i}`}
|
||||
label="Nachname"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{state.errors?.children?.[0] && (
|
||||
<p className="text-xs text-[var(--danger)]">
|
||||
{state.errors.children[0]}
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* Globaler Fehler */}
|
||||
{state.errors?._form?.[0] && (
|
||||
<p className="rounded-lg border border-[var(--danger)] bg-[var(--danger-soft)] p-3 text-sm text-[var(--danger)]">
|
||||
{state.errors._form[0]}
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* Globaler Fehler */}
|
||||
{state.errors?._form?.[0] && (
|
||||
<p className="rounded-lg border border-[var(--danger)] bg-[var(--danger-soft)] p-3 text-sm text-[var(--danger)]">
|
||||
{state.errors._form[0]}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -9,7 +9,7 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
const dutySchema = z.object({
|
||||
name: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein").max(50),
|
||||
description: z.string().optional(),
|
||||
description: z.string().trim().max(2000).optional(),
|
||||
});
|
||||
|
||||
export async function createDuty(rawPayload: unknown) {
|
||||
|
||||
@@ -16,7 +16,12 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
import { createDuty, assignDuty, removeDutyAssignment, deleteDuty } from "./duty-actions";
|
||||
import {
|
||||
createDuty,
|
||||
assignDuty,
|
||||
removeDutyAssignment,
|
||||
deleteDuty,
|
||||
} from "./duty-actions";
|
||||
|
||||
type DutyWithAssignments = {
|
||||
id: string;
|
||||
@@ -100,17 +105,25 @@ export function DutyManager({
|
||||
};
|
||||
|
||||
const unassignedDuties = allDuties.filter(
|
||||
(d) => !userAssignments.some((a) => a.dutyId === d.id)
|
||||
(d) => !userAssignments.some((a) => a.dutyId === d.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1"
|
||||
aria-label={`Ämter für ${user.firstName} ${user.lastName}`}
|
||||
>
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Ämter</span>
|
||||
<span>Ämter: {user.firstName}</span>
|
||||
{userAssignments.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 h-4 px-1 py-0 tabular-nums">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 h-4 px-1 py-0 tabular-nums"
|
||||
>
|
||||
{userAssignments.length}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -137,7 +150,11 @@ export function DutyManager({
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{userAssignments.map((assignment) => (
|
||||
<Badge key={assignment.id} variant="default" className="gap-1 pr-1">
|
||||
<Badge
|
||||
key={assignment.id}
|
||||
variant="default"
|
||||
className="gap-1 pr-1"
|
||||
>
|
||||
{assignment.duty.name}
|
||||
<button
|
||||
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-[oklch(0.98_0.005_45_/_0.18)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[oklch(0.98_0.005_45)]"
|
||||
@@ -172,7 +189,7 @@ export function DutyManager({
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{duty.name}
|
||||
|
||||
|
||||
{/* Only show delete if no one is assigned to it across the entire DB */}
|
||||
{duty.assignments.length === 0 && (
|
||||
<button
|
||||
@@ -199,7 +216,12 @@ export function DutyManager({
|
||||
Neues Amt anlegen
|
||||
</h4>
|
||||
<form action={handleCreateDuty} className="flex gap-2">
|
||||
<Input name="name" placeholder="z.B. Wäschedienst" className="h-8" required />
|
||||
<Input
|
||||
name="name"
|
||||
placeholder="z.B. Wäschedienst"
|
||||
className="h-8"
|
||||
required
|
||||
/>
|
||||
<Button size="sm" className="h-8" disabled={isPending}>
|
||||
Erstellen
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import {
|
||||
useMemo,
|
||||
useState,
|
||||
useTransition,
|
||||
type ChangeEventHandler,
|
||||
} from "react";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -17,12 +22,15 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { updateFamilyAction } from "./actions";
|
||||
import { InvitationStatus } from "./invitation-status";
|
||||
import type { FamilyInvitationView } from "@/lib/family-invitation-view";
|
||||
|
||||
type EditableParent = {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
invitation?: FamilyInvitationView;
|
||||
};
|
||||
|
||||
type EditableChild = {
|
||||
@@ -47,6 +55,7 @@ type EditableFamily = {
|
||||
};
|
||||
|
||||
export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const initialParents = useMemo(
|
||||
() =>
|
||||
@@ -154,13 +163,22 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Familie aktualisiert.");
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
else toast.success("Familie aktualisiert. Geänderte E-Mail-Adressen müssen über einen neuen Einladungslink bestätigt werden.");
|
||||
setOpen(false);
|
||||
setRemovedChildIds([]);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={(open) => !open && resetState()}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (isPending) return;
|
||||
resetState();
|
||||
setOpen(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
@@ -168,7 +186,12 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<form action={handleSubmit}>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleSubmit(new FormData(event.currentTarget));
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Familie bearbeiten</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -176,7 +199,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-6 py-4">
|
||||
<fieldset disabled={isPending} className="grid gap-6 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`familyName-${family.id}`}>Familienname</Label>
|
||||
<Input
|
||||
@@ -251,8 +274,38 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
||||
label="E-Mail-Adresse"
|
||||
type="email"
|
||||
defaultValue={parent.email}
|
||||
onChange={(event) => {
|
||||
const email = event.target.value;
|
||||
setParents((current) =>
|
||||
current.map((item) =>
|
||||
item.id === parent.id ? { ...item, email } : item,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{parent.kind === "existing" && parent.invitation && (
|
||||
<InvitationStatus
|
||||
userId={parent.id}
|
||||
name={`${parent.firstName} ${parent.lastName}`}
|
||||
invitation={parent.invitation}
|
||||
blocked={
|
||||
parent.email !==
|
||||
family.parents.find((item) => item.id === parent.id)
|
||||
?.email
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{parent.kind === "existing" &&
|
||||
parent.invitation?.status !== "active" &&
|
||||
parent.email !==
|
||||
family.parents.find((item) => item.id === parent.id)
|
||||
?.email && (
|
||||
<p className="mt-2 text-xs text-[var(--ink-soft)]">
|
||||
Beim Speichern wird eine neue Einladung an die geänderte
|
||||
Adresse versendet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</fieldset>
|
||||
@@ -319,7 +372,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
||||
))
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
@@ -338,12 +391,14 @@ function Field({
|
||||
label,
|
||||
type = "text",
|
||||
defaultValue,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
defaultValue: string;
|
||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
@@ -353,6 +408,7 @@ function Field({
|
||||
name={name}
|
||||
type={type}
|
||||
defaultValue={defaultValue}
|
||||
onChange={onChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { Mail } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { FamilyInvitationView } from "@/lib/family-invitation-view";
|
||||
import { resendFamilyInvitationAction } from "./actions";
|
||||
|
||||
const labels = {
|
||||
active: "Konto aktiviert",
|
||||
open: "Aktivierung offen",
|
||||
sending: "Versand läuft",
|
||||
sent: "Einladung versendet",
|
||||
failed: "Versand fehlgeschlagen",
|
||||
expired: "Einladung abgelaufen",
|
||||
};
|
||||
|
||||
export function InvitationStatus({
|
||||
userId,
|
||||
name,
|
||||
invitation,
|
||||
blocked = false,
|
||||
}: {
|
||||
userId: string;
|
||||
name: string;
|
||||
invitation: FamilyInvitationView;
|
||||
blocked?: boolean;
|
||||
}) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
<Badge variant={invitation.status === "active" ? "success" : "warning"}>
|
||||
{labels[invitation.status]}
|
||||
</Badge>
|
||||
{invitation.status !== "active" && (
|
||||
<>
|
||||
{invitation.expiresAt && (
|
||||
<span className="text-[var(--ink-muted)]">
|
||||
{invitation.status === "expired"
|
||||
? "Gültig gewesen bis"
|
||||
: "Link gültig bis"}{" "}
|
||||
{new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "short",
|
||||
timeZone: "Europe/Berlin",
|
||||
}).format(new Date(invitation.expiresAt))}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={blocked || pending || invitation.status === "sending"}
|
||||
aria-label={`Einladung erneut senden an ${name}`}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
const result = await resendFamilyInvitationAction(userId);
|
||||
if (result.error) toast.error(result.error);
|
||||
else if (result.warning) toast.warning(result.warning);
|
||||
else toast.success("Einladung versendet.");
|
||||
})
|
||||
}
|
||||
>
|
||||
<Mail className="h-3 w-3" />
|
||||
{pending ? "Wird versendet…" : "Einladung erneut senden"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { UserRole } from "@prisma/client";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { familyInvitationView } from "@/lib/family-invitation-view";
|
||||
import { FamiliesListClient } from "./_components/families-list-client";
|
||||
|
||||
export const metadata = { title: "Familienverwaltung · Kita-Planer" };
|
||||
@@ -31,7 +32,18 @@ export default async function FamiliesPage() {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
role: true,
|
||||
staffProfile: { select: { id: true } },
|
||||
emailVerifiedAt: true,
|
||||
familyInvitation: {
|
||||
select: {
|
||||
expiresAt: true,
|
||||
sentAt: true,
|
||||
failed: true,
|
||||
leaseUntil: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
dutyAssignments: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -73,7 +85,17 @@ export default async function FamiliesPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<FamiliesListClient
|
||||
initialFamilies={families}
|
||||
currentUserId={session.user.id}
|
||||
initialFamilies={families.map((family) => ({
|
||||
...family,
|
||||
users: family.users.map(({ familyInvitation, ...parent }) => ({
|
||||
...parent,
|
||||
invitation: familyInvitationView(
|
||||
parent.emailVerifiedAt,
|
||||
familyInvitation,
|
||||
),
|
||||
})),
|
||||
}))}
|
||||
allDuties={allDuties}
|
||||
canManageDuties={canManageDuties}
|
||||
/>
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import type { ComponentProps, FormEvent } from "react";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { TerminType } from "@prisma/client";
|
||||
import { RsvpMode, TerminType } from "@prisma/client";
|
||||
import { Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { suggestedRsvpMode } from "@/lib/rsvp";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -28,6 +29,11 @@ type AdminTerminFormState = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
allDay: boolean;
|
||||
requiresRsvp: boolean;
|
||||
rsvpMode: RsvpMode;
|
||||
rsvpDeadline: string;
|
||||
participantInfo: string;
|
||||
mitbringselListEnabled: boolean;
|
||||
};
|
||||
|
||||
const initialFormState: AdminTerminFormState = {
|
||||
@@ -37,6 +43,11 @@ const initialFormState: AdminTerminFormState = {
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
allDay: false,
|
||||
requiresRsvp: false,
|
||||
rsvpMode: RsvpMode.FAMILY,
|
||||
rsvpDeadline: "",
|
||||
participantInfo: "",
|
||||
mitbringselListEnabled: false,
|
||||
};
|
||||
|
||||
type AdminTerminModalProps = {
|
||||
@@ -51,6 +62,12 @@ type AdminTerminModalProps = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
allDay: boolean;
|
||||
requiresRsvp: boolean;
|
||||
rsvpMode: RsvpMode;
|
||||
rsvpDeadline: Date | null;
|
||||
participantInfo: string | null;
|
||||
mitbringselListEnabled: boolean;
|
||||
hasRsvpResponses: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -61,10 +78,12 @@ export function AdminTerminModal({
|
||||
existingTermin,
|
||||
}: AdminTerminModalProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [formState, setFormState] = useState<AdminTerminFormState>(initialFormState);
|
||||
const [formState, setFormState] =
|
||||
useState<AdminTerminFormState>(initialFormState);
|
||||
const [modeTouched, setModeTouched] = useState(false);
|
||||
const modeLocked = existingTermin?.hasRsvpResponses ?? false;
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []);
|
||||
const minEndDateTime = formState.startDate || minDateTime;
|
||||
|
||||
// Initialize and reset form fields when modal opens
|
||||
useEffect(() => {
|
||||
@@ -78,10 +97,18 @@ export function AdminTerminModal({
|
||||
startDate: getLocalDateTimeInputValue(existingTermin.startDate),
|
||||
endDate: getLocalDateTimeInputValue(existingTermin.endDate),
|
||||
allDay: existingTermin.allDay,
|
||||
requiresRsvp: existingTermin.requiresRsvp,
|
||||
rsvpMode: existingTermin.rsvpMode,
|
||||
rsvpDeadline: existingTermin.rsvpDeadline
|
||||
? getLocalDateTimeInputValue(existingTermin.rsvpDeadline)
|
||||
: "",
|
||||
participantInfo: existingTermin.participantInfo ?? "",
|
||||
mitbringselListEnabled: existingTermin.mitbringselListEnabled,
|
||||
});
|
||||
} else {
|
||||
setFormState(initialFormState);
|
||||
}
|
||||
setModeTouched(false);
|
||||
}
|
||||
}, [open, existingTermin]);
|
||||
|
||||
@@ -91,7 +118,14 @@ export function AdminTerminModal({
|
||||
) => {
|
||||
setFormState((current) => {
|
||||
const next = { ...current, [key]: value };
|
||||
if (key === "startDate" && typeof value === "string" && next.endDate && next.endDate < value) {
|
||||
if (key === "type" && !existingTermin && !modeTouched && !modeLocked)
|
||||
next.rsvpMode = suggestedRsvpMode(value as TerminType);
|
||||
if (
|
||||
key === "startDate" &&
|
||||
typeof value === "string" &&
|
||||
next.endDate &&
|
||||
next.endDate < value
|
||||
) {
|
||||
next.endDate = value;
|
||||
}
|
||||
return next;
|
||||
@@ -122,6 +156,13 @@ export function AdminTerminModal({
|
||||
startDate: new Date(formState.startDate).toISOString(),
|
||||
endDate: new Date(formState.endDate).toISOString(),
|
||||
allDay: formState.allDay,
|
||||
requiresRsvp: formState.requiresRsvp,
|
||||
rsvpMode: formState.rsvpMode,
|
||||
rsvpDeadline: formState.rsvpDeadline
|
||||
? new Date(formState.rsvpDeadline).toISOString()
|
||||
: null,
|
||||
participantInfo: formState.participantInfo,
|
||||
mitbringselListEnabled: formState.mitbringselListEnabled,
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
@@ -138,7 +179,7 @@ export function AdminTerminModal({
|
||||
toast.success(
|
||||
existingTermin
|
||||
? "Termin wurde erfolgreich aktualisiert!"
|
||||
: "Termin wurde direkt angelegt!"
|
||||
: "Termin wurde direkt angelegt!",
|
||||
);
|
||||
if (!existingTermin) {
|
||||
setFormState(initialFormState);
|
||||
@@ -156,11 +197,13 @@ export function AdminTerminModal({
|
||||
{triggerLabel}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{existingTermin ? "Termin bearbeiten (Admin)" : "Termin anlegen (Admin)"}
|
||||
{existingTermin
|
||||
? "Termin bearbeiten (Admin)"
|
||||
: "Termin anlegen (Admin)"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{existingTermin
|
||||
@@ -189,7 +232,9 @@ export function AdminTerminModal({
|
||||
name="description"
|
||||
placeholder="Details zum Termin..."
|
||||
value={formState.description}
|
||||
onChange={(event) => updateField("description", event.target.value)}
|
||||
onChange={(event) =>
|
||||
updateField("description", event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -200,18 +245,28 @@ export function AdminTerminModal({
|
||||
name="type"
|
||||
value={formState.type}
|
||||
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
onChange={(event) => updateField("type", event.target.value as TerminType)}
|
||||
onChange={(event) =>
|
||||
updateField("type", event.target.value as TerminType)
|
||||
}
|
||||
required
|
||||
>
|
||||
<option value={TerminType.KITA_FEST}>Kita-Fest</option>
|
||||
<option value={TerminType.SCHLIESSTAG}>Schließtag</option>
|
||||
<option value={TerminType.TEAMTAG}>Teamtag (Kita geschlossen)</option>
|
||||
<option value={TerminType.TEAMTAG}>
|
||||
Teamtag (Kita geschlossen)
|
||||
</option>
|
||||
<option value={TerminType.MITMACH_TAG}>Mitmach-Tag</option>
|
||||
<option value={TerminType.ELTERNABEND}>Elternabend</option>
|
||||
<option value={TerminType.MITGLIEDERVERSAMMLUNG}>Mitgliederversammlung</option>
|
||||
<option value={TerminType.MITGLIEDERVERSAMMLUNG}>
|
||||
Mitgliederversammlung
|
||||
</option>
|
||||
<option value={TerminType.ELTERNCAFE}>Elterncafe</option>
|
||||
<option value={TerminType.GEBURTSTAG_INTERN}>Geburtstag (Intern)</option>
|
||||
<option value={TerminType.GEBURTSTAG_EXTERN}>Raumanfrage (Extern)</option>
|
||||
<option value={TerminType.GEBURTSTAG_INTERN}>
|
||||
Geburtstag (Intern)
|
||||
</option>
|
||||
<option value={TerminType.GEBURTSTAG_EXTERN}>
|
||||
Raumanfrage (Extern)
|
||||
</option>
|
||||
<option value={TerminType.SONSTIGES}>Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -225,7 +280,9 @@ export function AdminTerminModal({
|
||||
type="datetime-local"
|
||||
min={existingTermin ? undefined : minDateTime}
|
||||
value={formState.startDate}
|
||||
onChange={(event) => updateField("startDate", event.target.value)}
|
||||
onChange={(event) =>
|
||||
updateField("startDate", event.target.value)
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -235,14 +292,82 @@ export function AdminTerminModal({
|
||||
id="endDate"
|
||||
name="endDate"
|
||||
type="datetime-local"
|
||||
min={formState.startDate || (existingTermin ? undefined : minDateTime)}
|
||||
min={
|
||||
formState.startDate ||
|
||||
(existingTermin ? undefined : minDateTime)
|
||||
}
|
||||
value={formState.endDate}
|
||||
onChange={(event) => updateField("endDate", event.target.value)}
|
||||
onChange={(event) =>
|
||||
updateField("endDate", event.target.value)
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formState.mitbringselListEnabled}
|
||||
onChange={(e) =>
|
||||
updateField("mitbringselListEnabled", e.target.checked)
|
||||
}
|
||||
/>
|
||||
Mitbring-Liste aktiv
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formState.requiresRsvp}
|
||||
onChange={(e) => updateField("requiresRsvp", e.target.checked)}
|
||||
/>
|
||||
Teilnahme-Abfrage aktivieren
|
||||
</label>
|
||||
{formState.requiresRsvp ? (
|
||||
<div className="grid gap-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
|
||||
<Label htmlFor="calendar-rsvp-mode">Antwortart</Label>
|
||||
<select
|
||||
id="calendar-rsvp-mode"
|
||||
value={formState.rsvpMode}
|
||||
disabled={modeLocked}
|
||||
onChange={(e) => {
|
||||
updateField("rsvpMode", e.target.value as RsvpMode);
|
||||
setModeTouched(true);
|
||||
}}
|
||||
className="h-10 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 text-sm"
|
||||
>
|
||||
<option value="FAMILY">
|
||||
Pro Familie · mit Anzahl Erwachsener
|
||||
</option>
|
||||
<option value="CHILD">Pro Kind · einzelne Zu-/Absage</option>
|
||||
</select>
|
||||
{modeLocked ? (
|
||||
<p className="text-xs">
|
||||
Die Antwortart ist wegen vorhandener Rückmeldungen gesperrt.
|
||||
</p>
|
||||
) : null}
|
||||
<Label htmlFor="calendar-rsvp-deadline">
|
||||
Rückmeldefrist (sonst Terminbeginn)
|
||||
</Label>
|
||||
<Input
|
||||
id="calendar-rsvp-deadline"
|
||||
type="datetime-local"
|
||||
value={formState.rsvpDeadline}
|
||||
onChange={(e) => updateField("rsvpDeadline", e.target.value)}
|
||||
/>
|
||||
<Label htmlFor="calendar-participant-info">
|
||||
Hinweise für Teilnehmer
|
||||
</Label>
|
||||
<Textarea
|
||||
id="calendar-participant-info"
|
||||
value={formState.participantInfo}
|
||||
onChange={(e) =>
|
||||
updateField("participantInfo", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -250,7 +375,9 @@ export function AdminTerminModal({
|
||||
name="allDay"
|
||||
checked={formState.allDay}
|
||||
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
|
||||
onChange={(event) => updateField("allDay", event.target.checked)}
|
||||
onChange={(event) =>
|
||||
updateField("allDay", event.target.checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="allDay" className="font-normal cursor-pointer">
|
||||
Ganztägiger Termin
|
||||
@@ -259,11 +386,19 @@ export function AdminTerminModal({
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Speichern..." : existingTermin ? "Änderungen speichern" : "Termin erstellen"}
|
||||
{isPending
|
||||
? "Speichern..."
|
||||
: existingTermin
|
||||
? "Änderungen speichern"
|
||||
: "Termin erstellen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useTransition } from "react";
|
||||
import {
|
||||
TerminType,
|
||||
TerminStatus,
|
||||
} from "@prisma/client";
|
||||
import { TerminType, TerminStatus } from "@prisma/client";
|
||||
import {
|
||||
format,
|
||||
startOfMonth,
|
||||
@@ -30,6 +27,7 @@ import {
|
||||
WashingMachine,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { RsvpForm } from "@/components/rsvp-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -41,7 +39,6 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -63,12 +60,32 @@ import {
|
||||
const categories = [
|
||||
{ label: "Schließtag", type: TerminType.SCHLIESSTAG, color: "var(--danger)" },
|
||||
{ label: "Teamtag", type: TerminType.TEAMTAG, color: "var(--danger)" },
|
||||
{ label: "Elternabend", type: TerminType.ELTERNABEND, color: "oklch(0.50 0.10 280)" },
|
||||
{
|
||||
label: "Elternabend",
|
||||
type: TerminType.ELTERNABEND,
|
||||
color: "oklch(0.50 0.10 280)",
|
||||
},
|
||||
{ label: "Fest", type: TerminType.KITA_FEST, color: "var(--good)" },
|
||||
{ label: "Geburtstag", type: TerminType.GEBURTSTAG_INTERN, color: "oklch(0.50 0.10 320)" },
|
||||
{ label: "Mein Elterndienst", type: "mein-elterndienst", color: "oklch(0.60 0.16 160)" },
|
||||
{ label: "Elterndienst", type: "elterndienst", color: "oklch(0.75 0.08 160)" },
|
||||
{ label: "Mein Notdienst", type: "mein-notdienst", color: "oklch(0.60 0.18 45)" },
|
||||
{
|
||||
label: "Geburtstag",
|
||||
type: TerminType.GEBURTSTAG_INTERN,
|
||||
color: "oklch(0.50 0.10 320)",
|
||||
},
|
||||
{
|
||||
label: "Mein Elterndienst",
|
||||
type: "mein-elterndienst",
|
||||
color: "oklch(0.60 0.16 160)",
|
||||
},
|
||||
{
|
||||
label: "Elterndienst",
|
||||
type: "elterndienst",
|
||||
color: "oklch(0.75 0.08 160)",
|
||||
},
|
||||
{
|
||||
label: "Mein Notdienst",
|
||||
type: "mein-notdienst",
|
||||
color: "oklch(0.60 0.18 45)",
|
||||
},
|
||||
{ label: "Notdienst", type: "notdienst", color: "oklch(0.75 0.10 50)" },
|
||||
{ label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" },
|
||||
];
|
||||
@@ -101,7 +118,9 @@ export function CalendarOverviewClient({
|
||||
const [view, setView] = useState<"liste" | "monat" | "agenda">("monat");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeTypes, setActiveTypes] = useState<Set<string>>(new Set());
|
||||
const [filterScope, setFilterScope] = useState<"own" | "all">(isAdmin ? "all" : "own");
|
||||
const [filterScope, setFilterScope] = useState<"own" | "all">(
|
||||
isAdmin ? "all" : "own",
|
||||
);
|
||||
|
||||
// Month navigation state
|
||||
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
|
||||
@@ -151,7 +170,8 @@ export function CalendarOverviewClient({
|
||||
item.kind === "notdienst" && item.childName
|
||||
? item.childName.toLowerCase().includes(query)
|
||||
: false;
|
||||
if (!titleMatch && !descMatch && !familyMatch && !childMatch) return false;
|
||||
if (!titleMatch && !descMatch && !familyMatch && !childMatch)
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Category filter
|
||||
@@ -162,10 +182,10 @@ export function CalendarOverviewClient({
|
||||
? "mein-elterndienst"
|
||||
: "elterndienst"
|
||||
: item.kind === "notdienst"
|
||||
? item.isOwn
|
||||
? "mein-notdienst"
|
||||
: "notdienst"
|
||||
: item.type;
|
||||
? item.isOwn
|
||||
? "mein-notdienst"
|
||||
: "notdienst"
|
||||
: item.type;
|
||||
if (!activeTypes.has(itemType)) return false;
|
||||
}
|
||||
|
||||
@@ -177,9 +197,21 @@ export function CalendarOverviewClient({
|
||||
const isEventOnDay = (item: CalendarListItemDto, day: Date) => {
|
||||
const eventStart = new Date(item.startDate);
|
||||
const eventEnd = new Date(item.endDate);
|
||||
const dayTime = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime();
|
||||
const startTime = new Date(eventStart.getFullYear(), eventStart.getMonth(), eventStart.getDate()).getTime();
|
||||
const endTime = new Date(eventEnd.getFullYear(), eventEnd.getMonth(), eventEnd.getDate()).getTime();
|
||||
const dayTime = new Date(
|
||||
day.getFullYear(),
|
||||
day.getMonth(),
|
||||
day.getDate(),
|
||||
).getTime();
|
||||
const startTime = new Date(
|
||||
eventStart.getFullYear(),
|
||||
eventStart.getMonth(),
|
||||
eventStart.getDate(),
|
||||
).getTime();
|
||||
const endTime = new Date(
|
||||
eventEnd.getFullYear(),
|
||||
eventEnd.getMonth(),
|
||||
eventEnd.getDate(),
|
||||
).getTime();
|
||||
return dayTime >= startTime && dayTime <= endTime;
|
||||
};
|
||||
|
||||
@@ -206,7 +238,7 @@ export function CalendarOverviewClient({
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{!isAdmin && pendingSwapRequests.length > 0 && (
|
||||
{pendingSwapRequests.length > 0 && (
|
||||
<NotdienstSwapRequestsPanel requests={pendingSwapRequests} />
|
||||
)}
|
||||
|
||||
@@ -329,7 +361,7 @@ export function CalendarOverviewClient({
|
||||
<AgendaView
|
||||
events={filteredEvents}
|
||||
isAdmin={isAdmin}
|
||||
userId={userId}
|
||||
onOpenDetails={(item) => setSelectedDay(new Date(item.startDate))}
|
||||
onDeleteTermin={(id, title) => {
|
||||
setDeleteEventId(id);
|
||||
setDeleteEventTitle(title);
|
||||
@@ -338,11 +370,15 @@ export function CalendarOverviewClient({
|
||||
)}
|
||||
|
||||
{/* Day details Dialog for Month View */}
|
||||
<Dialog open={!!selectedDay} onOpenChange={(open) => !open && setSelectedDay(null)}>
|
||||
<Dialog
|
||||
open={!!selectedDay}
|
||||
onOpenChange={(open) => !open && setSelectedDay(null)}
|
||||
>
|
||||
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">
|
||||
{selectedDay && format(selectedDay, "EEEE, d. MMMM yyyy", { locale: de })}
|
||||
{selectedDay &&
|
||||
format(selectedDay, "EEEE, d. MMMM yyyy", { locale: de })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Übersicht aller Zuteilungen, Feste und Einträge an diesem Tag.
|
||||
@@ -371,7 +407,14 @@ export function CalendarOverviewClient({
|
||||
}}
|
||||
/>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--surface-2)] text-[var(--ink-soft)] ml-2">
|
||||
<WashingMachine className="h-5 w-5 [stroke-width:1.7]" style={{ color: item.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)" }} />
|
||||
<WashingMachine
|
||||
className="h-5 w-5 [stroke-width:1.7]"
|
||||
style={{
|
||||
color: item.isOwn
|
||||
? "oklch(0.60 0.16 160)"
|
||||
: "oklch(0.75 0.08 160)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -409,7 +452,11 @@ export function CalendarOverviewClient({
|
||||
{isAdmin && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<a href="/dashboard/admin/dienste">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs px-2.5"
|
||||
>
|
||||
Im Planer bearbeiten
|
||||
</Button>
|
||||
</a>
|
||||
@@ -461,20 +508,30 @@ export function CalendarOverviewClient({
|
||||
</p>
|
||||
{isAdmin && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<a href={`/dashboard/notdienst/plan?year=${item.planYear}&month=${item.planMonth}`}>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
|
||||
<a
|
||||
href={`/dashboard/notdienst/plan?year=${item.planYear}&month=${item.planMonth}`}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs px-2.5"
|
||||
>
|
||||
Im Planer bearbeiten
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{!isAdmin && item.canRequestSwap && (
|
||||
{item.canRequestSwap && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<NotdienstSwapDialog
|
||||
target={item}
|
||||
options={notdienstSwapOptions}
|
||||
trigger={
|
||||
<Button size="sm" variant="outline" className="h-7 px-2.5 text-xs">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
>
|
||||
Tausch anfragen
|
||||
</Button>
|
||||
}
|
||||
@@ -499,7 +556,8 @@ export function CalendarOverviewClient({
|
||||
style={{
|
||||
color: getCategoryColor(item.type),
|
||||
borderColor: getCategoryColor(item.type) + "44",
|
||||
backgroundColor: getCategoryColor(item.type) + "11",
|
||||
backgroundColor:
|
||||
getCategoryColor(item.type) + "11",
|
||||
}}
|
||||
>
|
||||
{getTypeLabel(item.type)}
|
||||
@@ -522,13 +580,27 @@ export function CalendarOverviewClient({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{item.rsvpEvent ? (
|
||||
<section className="mt-4 border-t border-[var(--hairline)] pt-4">
|
||||
<h5 className="mb-3 font-medium">
|
||||
Eure Rückmeldung
|
||||
</h5>
|
||||
<RsvpForm
|
||||
key={JSON.stringify(item.rsvpEvent)}
|
||||
event={item.rsvpEvent}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{item.participantInfo && item.showParticipantInfo && (
|
||||
<div className="mt-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-3 text-xs text-[var(--ink)]">
|
||||
<div className="mb-0.5 flex items-center gap-1.5 font-medium">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-[var(--warn)]" />
|
||||
Hinweis für Teilnehmer
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap">{item.participantInfo}</p>
|
||||
<p className="whitespace-pre-wrap">
|
||||
{item.participantInfo}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -576,13 +648,17 @@ export function CalendarOverviewClient({
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={!!deleteEventId} onOpenChange={(open) => !open && setDeleteEventId(null)}>
|
||||
<Dialog
|
||||
open={!!deleteEventId}
|
||||
onOpenChange={(open) => !open && setDeleteEventId(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Möchtest du den Termin “{deleteEventTitle}” wirklich löschen? Alle
|
||||
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
|
||||
Möchtest du den Termin “{deleteEventTitle}” wirklich
|
||||
löschen? Alle zugehörigen Teilnahmen und Mitbringsel-Einträge
|
||||
gehen verloren.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -636,7 +712,9 @@ function NotdienstSwapRequestRow({
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const runAction = (
|
||||
action: (requestId: string) => Promise<{ success?: boolean; error?: string }>,
|
||||
action: (
|
||||
requestId: string,
|
||||
) => Promise<{ success?: boolean; error?: string }>,
|
||||
successMessage: string,
|
||||
) => {
|
||||
startTransition(async () => {
|
||||
@@ -653,13 +731,15 @@ function NotdienstSwapRequestRow({
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-[var(--ink)]">
|
||||
{request.direction === "incoming" ? "Anfrage an dich" : "Deine Anfrage"}
|
||||
{request.direction === "incoming"
|
||||
? "Anfrage an dich"
|
||||
: "Deine Anfrage"}
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-5 text-[var(--ink-soft)]">
|
||||
{format(request.targetDate, "dd.MM.yyyy", { locale: de })}{" "}
|
||||
({request.targetFamilyName}, {request.targetChildName}) gegen{" "}
|
||||
{format(request.offeredDate, "dd.MM.yyyy", { locale: de })}{" "}
|
||||
({request.offeredFamilyName}, {request.offeredChildName})
|
||||
{format(request.targetDate, "dd.MM.yyyy", { locale: de })} (
|
||||
{request.targetFamilyName}, {request.targetChildName}) gegen{" "}
|
||||
{format(request.offeredDate, "dd.MM.yyyy", { locale: de })} (
|
||||
{request.offeredFamilyName}, {request.offeredChildName})
|
||||
</p>
|
||||
{request.message && (
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
|
||||
@@ -673,7 +753,10 @@ function NotdienstSwapRequestRow({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
runAction(acceptNotdienstSwapRequest, "Tausch wurde angenommen.")
|
||||
runAction(
|
||||
acceptNotdienstSwapRequest,
|
||||
"Tausch wurde angenommen.",
|
||||
)
|
||||
}
|
||||
disabled={isPending}
|
||||
>
|
||||
@@ -683,7 +766,10 @@ function NotdienstSwapRequestRow({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
runAction(declineNotdienstSwapRequest, "Tausch wurde abgelehnt.")
|
||||
runAction(
|
||||
declineNotdienstSwapRequest,
|
||||
"Tausch wurde abgelehnt.",
|
||||
)
|
||||
}
|
||||
disabled={isPending}
|
||||
>
|
||||
@@ -695,7 +781,10 @@ function NotdienstSwapRequestRow({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
runAction(cancelNotdienstSwapRequest, "Tauschanfrage wurde zurückgezogen.")
|
||||
runAction(
|
||||
cancelNotdienstSwapRequest,
|
||||
"Tauschanfrage wurde zurückgezogen.",
|
||||
)
|
||||
}
|
||||
disabled={isPending}
|
||||
>
|
||||
@@ -785,7 +874,9 @@ function MonthView({
|
||||
onClick={() => onDayClick(day)}
|
||||
className={[
|
||||
"min-h-[105px] bg-[var(--surface)] p-2 transition-colors hover:bg-[var(--surface-2)] cursor-pointer flex flex-col justify-between",
|
||||
!isCurrentMonth ? "bg-[var(--surface-3)]/30 text-[var(--ink-muted)] opacity-60" : "text-[var(--ink)]",
|
||||
!isCurrentMonth
|
||||
? "bg-[var(--surface-3)]/30 text-[var(--ink-muted)] opacity-60"
|
||||
: "text-[var(--ink)]",
|
||||
].join(" ")}
|
||||
>
|
||||
{/* Day number cell top */}
|
||||
@@ -811,10 +902,10 @@ function MonthView({
|
||||
? "oklch(0.60 0.16 160)"
|
||||
: "oklch(0.75 0.08 160)"
|
||||
: item.kind === "notdienst"
|
||||
? item.isOwn
|
||||
? "oklch(0.60 0.18 45)"
|
||||
: "oklch(0.75 0.10 50)"
|
||||
: getCategoryColor(item.type);
|
||||
? item.isOwn
|
||||
? "oklch(0.60 0.18 45)"
|
||||
: "oklch(0.75 0.10 50)"
|
||||
: getCategoryColor(item.type);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
@@ -850,25 +941,33 @@ function MonthView({
|
||||
function AgendaView({
|
||||
events,
|
||||
isAdmin,
|
||||
userId,
|
||||
onDeleteTermin,
|
||||
onOpenDetails,
|
||||
}: {
|
||||
events: CalendarListItemDto[];
|
||||
isAdmin: boolean;
|
||||
userId: string;
|
||||
onDeleteTermin: (id: string, title: string) => void;
|
||||
onOpenDetails: (item: CalendarListItemDto) => void;
|
||||
}) {
|
||||
const grouped = useMemo(() => {
|
||||
const groups = new Map<string, { date: Date; items: CalendarListItemDto[] }>();
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ date: Date; items: CalendarListItemDto[] }
|
||||
>();
|
||||
|
||||
events.forEach((item) => {
|
||||
const key = format(new Date(item.startDate), "yyyy-MM-dd");
|
||||
const group = groups.get(key) ?? { date: new Date(item.startDate), items: [] };
|
||||
const group = groups.get(key) ?? {
|
||||
date: new Date(item.startDate),
|
||||
items: [],
|
||||
};
|
||||
group.items.push(item);
|
||||
groups.set(key, group);
|
||||
});
|
||||
|
||||
return Array.from(groups.values()).sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
return Array.from(groups.values()).sort(
|
||||
(a, b) => a.date.getTime() - b.date.getTime(),
|
||||
);
|
||||
}, [events]);
|
||||
|
||||
if (events.length === 0) {
|
||||
@@ -890,7 +989,10 @@ function AgendaView({
|
||||
return (
|
||||
<div className="space-y-6 pl-4 border-l-2 border-[var(--hairline)]">
|
||||
{grouped.map((group) => (
|
||||
<div key={group.date.toString()} className="relative grid grid-cols-[110px_1fr] gap-4 items-start">
|
||||
<div
|
||||
key={group.date.toString()}
|
||||
className="relative grid grid-cols-1 sm:grid-cols-[110px_minmax(0,1fr)] gap-4 items-start"
|
||||
>
|
||||
{/* Timeline marker */}
|
||||
<div className="absolute -left-[21px] top-1.5 h-3.5 w-3.5 rounded-full border-2 border-[var(--accent-deep)] bg-[var(--surface)]" />
|
||||
|
||||
@@ -911,19 +1013,19 @@ function AgendaView({
|
||||
item.kind === "termin"
|
||||
? getCategoryColor(item.type)
|
||||
: item.kind === "notdienst"
|
||||
? item.isOwn
|
||||
? "oklch(0.60 0.18 45)"
|
||||
: "oklch(0.75 0.10 50)"
|
||||
: item.kind === "duty"
|
||||
? item.isOwn
|
||||
? "oklch(0.60 0.16 160)"
|
||||
: "oklch(0.75 0.08 160)"
|
||||
: "oklch(0.60 0.15 160)";
|
||||
? item.isOwn
|
||||
? "oklch(0.60 0.18 45)"
|
||||
: "oklch(0.75 0.10 50)"
|
||||
: item.kind === "duty"
|
||||
? item.isOwn
|
||||
? "oklch(0.60 0.16 160)"
|
||||
: "oklch(0.75 0.08 160)"
|
||||
: "oklch(0.60 0.15 160)";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group relative flex items-start justify-between gap-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm transition-shadow hover:shadow-md"
|
||||
className="group relative flex flex-wrap items-start justify-between gap-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div
|
||||
@@ -959,13 +1061,19 @@ function AgendaView({
|
||||
{item.isOwn ? "Mein Notdienst" : "Notdienst"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)] text-[10px]">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)] text-[10px]"
|
||||
>
|
||||
Elterndienst
|
||||
</Badge>
|
||||
)}
|
||||
{item.kind === "termin" && item.status === TerminStatus.PENDING && (
|
||||
<Badge variant="warning" className="text-[10px]">Ausstehend</Badge>
|
||||
)}
|
||||
{item.kind === "termin" &&
|
||||
item.status === TerminStatus.PENDING && (
|
||||
<Badge variant="warning" className="text-[10px]">
|
||||
Ausstehend
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1.5">
|
||||
@@ -978,17 +1086,19 @@ function AgendaView({
|
||||
"HH:mm",
|
||||
)}`
|
||||
: item.kind === "notdienst"
|
||||
? `${format(new Date(item.startDate), "dd.MM.yyyy")} (ganztägig)`
|
||||
: `${format(new Date(item.startDate), "dd.MM.")} - ${format(
|
||||
new Date(item.endDate),
|
||||
"dd.MM.yyyy",
|
||||
)}`}
|
||||
{item.kind === "termin" && <MapPin className="ml-1 h-3.5 w-3.5" />}
|
||||
? `${format(new Date(item.startDate), "dd.MM.yyyy")} (ganztägig)`
|
||||
: `${format(new Date(item.startDate), "dd.MM.")} - ${format(
|
||||
new Date(item.endDate),
|
||||
"dd.MM.yyyy",
|
||||
)}`}
|
||||
{item.kind === "termin" && (
|
||||
<MapPin className="ml-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{item.kind === "termin"
|
||||
? "Kita"
|
||||
: item.kind === "notdienst"
|
||||
? `Kind: ${item.childName} · Familie: ${item.familyName}`
|
||||
: `Familie: ${item.familyName}`}
|
||||
? `Kind: ${item.childName} · Familie: ${item.familyName}`
|
||||
: `Familie: ${item.familyName}`}
|
||||
</p>
|
||||
|
||||
{item.kind === "termin" && item.description && (
|
||||
@@ -999,6 +1109,15 @@ function AgendaView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label={`Details zu ${item.title}`}
|
||||
onClick={() => onOpenDetails(item)}
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
|
||||
{/* Actions for admins */}
|
||||
{isAdmin && item.kind === "termin" && (
|
||||
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
|
||||