Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e50a8c646b | ||
|
|
e6a30be579 | ||
|
|
8915fe7df6 |
+5
-1
@@ -53,7 +53,7 @@ Grenzen: 10 MiB pro Datei, fünf Dateien und 25 MiB pro Nachricht; standardmäß
|
||||
|
||||
`npm run uploads:cleanup` zeigt zunächst nur verwaiste Dateien mit mindestens 24 Stunden Alter. Nach Prüfung entfernt `npm run uploads:cleanup -- --delete` diese Dateien. Backups, Speicherüberwachung und eine Aufbewahrungs-/Löschstrategie für Backups separat einrichten. Dateien gelöschter Datensätze werden auch unmittelbar bestmöglich entfernt.
|
||||
|
||||
Die SQL-Migrationen ergänzen **41 zusammengesetzte Tenant-Fremdschlüssel**, die Prisma wegen der vorhandenen optionalen Cascade-/SetNull-Beziehungen nicht vollständig abbildet. Zukünftige generierte Migrationen auf unbeabsichtigtes Entfernen dieser Constraints prüfen. Kein `prisma db push` auf verwalteten Datenbanken einsetzen. Runtime-Grants nach zukünftigen Migrationen erneut bereitstellen.
|
||||
Die SQL-Migrationen ergänzen **42 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
|
||||
|
||||
@@ -68,3 +68,7 @@ npm audit --omit=dev
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
@@ -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,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). 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.
|
||||
+3
-1
@@ -24,7 +24,9 @@
|
||||
"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:parent-flows:integration": "tsx --experimental-test-module-mocks --test tests/parent-flows.integration.test.ts",
|
||||
"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"
|
||||
|
||||
@@ -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;
|
||||
@@ -268,6 +268,7 @@ model User {
|
||||
|
||||
mitbringselItems MitbringselItem[]
|
||||
invitationsCreated Invitation[] @relation("InvitationCreator")
|
||||
familyInvitation Invitation? @relation("InvitationRecipient")
|
||||
auditLogsAuthored AuditLog[] @relation("AuditLogActor")
|
||||
staffProfile StaffProfile?
|
||||
staffNotifications StaffNotification[]
|
||||
@@ -749,10 +750,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)
|
||||
|
||||
@@ -8,6 +8,10 @@ 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:-}" ]; then
|
||||
if [ "${#NOTDIENST_JOB_SECRET}" -lt 32 ]; then
|
||||
echo "NOTDIENST_JOB_SECRET muss mindestens 32 Zeichen haben." >&2
|
||||
exit 1
|
||||
fi
|
||||
node scripts/notdienst-alarm-worker.mjs &
|
||||
fi
|
||||
exec node server.js
|
||||
|
||||
+223
-123
@@ -1,24 +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: dateOnlySchema,
|
||||
endDate: dateOnlySchema,
|
||||
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: dateOnlySchema,
|
||||
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;
|
||||
@@ -28,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(
|
||||
@@ -144,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,
|
||||
@@ -167,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,
|
||||
@@ -192,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(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,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;
|
||||
@@ -26,6 +28,7 @@ interface FamilyUser {
|
||||
lastName: string;
|
||||
email: string;
|
||||
emailVerifiedAt: Date | null;
|
||||
invitation: FamilyInvitationView;
|
||||
dutyAssignments: {
|
||||
id: string;
|
||||
dutyId: string;
|
||||
@@ -250,6 +253,7 @@ export function FamiliesListClient({
|
||||
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
|
||||
</span>
|
||||
</div>
|
||||
<InvitationStatus userId={parent.id} name={`${parent.firstName} ${parent.lastName}`} invitation={parent.invitation} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -288,6 +292,7 @@ export function FamiliesListClient({
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
email: parent.email,
|
||||
invitation: parent.invitation,
|
||||
})),
|
||||
children: family.children,
|
||||
}}
|
||||
@@ -357,6 +362,7 @@ export function FamiliesListClient({
|
||||
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
|
||||
</span>
|
||||
</div>
|
||||
<InvitationStatus userId={parent.id} name={`${parent.firstName} ${parent.lastName}`} invitation={parent.invitation} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -393,6 +399,7 @@ export function FamiliesListClient({
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
email: parent.email,
|
||||
invitation: parent.invitation,
|
||||
})),
|
||||
children: family.children,
|
||||
}}
|
||||
|
||||
@@ -1,45 +1,54 @@
|
||||
"use server";
|
||||
|
||||
import { hashToken, newToken } from "@/lib/tokens";
|
||||
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 { prisma } from "@/lib/prisma";
|
||||
|
||||
const INVITE_TOKEN_TTL_DAYS = 7;
|
||||
const BASE_URL =
|
||||
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
||||
|
||||
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(),
|
||||
@@ -52,12 +61,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(
|
||||
@@ -83,12 +99,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>) {
|
||||
@@ -115,32 +126,14 @@ function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
|
||||
return { ok: true as const, parent: parsed.data };
|
||||
}
|
||||
|
||||
function createInviteToken() {
|
||||
const token = newToken();
|
||||
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(
|
||||
@@ -185,11 +178,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 },
|
||||
@@ -211,12 +199,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) => {
|
||||
@@ -239,19 +229,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: hashToken(invite.token),
|
||||
expires: invite.expires,
|
||||
},
|
||||
});
|
||||
const invite = await queueFamilyInvitation(
|
||||
tx,
|
||||
createdParent.id,
|
||||
session.user.id,
|
||||
);
|
||||
inviteJobs.push(invite.id);
|
||||
}
|
||||
|
||||
await tx.child.createMany({
|
||||
@@ -274,26 +257,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;
|
||||
|
||||
@@ -316,9 +298,13 @@ 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);
|
||||
@@ -328,13 +314,6 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
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 },
|
||||
@@ -344,7 +323,7 @@ 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({
|
||||
@@ -362,8 +341,6 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
|
||||
const needsEmail = newParents.length > 0 || parents.some(parent => existingFamily.users.find(user => user.id === parent.id)?.email !== parent.email);
|
||||
if (needsEmail) {
|
||||
const configError = getAppEmailConfigError();
|
||||
if (configError) return { error: configError };
|
||||
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { error: "Zu viele Einladungen. Bitte versuche es später erneut." };
|
||||
}
|
||||
|
||||
@@ -371,7 +348,9 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
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))) {
|
||||
@@ -402,10 +381,12 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
...(emailChanged ? { passwordHash: "", emailVerifiedAt: null, sessionVersion: { increment: 1 } } : {}) },
|
||||
});
|
||||
if (emailChanged) {
|
||||
const invite = createInviteToken();
|
||||
await tx.verificationToken.deleteMany({ where: { identifier: parent.id } });
|
||||
await tx.verificationToken.create({ data: { identifier: parent.id, token: hashToken(invite.token), expires: invite.expires } });
|
||||
inviteJobs.push({ to: parent.email, parentName: `${parent.firstName} ${parent.lastName}`, inviteUrl: invite.inviteUrl });
|
||||
const invite = await queueFamilyInvitation(
|
||||
tx,
|
||||
parent.id,
|
||||
session.user.id,
|
||||
);
|
||||
inviteJobs.push(invite.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,19 +402,12 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
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: hashToken(invite.token),
|
||||
expires: invite.expires,
|
||||
},
|
||||
});
|
||||
const invite = await queueFamilyInvitation(
|
||||
tx,
|
||||
createdParent.id,
|
||||
session.user.id,
|
||||
);
|
||||
inviteJobs.push(invite.id);
|
||||
}
|
||||
|
||||
for (const child of activeChildren) {
|
||||
@@ -473,41 +447,48 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
return { error: "Familie konnte nicht aktualisiert werden." };
|
||||
}
|
||||
|
||||
const emailError = await sendInviteJobs(kita.name, inviteJobs);
|
||||
if (emailError) {
|
||||
return {
|
||||
error: `Familie wurde aktualisiert, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`,
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
if (typeof userId !== "string" || !userId || userId.length > 64) return { error: "Ungültige Eingabedaten." };
|
||||
if (!(await rateLimit(`family-invites:${session.user.kitaId}`, 30, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
|
||||
if (!(await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
|
||||
const user = await prisma.user.findFirst({ where: { id: userId, kitaId: session.user.kitaId, emailVerifiedAt: null, passwordHash: "", disabledAt: null },
|
||||
select: { id: true, email: true, firstName: true, lastName: true, kita: { select: { name: true } } } });
|
||||
if (!user?.kita) return { error: "Keine offene Einladung gefunden." };
|
||||
const configError = getAppEmailConfigError(); if (configError) return { error: configError };
|
||||
const invite = createInviteToken();
|
||||
const renewed = await prisma.$transaction(async tx => {
|
||||
// Lock and re-check the account before issuing a link for the selected mailbox.
|
||||
// A concurrent address change/activation must invalidate this resend.
|
||||
const eligible = await tx.user.updateMany({ where: { id: user.id, email: user.email, kitaId: session.user.kitaId,
|
||||
emailVerifiedAt: null, passwordHash: "", disabledAt: null }, data: { sessionVersion: { increment: 1 } } });
|
||||
if (eligible.count !== 1) return false;
|
||||
await tx.verificationToken.deleteMany({ where: { identifier: user.id } });
|
||||
await tx.verificationToken.create({ data: { identifier: user.id, token: hashToken(invite.token), expires: invite.expires } });
|
||||
return true;
|
||||
});
|
||||
if (!renewed) return { error: "Die Einladung hat sich geändert. Bitte lade die Seite neu." };
|
||||
const error = await sendInviteJobs(user.kita.name, [{ to: user.email, parentName: `${user.firstName} ${user.lastName}`, inviteUrl: invite.inviteUrl }]);
|
||||
return error ? { error } : { success: true };
|
||||
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"
|
||||
|
||||
@@ -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. Geänderte E-Mail-Adressen müssen über einen neuen Einladungslink bestätigt werden.");
|
||||
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" };
|
||||
@@ -32,6 +33,9 @@ export default async function FamiliesPage() {
|
||||
lastName: true,
|
||||
email: true,
|
||||
emailVerifiedAt: true,
|
||||
familyInvitation: {
|
||||
select: { expiresAt: true, sentAt: true, failed: true, leaseUntil: true, status: true },
|
||||
},
|
||||
dutyAssignments: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -73,7 +77,13 @@ 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}
|
||||
initialFamilies={families.map((family) => ({
|
||||
...family,
|
||||
users: family.users.map(({ familyInvitation, ...parent }) => ({
|
||||
...parent,
|
||||
invitation: familyInvitationView(parent.emailVerifiedAt, familyInvitation),
|
||||
})),
|
||||
}))}
|
||||
allDuties={allDuties}
|
||||
canManageDuties={canManageDuties}
|
||||
/>
|
||||
|
||||
+10
-13
@@ -16,6 +16,7 @@ import { UserRole } from "@prisma/client";
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { getTargetMonthData } from "@/lib/date-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { absenceDate, absenceToday, canManageKitaAbsences } from "@/lib/absence-policy";
|
||||
import { Stat } from "@/components/ui/stat";
|
||||
import { OnboardingDialog } from "@/components/OnboardingDialog";
|
||||
import { NewsTicker } from "./news-ticker";
|
||||
@@ -30,8 +31,8 @@ export const metadata = { title: "Übersicht · Kita-Planer" };
|
||||
export default async function DashboardPage() {
|
||||
const session = await requireKitaSession();
|
||||
const now = new Date();
|
||||
const today = new Date(now);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayKey = absenceToday(now);
|
||||
const today = absenceDate(todayKey);
|
||||
|
||||
const targetData = getTargetMonthData();
|
||||
const targetMonthStart = new Date(
|
||||
@@ -138,6 +139,7 @@ export default async function DashboardPage() {
|
||||
note: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
updatedAt: true,
|
||||
child: {
|
||||
select: {
|
||||
firstName: true,
|
||||
@@ -225,32 +227,27 @@ export default async function DashboardPage() {
|
||||
)}
|
||||
{canReportAbsences ? (
|
||||
<AbsenceCard
|
||||
today={format(today, "yyyy-MM-dd")}
|
||||
today={todayKey}
|
||||
childOptions={absenceChildren.map((child) => ({
|
||||
id: child.id,
|
||||
name: `${child.firstName} ${child.lastName}`,
|
||||
}))}
|
||||
absences={upcomingAbsences.map((absence) => ({
|
||||
id: absence.id,
|
||||
childName: `${absence.child.firstName} ${absence.child.lastName}`,
|
||||
reason: absence.reason,
|
||||
note: absence.note,
|
||||
startDate: format(absence.startDate, "yyyy-MM-dd"),
|
||||
endDate: format(absence.endDate, "yyyy-MM-dd"),
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
<UpcomingAbsencesCard
|
||||
today={todayKey}
|
||||
team={canManageKitaAbsences(session.user.role)}
|
||||
absences={upcomingAbsences.map((absence) => ({
|
||||
id: absence.id,
|
||||
childName: `${absence.child.firstName} ${absence.child.lastName}`,
|
||||
reason: absence.reason,
|
||||
note: absence.note,
|
||||
startDate: format(absence.startDate, "yyyy-MM-dd"),
|
||||
endDate: format(absence.endDate, "yyyy-MM-dd"),
|
||||
startDate: absence.startDate.toISOString().slice(0, 10),
|
||||
endDate: absence.endDate.toISOString().slice(0, 10),
|
||||
updatedAt: absence.updatedAt.toISOString(),
|
||||
}))}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
@@ -153,6 +153,10 @@ export async function acceptInviteAction(
|
||||
sessionVersion: { increment: 1 } },
|
||||
});
|
||||
if (activated.count !== 1) throw new Error("INVITE_USED");
|
||||
await tx.invitation.updateMany({
|
||||
where: { token, userId },
|
||||
data: { status: "ACCEPTED", acceptedAt: now, leaseUntil: null },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "INVITE_USED") return { attempt, values, errors: { _form: ["Dieser Link kann nicht mehr verwendet werden."] } };
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Pencil, Undo2 } from "lucide-react";
|
||||
import { AbsenceReason } from "@prisma/client";
|
||||
import { toast } from "sonner";
|
||||
import { deleteAbsence, updateAbsence } from "@/actions/absences";
|
||||
import { absenceDate, canEditOwnAbsence } from "@/lib/absence-policy";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export type ManagedAbsence = {
|
||||
id: string;
|
||||
childName: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
reason: AbsenceReason;
|
||||
note: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function AbsenceManagementActions({
|
||||
absence,
|
||||
today,
|
||||
team = false,
|
||||
onChanged,
|
||||
}: {
|
||||
absence: ManagedAbsence;
|
||||
today: string;
|
||||
team?: boolean;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<"edit" | "withdraw" | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [startDate, setStartDate] = useState(absence.startDate);
|
||||
const [endDate, setEndDate] = useState(absence.endDate);
|
||||
const router = useRouter();
|
||||
if (!team && !canEditOwnAbsence(absence.startDate, today))
|
||||
return (
|
||||
<p className="mt-2 text-xs text-[var(--ink-muted)]">
|
||||
Bereits begonnene Meldung: Änderungen über die Kita.
|
||||
</p>
|
||||
);
|
||||
const tomorrow = new Date(absenceDate(today).getTime() + 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
function open(next: "edit" | "withdraw") {
|
||||
setStartDate(absence.startDate);
|
||||
setEndDate(absence.endDate);
|
||||
setError(null);
|
||||
setMode(next);
|
||||
}
|
||||
function submit(data?: FormData) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const identity = { id: absence.id, updatedAt: absence.updatedAt };
|
||||
const result =
|
||||
mode === "withdraw"
|
||||
? await deleteAbsence(identity)
|
||||
: await updateAbsence({
|
||||
...identity,
|
||||
startDate,
|
||||
endDate,
|
||||
reason: String(data?.get("reason") ?? ""),
|
||||
note: String(data?.get("note") ?? ""),
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
mode === "withdraw"
|
||||
? "Abwesenheitsmeldung zurückgenommen."
|
||||
: "Abwesenheitsmeldung aktualisiert.",
|
||||
);
|
||||
setMode(null);
|
||||
onChanged?.();
|
||||
} catch {
|
||||
setError(
|
||||
"Die Meldung konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => open("edit")}
|
||||
aria-label={`Abwesenheit bearbeiten: ${absence.childName}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" /> Bearbeiten
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => open("withdraw")}
|
||||
aria-label={`Abwesenheit zurücknehmen: ${absence.childName}`}
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" /> Zurücknehmen
|
||||
</Button>
|
||||
<Dialog
|
||||
open={mode !== null}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen && !pending) setMode(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === "withdraw"
|
||||
? "Abwesenheit zurücknehmen"
|
||||
: "Abwesenheit bearbeiten"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{absence.childName}
|
||||
{mode === "withdraw"
|
||||
? ": Diese Meldung wird aus der Abwesenheitsübersicht entfernt."
|
||||
: ": Passe den Zeitraum, den Grund oder die Bemerkung an."}
|
||||
{!team && mode === "edit"
|
||||
? " Änderungen sind nur für zukünftige Tage möglich."
|
||||
: ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit(new FormData(event.currentTarget));
|
||||
}}
|
||||
>
|
||||
{mode === "edit" && (
|
||||
<fieldset disabled={pending} className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`absence-start-${absence.id}`}>Von</Label>
|
||||
<Input
|
||||
id={`absence-start-${absence.id}`}
|
||||
type="date"
|
||||
required
|
||||
min={team ? undefined : tomorrow}
|
||||
value={startDate}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setStartDate(value);
|
||||
if (value > endDate) setEndDate(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`absence-end-${absence.id}`}>Bis</Label>
|
||||
<Input
|
||||
id={`absence-end-${absence.id}`}
|
||||
type="date"
|
||||
required
|
||||
min={startDate}
|
||||
value={endDate}
|
||||
onChange={(event) => setEndDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`absence-reason-${absence.id}`}>Grund</Label>
|
||||
<select
|
||||
id={`absence-reason-${absence.id}`}
|
||||
name="reason"
|
||||
defaultValue={absence.reason}
|
||||
className="h-10 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 text-sm"
|
||||
required
|
||||
>
|
||||
<option value={AbsenceReason.ILLNESS}>Krank</option>
|
||||
<option value={AbsenceReason.VACATION}>Urlaub</option>
|
||||
<option value={AbsenceReason.OTHER}>Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`absence-note-${absence.id}`}>
|
||||
Bemerkung (optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id={`absence-note-${absence.id}`}
|
||||
name="note"
|
||||
maxLength={500}
|
||||
defaultValue={absence.note ?? ""}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="my-4 rounded-lg bg-[var(--danger-soft)] p-3 text-sm text-[var(--danger)]"
|
||||
>
|
||||
{error}
|
||||
{error.includes("aktualisieren") && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setMode(null);
|
||||
onChanged?.();
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
Ansicht aktualisieren
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={pending}
|
||||
onClick={() => setMode(null)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
variant={mode === "withdraw" ? "destructive" : "default"}
|
||||
>
|
||||
{pending
|
||||
? "Wird gespeichert…"
|
||||
: mode === "withdraw"
|
||||
? "Meldung zurücknehmen"
|
||||
: "Änderungen speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,14 @@ const navItems = [
|
||||
icon: Megaphone,
|
||||
exact: false,
|
||||
},
|
||||
{
|
||||
section: "Heute",
|
||||
href: "/dashboard/abwesenheiten",
|
||||
label: "Meine Abwesenheiten",
|
||||
icon: Stethoscope,
|
||||
exact: true,
|
||||
allowedRoles: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR],
|
||||
},
|
||||
{
|
||||
section: "Heute",
|
||||
href: "/dashboard/notdienst",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Absences are calendar dates stored as UTC dates, independent of the server timezone.
|
||||
export function absenceDate(value: string) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error("Ungültiges Datum.");
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value)
|
||||
throw new Error("Ungültiges Datum.");
|
||||
return date;
|
||||
}
|
||||
|
||||
export function isAbsenceDate(value: string) {
|
||||
try {
|
||||
absenceDate(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function absenceToday(now = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(now);
|
||||
const part = (type: string) =>
|
||||
parts.find((item) => item.type === type)!.value;
|
||||
return `${part("year")}-${part("month")}-${part("day")}`;
|
||||
}
|
||||
|
||||
export function canManageKitaAbsences(role: string) {
|
||||
return ["ADMIN", "KOORDINATOR", "ERZIEHER"].includes(role);
|
||||
}
|
||||
|
||||
export function canEditOwnAbsence(startDate: string, today: string) {
|
||||
return startDate > today;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export type FamilyInvitationView = {
|
||||
status: "active" | "open" | "sending" | "sent" | "failed" | "expired";
|
||||
expiresAt: string | null;
|
||||
sentAt: string | null;
|
||||
};
|
||||
|
||||
export function familyInvitationView(
|
||||
emailVerifiedAt: Date | null,
|
||||
invitation: {
|
||||
expiresAt: Date;
|
||||
sentAt: Date | null;
|
||||
failed: boolean;
|
||||
leaseUntil: Date | null;
|
||||
status: string;
|
||||
} | null,
|
||||
now = new Date(),
|
||||
): FamilyInvitationView {
|
||||
let status: FamilyInvitationView["status"] = "open";
|
||||
if (emailVerifiedAt) status = "active";
|
||||
else if (invitation) {
|
||||
if (
|
||||
invitation.expiresAt <= now ||
|
||||
["EXPIRED", "REVOKED"].includes(invitation.status)
|
||||
)
|
||||
status = "expired";
|
||||
else if (invitation.leaseUntil && invitation.leaseUntil > now)
|
||||
status = "sending";
|
||||
else if (invitation.failed) status = "failed";
|
||||
else if (invitation.sentAt) status = "sent";
|
||||
}
|
||||
return {
|
||||
status,
|
||||
expiresAt: invitation?.expiresAt.toISOString() ?? null,
|
||||
sentAt: invitation?.sentAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { hashToken, newToken } from "@/lib/tokens";
|
||||
import { createElement } from "react";
|
||||
import { InvitationStatus, Prisma } from "@prisma/client";
|
||||
|
||||
import { InviteEmail } from "@/emails/InviteEmail";
|
||||
import { sendAppEmail } from "@/lib/mail";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const INVITE_LIFETIME_MS = 7 * 24 * 60 * 60_000;
|
||||
|
||||
// The account, activation token and delivery status commit together.
|
||||
export async function queueFamilyInvitation(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
invitedById: string,
|
||||
) {
|
||||
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
if (!user.kitaId || !user.familyId || user.emailVerifiedAt || user.passwordHash || user.disabledAt)
|
||||
throw new Error("Dieses Konto benötigt keine Familieneinladung.");
|
||||
const existing = await tx.invitation.findFirst({
|
||||
where: {
|
||||
OR: [{ userId }, { kitaId: user.kitaId, email: user.email }],
|
||||
},
|
||||
});
|
||||
if (existing?.userId && existing.userId !== userId)
|
||||
throw new Error("Die Einladung gehört zu einem anderen Konto.");
|
||||
if (existing?.leaseUntil && existing.leaseUntil > new Date())
|
||||
throw new Error("Eine Einladung wird gerade versendet. Bitte kurz warten.");
|
||||
const token = newToken();
|
||||
const expiresAt = new Date(Date.now() + INVITE_LIFETIME_MS);
|
||||
await tx.verificationToken.deleteMany({ where: { identifier: userId } });
|
||||
await tx.verificationToken.create({
|
||||
data: { identifier: userId, token: hashToken(token), expires: expiresAt },
|
||||
});
|
||||
const data = {
|
||||
userId,
|
||||
kitaId: user.kitaId,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token,
|
||||
expiresAt,
|
||||
invitedById,
|
||||
status: InvitationStatus.PENDING,
|
||||
acceptedAt: null,
|
||||
sentAt: null,
|
||||
lastAttemptAt: null,
|
||||
leaseUntil: null,
|
||||
attempts: 0,
|
||||
failed: false,
|
||||
};
|
||||
return existing
|
||||
? tx.invitation.update({ where: { id: existing.id }, data })
|
||||
: tx.invitation.create({ data });
|
||||
}
|
||||
|
||||
// Tests inject a transport. Every recipient is attempted despite individual failures.
|
||||
export async function deliverFamilyInvitations(
|
||||
ids: string[],
|
||||
send = sendAppEmail,
|
||||
) {
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
for (const id of new Set(ids)) {
|
||||
const now = new Date();
|
||||
const leaseUntil = new Date(now.getTime() + 5 * 60_000);
|
||||
const claim = await prisma.invitation.updateMany({
|
||||
where: {
|
||||
id,
|
||||
userId: { not: null },
|
||||
status: InvitationStatus.PENDING,
|
||||
sentAt: null,
|
||||
expiresAt: { gt: now },
|
||||
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
|
||||
},
|
||||
data: { leaseUntil, lastAttemptAt: now, attempts: { increment: 1 } },
|
||||
});
|
||||
if (!claim.count) continue;
|
||||
const finish = (data: Prisma.InvitationUpdateManyMutationInput) =>
|
||||
prisma.invitation.updateMany({
|
||||
where: { id, token: jobToken, leaseUntil },
|
||||
data: { ...data, leaseUntil: null },
|
||||
});
|
||||
let jobToken: string | undefined;
|
||||
try {
|
||||
const job = await prisma.invitation.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { user: true, kita: { select: { name: true } } },
|
||||
});
|
||||
jobToken = job.token;
|
||||
const token = await prisma.verificationToken.findUnique({
|
||||
where: { token: hashToken(job.token) },
|
||||
});
|
||||
const user = job.user;
|
||||
if (
|
||||
!user ||
|
||||
user.kitaId !== job.kitaId ||
|
||||
!user.familyId ||
|
||||
user.email !== job.email ||
|
||||
user.emailVerifiedAt ||
|
||||
user.disabledAt ||
|
||||
user.passwordHash !== "" ||
|
||||
!token ||
|
||||
token.identifier !== user.id ||
|
||||
token.expires <= now
|
||||
) {
|
||||
await finish({
|
||||
status: user?.emailVerifiedAt
|
||||
? InvitationStatus.ACCEPTED
|
||||
: InvitationStatus.REVOKED,
|
||||
failed: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||
process.env.AUTH_URL ||
|
||||
process.env.NEXTAUTH_URL;
|
||||
if (!base) throw new Error("App-Adresse für Einladungen fehlt.");
|
||||
const site = new URL(base);
|
||||
if (
|
||||
!["https:", "http:"].includes(site.protocol) ||
|
||||
site.username ||
|
||||
site.password
|
||||
)
|
||||
throw new Error("App-Adresse für Einladungen ist ungültig.");
|
||||
const result = await send({
|
||||
to: job.email,
|
||||
subject: `Einladung zu ${job.kita.name} im Kita-Planer`,
|
||||
idempotencyKey: `family-invitation/${job.token}`,
|
||||
react: createElement(InviteEmail, {
|
||||
parentName: `${user.firstName} ${user.lastName}`,
|
||||
kitaName: job.kita.name,
|
||||
inviteLink: new URL(`/invite/${job.token}`, site.origin).toString(),
|
||||
}),
|
||||
});
|
||||
if (result.success) {
|
||||
await finish({ sentAt: new Date(), failed: false });
|
||||
sent++;
|
||||
} else {
|
||||
await finish({ failed: true });
|
||||
failed++;
|
||||
}
|
||||
} catch {
|
||||
await finish({ failed: true });
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
return { sent, failed };
|
||||
}
|
||||
|
||||
export async function resendFamilyInvitation(
|
||||
kitaId: string,
|
||||
userId: string,
|
||||
actorId: string,
|
||||
expectedEmail?: string,
|
||||
) {
|
||||
const outcome = await prisma.$transaction(async (tx) => {
|
||||
const actor = await tx.user.findFirst({
|
||||
where: { id: actorId, kitaId, role: "ADMIN", disabledAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!actor)
|
||||
return {
|
||||
error: "Nur Kita-Admins dürfen Familieneinladungen erneut senden.",
|
||||
};
|
||||
// Serializes renewal with other renewals for this account.
|
||||
await tx.$queryRaw`SELECT "id" FROM "users" WHERE "id" = ${userId} AND "kitaId" = ${kitaId} FOR UPDATE`;
|
||||
const user = await tx.user.findFirst({
|
||||
where: { id: userId, kitaId, familyId: { not: null } },
|
||||
});
|
||||
if (!user) return { error: "Elternkonto wurde nicht gefunden." };
|
||||
if (user.emailVerifiedAt || user.passwordHash || user.disabledAt)
|
||||
return { error: "Keine offene Einladung gefunden." };
|
||||
if (expectedEmail && user.email !== expectedEmail)
|
||||
return { error: "Die Einladung hat sich geändert. Bitte lade die Seite neu." };
|
||||
const existing = await tx.invitation.findUnique({ where: { userId } });
|
||||
const now = new Date();
|
||||
if (existing?.leaseUntil && existing.leaseUntil > now)
|
||||
return {
|
||||
error: "Die Einladung wird gerade versendet. Bitte kurz warten.",
|
||||
};
|
||||
if (existing?.sentAt && now.getTime() - existing.sentAt.getTime() < 60_000)
|
||||
return {
|
||||
error:
|
||||
"Die Einladung wurde gerade versendet. Bitte nach einer Minute erneut versuchen.",
|
||||
};
|
||||
const token = existing
|
||||
? await tx.verificationToken.findUnique({
|
||||
where: { token: hashToken(existing.token) },
|
||||
})
|
||||
: null;
|
||||
if (
|
||||
existing &&
|
||||
existing.status === InvitationStatus.PENDING &&
|
||||
!existing.sentAt &&
|
||||
existing.email === user.email &&
|
||||
existing.expiresAt > now &&
|
||||
token?.identifier === user.id &&
|
||||
token.expires > now
|
||||
)
|
||||
return { id: existing.id };
|
||||
const invitation = await queueFamilyInvitation(tx, userId, actorId);
|
||||
return { id: invitation.id };
|
||||
});
|
||||
if (outcome.error || !outcome.id)
|
||||
return {
|
||||
error: outcome.error ?? "Einladung konnte nicht erstellt werden.",
|
||||
};
|
||||
const result = await deliverFamilyInvitations([outcome.id]);
|
||||
return {
|
||||
success: true,
|
||||
...(result.failed
|
||||
? {
|
||||
warning:
|
||||
"Einladung ist gespeichert, konnte aber nicht versendet werden. Du kannst den Versand erneut versuchen.",
|
||||
}
|
||||
: !result.sent
|
||||
? {
|
||||
warning:
|
||||
"Die Einladung ist vorbereitet. Bitte prüfe den aktuellen Versandstatus.",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
absenceDate,
|
||||
absenceToday,
|
||||
canEditOwnAbsence,
|
||||
canManageKitaAbsences,
|
||||
} from "../src/lib/absence-policy";
|
||||
import { familyInvitationView } from "../src/lib/family-invitation-view";
|
||||
|
||||
test("Abwesenheitsdatum akzeptiert echte Kalendertage und lehnt Überläufe ab", () => {
|
||||
assert.equal(
|
||||
absenceDate("2028-02-29").toISOString(),
|
||||
"2028-02-29T00:00:00.000Z",
|
||||
);
|
||||
for (const value of [
|
||||
"2026-02-29",
|
||||
"2026-04-31",
|
||||
"2026-13-01",
|
||||
"2026-1-09",
|
||||
"2026-10-09T12:00:00",
|
||||
"invalid",
|
||||
])
|
||||
assert.throws(() => absenceDate(value));
|
||||
});
|
||||
|
||||
test("Die Datumsgrenze folgt Berlin auch über Mitternacht und die Zeitumstellung", () => {
|
||||
assert.equal(absenceToday(new Date("2026-10-09T21:59:59Z")), "2026-10-09");
|
||||
assert.equal(absenceToday(new Date("2026-10-09T22:00:00Z")), "2026-10-10");
|
||||
assert.equal(absenceToday(new Date("2026-10-25T22:59:59Z")), "2026-10-25");
|
||||
assert.equal(absenceToday(new Date("2026-10-25T23:00:00Z")), "2026-10-26");
|
||||
assert.equal(canEditOwnAbsence("2026-10-09", "2026-10-09"), false);
|
||||
assert.equal(canEditOwnAbsence("2026-10-10", "2026-10-09"), true);
|
||||
assert.equal(canManageKitaAbsences("ELTERN"), false);
|
||||
for (const role of ["ADMIN", "KOORDINATOR", "ERZIEHER"])
|
||||
assert.equal(canManageKitaAbsences(role), true);
|
||||
});
|
||||
|
||||
test("Einladungsanzeige unterscheidet Aktivierung, Fehler, Ablauf und laufenden Versand", () => {
|
||||
const now = new Date("2026-10-09T12:00:00Z");
|
||||
const base = {
|
||||
expiresAt: new Date("2026-10-16T12:00:00Z"),
|
||||
sentAt: null,
|
||||
failed: false,
|
||||
leaseUntil: null,
|
||||
status: "PENDING",
|
||||
};
|
||||
assert.equal(familyInvitationView(null, null, now).status, "open");
|
||||
assert.equal(
|
||||
familyInvitationView(null, { ...base, failed: true }, now).status,
|
||||
"failed",
|
||||
);
|
||||
assert.equal(
|
||||
familyInvitationView(null, { ...base, expiresAt: now }, now).status,
|
||||
"expired",
|
||||
);
|
||||
assert.equal(
|
||||
familyInvitationView(null, { ...base, sentAt: now }, now).status,
|
||||
"sent",
|
||||
);
|
||||
assert.equal(
|
||||
familyInvitationView(
|
||||
null,
|
||||
{ ...base, leaseUntil: new Date(now.getTime() + 1000) },
|
||||
now,
|
||||
).status,
|
||||
"sending",
|
||||
);
|
||||
assert.equal(
|
||||
familyInvitationView(now, { ...base, failed: true }, now).status,
|
||||
"active",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,786 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mock, test } from "node:test";
|
||||
import { createRequire } from "node:module";
|
||||
import { resolve } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { PrismaClient, UserRole } from "@prisma/client";
|
||||
import { hashToken } from "../src/lib/tokens";
|
||||
import { absenceDate, absenceToday } from "../src/lib/absence-policy";
|
||||
|
||||
test(
|
||||
"Familieneinladungen und Abwesenheiten gegen isoliertes PostgreSQL",
|
||||
{ skip: !process.env.TEST_DATABASE_URL },
|
||||
async (t) => {
|
||||
const url = new URL(process.env.TEST_DATABASE_URL!);
|
||||
assert.ok(["127.0.0.1", "localhost"].includes(url.hostname));
|
||||
assert.ok(
|
||||
/test/.test(url.pathname),
|
||||
"Nur ausdrücklich bezeichnete Testdatenbanken",
|
||||
);
|
||||
process.env.DATABASE_URL = url.toString();
|
||||
process.env.NEXT_PUBLIC_SITE_URL = "http://127.0.0.1:3010";
|
||||
const db = new PrismaClient();
|
||||
const suffix = randomUUID();
|
||||
const kitaId = `flow-test-${suffix}`,
|
||||
otherKitaId = `other-flow-${suffix}`;
|
||||
let actor: {
|
||||
id: string;
|
||||
kitaId: string;
|
||||
familyId: string | null;
|
||||
role: UserRole;
|
||||
};
|
||||
const deliveries: { to: string; key: string | undefined }[] = [];
|
||||
const fail = new Set<string>();
|
||||
let throwTransport = false;
|
||||
const refreshed: string[] = [];
|
||||
const moduleMock = mock as typeof mock & {
|
||||
module: (
|
||||
name: string,
|
||||
options: { namedExports: Record<string, unknown> },
|
||||
) => unknown;
|
||||
};
|
||||
moduleMock.module("../src/lib/auth-utils.ts", {
|
||||
namedExports: {
|
||||
requireKitaSession: async () => ({ user: actor }),
|
||||
requireRole: async (roles: UserRole[]) => {
|
||||
assert.ok(roles.includes(actor.role), "Rolle muss erlaubt sein");
|
||||
return { user: actor };
|
||||
},
|
||||
},
|
||||
});
|
||||
moduleMock.module("next/cache", {
|
||||
namedExports: { revalidatePath: (path: string) => refreshed.push(path) },
|
||||
});
|
||||
moduleMock.module("../src/auth.ts", {
|
||||
namedExports: { signIn: async () => {} },
|
||||
});
|
||||
moduleMock.module("../src/lib/mail.ts", {
|
||||
namedExports: {
|
||||
sendAppEmail: async (input: {
|
||||
to: string;
|
||||
idempotencyKey?: string;
|
||||
}) => {
|
||||
deliveries.push({ to: input.to, key: input.idempotencyKey });
|
||||
if (throwTransport) throw new Error("Simulierter Transportfehler");
|
||||
return fail.has(input.to)
|
||||
? { success: false, error: "Simulierter Mailfehler" }
|
||||
: { success: true, id: "test-only" };
|
||||
},
|
||||
getAppEmailConfigError: () => "Test ohne echte Mail-Konfiguration",
|
||||
},
|
||||
});
|
||||
const load = createRequire(
|
||||
resolve("tests/family-absence-flows.integration.test.ts"),
|
||||
);
|
||||
const familyActions = load(
|
||||
"../src/app/dashboard/families/actions",
|
||||
) as typeof import("../src/app/dashboard/families/actions");
|
||||
const absences = load(
|
||||
"../src/actions/absences",
|
||||
) as typeof import("../src/actions/absences");
|
||||
const { acceptInviteAction } = load(
|
||||
"../src/app/invite/[token]/actions",
|
||||
) as typeof import("../src/app/invite/[token]/actions");
|
||||
const { prisma } = load(
|
||||
"../src/lib/prisma",
|
||||
) as typeof import("../src/lib/prisma");
|
||||
const today = absenceToday();
|
||||
const day = (offset: number) =>
|
||||
new Date(absenceDate(today).getTime() + offset * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const actorOf = (user: {
|
||||
id: string;
|
||||
kitaId: string | null;
|
||||
familyId: string | null;
|
||||
role: UserRole;
|
||||
}) => ({ ...user, kitaId: user.kitaId! });
|
||||
const createUser = (
|
||||
role: UserRole,
|
||||
tenant = kitaId,
|
||||
familyId: string | null = null,
|
||||
) =>
|
||||
db.user.create({
|
||||
data: {
|
||||
kitaId: tenant,
|
||||
familyId,
|
||||
role,
|
||||
email: `${randomUUID()}@example.test`,
|
||||
passwordHash: "",
|
||||
firstName: "Test",
|
||||
lastName: role,
|
||||
privacyPolicyAcceptedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const form = (name: string, emails: string[]) => {
|
||||
const data = new FormData();
|
||||
for (const [key, value] of Object.entries({
|
||||
familyName: name,
|
||||
parent1FirstName: "Eva",
|
||||
parent1LastName: "Test",
|
||||
parent1Email: emails[0],
|
||||
childCount: "1",
|
||||
childFirstName_0: "Kind",
|
||||
childLastName_0: "Test",
|
||||
}))
|
||||
data.set(key, value);
|
||||
if (emails[1])
|
||||
for (const [key, value] of Object.entries({
|
||||
parent2FirstName: "Jan",
|
||||
parent2LastName: "Test",
|
||||
parent2Email: emails[1],
|
||||
}))
|
||||
data.set(key, value);
|
||||
return data;
|
||||
};
|
||||
const createAbsence = (
|
||||
childId: string,
|
||||
start: number,
|
||||
end = start,
|
||||
tenant = kitaId,
|
||||
) =>
|
||||
db.absence.create({
|
||||
data: {
|
||||
kitaId: tenant,
|
||||
childId,
|
||||
startDate: absenceDate(day(start)),
|
||||
endDate: absenceDate(day(end)),
|
||||
reason: "ILLNESS",
|
||||
},
|
||||
});
|
||||
const identity = (row: { id: string; updatedAt: Date }) => ({
|
||||
id: row.id,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
try {
|
||||
await db.kita.createMany({
|
||||
data: [
|
||||
{ id: kitaId, name: "Ablauftest", slug: kitaId },
|
||||
{ id: otherKitaId, name: "Andere Kita", slug: otherKitaId },
|
||||
],
|
||||
});
|
||||
const admin = await createUser("ADMIN");
|
||||
actor = actorOf(admin);
|
||||
const emails = [
|
||||
`failure-${suffix}@example.test`,
|
||||
`success-${suffix}@example.test`,
|
||||
];
|
||||
fail.add(emails[0]);
|
||||
let invitedUsers: Awaited<ReturnType<typeof db.user.findMany>> = [];
|
||||
|
||||
await t.test(
|
||||
"Mailfehler lässt Familie erfolgreich gespeichert; alle Empfänger werden versucht",
|
||||
async () => {
|
||||
const result = await familyActions.addFamilyAction(
|
||||
{},
|
||||
form("Eingeladen", emails),
|
||||
);
|
||||
assert.equal(result.success, true);
|
||||
assert.ok(result.warning);
|
||||
assert.equal(result.errors, undefined);
|
||||
assert.equal(
|
||||
await db.family.count({ where: { kitaId, name: "Eingeladen" } }),
|
||||
1,
|
||||
);
|
||||
assert.ok(refreshed.includes("/dashboard/families"));
|
||||
invitedUsers = await db.user.findMany({
|
||||
where: { email: { in: emails } },
|
||||
});
|
||||
assert.equal(invitedUsers.length, 2);
|
||||
const jobs = await db.invitation.findMany({
|
||||
where: { userId: { in: invitedUsers.map((user) => user.id) } },
|
||||
});
|
||||
assert.equal(jobs.length, 2);
|
||||
assert.equal(
|
||||
jobs.find((job) => job.email === emails[0])!.failed,
|
||||
true,
|
||||
);
|
||||
assert.ok(jobs.find((job) => job.email === emails[1])!.sentAt);
|
||||
assert.equal(deliveries.length, 2);
|
||||
const repeated = await familyActions.addFamilyAction(
|
||||
{},
|
||||
form("Eingeladen", emails),
|
||||
);
|
||||
assert.ok(repeated.errors);
|
||||
assert.equal(
|
||||
await db.family.count({ where: { kitaId, name: "Eingeladen" } }),
|
||||
1,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
"Gezielte Wiederholung behält den gültigen fehlgeschlagenen Link und erstellt keine Person erneut",
|
||||
async () => {
|
||||
const user = invitedUsers.find((user) => user.email === emails[0])!;
|
||||
const before = await db.invitation.findUniqueOrThrow({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
fail.clear();
|
||||
const result = await familyActions.resendFamilyInvitationAction(
|
||||
user.id,
|
||||
);
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.warning, undefined);
|
||||
const after = await db.invitation.findUniqueOrThrow({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
assert.equal(after.token, before.token);
|
||||
assert.equal(after.attempts, 2);
|
||||
assert.equal(after.failed, false);
|
||||
assert.ok(after.sentAt);
|
||||
assert.equal(deliveries.length, 3);
|
||||
assert.equal(deliveries[2].key, deliveries[0].key);
|
||||
assert.equal(
|
||||
await db.user.count({ where: { email: user.email } }),
|
||||
1,
|
||||
);
|
||||
const again = await familyActions.resendFamilyInvitationAction(
|
||||
user.id,
|
||||
);
|
||||
assert.ok(again.error);
|
||||
assert.equal(deliveries.length, 3);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
"Abgelaufener Link wird erneuert und alter Token ungültig",
|
||||
async () => {
|
||||
const user = invitedUsers[0];
|
||||
const before = await db.invitation.update({
|
||||
where: { userId: user.id },
|
||||
data: {
|
||||
expiresAt: absenceDate(day(-1)),
|
||||
sentAt: absenceDate(day(-2)),
|
||||
},
|
||||
});
|
||||
await db.verificationToken.update({
|
||||
where: { token: hashToken(before.token) },
|
||||
data: { expires: absenceDate(day(-1)) },
|
||||
});
|
||||
assert.equal(
|
||||
(await familyActions.resendFamilyInvitationAction(user.id)).success,
|
||||
true,
|
||||
);
|
||||
const after = await db.invitation.findUniqueOrThrow({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
assert.notEqual(after.token, before.token);
|
||||
assert.ok(after.expiresAt > new Date());
|
||||
assert.equal(
|
||||
await db.verificationToken.findUnique({
|
||||
where: { token: hashToken(before.token) },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
"Aktivierte und kita-fremde Konten werden nicht angeschrieben",
|
||||
async () => {
|
||||
const user = invitedUsers[0];
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { emailVerifiedAt: new Date() },
|
||||
});
|
||||
const before = deliveries.length;
|
||||
assert.ok(
|
||||
(await familyActions.resendFamilyInvitationAction(user.id)).error,
|
||||
);
|
||||
const foreignFamily = await db.family.create({
|
||||
data: { kitaId: otherKitaId, name: "Fremd" },
|
||||
});
|
||||
const foreign = await createUser(
|
||||
"ELTERN",
|
||||
otherKitaId,
|
||||
foreignFamily.id,
|
||||
);
|
||||
assert.ok(
|
||||
(await familyActions.resendFamilyInvitationAction(foreign.id))
|
||||
.error,
|
||||
);
|
||||
assert.equal(deliveries.length, before);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
"Hinzufügen eines Elternteils bei Mailfehler ist abgeschlossen und wiederholbar",
|
||||
async () => {
|
||||
const first = `${randomUUID()}@example.test`;
|
||||
assert.equal(
|
||||
(await familyActions.addFamilyAction({}, form("Ergänzen", [first])))
|
||||
.success,
|
||||
true,
|
||||
);
|
||||
const family = await db.family.findFirstOrThrow({
|
||||
where: { kitaId, name: "Ergänzen" },
|
||||
include: { users: true, children: true },
|
||||
});
|
||||
const second = `${randomUUID()}@example.test`;
|
||||
fail.add(second);
|
||||
const result = await familyActions.updateFamilyAction({
|
||||
familyId: family.id,
|
||||
familyName: family.name,
|
||||
parents: family.users,
|
||||
children: family.children,
|
||||
newParents: [{ firstName: "Neu", lastName: "Test", email: second }],
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
assert.ok(result.warning);
|
||||
assert.equal(result.error, undefined);
|
||||
assert.equal(
|
||||
await db.user.count({ where: { familyId: family.id } }),
|
||||
2,
|
||||
);
|
||||
fail.clear();
|
||||
const parent = await db.user.findUniqueOrThrow({
|
||||
where: { email: second },
|
||||
});
|
||||
assert.equal(
|
||||
(await familyActions.resendFamilyInvitationAction(parent.id))
|
||||
.success,
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
"Adresskorrektur eines offenen Kontos erneuert die Einladung für die neue Adresse",
|
||||
async () => {
|
||||
const family = await db.family.findFirstOrThrow({
|
||||
where: { kitaId, name: "Eingeladen" },
|
||||
include: { users: true, children: true },
|
||||
});
|
||||
const pending = family.users.find((user) => !user.emailVerifiedAt)!;
|
||||
const old = await db.invitation.findUniqueOrThrow({
|
||||
where: { userId: pending.id },
|
||||
});
|
||||
const email = `${randomUUID()}@example.test`;
|
||||
const result = await familyActions.updateFamilyAction({
|
||||
familyId: family.id,
|
||||
familyName: family.name,
|
||||
parents: family.users.map((user) => ({
|
||||
...user,
|
||||
email: user.id === pending.id ? email : user.email,
|
||||
})),
|
||||
children: family.children,
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
const renewed = await db.invitation.findUniqueOrThrow({
|
||||
where: { userId: pending.id },
|
||||
});
|
||||
assert.equal(renewed.email, email);
|
||||
assert.notEqual(renewed.token, old.token);
|
||||
assert.equal(
|
||||
await db.verificationToken.findUnique({
|
||||
where: { token: hashToken(old.token) },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(deliveries.at(-1)!.to, email);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
"Transportausnahme wird gespeichert statt den Familienvorgang zurückzuwerfen",
|
||||
async () => {
|
||||
throwTransport = true;
|
||||
const result = await familyActions.addFamilyAction(
|
||||
{},
|
||||
form("Transportfehler", [`${randomUUID()}@example.test`]),
|
||||
);
|
||||
assert.equal(result.success, true);
|
||||
assert.ok(result.warning);
|
||||
throwTransport = false;
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
"Alte Einladungen werden übernommen; paralleles erneutes Senden verschickt nur einmal und Aktivierung schließt den Status ab",
|
||||
async () => {
|
||||
const household = await db.family.create({
|
||||
data: { kitaId, name: "Alte Einladung" },
|
||||
});
|
||||
const user = await createUser("ELTERN", kitaId, household.id);
|
||||
const oldToken = randomUUID();
|
||||
await db.verificationToken.create({
|
||||
data: {
|
||||
identifier: user.id,
|
||||
token: hashToken(oldToken),
|
||||
expires: absenceDate(day(-1)),
|
||||
},
|
||||
});
|
||||
await db.invitation.create({
|
||||
data: {
|
||||
kitaId,
|
||||
email: user.email,
|
||||
token: oldToken,
|
||||
expiresAt: absenceDate(day(-1)),
|
||||
},
|
||||
});
|
||||
const before = deliveries.length;
|
||||
const results = await Promise.all([
|
||||
familyActions.resendFamilyInvitationAction(user.id),
|
||||
familyActions.resendFamilyInvitationAction(user.id),
|
||||
]);
|
||||
assert.ok(results.some((result) => result.success));
|
||||
assert.equal(deliveries.length, before + 1);
|
||||
assert.equal(
|
||||
await db.invitation.count({ where: { userId: user.id } }),
|
||||
1,
|
||||
);
|
||||
const job = await db.invitation.findUniqueOrThrow({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
assert.equal(
|
||||
await db.verificationToken.findUnique({
|
||||
where: { token: hashToken(oldToken) },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
const activation = new FormData();
|
||||
activation.set("token", job.token);
|
||||
activation.set("password", "Test-only-password-2026");
|
||||
activation.set("confirmPassword", "Test-only-password-2026");
|
||||
activation.set("acceptPrivacyPolicy", "on");
|
||||
await acceptInviteAction({ attempt: 0 }, activation);
|
||||
assert.ok(
|
||||
(await db.user.findUniqueOrThrow({ where: { id: user.id } }))
|
||||
.emailVerifiedAt,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await db.invitation.findUniqueOrThrow({
|
||||
where: { userId: user.id },
|
||||
})
|
||||
).status,
|
||||
"ACCEPTED",
|
||||
);
|
||||
assert.equal(
|
||||
await db.verificationToken.findUnique({
|
||||
where: { token: hashToken(job.token) },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const ownFamily = await db.family.create({
|
||||
data: { kitaId, name: "Eigener Haushalt" },
|
||||
});
|
||||
const otherFamily = await db.family.create({
|
||||
data: { kitaId, name: "Anderer Haushalt" },
|
||||
});
|
||||
const parent = await createUser("ELTERN", kitaId, ownFamily.id);
|
||||
const child = await db.child.create({
|
||||
data: {
|
||||
kitaId,
|
||||
familyId: ownFamily.id,
|
||||
firstName: "Eigen",
|
||||
lastName: "Kind",
|
||||
},
|
||||
});
|
||||
const otherChild = await db.child.create({
|
||||
data: {
|
||||
kitaId,
|
||||
familyId: otherFamily.id,
|
||||
firstName: "Fremd",
|
||||
lastName: "Kind",
|
||||
},
|
||||
});
|
||||
actor = actorOf(parent);
|
||||
let future = await createAbsence(child.id, 3, 4);
|
||||
|
||||
await t.test(
|
||||
"Eltern bearbeiten zukünftige Meldungen und die eigene Tagesgrenze wird eingehalten",
|
||||
async () => {
|
||||
const result = await absences.updateAbsence({
|
||||
...identity(future),
|
||||
startDate: day(5),
|
||||
endDate: day(6),
|
||||
reason: "VACATION",
|
||||
note: "Korrigiert",
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
future = await db.absence.findUniqueOrThrow({
|
||||
where: { id: future.id },
|
||||
});
|
||||
assert.equal(future.startDate.toISOString().slice(0, 10), day(5));
|
||||
assert.equal(future.note, "Korrigiert");
|
||||
assert.ok(refreshed.includes("/dashboard/abwesenheiten"));
|
||||
assert.ok(
|
||||
(
|
||||
await absences.updateAbsence({
|
||||
...identity(future),
|
||||
startDate: today,
|
||||
endDate: day(6),
|
||||
reason: "VACATION",
|
||||
})
|
||||
).error,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
"Begonnene Mehrtagesmeldungen bleiben für Eltern vollständig unveränderbar",
|
||||
async () => {
|
||||
const running = await createAbsence(child.id, -1, 1);
|
||||
assert.ok(
|
||||
(
|
||||
await absences.updateAbsence({
|
||||
...identity(running),
|
||||
startDate: day(1),
|
||||
endDate: day(2),
|
||||
reason: "OTHER",
|
||||
})
|
||||
).error,
|
||||
);
|
||||
assert.ok((await absences.deleteAbsence(identity(running))).error);
|
||||
assert.equal(
|
||||
await db.absence.count({ where: { id: running.id } }),
|
||||
1,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
"Andere Haushalte und Kitas bleiben bei Änderungen und Rücknahmen geschützt",
|
||||
async () => {
|
||||
const other = await createAbsence(otherChild.id, 10);
|
||||
assert.ok((await absences.deleteAbsence(identity(other))).error);
|
||||
assert.ok(
|
||||
(
|
||||
await absences.updateAbsence({
|
||||
...identity(other),
|
||||
startDate: day(11),
|
||||
endDate: day(11),
|
||||
reason: "OTHER",
|
||||
})
|
||||
).error,
|
||||
);
|
||||
actor = { ...actor, kitaId: otherKitaId };
|
||||
assert.ok((await absences.deleteAbsence(identity(future))).error);
|
||||
actor = actorOf(parent);
|
||||
await assert.rejects(() =>
|
||||
absences.getDailyAbsences({ date: today }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
"Ungültige Daten, Überschneidungen und veraltete Bearbeitungsstände werden abgewiesen",
|
||||
async () => {
|
||||
await createAbsence(child.id, 8, 9);
|
||||
assert.ok(
|
||||
(
|
||||
await absences.updateAbsence({
|
||||
...identity(future),
|
||||
startDate: day(8),
|
||||
endDate: day(8),
|
||||
reason: "OTHER",
|
||||
})
|
||||
).error,
|
||||
);
|
||||
assert.ok(
|
||||
(
|
||||
await absences.updateAbsence({
|
||||
...identity(future),
|
||||
startDate: "2026-02-30",
|
||||
endDate: day(8),
|
||||
reason: "OTHER",
|
||||
})
|
||||
).error,
|
||||
);
|
||||
assert.ok(
|
||||
(
|
||||
await absences.updateAbsence({
|
||||
...identity(future),
|
||||
startDate: day(7),
|
||||
endDate: day(6),
|
||||
reason: "OTHER",
|
||||
})
|
||||
).error,
|
||||
);
|
||||
assert.ok(
|
||||
(
|
||||
await absences.deleteAbsence({
|
||||
id: future.id,
|
||||
updatedAt: "2000-01-01T00:00:00.000Z",
|
||||
})
|
||||
).error,
|
||||
);
|
||||
assert.equal(await db.absence.count({ where: { id: future.id } }), 1);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
"Gleichzeitige Abmeldungen erzeugen höchstens eine überlappende Meldung",
|
||||
async () => {
|
||||
const payload = {
|
||||
childId: child.id,
|
||||
startDate: day(20),
|
||||
endDate: day(22),
|
||||
reason: "ILLNESS",
|
||||
};
|
||||
const results = await Promise.all([
|
||||
absences.reportAbsence(payload),
|
||||
absences.reportAbsence(payload),
|
||||
]);
|
||||
assert.equal(results.filter((result) => result.success).length, 1);
|
||||
assert.equal(results.filter((result) => result.error).length, 1);
|
||||
assert.equal(
|
||||
await db.absence.count({
|
||||
where: { childId: child.id, startDate: absenceDate(day(20)) },
|
||||
}),
|
||||
1,
|
||||
);
|
||||
assert.ok(
|
||||
(
|
||||
await absences.reportAbsence({
|
||||
...payload,
|
||||
startDate: day(-2),
|
||||
endDate: day(-2),
|
||||
})
|
||||
).error,
|
||||
);
|
||||
assert.ok(
|
||||
(
|
||||
await absences.reportAbsence({
|
||||
...payload,
|
||||
childId: otherChild.id,
|
||||
})
|
||||
).error,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
"Kita-Team korrigiert laufende Meldungen ohne eigenen Haushalt; Eltern ziehen zukünftige zurück",
|
||||
async () => {
|
||||
const running = await db.absence.findFirstOrThrow({
|
||||
where: { childId: child.id, startDate: absenceDate(day(-1)) },
|
||||
});
|
||||
for (const role of [
|
||||
"ADMIN",
|
||||
"KOORDINATOR",
|
||||
"ERZIEHER",
|
||||
] as UserRole[]) {
|
||||
const team = role === "ADMIN" ? admin : await createUser(role);
|
||||
actor = actorOf(team);
|
||||
const latest = await db.absence.findUniqueOrThrow({
|
||||
where: { id: running.id },
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await absences.updateAbsence({
|
||||
...identity(latest),
|
||||
startDate: day(-1),
|
||||
endDate: day(2),
|
||||
reason: "OTHER",
|
||||
note: role,
|
||||
})
|
||||
).success,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
(await absences.getDailyAbsences({ date: today })).absences![0]
|
||||
.startDate,
|
||||
day(-1),
|
||||
);
|
||||
}
|
||||
actor = actorOf(parent);
|
||||
assert.equal(
|
||||
(await absences.deleteAbsence(identity(future))).success,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await db.absence.findUnique({ where: { id: future.id } }),
|
||||
null,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
"Ein veraltetes Rollen-Token verleiht keine Teamrechte",
|
||||
async () => {
|
||||
actor = { ...actorOf(parent), role: "ADMIN" };
|
||||
const running = await db.absence.findFirstOrThrow({
|
||||
where: { childId: child.id, startDate: absenceDate(day(-1)) },
|
||||
});
|
||||
assert.ok((await absences.deleteAbsence(identity(running))).error);
|
||||
assert.ok(
|
||||
(
|
||||
await familyActions.resendFamilyInvitationAction(
|
||||
invitedUsers[1].id,
|
||||
)
|
||||
).error,
|
||||
);
|
||||
},
|
||||
);
|
||||
await t.test(
|
||||
"Additives Upgrade funktioniert auf leerem und bestehendem Schema und ist wiederholbar",
|
||||
async () => {
|
||||
const sql = await readFile(
|
||||
resolve("deploy/sql/familieneinladungen.sql"),
|
||||
"utf8",
|
||||
);
|
||||
const schema = `upgrade_test_${suffix.replaceAll("-", "")}`;
|
||||
await db.$executeRawUnsafe(`CREATE SCHEMA "${schema}"`);
|
||||
try {
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.$executeRawUnsafe(
|
||||
`SET LOCAL search_path TO "${schema}"`,
|
||||
);
|
||||
await tx.$executeRawUnsafe(sql);
|
||||
const fresh = await tx.$queryRawUnsafe<
|
||||
{ relation: string | null }[]
|
||||
>("SELECT to_regclass('invitations')::text AS relation");
|
||||
assert.equal(fresh[0].relation, null);
|
||||
await tx.$executeRawUnsafe(
|
||||
'CREATE TABLE "users" ("id" TEXT PRIMARY KEY)',
|
||||
);
|
||||
await tx.$executeRawUnsafe(
|
||||
'CREATE TABLE "invitations" ("id" TEXT PRIMARY KEY, "token" TEXT NOT NULL)',
|
||||
);
|
||||
await tx.$executeRawUnsafe(
|
||||
"INSERT INTO \"invitations\" (\"id\", \"token\") VALUES ('a', 'old-a'), ('b', 'old-b')",
|
||||
);
|
||||
await tx.$executeRawUnsafe(sql);
|
||||
await tx.$executeRawUnsafe(sql);
|
||||
const rows = await tx.$queryRawUnsafe<
|
||||
{
|
||||
token: string;
|
||||
userId: string | null;
|
||||
failed: boolean;
|
||||
attempts: number;
|
||||
}[]
|
||||
>(
|
||||
'SELECT "token", "userId", "failed", "attempts" FROM "invitations" ORDER BY "id"',
|
||||
);
|
||||
assert.deepEqual(rows, [
|
||||
{ token: "old-a", userId: null, failed: false, attempts: 0 },
|
||||
{ token: "old-b", userId: null, failed: false, attempts: 0 },
|
||||
]);
|
||||
});
|
||||
} finally {
|
||||
await db.$executeRawUnsafe(`DROP SCHEMA "${schema}" CASCADE`);
|
||||
}
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
const users = await db.user.findMany({
|
||||
where: { kitaId: { in: [kitaId, otherKitaId] } },
|
||||
select: { id: true },
|
||||
});
|
||||
await db.verificationToken.deleteMany({
|
||||
where: { identifier: { in: users.map((user) => user.id) } },
|
||||
});
|
||||
await db.kita.deleteMany({
|
||||
where: { id: { in: [kitaId, otherKitaId] } },
|
||||
});
|
||||
await Promise.all([db.$disconnect(), prisma.$disconnect()]);
|
||||
}
|
||||
},
|
||||
);
|
||||
+30
-1
@@ -163,6 +163,7 @@ test('database blocks cross-tenant child, user, RSVP and announcement links', as
|
||||
await assert.rejects(prisma.child.create({ data: { firstName: 'Wrong', lastName: 'Tenant', kitaId: kitaA.id, familyId: familyB.id } }), error => error.code === 'P2003');
|
||||
await assert.rejects(prisma.user.update({ where: { id: parentA.id }, data: { familyId: familyB.id } }), error => error.code === 'P2003');
|
||||
await assert.rejects(prisma.announcement.create({ data: { kitaId: kitaA.id, authorId: parentB.id, title: 'Wrong', content: 'Wrong' } }), error => error.code === 'P2003');
|
||||
await assert.rejects(prisma.invitation.create({ data: { kitaId: kitaA.id, userId: parentB.id, email: 'cross-' + prefix + '@example.invalid', token: randomUUID(), expiresAt: new Date(Date.now() + 60000), invitedById: adminA.id } }), error => error.code === 'P2003');
|
||||
const event = await prisma.termin.create({ data: { kitaId: kitaA.id, title: 'Event', type: 'SONSTIGES', status: 'CONFIRMED', startDate: new Date(), endDate: new Date() } });
|
||||
await assert.rejects(prisma.eventParticipation.create({ data: { kitaId: kitaA.id, eventId: event.id, childId: childB.id, status: 'ATTENDING' } }), error => error.code === 'P2003');
|
||||
});
|
||||
@@ -304,6 +305,7 @@ test('changing a parent email revokes sessions and requires fresh mailbox activa
|
||||
assert.equal(await authConfig.callbacks.jwt({ token: oldToken }),null);
|
||||
assert.equal(await authConfig.providers[0].authorize({ email: newEmail, password: 'Safe-test-password' }),null);
|
||||
const initialInvite = emails.at(-1).react.props.inviteLink.split('/').at(-1);
|
||||
await prisma.invitation.update({ where: { userId: parentA.id }, data: { sentAt: new Date(Date.now() - 61_000) } });
|
||||
assert.equal((await actions.resendParentInvite(parentA.id)).success,true);
|
||||
const renewedInvite = emails.at(-1).react.props.inviteLink.split('/').at(-1);
|
||||
assert.notEqual(initialInvite,renewedInvite);
|
||||
@@ -313,6 +315,33 @@ test('changing a parent email revokes sessions and requires fresh mailbox activa
|
||||
assert.equal((await accept({ attempt: 0 }, form(renewedInvite))).errors,undefined);
|
||||
assert.ok((await prisma.user.findUnique({ where: { id: parentA.id } })).emailVerifiedAt);
|
||||
});
|
||||
test('family admins keep their role when a changed mailbox is invited and activated', async () => {
|
||||
const family = await prisma.family.create({ data: { kitaId: kitaA.id, name: 'Admin family' } });
|
||||
const child = await prisma.child.create({ data: { kitaId: kitaA.id, familyId: family.id, firstName: 'Admin', lastName: 'Child' } });
|
||||
const user = await prisma.user.create({ data: { email: `family-admin.${prefix}@example.invalid`, firstName: 'Family', lastName: 'Admin',
|
||||
role: 'ADMIN', kitaId: kitaA.id, familyId: family.id, passwordHash: passHash,
|
||||
emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } });
|
||||
try {
|
||||
const load = loader({ '@/auth': { auth: async () => session(adminA), signIn: async () => {} }, '@/lib/mail': emailMock });
|
||||
const actions = load('src/app/dashboard/families/actions.ts');
|
||||
const updated = await actions.updateFamilyAction({ familyId: family.id, familyName: 'Family A',
|
||||
parents: [{ id: user.id, firstName: 'Family', lastName: 'Admin', email: `changed-family-admin.${prefix}@example.invalid` }],
|
||||
children: [{ id: child.id, firstName: 'A', lastName: 'Test' }] });
|
||||
assert.equal(updated.success,true,updated.error); assert.equal(updated.warning,undefined);
|
||||
assert.equal((await prisma.user.findUnique({ where: { id: user.id } })).role,'ADMIN');
|
||||
await prisma.invitation.update({ where: { userId: user.id }, data: { sentAt: new Date(Date.now() - 61_000) } });
|
||||
assert.equal((await actions.resendParentInvite(user.id)).success,true);
|
||||
const token = emails.at(-1).react.props.inviteLink.split('/').at(-1);
|
||||
const accepted = await load('src/app/invite/[token]/actions.ts').acceptInviteAction({ attempt: 0 }, fields({ token,
|
||||
password: 'Family-admin-new-password', confirmPassword: 'Family-admin-new-password', acceptPrivacyPolicy: 'on' }));
|
||||
assert.equal(accepted.errors,undefined);
|
||||
const activated = await prisma.user.findUnique({ where: { id: user.id } });
|
||||
assert.equal(activated.role,'ADMIN'); assert.ok(activated.emailVerifiedAt);
|
||||
} finally {
|
||||
await prisma.verificationToken.deleteMany({ where: { identifier: user.id } });
|
||||
await prisma.family.delete({ where: { id: family.id } });
|
||||
}
|
||||
});
|
||||
test('resending an invite cannot issue an active link to an obsolete mailbox', async () => {
|
||||
const user = await prisma.user.create({ data: { email: `resend-race.${prefix}@example.invalid`, firstName: 'Race', lastName: 'Test',
|
||||
role: 'ELTERN', kitaId: kitaA.id, familyId: familyA.id, passwordHash: '' } });
|
||||
@@ -355,7 +384,7 @@ test('upload quota holds under concurrent writes and accepted MIME comes from fi
|
||||
|
||||
test('all supplemental database tenant constraints are present', async () => {
|
||||
const [row] = await prisma.$queryRaw`SELECT count(*)::int AS count FROM pg_constraint WHERE conname LIKE '%_tenant_fkey'`;
|
||||
assert.equal(row.count,41);
|
||||
assert.equal(row.count,42);
|
||||
});
|
||||
test('role guard authenticates once and keeps role validation on that session', async () => {
|
||||
let calls = 0;
|
||||
|
||||
Reference in New Issue
Block a user