6 Commits
97 changed files with 7435 additions and 1093 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
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.
Fortschreibung: F01 (Familieneinladungen), F02 (Abwesenheitsverwaltung) und die Entfernung des künstlichen Gruppenfilters aus F12 wurden anschließend auf `codex/family-absence-flows` bearbeitet. Den neuen Ablauf und die Deployment-Schritte beschreibt [Familien und Abwesenheiten](FAMILIEN_UND_ABWESENHEITEN.md). F03, F04 und F05 (Verfügbarkeit bei manueller Zuweisung sowie Erstveröffentlichung) sind inzwischen ebenfalls umgesetzt; den aktuellen Stand und die Prüfungen dokumentiert die Ergänzung vom 12.10.2026 in [Funktionsübersicht](FUNKTIONSUEBERSICHT.md). F06 (Agenda-Details und Mitbringen), F09 (Arbeitszeitkontext und Ein-Tages-Anträge), F10 (personenbezogene Ämter-Schaltflächen) und F11 (Passwort-Wiederherstellung/-Änderung) sind ebenfalls umgesetzt; F08 enthält nun kontoabhängiges Ausblenden des Einrichtungshinweises. Die weiteren Teile von F08 und F11 bleiben durch diese Ergänzung unberührt. F07 (Mitteilungskorrektur, Verwaltung älterer Beiträge und getrennte Versandwiederholung) ist mit der Ergänzung vom 13.10.2026 ebenfalls umgesetzt. Die folgenden Befunde dokumentieren weiterhin den ursprünglichen Prüfstand.
## Umfang und Methode
+67
View File
@@ -1,5 +1,72 @@
# Kita-Planer – Funktionsübersicht und aktueller Stand
## Ergänzung vom 13.10.2026: Landingpage und SEO
- Die öffentliche Landingpage beschreibt jetzt sechs Bereiche, einschließlich Personal/Arbeitszeit mit eigener Detailseite `/features/personal-arbeitszeit`. Rollenvergabe, Wiederherstellung des Kontozugangs, Kalender-Rückmeldungen, Mitbringkorrekturen, zukünftige Abwesenheitsänderungen, Notdienst-Erinnerungen/Veröffentlichung und Mitteilungskorrekturen samt Versandübersicht sind in den passenden Texten ergänzt. Die Darstellung behält das vorhandene Design bei und ist auf Desktop sowie bei 390 und 360 Pixeln geprüft; der mobile Logo-Umbruch wurde behoben.
- Veraltete Aussagen zu „1-Klick-Alarmierung“, Raumbuchungen und Lesebestätigungen wurden durch die tatsächlich vorhandenen Abläufe ersetzt. Der persönliche Ungelesen-Status wird nicht als Nachweis einer tatsächlichen Kenntnisnahme oder als Admin-Auswertung beworben.
- `src/lib/marketing/seo.ts` bündelt öffentliche Domain, Titel, Beschreibungen, Canonicals und Vorschaudaten. Öffentliche Seiten besitzen eigene absolute Canonicals und `og:url`; Titel wiederholen den Markennamen nicht. Open Graph und Twitter verwenden passende Beschreibungen und das vorhandene, tatsächlich 1200 × 630 Pixel große Bild. Sein Alternativtext beschreibt die sichtbaren Spielklötze.
- Die Startseite liefert JSON-LD für WebSite, WebPage und SoftwareApplication; Feature-Seiten ergänzen BreadcrumbList und ihre eigene WebPage. Die Daten stammen aus den vorhandenen Produktbeschreibungen, enthalten keine erfundenen Bewertungen, Preise oder Anbieterangaben und werden gegen eingeschleuste HTML-Script-Enden abgesichert. Die Scripts verwenden den vorhandenen CSP-Nonce. Dies ist keine Zusage besonderer Google-Suchergebnisdarstellungen.
- Die Sitemap enthält ausschließlich neun öffentliche Canonical-Seiten: Startseite, sechs Feature-Seiten, Impressum und Datenschutz. Das Änderungsdatum folgt der tatsächlichen Änderung der Marketinginhalte (`marketingLastModified`) und wechselt nicht mehr bei jedem Build. Für rechtliche Seiten wird kein unbekanntes Änderungsdatum erfunden. Bei weiteren Änderungen der Produkttexte ist dieses Datum gezielt fortzuschreiben.
- Indexierung ist im Root-Layout standardmäßig ausgeschaltet; nur die öffentlichen Seiten schalten sie ausdrücklich ein. Private Seiten behalten `noindex, nofollow` und `private, no-store` auch als HTTP-Header. Login, Registrierung und Wiederherstellungs-Anforderungsformular bleiben für Crawler erreichbar, damit sie die `noindex`-Markierung lesen können. Interne und Tokenrouten bleiben zusätzlich in robots.txt ausgeschlossen. robots.txt ist keine Zugriffskontrolle und eine blockierte URL allein garantiert keine Entfernung aus einem Suchindex.
Prüfung: `npm run test:seo` (11 Tests gegen tatsächliche Metadaten-/Sitemap-/JSON-LD-Funktionen) sowie `TEST_SEO_ORIGIN=http://127.0.0.1:PORT npm run test:seo:http` (24 Tests gegen den lokal gestarteten Standalone-Produktionsbuild). Die HTTP-Prüfung umfasst alle neun öffentlichen HTML-Seiten, Social-Crawler-Metadaten direkt im Head, strukturierte Daten und CSP-Nonce, Sitemap, robots.txt, tatsächliche PNG-Bildmaße, private Seiten/Weiterleitungen und unbekannte Feature-URLs mit 404/noindex. Insgesamt 35 Tests erfolgreich, keine übersprungenen Tests. Build und TypeScript erfolgreich; ESLint ohne Fehler, mit fünf bereits bestehenden Warnungen.
Die bestehende Produktions-Landingpage, robots.txt und Sitemap wurden zusätzlich lesend abgeglichen. Die neuen Änderungen wurden lokal geprüft und sind noch nicht veröffentlicht. Für diese SEO-Ergänzung gibt es keine weitere Datenbankmigration; der Branch baut auf der vorherigen Mitteilungs-Etappe mit deren eigener Migration auf. Es wurden keine produktiven Daten verändert, Anfragen versendet oder Analytics-Ereignisse ausgelöst.
Offen: Impressum und Datenschutzhinweise enthalten im bestehenden Seiteninhalt weiterhin Platzhalter für Anbieter, Anschrift, Kontakt, verantwortliche Stelle und Dienstleister. Diese Angaben müssen von der verantwortlichen Person geliefert werden; die Metadaten-Ergänzung ersetzt sie nicht. Search-Console-Zugriff, tatsächlicher Google-Indexierungsstand und reale Nutzungs-/Performance-Messwerte waren nicht Teil der verfügbaren Prüfung.
Grundlagen: [Google zu Canonical-URLs](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls), [Sitemap-Änderungsdatum](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap) und [noindex](https://developers.google.com/search/docs/crawling-indexing/block-indexing); außerdem die mit Next.js 16.4 gelieferten Metadaten- und JSON-LD-Guides.
## Ergänzung vom 13.10.2026: Mitteilungen korrigieren und Versand verwalten
- Unter `/dashboard/admin/news` können Kita-Admins alle eigenen Beiträge nach Titel/Text durchsuchen und in Seiten mit zwölf Meldungen durchblättern. „Bearbeiten & Versand“ öffnet die Korrektur und den Versandstatus. Eltern- und Koordinationskonten erhalten keinen Verwaltungszugriff.
- Titel und Markdown-Text sind bearbeitbar. Empfängergruppe, ursprünglicher Autor und geschützte Anhänge bleiben erhalten. Gleichzeitige bzw. veraltete Korrekturen überschreiben keinen neueren Stand; Akteur, Kita, aktive Sitzung und Adminrolle werden beim Speichern erneut geprüft.
- Eine tatsächliche Textänderung erhält einen sichtbaren Aktualisierungszeitpunkt, setzt bisherige Lesemarkierungen zurück und rückt den Beitrag im Elternfeed nach vorne. Unverändertes Speichern erhält Lesestatus und Beitragsfassung. Ein noch geöffnetes altes Dialogfenster kann eine neuere Fassung nicht als gelesen markieren.
- Die aktuelle Fassung kann beim Speichern ausdrücklich auch per E-Mail vorgemerkt werden. Einmalige Aufträge je Beitrag/Fassung/Konto entstehen innerhalb derselben Datenbanktransaktion. Ohne ausdrückliche Auswahl erzeugt eine Korrektur keine neue Mailrunde. Ungesendete Aufträge älterer Fassungen werden übersprungen; historische erfolgreiche Sendungen bleiben in der Versandübersicht nachvollziehbar.
- Jede Person hat einen eigenen Status: ausstehend, gesendet, fehlgeschlagen oder übersprungen. „Gesendet“ bedeutet die Annahme durch den Maildienst, keine Lesebestätigung oder garantierte Postfachzustellung. Bestehende Beiträge erhalten keinen erfundenen historischen Versandstatus. Aktive, verifizierte Konten mit Zustimmung und passender Empfängergruppe werden vorbereitet; vor jedem Versand werden diese Voraussetzungen erneut geprüft. Haushaltskonten behalten ihre Eltern-Mailzugehörigkeit bei Admin-/Koordinationsrechten; Fachkräfte mit Adminrechten erhalten über ihr verknüpftes Personalprofil weiterhin Teammeldungen. Systemadmins gehören nicht zu diesen Empfängergruppen.
- Erste Versuche laufen nach der Antwort über Next.js `after`. Die gespeicherten Aufträge überleben Neustarts und werden zusätzlich vom bestehenden geschützten Hintergrundjob `/api/jobs/notdienstalarme` verarbeitet: zwölf fällige Aufträge je Lauf, bis zu vier parallel, wiederholte Fehler mit wachsendem Abstand bis sechs Stunden. Reservierungen laufen nach fünf Minuten ab, Wiederholungen behalten denselben Idempotenzschlüssel. Erfolgreiche Sendungen werden nicht erneut vorgemerkt. Der Maildienst begrenzt die zeitliche Gültigkeit seiner Idempotenzschlüssel; eine absolute Zustellgarantie über beliebig lange Abbrüche gibt es nicht.
- „Fehlgeschlagene erneut versuchen“ zieht ausschließlich fehlgeschlagene Aufträge der eigenen Kita und aktuellen Beitragsfassung vor. Während eines laufenden Versands wird eine Korrektur kurzzeitig zurückgewiesen, damit unterschiedliche Texte nicht unter derselben Fassung versendet werden. Verwaltung und Versandversuche haben eigene Anfragelimits.
- Aufträge mit Anhängen werden erst nach vollständiger Speicherung freigegeben. Dateischreibfehler nehmen Beitrag und Aufträge gemeinsam zurück. Nach einem Abbruch zwischen fertig geschriebenen Dateien und Freigabe prüft der Hintergrundlauf nach fünf Minuten die gespeicherten Dateigrößen und gibt nur vollständige Anhänge frei. Fehlende oder teilweise geschriebene Dateien bleiben gesperrt und benötigen eine Prüfung durch die Hosting-Administration.
Vor dem produktiven Start die Migration `20261012010000_announcement_delivery` über den bestehenden Maintenance-/Deployment-Ablauf anwenden (`npm run db:migrate:deploy`) und die Runtime-Berechtigungen wie gewohnt erneuern (`npm run db:provision-runtime`). Sie ergänzt Beitragsfassung, Änderungszeitpunkt, Freigabe und Versandaufträge; bestehende Beiträge behalten ihre bisherige Reihenfolge. Zwei zusammengesetzte Fremdschlüssel sichern die Beziehungen zur selben Kita zusätzlich in PostgreSQL. Keine neue Umgebungsvariable: bestehende Mailkonfiguration und App-Adresse werden verwendet. Automatische Wiederholungen benötigen den bereits vorhandenen Worker mit `NOTDIENST_JOB_SECRET` (mindestens 32 Zeichen); der gewöhnliche `npm run start` startet diesen Worker nicht. Fehlende Jobkonfiguration wird in der Versandansicht angezeigt.
Prüfbefehl: `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_announcements_test npm run test:announcements` nach Anwendung der Migrationen auf dieser separaten lokalen Datenbank. 31 neue PostgreSQL-Integrationstests erfolgreich, darunter drei konkurrierende Worker mit 24 Empfängern. Dazu 84 bestehende Sicherheits-, Notdienst-, Mailtransport- und Elternablaufprüfungen (115 insgesamt, keine übersprungen). Die Tests ersetzen Request-/UI-Grenzen, Mailtransport und bei Dateiabbruchtests die Dateisystemgrenze; Anwendungscode und PostgreSQL-Transaktionen werden tatsächlich ausgeführt. Produktionsbuild und TypeScript erfolgreich; ESLint ohne Fehler mit fünf bestehenden Warnungen.
Browser-Abnahme mit synthetischen Konten auf Desktop und bei 390 Pixeln: Suche und zweite Verwaltungsseite, ältere Meldung direkt korrigieren, isolierte Wiederholung einer fehlgeschlagenen Sendung, Korrektur ohne neue Mail, ausdrücklicher Versand der aktuellen Fassung sowie frühere Versandstände. Eltern sehen ältere Korrekturen wieder vorne und ungelesen; Öffnen speichert die aktuelle Lesemarkierung. Keine horizontale Überbreite. Der echte geschützte Hintergrundjob hat eine fällige synthetische Sendung verarbeitet. Es wurden keine produktiven Daten verändert und keine echten E-Mails verschickt.
## Ergänzung vom 12.10.2026: Kontozugang und weniger wiederholte Eingaben
- Die Ämter-Schaltflächen nennen die betroffene Person („Ämter: Vorname“) und tragen einen vollständigen zugänglichen Namen. Der Einrichtungshinweis wird je Konto in diesem Browser ausgeblendet. Das alte gemeinsame Ausblenden gilt nicht mehr für alle Konten; fehlender Browser-Speicher verhindert die Nutzung nicht.
- Unter `/passwort-vergessen` können aktivierte Konten einen Wiederherstellungslink anfordern. Die öffentliche Antwort bleibt für bestehende, unbekannte, deaktivierte und begrenzte Anfragen gleich. Kontosuche und Mailversand laufen nach der Antwort über Next.js `after`; ein Fehler verrät öffentlich keine Kontodaten.
- Reset-Links sind 30 Minuten gültig, einmal nutzbar und an die aktuelle Sitzungsfassung gebunden. Die vorhandene Verifikationstabelle speichert ausschließlich den Tokenhash und einen eigenen Reset-Zweck. Einladungslinks können kein Passwort zurücksetzen. Neue Reset-Anfragen ersetzen ältere Links; zwischenzeitliche E-Mail-/Passwortänderungen, administrative Kontosperren oder Löschungen verhindern die Verwendung alter Links. Fehlgeschlagener Versand entfernt ausschließlich den eigenen neu erzeugten Link.
- Im Profil lässt sich das Passwort mit dem aktuellen Passwort und übereinstimmender Bestätigung ändern. Sowohl Wiederherstellung als auch eigene Änderung erhöhen die Sitzungsfassung und beenden alte Anmeldungen; eine neue Anmeldung ist erforderlich. Persönliche Alarmlinks, die an diese frühere Sitzungsfassung gebunden sind, werden ebenfalls ungültig. Kontoaktivierung, Kita, Haushalt, Rollen und Einwilligungen bleiben erhalten. Es gelten weiterhin mindestens acht Zeichen und die bcrypt-Grenze von 72 UTF-8-Bytes.
- Anfragebegrenzungen: drei Reset-Anfragen je E-Mail/Stunde, fünf Reset-Abschlussversuche je Link/15 Minuten und fünf eigene Passwortänderungsversuche je Konto/15 Minuten; zusätzlich die bestehenden gemeinsamen und ggf. Proxy-IP-Limits. Bestehende Mailkonfiguration und App-Adresse werden verwendet. Keine neue Umgebungsvariable oder Datenbankmigration erforderlich.
- Die Kalender-Agenda öffnet den bereits vorhandenen Tagesdetaildialog mit Beschreibung, Rückmeldung, Mitbringliste und berechtigten Verwaltungsaktionen. Ansicht und Suche bleiben beim Schließen erhalten. Die Agenda passt sich auf schmalen Bildschirmen an.
- Neue Arbeitszeitformulare übernehmen die betrachtete Woche bzw. den ausgewählten Tag. Tage in der Zukunft sind nicht erfassbar. „Speichern und weiteren Block erfassen“ hält den tatsächlich eingegebenen Tag fest, leert die Uhrzeiten, setzt die Pause auf null und fokussiert den Beginn. Bestehende Zeiteinträge werden weiterhin einzeln bearbeitet; serverseitige Zeit-, Überschneidungs- und Freigaberegeln bleiben maßgeblich.
- Freizeitausgleich und halber Urlaubstag benötigen ein Datum. Das Ende wird aus diesem Datum übernommen; mehrtägiger Urlaub und Krankheit behalten Von/Bis. Die Berechnungs- und Genehmigungsregeln ändern sich nicht.
Prüfbefehl: `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_account_test npm run test:account` nach Anwendung der vorhandenen Migrationen auf dieser separaten lokalen Testdatenbank. Die 20 neuen PostgreSQL-Tests prüfen den tatsächlichen Passwortdienst, Server Actions, bcrypt und JWT-Entwertung mit ersetztem Mailtransport. Dazu 139 bestehende Sicherheits-, Rollen-, Kalender-, Familien- und Personaltests erfolgreich (159 insgesamt, keine übersprungenen Tests).
Browser-Abnahme mit synthetischen Konten auf Desktop und bei 390 Pixeln: neutrale Reset-Bestätigung und tatsächliche nachgelagerte Vorbereitung, gültige/ungültige Linkseiten, Passwortformular im Profil, getrennte Einrichtungshinweise bei Konto-Wechsel, eindeutige Ämter, Agenda-Details samt gespeichertem Mitbringbeitrag und erhaltenem Suchfilter. Arbeitszeit in vergangener Woche und am ausgewählten Tag geprüft; zwei Blöcke mit 60 und 45 Minuten am selben eingegebenen Tag gespeichert. Halber Urlaubstag mit identischem Start/Ende gespeichert; Freizeitausgleich zeigt ebenfalls nur ein Datum. Passwortänderungen selbst wurden in den Integrationstests ausgeführt. Es wurden keine echten Mails verschickt oder produktiven Daten verändert. Produktionsbuild und TypeScript erfolgreich; ESLint ohne Fehler mit fünf bestehenden Warnungen.
## Ergänzung vom 12.10.2026: Manuelle Planung und Zugriffsrechte
- Die manuelle Notdienst-Zuweisung gruppiert verfügbare Haushalte zuerst. Familien ohne Verfügbarkeit für den gewählten Tag bleiben als abgestimmter Sonderfall auswählbar und werden nach einer solchen Einteilung ausdrücklich gekennzeichnet. Geschwister zählen als ein Haushalt; Einteilungszahlen gelten ebenfalls je Haushalt. Die Anzeige verwendet dieselben gültigen Monatstage wie die Planung. Bestehende Alarm- und Schließtagssperren bleiben bestehen.
- Kita-Admins verwalten Zugriffsrollen unter `/dashboard/zugriffsrechte` sowie direkt bei Personen in Familien- und Teamverwaltung. Die Übersicht enthält auch Konten ohne eigenen Haushalt, eine Suche und die letzten 15 protokollierten Rollenänderungen der eigenen Kita.
- Haushaltskonten können Eltern, Notdienst-Koordination oder Kita-Admin sein. Ohne Haushalt stehen Koordination und Kita-Admin zur Verfügung. Konten mit Personalprofil können Fachkraft oder Kita-Admin sein; Arbeitszeiten, Anträge und Haushalt bleiben erhalten. Ein Personalprofil wird durch eine Rollenänderung nicht neu angelegt. Systemadmins werden hier nicht verwaltet. Ämter wie „Vorstand“ verleihen keine Zugriffsrechte.
- Rollenänderungen prüfen Akteur und Zielkonto erneut innerhalb einer serialisierbaren Transaktion. Eine zwischenzeitlich geänderte Rolle wird nicht überschrieben. Das bestehende Login liest die Rolle bei jeder Anfrage neu; eigene Herabstufung führt zurück zur Übersicht. Offene Aktivierungslinks benötigen weiterhin die Aktivierung, bevor Zugang entsteht.
- Mindestens ein aktiver Kita-Admin mit verifiziertem Konto, Passwort und Datenschutzzustimmung bleibt erhalten. Herabstufung, eigene Kontolöschung, Support-Löschung/-Sperre und eine E-Mail-Änderung mit erneuter Aktivierung berücksichtigen denselben Schutz. Offene Einladungen, deaktivierte/gesperrte Konten oder fehlende Zustimmung zählen nicht als Ersatz. Gleichzeitige Änderungen werden abgefangen.
- Eltern mit Admin-/Koordinationsrolle erhalten weiterhin Haushaltsalarme, können über ihre persönlichen Links antworten und erhalten Mails nach manueller Umteilung. Fachkräfte mit Admin-Rolle können ihr verknüpftes Personalprofil weiter nutzen; persönliche Benachrichtigungseinstellungen und offene Fachkraftaktivierungen bleiben funktionsfähig.
- Kein Schema-Upgrade erforderlich.
Prüfbefehl: `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_roles_test npm run test:roles` nach Anwendung der vorhandenen Migrationen auf dieser separaten lokalen Testdatenbank. Die 27 Rollen-/PostgreSQL-Tests und sechs tatsächlichen Komponenten-Rendertests verwenden synthetische Konten und ersetzte Mailtransporte.
Abnahme: 33 neue Tests und 193 bestehende Sicherheits-, Planungs-, Kalender-, Versand-, Familien-, Personal- und Alarmtests erfolgreich (226 insgesamt, keine übersprungenen Tests). Browserprüfung auf Desktop und bei 390 Pixeln: verfügbare und bewusst abweichende Einteilung, Rollenänderung aus Familien-/Teamverwaltung, Suche, Änderungshistorie, Schutz für einzigen Admin und eigene Herabstufung mit unmittelbar verweigertem Verwaltungszugriff. Produktionsbuild und TypeScript erfolgreich; ESLint ohne Fehler mit sieben bestehenden Warnungen.
## Ergänzung vom 12.10.2026: Notdienst-Benachrichtigungen
- Automatische Erinnerungen erfassen auch unvollständige Verfügbarkeiten. Das Soll wird aus allen aktiven Kindern und die Istzahl aus unterschiedlichen gültigen Tagen berechnet. Admin-/Koordinationskonten mit eigenem Haushalt gehören zur Eltern-Empfängergruppe.
+1 -1
View File
@@ -35,7 +35,7 @@ Aktive Alarme sperren manuelle Umzuordnung und Diensttausche. Für einen Ersatz
Benötigt werden die vorhandene Resend-Konfiguration (`RESEND_API_KEY`, `EMAIL_FROM`), die korrekte öffentliche App-Adresse (`NEXT_PUBLIC_SITE_URL`, alternativ `AUTH_URL`/`NEXTAUTH_URL`) und neu `NOTDIENST_JOB_SECRET` mit mindestens 32 Zeichen. Ein zufälliges Secret kann beispielsweise mit `openssl rand -hex 32` erzeugt und in der Hosting-Konfiguration gespeichert werden. Das Secret gehört nicht in Git oder ins Browser-Frontend.
Der bestehende Docker-Produktionsstart startet `scripts/notdienst-alarm-worker.mjs`. Dieser ruft nach fünf Sekunden und danach ungefähr jede Minute lokal `POST /api/jobs/notdienstalarme` auf. Fristmeldungen entstehen dadurch auch ohne geöffneten Browser, normalerweise innerhalb einer Minute nach Fristende, zuzüglich Laufzeit und möglicher Ausfälle. Bei fehlendem Secret startet der allgemeine Produktionsstart ohne Fristjob und die Verwaltungsseite zeigt einen Hinweis; die Coolify-Vorlage verlangt das Secret. Ein zu kurzes gesetztes Secret stoppt den Produktionsstart.
Der bestehende Docker-Produktionsstart startet `scripts/notdienst-alarm-worker.mjs`. Derselbe geschützte Job verarbeitet zusätzlich die gespeicherten Mitteilungs-Mails und deren Wiederholungen. Der Worker ruft nach fünf Sekunden und danach ungefähr jede Minute lokal `POST /api/jobs/notdienstalarme` auf. Fristmeldungen entstehen dadurch auch ohne geöffneten Browser, normalerweise innerhalb einer Minute nach Fristende, zuzüglich Laufzeit und möglicher Ausfälle. Bei fehlendem Secret startet der allgemeine Produktionsstart ohne Fristjob und die Verwaltungsseite zeigt einen Hinweis; die Coolify-Vorlage verlangt das Secret. Ein zu kurzes gesetztes Secret stoppt den Produktionsstart.
Bei einem anderen Hosting-Verfahren muss der Worker als eigener dauerhaft überwachter Prozess gestartet werden oder ein externer Scheduler die Route mindestens einmal pro Minute per POST und `Authorization: Bearer <NOTDIENST_JOB_SECRET>` aufrufen. Nur `npm run start` startet den Worker nicht. Der Scheduler/Host muss auch nach Neustarts laufen; ein gesetztes Secret allein belegt keine laufende Fristprüfung.
+18
View File
@@ -0,0 +1,18 @@
# Produkt-Screenshots
Die Bilder in `public/landing/screenshots/` sind echte Browseraufnahmen der WebApp mit ausschließlich fiktiven Beispieldaten. Sie wurden am 13. Oktober 2026 aufgenommen. Keine Produktionsdaten, echten Kontaktadressen oder Zugangsdaten sind enthalten.
Die Metadaten und Bildbeschreibungen stehen in `src/lib/marketing/screenshots.ts`. Landingpage und Featureseiten verwenden dieselben Dateien. Die Vorschau reserviert den Bildbereich, lädt Bilder bedarfsgerecht über Next Image und bietet eine tastaturbedienbare Vollansicht mit Fensteranpassung, Originalgröße und Scrollbereich.
## Aufnahmen erneuern
1. Eine **leere**, ausschließlich lokale PostgreSQL-Datenbank namens `kita_screenshots_test` erstellen. Andere Datenbanknamen oder Hosts werden vom Fixture-Skript abgewiesen; bestehende Daten werden niemals gelöscht.
2. `DATABASE_URL` nur für diese Sitzung auf diese Datenbank setzen und `npm run db:migrate:deploy` ausführen.
3. Mit derselben URL `npx tsx scripts/seed-screenshots.ts` ausführen. Das Skript legt Kita Sonnenwinkel, sechs fiktive Familien und eine Fachkraft an. Die Zugangsdaten werden nur lokal ausgegeben. Alle E-Mail-Adressen verwenden die nicht zustellbare Domain `example.invalid`.
4. Die App mit einer eigenen lokalen Auth-URL und einem Test-Secret starten. E-Mail-Versand und Analytics deaktivieren (`RESEND_API_KEY`, `NEXT_PUBLIC_UMAMI_SCRIPT_URL` und `NEXT_PUBLIC_UMAMI_WEBSITE_ID` leer; öffentliche Variablen schon beim Build leer setzen). Keine Jobs starten.
5. Über die normale Anmeldung und Navigation die Ansichten aufnehmen. Der Fixture-Stand verwendet Oktober/November 2026. Bei späteren Aufnahmen die Termine, Auswahl und Arbeitswoche passend aktualisieren.
6. Browseraufnahmen als JPEG speichern. Die Elternansichten wurden bei 1600 × 1000, die Arbeitszeitansicht bei 1600 × 1280 aufgenommen. Der Browser kann beim Ausschließen der Scrollbar leicht abweichende tatsächliche Bildmaße liefern; die Metadaten immer aus den **gespeicherten Dateien** lesen und aktualisieren.
7. Notdienst, Kalender, Adressbuch und eigene Abwesenheiten zeigen die Elternansicht. Bei Mitteilungen die erste aktualisierte Meldung öffnen. Für Arbeitszeit mit dem Teamkonto anmelden und zur erfassten Woche vom 5. bis 11. Oktober wechseln.
8. Bilder und Vergrößerung auf Desktop und Mobilgerät prüfen, dann Beispieldatenbank und lokalen Server entfernen.
Die sechs Screenshot-Files der Galerie sind zusammen unter 500 KiB groß. Die zusätzliche Aufnahme `termin-details.jpg` zeigt Teilnahme und Mitbringliste für das Erklärvideo. Das alte Bild `notdienst-calendar-linen.png` wird nicht mehr von der Landingpage verwendet.
+28
View File
@@ -0,0 +1,28 @@
# Erklärvideo für Kita-Planer
Stand: 14. Oktober 2026. Das Video ist ein zweiminütiger, tonloser Rundgang durch die WebApp. Es verwendet echte App-Screenshots mit ausschließlich fiktiven Beispieldaten. Bildausschnitte und grüne Markierungen führen durch die Funktionen; es wird keine Bildschirmaufnahme von Klicks vorgetäuscht.
## Dateien
- `public/landing/video/kita-planer-rundgang.mp4`: teilbare Fassung mit fest eingeblendeten deutschen Untertiteln. H.264, 1920 × 1080, 25 Bilder/s, 2:00 Minuten, ohne Audiospur.
- `public/landing/video/kita-planer-rundgang-web.mp4`: Website-Fassung ohne eingebrannte Untertitel. Der native Videoplayer lädt standardmäßig die deutschen WebVTT-Untertitel; sie passen sich dem Player an und lassen sich abschalten.
- `kita-planer-untertitel.vtt`: synchronisierte deutsche Untertitel für beide Fassungen.
- `kita-planer-kapitel.vtt`: acht Kapitel mit Zeitmarken.
- `kita-planer-poster.jpg`: Vorschaubild für die Landingpage.
- `kita-planer-transkript.txt`: vollständige Textfassung.
Alle Dateien liegen unter `public/landing/video/`. Der Player startet erst auf Wunsch und lädt das Video nicht beim Seitenaufruf (`preload="none"`). Er nutzt keine externen Videoanbieter. Unter dem Player gibt es Downloadlinks und eine aufklappbare Textfassung.
## Erneut rendern
Das Storyboard in `scripts/product-video/storyboard.json` enthält Dauer, Texte, Quellen, Bildausschnitte und Markierungen jeder Szene. Die Quellen liegen in `public/landing/screenshots/`; die Terminaufnahme entstand ebenfalls mit dem isolierten Screenshot-Fixture. Anleitung siehe `product-screenshots.md`.
Mit installierten Projektabhängigkeiten und lokalem `ffmpeg`:
```
node scripts/product-video/render.mjs
```
Der Renderer verwendet `sharp` aus den installierten Next.js-Abhängigkeiten für SVG-Titel und Video-Compositing. Unter macOS wird die vorhandene Schrift Avenir Next verwendet. Auf anderen Systemen diese Schrift oder einen geeigneten Ersatz bereitstellen und die Umbrüche visuell prüfen. Rendern ist ein lokaler Vorbereitungsschritt und kein Teil des Produktionsbuilds; das Deployment liefert die fertigen Dateien aus.
Bei Textänderungen beide Fassungen rendern, den Film vollständig auf Lesbarkeit und Synchronität prüfen und die Kapitelstarts mit dem Storyboard abgleichen. Bei Aktualisierung der App die echten Screenshots erneuern und Ausschnitte/Markierungen anpassen. Die temporären Renderdateien werden nach dem Lauf automatisch entfernt.
+5
View File
@@ -29,7 +29,12 @@
"test:notdienst:integration": "node --test tests/notdienst-planning.integration.test.cjs",
"test:calendar:integration": "node --test tests/calendar-actions.integration.test.cjs",
"test:notdienst:notifications": "node --test tests/notdienst-notifications.integration.test.cjs",
"test:announcements": "node --test tests/announcements.integration.test.cjs",
"test:seo:http": "node --test tests/marketing-seo-http.cjs",
"test:seo": "node --test tests/marketing-seo.test.cjs",
"test:mail": "node --test tests/mail-transport.test.cjs",
"test:account": "node --test tests/password-management.integration.test.cjs",
"test:roles": "node --test tests/kita-roles.integration.test.cjs tests/notdienst-assignment-view.test.cjs",
"test:runtime": "node --test tests/runtime-supervisor.test.cjs",
"test:family-absence": "tsx --test tests/absence-policy.test.ts",
"test:family-absence:integration": "tsx --experimental-test-module-mocks --test tests/family-absence-flows.integration.test.ts"
@@ -0,0 +1,23 @@
BEGIN;
ALTER TABLE "announcements" ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "editedAt" TIMESTAMP(3), ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, ADD COLUMN "emailReady" BOOLEAN NOT NULL DEFAULT true;
UPDATE "announcements" SET "updatedAt" = "createdAt";
CREATE INDEX "announcements_kitaId_updatedAt_idx" ON "announcements"("kitaId", "updatedAt");
CREATE TABLE "announcement_emails" (
"id" TEXT NOT NULL, "kitaId" TEXT NOT NULL, "announcementId" TEXT NOT NULL, "userId" TEXT NOT NULL,
"revision" INTEGER NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "attempts" INTEGER NOT NULL DEFAULT 0,
"leaseUntil" TIMESTAMP(3), "claimToken" TEXT, "sentAt" TIMESTAMP(3), "skippedAt" TIMESTAMP(3),
"failed" BOOLEAN NOT NULL DEFAULT false, "lastError" TEXT,
CONSTRAINT "announcement_emails_pkey" PRIMARY KEY ("id"),
CONSTRAINT "announcement_emails_revision_check" CHECK ("revision" > 0)
);
CREATE UNIQUE INDEX "announcement_emails_announcementId_revision_userId_key" ON "announcement_emails"("announcementId", "revision", "userId");
CREATE INDEX "announcement_emails_kitaId_announcementId_revision_idx" ON "announcement_emails"("kitaId", "announcementId", "revision");
CREATE INDEX "announcement_emails_sentAt_skippedAt_nextAttemptAt_idx" ON "announcement_emails"("sentAt", "skippedAt", "nextAttemptAt");
ALTER TABLE "announcement_emails" ADD CONSTRAINT "announcement_emails_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "announcement_emails" ADD CONSTRAINT "announcement_emails_announcementId_fkey" FOREIGN KEY ("announcementId") REFERENCES "announcements"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "announcement_emails" ADD CONSTRAINT "announcement_emails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "announcement_emails" ADD CONSTRAINT "announcement_emails_announcementId_tenant_fkey" FOREIGN KEY ("announcementId", "kitaId") REFERENCES "announcements"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE "announcement_emails" ADD CONSTRAINT "announcement_emails_userId_tenant_fkey" FOREIGN KEY ("userId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
COMMIT;
+33
View File
@@ -155,6 +155,7 @@ model Kita {
dutyAssignments DutyAssignment[]
absences Absence[]
announcements Announcement[]
announcementEmails AnnouncementEmail[]
invitations Invitation[]
termine Termin[]
mitbringselItems MitbringselItem[]
@@ -280,6 +281,7 @@ model User {
alarmNotifications NotdienstAlarmNotification[]
alarmEmails NotdienstAlarmEmail[]
notdienstEmails NotdienstEmail[]
announcementEmails AnnouncementEmail[]
@@index([kitaId])
@@index([familyId])
@@ -697,6 +699,10 @@ model Announcement {
title String
content String
audience AnnouncementAudience @default(ALLE)
revision Int @default(1)
editedAt DateTime?
updatedAt DateTime @default(now())
emailReady Boolean @default(true)
authorId String
author User @relation("AnnouncementAuthor", fields: [authorId], references: [id], onDelete: Cascade)
@@ -705,13 +711,40 @@ model Announcement {
attachments Attachment[]
reads AnnouncementRead[]
emails AnnouncementEmail[]
@@index([kitaId, createdAt])
@@index([kitaId, updatedAt])
@@index([authorId])
@@unique([id, kitaId])
@@map("announcements")
}
model AnnouncementEmail {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
announcementId String
announcement Announcement @relation(fields: [announcementId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
revision Int
createdAt DateTime @default(now())
nextAttemptAt DateTime @default(now())
attempts Int @default(0)
leaseUntil DateTime?
claimToken String?
sentAt DateTime?
skippedAt DateTime?
failed Boolean @default(false)
lastError String?
@@unique([announcementId, revision, userId])
@@index([kitaId, announcementId, revision])
@@index([sentAt, skippedAt, nextAttemptAt])
@@map("announcement_emails")
}
model Attachment {
id String @id @default(cuid())
announcementId String
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

@@ -0,0 +1,33 @@
WEBVTT
1
00:00:00.000 --> 00:00:10.000
Willkommen im Kita-Planer
2
00:00:10.000 --> 00:00:32.000
Notdienst gemeinsam planen
3
00:00:32.000 --> 00:00:52.000
Termine und Feste im Blick
4
00:00:52.000 --> 00:01:06.000
Freigegebene Kontakte
5
00:01:06.000 --> 00:01:20.000
Abwesenheiten verwalten
6
00:01:20.000 --> 00:01:34.000
Offizielle Mitteilungen
7
00:01:34.000 --> 00:01:50.000
Arbeitszeit für das Team
8
00:01:50.000 --> 00:02:00.000
Kita-Planer kennenlernen
Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.
Binary file not shown.
@@ -0,0 +1,50 @@
Kita-Planer – Ein Rundgang durch die Anwendung
Dauer: 120 Sekunden. Ohne Ton. App-Ansichten mit fiktiven Beispieldaten.
Willkommen im Kita-Planer
Kita-Planer bringt Dienste, Termine und offizielle Informationen an einen gemeinsamen Ort – für Eltern, Vorstand und Kita-Team.
Notdienst gemeinsam planen
Eltern markieren die Tage, an denen ihr Haushalt für den Notdienst verfügbar ist.
Notdienst gemeinsam planen
Feste, Elternabende und gesperrte Teamtage stehen direkt im Kalender. So lässt sich die Auswahl mit dem Kita-Alltag abstimmen.
Notdienst gemeinsam planen
Die Anzeige zeigt, ob genug Tage ausgewählt sind. Mit Speichern wird die Verfügbarkeit für die Planung übernommen.
Termine und Feste im Blick
Im Monatskalender stehen Ausflüge, Elternabende, Feste und Schließtage. Alle sehen die Termine im selben Zusammenhang.
Termine und Feste im Blick
Für dieses Herbstfest antwortet ihr pro Familie und gebt an, wie viele Erwachsene teilnehmen.
Termine und Feste im Blick
In der Mitbringliste seht ihr, wer schon etwas beisteuert. Eigene Beiträge könnt ihr bearbeiten oder löschen.
Freigegebene Kontakte
Das interne Adressbuch verbindet Haushalte, Kinder und die freigegebenen Kontakte der Eltern.
Freigegebene Kontakte
Jeder Elternteil entscheidet selbst über die Freigabe seiner Kontaktdaten. Im Adressbuch erscheinen nur ausdrücklich freigegebene Angaben.
Abwesenheiten verwalten
Eltern melden Abwesenheiten digital. Die Kita sieht die Meldung in ihrer Übersicht.
Abwesenheiten verwalten
Zukünftige Abwesenheiten können Eltern bearbeiten oder zurücknehmen. Sobald eine Meldung begonnen hat, übernimmt die Kita die Änderung.
Offizielle Mitteilungen
Mitteilungen bündeln offizielle Informationen an einem Ort. Formatierter Text und der Aktualisierungshinweis machen Änderungen sichtbar.
Offizielle Mitteilungen
Kita-Admins können Beiträge korrigieren; Anhänge bleiben geschützt abrufbar. Beim optionalen E-Mail-Versand bleibt der Status je Empfänger nachvollziehbar.
Arbeitszeit für das Team
Fachkräfte erfassen ihre Arbeitszeit mit Pausen und reichen vollständige Wochen zur Prüfung ein.
Arbeitszeit für das Team
Das bestätigte Zeitkonto und der verfügbare Urlaub bleiben sichtbar. Urlaub und Freizeitausgleich lassen sich im Personalbereich beantragen.
Kita-Planer kennenlernen
Lernt Kita-Planer in Ruhe kennen und entdeckt die passenden Funktionen. Alle gezeigten App-Ansichten verwenden ausschließlich Beispieldaten.
@@ -0,0 +1,81 @@
WEBVTT
1
00:00:00.000 --> 00:00:10.000
Kita-Planer bringt Dienste, Termine und offizielle Informationen
an einen gemeinsamen Ort – für Eltern, Vorstand und Kita-Team.
2
00:00:10.000 --> 00:00:17.000
Eltern markieren die Tage, an denen ihr Haushalt
für den Notdienst verfügbar ist.
3
00:00:17.000 --> 00:00:24.000
Feste, Elternabende und gesperrte Teamtage stehen direkt im Kalender.
So lässt sich die Auswahl mit dem Kita-Alltag abstimmen.
4
00:00:24.000 --> 00:00:32.000
Die Anzeige zeigt, ob genug Tage ausgewählt sind.
Mit Speichern wird die Verfügbarkeit für die Planung übernommen.
5
00:00:32.000 --> 00:00:41.000
Im Monatskalender stehen Ausflüge, Elternabende, Feste und Schließtage.
Alle sehen die Termine im selben Zusammenhang.
6
00:00:41.000 --> 00:00:46.000
Für dieses Herbstfest antwortet ihr pro Familie
und gebt an, wie viele Erwachsene teilnehmen.
7
00:00:46.000 --> 00:00:52.000
In der Mitbringliste seht ihr, wer schon etwas beisteuert.
Eigene Beiträge könnt ihr bearbeiten oder löschen.
8
00:00:52.000 --> 00:00:59.000
Das interne Adressbuch verbindet Haushalte, Kinder
und die freigegebenen Kontakte der Eltern.
9
00:00:59.000 --> 00:01:06.000
Jeder Elternteil entscheidet selbst über die Freigabe seiner Kontaktdaten.
Im Adressbuch erscheinen nur ausdrücklich freigegebene Angaben.
10
00:01:06.000 --> 00:01:13.000
Eltern melden Abwesenheiten digital.
Die Kita sieht die Meldung in ihrer Übersicht.
11
00:01:13.000 --> 00:01:20.000
Zukünftige Abwesenheiten können Eltern bearbeiten oder zurücknehmen.
Sobald eine Meldung begonnen hat, übernimmt die Kita die Änderung.
12
00:01:20.000 --> 00:01:27.000
Mitteilungen bündeln offizielle Informationen an einem Ort.
Formatierter Text und der Aktualisierungshinweis machen Änderungen sichtbar.
13
00:01:27.000 --> 00:01:34.000
Kita-Admins können Beiträge korrigieren; Anhänge bleiben geschützt abrufbar.
Beim optionalen E-Mail-Versand bleibt der Status je Empfänger nachvollziehbar.
14
00:01:34.000 --> 00:01:42.000
Fachkräfte erfassen ihre Arbeitszeit mit Pausen
und reichen vollständige Wochen zur Prüfung ein.
15
00:01:42.000 --> 00:01:50.000
Das bestätigte Zeitkonto und der verfügbare Urlaub bleiben sichtbar.
Urlaub und Freizeitausgleich lassen sich im Personalbereich beantragen.
16
00:01:50.000 --> 00:02:00.000
Lernt Kita-Planer in Ruhe kennen und entdeckt die passenden Funktionen.
Alle gezeigten App-Ansichten verwenden ausschließlich Beispieldaten.
+4
View File
@@ -66,4 +66,8 @@ UNION ALL
SELECT 'notdienst_emails.userId' AS relation, count(*) AS invalid_rows FROM "notdienst_emails" c JOIN "users" p ON c."userId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'notdienst_emails.planId' AS relation, count(*) AS invalid_rows FROM "notdienst_emails" c JOIN "notdienst_plans" p ON c."planId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'announcement_emails.userId' AS relation, count(*) AS invalid_rows FROM "announcement_emails" c JOIN "users" p ON c."userId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'announcement_emails.announcementId' AS relation, count(*) AS invalid_rows FROM "announcement_emails" c JOIN "announcements" p ON c."announcementId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
) violations WHERE invalid_rows > 0;
+82
View File
@@ -0,0 +1,82 @@
/** Render the local, silent product tour from real app captures and SVG titles. */
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import sharp from 'sharp';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const storyboard = JSON.parse(await fs.readFile(path.join(root, 'scripts/product-video/storyboard.json'), 'utf8'));
const out = path.join(root, 'public/landing/video');
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'kita-product-video-'));
await fs.mkdir(out, { recursive: true });
const { width: W, height: H, fps } = storyboard;
const colors = { paper: '#f7f4ed', ink: '#302820', brown: '#7b492e', green: '#3f745a', muted: '#696358' };
const esc = text => String(text).replace(/[<>&"']/g, c => ({ '<':'&lt;', '>':'&gt;', '&':'&amp;', '"':'&quot;', "'":'&apos;' }[c]));
const text = (x,y,value,size=30,color=colors.ink,weight=400) => `<text x="${x}" y="${y}" font-family="Avenir Next, sans-serif" font-size="${size}" font-weight="${weight}" fill="${color}">${esc(value)}</text>`;
const lines = (x,y,values,size=30,step=44,color=colors.ink,weight=400) => values.map((v,i)=>text(x,y+i*step,v,size,color,weight)).join('');
const imageStage = { x:600, y:162, width:1240, height:714 };
const total = storyboard.scenes.reduce((sum,scene)=>sum+scene.duration,0);
const timestamp = seconds => `${String(Math.floor(seconds/3600)).padStart(2,'0')}:${String(Math.floor(seconds/60)%60).padStart(2,'0')}:${String(seconds%60).padStart(2,'0')}.000`;
let time = 0;
const cues = [];
const assets = [];
const webAssets = [];
function ffmpeg(args) {
const result = spawnSync('ffmpeg', ['-hide_banner','-loglevel','error','-y',...args], { encoding:'utf8', maxBuffer:8*1024*1024 });
if (result.status !== 0) throw new Error(result.stderr || 'ffmpeg failed');
}
try {
for (const [index,scene] of storyboard.scenes.entries()) {
const [left,top,width,height] = scene.crop;
const shot = await sharp(path.join(root, 'public/landing/screenshots',scene.image))
.extract({left,top,width,height}).resize(imageStage.width,imageStage.height,{fit:'contain',background:colors.paper}).png().toBuffer();
const scale = Math.min(imageStage.width/width,imageStage.height/height);
const offsetX = imageStage.x+(imageStage.width-width*scale)/2;
const offsetY = imageStage.y+(imageStage.height-height*scale)/2;
let focus = '';
if (scene.focus) {
const [fx,fy,fw,fh] = scene.focus;
focus = `<rect x="${offsetX+(fx-left)*scale}" y="${offsetY+(fy-top)*scale}" width="${fw*scale}" height="${fh*scale}" rx="12" fill="none" stroke="${colors.green}" stroke-width="5"/><circle cx="${offsetX+(fx-left)*scale+12}" cy="${offsetY+(fy-top)*scale-10}" r="18" fill="${colors.green}"/>`;
}
const chapter = storyboard.chapters[scene.chapter];
const title = lines(74,290,scene.title,56,72,colors.ink,600);
const hintWords = scene.hint.split(' '); const hintLines = []; let line = '';
for (const word of hintWords) { if ((line+' '+word).trim().length > 28) { hintLines.push(line); line=word; } else line=(line+' '+word).trim(); } if(line)hintLines.push(line);
const header = `<rect width="${W}" height="${H}" fill="${colors.paper}"/><rect x="72" y="44" width="54" height="54" rx="16" fill="${colors.brown}"/>${text(88,82,'K',30,'white',600)}${text(144,80,'Kita-Planer',32,colors.ink,600)}${text(144,111,'Ein Rundgang durch die Anwendung',19,colors.muted)}${text(1490,80,`${String(scene.chapter+1).padStart(2,'0')} / 08`,24,colors.green,600)}${text(1490,111,'App-Ansichten mit Beispieldaten',18,colors.muted)}<line x1="72" y1="132" x2="1840" y2="132" stroke="#dcd6cb"/>${text(74,208,scene.chapter===0?'WILLKOMMEN':scene.chapter===7?'GEMEINSAM STARTEN':`BEREICH ${String(scene.chapter).padStart(2,'0')}`,20,colors.green,600)}${title}<rect x="74" y="466" width="4" height="${Math.max(90,hintLines.length*40)}" rx="2" fill="${colors.green}"/>${lines(96,498,hintLines,28,40,colors.muted)}${text(74,775,'Für Eltern, Vorstand',24,colors.muted)}${text(74,811,'und das Kita-Team.',24,colors.muted)}<rect x="598" y="160" width="1244" height="718" rx="16" fill="white" stroke="#d9d3c7" stroke-width="2"/>`;
const footer = `<rect x="0" y="920" width="1920" height="160" fill="#ebe7dc"/><rect x="72" y="923" width="1768" height="4" rx="2" fill="#d8d1c2"/><rect x="72" y="923" width="${1768*(time+scene.duration)/total}" height="4" rx="2" fill="${colors.green}"/>${text(74,899,chapter.title,20,colors.muted)}${text(1470,899,'mein-kitaplaner.de',20,colors.brown,600)}`;
const base = await sharp(Buffer.from(`<svg width="${W}" height="${H}">${header}${footer}</svg>`)).png().toBuffer();
const captions = `<rect x="210" y="951" width="1500" height="100" rx="18" fill="#302820"/>${scene.subtitle.map((value,i)=>`<text x="960" y="${989+i*42}" text-anchor="middle" font-family="Avenir Next, sans-serif" font-size="34" font-weight="500" fill="#ffffff">${esc(value)}</text>`).join('')}`;
const foreground = Buffer.from(`<svg width="${W}" height="${H}">${focus}${captions}</svg>`);
const cleanFrame = await sharp(base).composite([{input:shot,left:imageStage.x,top:imageStage.y},{input:Buffer.from(`<svg width="${W}" height="${H}">${focus}</svg>`),left:0,top:0}]).png().toBuffer();
const cleanPath = path.join(temp,`web-frame-${index}.png`);
await fs.writeFile(cleanPath,cleanFrame);
const frame = await sharp(base).composite([{input:shot,left:imageStage.x,top:imageStage.y},{input:foreground,left:0,top:0}]).png().toBuffer();
const framePath = path.join(temp,`frame-${index}.png`);
await fs.writeFile(framePath,frame);
if(index===0) await sharp(frame).jpeg({quality:88}).toFile(path.join(out,'kita-planer-poster.jpg'));
const clip = path.join(temp,`clip-${index}.mp4`);
// Gentle transitions leave the UI and all captions still and legible.
ffmpeg(['-loop','1','-framerate',String(fps),'-i',framePath,'-t',String(scene.duration),'-vf',`fade=t=in:st=0:d=0.25:color=0xf7f4ed,fade=t=out:st=${scene.duration-0.25}:d=0.25:color=0xf7f4ed,format=yuv420p`,'-c:v','libx264','-preset','fast','-crf','20','-threads','2','-an',clip]);
assets.push(clip);
const webClip = path.join(temp,`web-clip-${index}.mp4`);
ffmpeg(['-loop','1','-framerate',String(fps),'-i',cleanPath,'-t',String(scene.duration),'-vf',`fade=t=in:st=0:d=0.25:color=0xf7f4ed,fade=t=out:st=${scene.duration-0.25}:d=0.25:color=0xf7f4ed,format=yuv420p`,'-c:v','libx264','-preset','fast','-crf','20','-threads','2','-an',webClip]);
webAssets.push(webClip);
cues.push(`${index+1}\n${timestamp(time)} --> ${timestamp(time+scene.duration)}\n${scene.subtitle.join('\n')}`);
time+=scene.duration;
console.log(`Rendered scene ${index+1}/${storyboard.scenes.length}: ${chapter.title}`);
}
await fs.writeFile(path.join(temp,'concat.txt'),assets.map(p=>`file '${p}'`).join('\n'));
ffmpeg(['-f','concat','-safe','0','-i',path.join(temp,'concat.txt'),'-c','copy','-movflags','+faststart','-metadata','title=Kita-Planer – Ein Rundgang durch die Anwendung','-metadata','comment=Echte App-Ansichten mit fiktiven Beispieldaten. Deutsche Untertitel und visuelle Hinweise. Ohne Ton.',path.join(out,'kita-planer-rundgang.mp4')]);
await fs.writeFile(path.join(temp,'web-concat.txt'),webAssets.map(p=>`file '${p}'`).join('\n'));
ffmpeg(['-f','concat','-safe','0','-i',path.join(temp,'web-concat.txt'),'-c','copy','-movflags','+faststart',path.join(out,'kita-planer-rundgang-web.mp4')]);
await fs.writeFile(path.join(out,'kita-planer-untertitel.vtt'),`WEBVTT\n\n${cues.join('\n\n')}\n`);
await fs.writeFile(path.join(out,'kita-planer-kapitel.vtt'),`WEBVTT\n\n${storyboard.chapters.map((chapter,index)=>`${index+1}\n${timestamp(chapter.start)} --> ${timestamp(storyboard.chapters[index+1]?.start??total)}\n${chapter.title}`).join('\n\n')}\n`);
await fs.writeFile(path.join(out,'kita-planer-transkript.txt'),`Kita-Planer – Ein Rundgang durch die Anwendung\nDauer: ${total} Sekunden. Ohne Ton. App-Ansichten mit fiktiven Beispieldaten.\n\n${storyboard.scenes.map(scene=>`${storyboard.chapters[scene.chapter].title}\n${scene.subtitle.join(' ')}`).join('\n\n')}\n`);
console.log(`Finished ${total}s video in ${out}`);
} finally {
await fs.rm(temp,{recursive:true,force:true});
}
+445
View File
@@ -0,0 +1,445 @@
{
"width": 1920,
"height": 1080,
"fps": 25,
"chapters": [
{
"start": 0,
"title": "Willkommen im Kita-Planer"
},
{
"start": 10,
"title": "Notdienst gemeinsam planen"
},
{
"start": 32,
"title": "Termine und Feste im Blick"
},
{
"start": 52,
"title": "Freigegebene Kontakte"
},
{
"start": 66,
"title": "Abwesenheiten verwalten"
},
{
"start": 80,
"title": "Offizielle Mitteilungen"
},
{
"start": 94,
"title": "Arbeitszeit für das Team"
},
{
"start": 110,
"title": "Kita-Planer kennenlernen"
}
],
"scenes": [
{
"duration": 10,
"chapter": 0,
"title": [
"Mehr Ruhe im",
"Kita-Alltag."
],
"hint": "Für Elterninitiativen und Kita-Vereine",
"image": "notdienst.jpg",
"crop": [
0,
0,
1600,
1000
],
"subtitle": [
"Kita-Planer bringt Dienste, Termine und offizielle Informationen",
"an einen gemeinsamen Ort – für Eltern, Vorstand und Kita-Team."
]
},
{
"duration": 7,
"chapter": 1,
"title": [
"Wann könnt",
"ihr helfen?"
],
"hint": "Verfügbarkeiten gemeinsam eintragen",
"image": "notdienst.jpg",
"crop": [
310,
45,
1230,
870
],
"focus": [
325,
365,
394,
200
],
"subtitle": [
"Eltern markieren die Tage, an denen ihr Haushalt",
"für den Notdienst verfügbar ist."
]
},
{
"duration": 7,
"chapter": 1,
"title": [
"Kita-Termine",
"gleich sehen."
],
"hint": "Teamtage sind für die Auswahl gesperrt",
"image": "notdienst.jpg",
"crop": [
330,
170,
1200,
720
],
"focus": [
355,
670,
335,
105
],
"subtitle": [
"Feste, Elternabende und gesperrte Teamtage stehen direkt im Kalender.",
"So lässt sich die Auswahl mit dem Kita-Alltag abstimmen."
]
},
{
"duration": 8,
"chapter": 1,
"title": [
"Auswahl prüfen.",
"Dann speichern."
],
"hint": "Der Fortschritt bleibt sichtbar",
"image": "notdienst.jpg",
"crop": [
310,
45,
1230,
870
],
"focus": [
1050,
80,
450,
65
],
"subtitle": [
"Die Anzeige zeigt, ob genug Tage ausgewählt sind.",
"Mit Speichern wird die Verfügbarkeit für die Planung übernommen."
]
},
{
"duration": 9,
"chapter": 2,
"title": [
"Was steht",
"als Nächstes an?"
],
"hint": "Ein gemeinsamer Kalender",
"image": "kalender.jpg",
"crop": [
300,
35,
1240,
920
],
"focus": [
830,
660,
415,
155
],
"subtitle": [
"Im Monatskalender stehen Ausflüge, Elternabende, Feste und Schließtage.",
"Alle sehen die Termine im selben Zusammenhang."
]
},
{
"duration": 5,
"chapter": 2,
"title": [
"Zusagen pro",
"Familie."
],
"hint": "Anzahl der Erwachsenen angeben",
"image": "termin-details.jpg",
"crop": [
543,
335,
501,
370
],
"focus": [
561,
444,
470,
150
],
"subtitle": [
"Für dieses Herbstfest antwortet ihr pro Familie",
"und gebt an, wie viele Erwachsene teilnehmen."
]
},
{
"duration": 6,
"chapter": 2,
"title": [
"Wer bringt",
"was mit?"
],
"hint": "Eigene Beiträge bearbeiten oder löschen",
"image": "termin-details.jpg",
"crop": [
543,
690,
501,
318
],
"focus": [
575,
748,
440,
62
],
"subtitle": [
"In der Mitbringliste seht ihr, wer schon etwas beisteuert.",
"Eigene Beiträge könnt ihr bearbeiten oder löschen."
]
},
{
"duration": 7,
"chapter": 3,
"title": [
"Familien und",
"Kontakte finden."
],
"hint": "Haushalte und Kinder zusammen sehen",
"image": "adressbuch.jpg",
"crop": [
295,
35,
1220,
700
],
"focus": [
315,
325,
1080,
120
],
"subtitle": [
"Das interne Adressbuch verbindet Haushalte, Kinder",
"und die freigegebenen Kontakte der Eltern."
]
},
{
"duration": 7,
"chapter": 3,
"title": [
"Freigabe bleibt",
"eine Entscheidung."
],
"hint": "Kontaktdaten erscheinen nach Opt-in",
"image": "adressbuch.jpg",
"crop": [
580,
290,
900,
310
],
"focus": [
638,
330,
420,
95
],
"subtitle": [
"Jeder Elternteil entscheidet selbst über die Freigabe seiner Kontaktdaten.",
"Im Adressbuch erscheinen nur ausdrücklich freigegebene Angaben."
]
},
{
"duration": 7,
"chapter": 4,
"title": [
"Kind abmelden.",
"Kita informieren."
],
"hint": "Krankheit, Urlaub und andere Gründe",
"image": "abwesenheiten.jpg",
"crop": [
395,
25,
1060,
640
],
"focus": [
420,
320,
1010,
130
],
"subtitle": [
"Eltern melden Abwesenheiten digital.",
"Die Kita sieht die Meldung in ihrer Übersicht."
]
},
{
"duration": 7,
"chapter": 4,
"title": [
"Pläne dürfen",
"sich ändern."
],
"hint": "Zukünftige Meldungen korrigieren",
"image": "abwesenheiten.jpg",
"crop": [
415,
305,
1020,
340
],
"focus": [
446,
565,
320,
45
],
"subtitle": [
"Zukünftige Abwesenheiten können Eltern bearbeiten oder zurücknehmen.",
"Sobald eine Meldung begonnen hat, übernimmt die Kita die Änderung."
]
},
{
"duration": 7,
"chapter": 5,
"title": [
"Offizielle Infos",
"bleiben auffindbar."
],
"hint": "Das digitale Schwarze Brett",
"image": "mitteilungen.jpg",
"crop": [
410,
275,
780,
450
],
"focus": [
484,
367,
630,
80
],
"subtitle": [
"Mitteilungen bündeln offizielle Informationen an einem Ort.",
"Formatierter Text und der Aktualisierungshinweis machen Änderungen sichtbar."
]
},
{
"duration": 7,
"chapter": 5,
"title": [
"Auch nach einer",
"Korrektur klar."
],
"hint": "Geschützte Anhänge und optionale E-Mails",
"image": "mitteilungen.jpg",
"crop": [
450,
330,
700,
335
],
"focus": [
485,
580,
625,
53
],
"subtitle": [
"Kita-Admins können Beiträge korrigieren; Anhänge bleiben geschützt abrufbar.",
"Beim optionalen E-Mail-Versand bleibt der Status je Empfänger nachvollziehbar."
]
},
{
"duration": 8,
"chapter": 6,
"title": [
"Arbeitszeit für",
"das Kita-Team."
],
"hint": "Stunden erfassen und Wochen einreichen",
"image": "personal.jpg",
"crop": [
315,
445,
1210,
780
],
"focus": [
345,
1105,
780,
105
],
"subtitle": [
"Fachkräfte erfassen ihre Arbeitszeit mit Pausen",
"und reichen vollständige Wochen zur Prüfung ein."
]
},
{
"duration": 8,
"chapter": 6,
"title": [
"Zeitkonto und",
"Urlaub im Blick."
],
"hint": "Bestätigte Zeit von vorläufiger unterscheiden",
"image": "personal.jpg",
"crop": [
308,
235,
1210,
200
],
"focus": [
315,
275,
1185,
135
],
"subtitle": [
"Das bestätigte Zeitkonto und der verfügbare Urlaub bleiben sichtbar.",
"Urlaub und Freizeitausgleich lassen sich im Personalbereich beantragen."
]
},
{
"duration": 10,
"chapter": 7,
"title": [
"Gemeinsam",
"besser planen."
],
"hint": "Startet mit den Abläufen, die euch entlasten",
"image": "kalender.jpg",
"crop": [
300,
35,
1240,
920
],
"subtitle": [
"Lernt Kita-Planer in Ruhe kennen und entdeckt die passenden Funktionen.",
"Alle gezeigten App-Ansichten verwenden ausschließlich Beispieldaten."
]
}
]
}
+92
View File
@@ -0,0 +1,92 @@
/** Synthetic fixture for public product screenshots. Never run against real data. */
import { PrismaClient, UserRole, TerminType, type User, type Child } from "@prisma/client";
import { hash } from "bcryptjs";
const url = new URL(process.env.DATABASE_URL ?? "postgresql://invalid");
if (!['localhost', '127.0.0.1'].includes(url.hostname) ||
url.pathname !== '/kita_screenshots_test') {
throw new Error('Screenshot fixtures require the isolated local kita_screenshots_test database.');
}
const prisma = new PrismaClient();
const date = (value: string) => new Date(`${value}T00:00:00Z`);
async function main() {
if (await prisma.kita.count() || await prisma.user.count()) {
throw new Error('Database must be empty; existing data will never be reset.');
}
const passwordHash = await hash('demo-screenshots-only', 10);
await prisma.$transaction(async (tx) => {
const kita = await tx.kita.create({ data: { name: 'Kita Sonnenwinkel', slug: 'sonnenwinkel-demo' } });
const kitaId = kita.id;
const common = { kitaId, passwordHash, emailVerifiedAt: date('2026-09-01'),
privacyPolicyAcceptedAt: date('2026-09-01'), privacyPolicyVersion: '2026-05-01' };
const admin = await tx.user.create({ data: { ...common, email: 'admin@example.invalid', firstName: 'Mara', lastName: 'Winter', role: UserRole.ADMIN } });
const households = [
['Berger', 'Lena', 'Jonas', 'Emma', 'Leo'],
['Hoffmann', 'Anna', 'Tim', 'Mila'],
['Schneider', 'Clara', 'Ben', 'Paul'],
['Neumann', 'Nora', 'Felix', 'Lotta'],
['Weber', 'Sarah', 'David', 'Finn'],
['Koch', 'Julia', 'Max', 'Ella'],
];
const parents: User[] = [];
const children: Child[] = [];
for (const [lastName, firstName, partner, ...childNames] of households) {
const family = await tx.family.create({ data: { kitaId, name: `Familie ${lastName}` } });
for (const name of [firstName, partner]) {
parents.push(await tx.user.create({ data: { ...common, familyId: family.id, firstName: name, lastName,
email: `${name.toLowerCase()}@example.invalid`, role: UserRole.ELTERN,
directoryOptInAt: date('2026-09-01'), city: 'Musterstadt' } }));
}
for (const name of childNames) {
children.push(await tx.child.create({ data: { kitaId, familyId: family.id, firstName: name, lastName } }));
}
}
await tx.notdienstAvailability.createMany({ data: [2, 4, 6, 10, 12, 17, 20, 25].map(day => ({
kitaId, childId: children[0].id, userId: parents[0].id,
date: date(`2026-11-${String(day).padStart(2, '0')}`),
})) });
await tx.absence.createMany({ data: [
{ kitaId, childId: children[0].id, startDate: date('2026-10-12'), endDate: date('2026-10-14'), reason: 'ILLNESS' },
{ kitaId, childId: children[1].id, startDate: date('2026-10-26'), endDate: date('2026-10-30'), reason: 'VACATION', note: 'Familienurlaub in den Herbstferien' },
] });
const events: [string, TerminType, string, string, boolean][] = [
['Elterncafé', 'ELTERNCAFE', '2026-10-08T13:00:00Z', '2026-10-08T14:30:00Z', false],
['Ausflug in den Stadtwald', 'SONSTIGES', '2026-10-15T07:00:00Z', '2026-10-15T11:00:00Z', false],
['Elternabend', 'ELTERNABEND', '2026-10-20T16:00:00Z', '2026-10-20T18:00:00Z', false],
['Herbstfest im Garten', 'KITA_FEST', '2026-10-23T13:00:00Z', '2026-10-23T16:00:00Z', false],
['Teamtag · Kita geschlossen', 'TEAMTAG', '2026-10-30T00:00:00Z', '2026-10-30T23:59:00Z', true],
['Elterncafé', 'ELTERNCAFE', '2026-11-05T14:00:00Z', '2026-11-05T15:30:00Z', false],
['Laternenfest', 'KITA_FEST', '2026-11-11T15:00:00Z', '2026-11-11T17:00:00Z', false],
['Gartenaktion', 'MITMACH_TAG', '2026-11-14T09:00:00Z', '2026-11-14T12:00:00Z', false],
['Teamtag · Kita geschlossen', 'TEAMTAG', '2026-11-23T00:00:00Z', '2026-11-23T23:59:00Z', true],
];
for (const [title, type, start, end, allDay] of events) {
const event = await tx.termin.create({ data: { kitaId, title, type, status: 'CONFIRMED', startDate: new Date(start), endDate: new Date(end), allDay,
description: title.includes('Herbstfest') ? 'Wir feiern gemeinsam mit allen Familien. Für Snacks und Getränke gibt es eine Mitbringliste.' : 'Gemeinsam planen wir unseren Kita-Alltag.',
createdById: admin.id, approvedById: admin.id, approvedAt: date('2026-10-01'),
requiresRsvp: type === 'KITA_FEST', rsvpMode: 'FAMILY', mitbringselListEnabled: type === 'KITA_FEST' } });
if (type === 'KITA_FEST') {
await tx.familyEventParticipation.create({ data: { kitaId, eventId: event.id, familyId: parents[0].familyId!, status: 'ATTENDING', adultCount: 2 } });
await tx.mitbringselItem.createMany({ data: parents.slice(0, 3).map((parent, index) => ({ kitaId, terminId: event.id, userId: parent.id, content: ['Apfelkuchen', 'Gemüsesticks', 'Eine Kanne Tee'][index] })) });
}
}
for (const [title, content, created, updated] of [
['Herbstfest: Wir freuen uns auf euch!', 'Am **Freitag, 23. Oktober**, feiern wir ab 15 Uhr im Kita-Garten.\n\nBitte tragt euch im Kalender für die Teilnahme und in die Mitbringliste ein. Gemeinsam sorgen wir für ein buntes Buffet.\n\n**Aktualisierung:** Bei Regen feiern wir im Gruppenraum.', '2026-10-09', '2026-10-13'],
['Elternabend am 20. Oktober', 'Wir treffen uns um **18 Uhr** im großen Gruppenraum.\n\nAuf der Agenda stehen der Kita-Alltag, die nächsten Ausflüge und eure Fragen. Wir freuen uns auf den Austausch!', '2026-10-08', '2026-10-08'],
['Gemeinsam unseren Garten vorbereiten', 'Am 14. November machen wir unseren Garten winterfest. Kleine und große Helfer sind willkommen!\n\nHandschuhe und gute Laune reichen. Alle Details findet ihr im Kalender.', '2026-10-06', '2026-10-06'],
]) {
await tx.announcement.create({ data: { kitaId, authorId: admin.id, title, content, audience: 'ALLE',
createdAt: date(created), updatedAt: date(updated), revision: created === updated ? 1 : 2, editedAt: created === updated ? null : date(updated) } });
}
const staff = await tx.user.create({ data: { ...common, email: 'team@example.invalid', firstName: 'Sophie', lastName: 'Lind', role: UserRole.ERZIEHER } });
const educator = await tx.educator.create({ data: { kitaId, firstName: staff.firstName, lastName: staff.lastName } });
const profile = await tx.staffProfile.create({ data: { kitaId, educatorId: educator.id, userId: staff.id,
employmentStart: date('2026-01-01'), trackingStart: date('2026-10-01'), openingMinutes: 120,
workPatterns: { create: { effectiveFrom: date('2026-01-01'), dailyMinutes: [420,420,420,420,420,0,0] } },
leaveAccounts: { create: { year: 2026, entitlementUnits: 60, carryUnits: 0 } } } });
await tx.staffTimeEntry.createMany({ data: [5,6,7,8,9].map(day => ({ profileId: profile.id, date: date(`2026-10-${String(day).padStart(2,'0')}`), startMinute: 480, endMinute: 930, breakMinutes: 30 })) });
}, { timeout: 30000 });
console.log('Synthetic screenshot data ready. Logins: lena@example.invalid / team@example.invalid. Password: demo-screenshots-only.');
}
main().finally(() => prisma.$disconnect());
+292 -93
View File
@@ -2,22 +2,50 @@
import { randomUUID } from "crypto";
import { mkdir, rm, writeFile } from "fs/promises";
import { attachmentDirectory, attachmentPath, MAX_ATTACHMENT_SIZE, detectAttachmentType, attachmentExtensionMatches } from "@/lib/attachments";
import {
attachmentDirectory,
attachmentPath,
MAX_ATTACHMENT_SIZE,
detectAttachmentType,
attachmentExtensionMatches,
} from "@/lib/attachments";
import { serializableTransaction } from "@/lib/transaction";
import { rateLimit } from "@/lib/rate-limit";
import { createElement } from "react";
import { after } from "next/server";
import { revalidatePath } from "next/cache";
import { AnnouncementAudience, UserRole, type Prisma } from "@prisma/client";
import { UserRole } from "@prisma/client";
import { z } from "zod";
import { NewsEmail } from "@/emails/NewsEmail";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
import { visibleAnnouncementAudiences } from "@/lib/announcement-access";
import {
announcementRecipientWhere,
visibleAnnouncementAudiences,
} from "@/lib/announcement-access";
assertAnnouncementAdmin,
assertAnnouncementEditable,
deliverDueAnnouncementEmails,
lockAnnouncement,
queueAnnouncementEmails,
} from "@/lib/announcement-delivery";
import { publicActionError, UserFacingError } from "@/lib/action-error";
function revalidateAnnouncements() {
revalidatePath("/dashboard");
revalidatePath("/dashboard/admin/news");
revalidatePath("/dashboard/mitteilungen");
}
function startAnnouncementDelivery(kitaId: string, announcementId: string) {
after(async () => {
try {
await deliverDueAnnouncementEmails({ kitaId, announcementId });
} catch {
console.error(
"Mitteilungsversand wird im nächsten Hintergrundlauf erneut versucht.",
);
}
revalidateAnnouncements();
});
}
const audienceSchema = z.enum(["ELTERN", "ERZIEHER", "ALLE"]);
@@ -32,17 +60,6 @@ function getSafeFileName(name: string) {
return name.replace(/[^\w.\- äöüÄÖÜß]/g, "_").slice(0, 160);
}
async function getRecipients(kitaId: string, audience: z.infer<typeof audienceSchema>) {
const where: Prisma.UserWhereInput = { kitaId, emailVerifiedAt: { not: null }, ...announcementRecipientWhere(audience as AnnouncementAudience) };
const users = await prisma.user.findMany({
where: { ...where, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true },
});
return Array.from(new Set(users.map((user) => user.email)));
}
export async function createAnnouncement(formData: FormData) {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
@@ -66,47 +83,89 @@ export async function createAnnouncement(formData: FormData) {
.getAll("attachments")
.filter((value): value is File => value instanceof File && value.size > 0);
if (attachmentFiles.length > 5 || attachmentFiles.some(file => file.size > MAX_ATTACHMENT_SIZE) ||
attachmentFiles.reduce((total, file) => total + file.size, 0) > 25 * 1024 * 1024) {
return { error: "Maximal 5 Anhänge, je 10 MB und insgesamt 25 MB sind erlaubt." };
if (
attachmentFiles.length > 5 ||
attachmentFiles.some((file) => file.size > MAX_ATTACHMENT_SIZE) ||
attachmentFiles.reduce((total, file) => total + file.size, 0) >
25 * 1024 * 1024
) {
return {
error: "Maximal 5 Anhänge, je 10 MB und insgesamt 25 MB sind erlaubt.",
};
}
if (!(await rateLimit(`announcements:${kitaId}:${session.user.id}`, 20, 3600)).allowed) {
return { error: "Zu viele Ankündigungen. Bitte versuche es später erneut." };
if (
!(await rateLimit(`announcements:${kitaId}:${session.user.id}`, 20, 3600))
.allowed
) {
return {
error: "Zu viele Ankündigungen. Bitte versuche es später erneut.",
};
}
const attachmentInputs: { id: string; bytes: Buffer; fileName: string; fileType: string; fileUrl: string }[] = [];
const attachmentInputs: {
id: string;
bytes: Buffer;
fileName: string;
fileType: string;
fileUrl: string;
}[] = [];
for (const file of attachmentFiles) {
const bytes = Buffer.from(await file.arrayBuffer());
const fileType = detectAttachmentType(bytes);
if (!fileType || !attachmentExtensionMatches(file.name, fileType) || (file.type && file.type !== fileType)) {
return { error: "Nur PDF-, JPG- und PNG-Dateien mit passenden Dateiinhalten sind erlaubt." };
if (
!fileType ||
!attachmentExtensionMatches(file.name, fileType) ||
(file.type && file.type !== fileType)
) {
return {
error:
"Nur PDF-, JPG- und PNG-Dateien mit passenden Dateiinhalten sind erlaubt.",
};
}
const id = randomUUID();
attachmentInputs.push({ id, bytes, fileName: getSafeFileName(file.name), fileType,
fileUrl: `/api/announcements/attachments/${id}` });
attachmentInputs.push({
id,
bytes,
fileName: getSafeFileName(file.name),
fileType,
fileUrl: `/api/announcements/attachments/${id}`,
});
}
const announcement = await serializableTransaction(async tx => {
const announcement = await serializableTransaction(async (tx) => {
if (attachmentInputs.length) {
const pending = await tx.attachment.count({ where: { sizeBytes: null } });
if (pending) return null;
const [tenantUsage, globalUsage] = await Promise.all([
tx.attachment.aggregate({ where: { announcement: { kitaId } }, _sum: { sizeBytes: true } }),
tx.attachment.aggregate({
where: { announcement: { kitaId } },
_sum: { sizeBytes: true },
}),
tx.attachment.aggregate({ _sum: { sizeBytes: true } }),
]);
const bytes = attachmentInputs.reduce((total, item) => total + item.bytes.length, 0);
const bytes = attachmentInputs.reduce(
(total, item) => total + item.bytes.length,
0,
);
const quota = (value: string | undefined, fallback: number) => {
const parsed = Number(value); return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
};
if ((tenantUsage._sum.sizeBytes ?? 0) + bytes > quota(process.env.UPLOAD_TENANT_QUOTA_BYTES, 256 * 1024 ** 2) ||
(globalUsage._sum.sizeBytes ?? 0) + bytes > quota(process.env.UPLOAD_GLOBAL_QUOTA_BYTES, 10 * 1024 ** 3)) return null;
if (
(tenantUsage._sum.sizeBytes ?? 0) + bytes >
quota(process.env.UPLOAD_TENANT_QUOTA_BYTES, 256 * 1024 ** 2) ||
(globalUsage._sum.sizeBytes ?? 0) + bytes >
quota(process.env.UPLOAD_GLOBAL_QUOTA_BYTES, 10 * 1024 ** 3)
)
return null;
}
return tx.announcement.create({
const row = await tx.announcement.create({
data: {
kitaId,
title: parsed.data.title,
content: parsed.data.content,
audience: parsed.data.audience,
authorId: session.user.id,
emailReady: attachmentInputs.length === 0,
attachments: {
create: attachmentInputs.map((attachment) => ({
id: attachment.id,
@@ -121,10 +180,19 @@ export async function createAnnouncement(formData: FormData) {
id: true,
title: true,
content: true,
kitaId: true,
audience: true,
revision: true,
},
});
if (parsed.data.sendEmail) await queueAnnouncementEmails(tx, row);
return row;
});
if (!announcement) return { error: "Anhänge können momentan nicht gespeichert werden. Bitte wende dich an die Administration." };
if (!announcement)
return {
error:
"Anhänge können momentan nicht gespeichert werden. Bitte wende dich an die Administration.",
};
if (attachmentInputs.length > 0) {
const uploadDir = attachmentDirectory();
@@ -132,11 +200,10 @@ export async function createAnnouncement(formData: FormData) {
await mkdir(uploadDir, { recursive: true });
await Promise.all(
attachmentInputs.map(async (attachment) => {
await writeFile(
attachmentPath(attachment.id),
attachment.bytes,
{ flag: "wx", mode: 0o600 },
);
await writeFile(attachmentPath(attachment.id), attachment.bytes, {
flag: "wx",
mode: 0o600,
});
}),
);
} catch (error) {
@@ -154,79 +221,211 @@ export async function createAnnouncement(formData: FormData) {
}
}
let emailError: string | null = null;
if (parsed.data.sendEmail) {
const configError = getAppEmailConfigError();
if (configError) {
emailError = configError;
} else {
const recipients = await getRecipients(kitaId, parsed.data.audience);
if (recipients.length > 0) {
const result = await sendAppEmail({
to: recipients,
subject: `Neue Kita-Ankündigung: ${announcement.title}`,
react: createElement(NewsEmail, {
title: announcement.title,
content: announcement.content,
dashboardUrl: `${process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000"}/dashboard`,
}),
if (attachmentInputs.length)
await prisma.announcement.update({
where: { id: announcement.id, kitaId },
data: { emailReady: true },
});
if (parsed.data.sendEmail) startAnnouncementDelivery(kitaId, announcement.id);
revalidateAnnouncements();
return { success: true, warning: undefined };
}
const editSchema = z.object({
id: z.string().min(1).max(64),
revision: z.coerce.number().int().positive(),
title: announcementSchema.shape.title,
content: announcementSchema.shape.content,
sendEmail: z.boolean(),
});
if (!result.success) {
emailError = result.error;
}
}
}
}
revalidatePath("/dashboard");
revalidatePath("/dashboard/admin/news");
revalidatePath("/dashboard/mitteilungen");
if (emailError) {
export async function updateAnnouncement(formData: FormData) {
const session = await requireRole([UserRole.ADMIN]);
const input = editSchema.safeParse({
id: formData.get("id"),
revision: formData.get("revision"),
title: formData.get("title"),
content: formData.get("content"),
sendEmail: formData.get("sendEmail") === "on",
});
if (!input.success)
return {
success: true,
warning: `Ankündigung gespeichert, E-Mail-Versand fehlgeschlagen: ${emailError}`,
error:
"Bitte Titel (3–160 Zeichen) und Inhalt (3–20.000 Zeichen) prüfen.",
};
const actor = {
id: session.user.id,
kitaId: session.user.kitaId!,
sessionVersion: session.user.sessionVersion,
};
if (
!(
await rateLimit(`announcement-edit:${actor.kitaId}:${actor.id}`, 30, 3600)
).allowed
)
return { error: "Zu viele Änderungen. Bitte versuche es später erneut." };
try {
const result = await serializableTransaction(async (tx) => {
await assertAnnouncementAdmin(tx, actor);
const previous = await assertAnnouncementEditable(
tx,
actor.kitaId,
input.data.id,
input.data.revision,
);
const changed =
previous.title !== input.data.title ||
previous.content !== input.data.content;
const row = changed
? await tx.announcement.update({
where: { id: previous.id, kitaId: actor.kitaId },
data: {
title: input.data.title,
content: input.data.content,
editedAt: new Date(),
updatedAt: new Date(),
revision: { increment: 1 },
},
})
: previous;
if (changed) {
await tx.announcementRead.deleteMany({
where: { kitaId: actor.kitaId, announcementId: row.id },
});
await tx.announcementEmail.updateMany({
where: {
kitaId: actor.kitaId,
announcementId: row.id,
sentAt: null,
skippedAt: null,
},
data: {
skippedAt: new Date(),
failed: false,
lastError: "Durch eine neuere Beitragsversion ersetzt.",
claimToken: null,
leaseUntil: null,
},
});
}
const queued = input.data.sendEmail
? await queueAnnouncementEmails(tx, row)
: 0;
return { changed, queued };
});
if (result.queued) startAnnouncementDelivery(actor.kitaId, input.data.id);
revalidateAnnouncements();
return { success: true, ...result };
} catch (error) {
return {
error: publicActionError(
error,
"Mitteilung konnte nicht gespeichert werden. Bitte versuche es erneut.",
),
};
}
return { success: true };
}
export async function markAnnouncementRead(announcementId: string) {
export async function retryAnnouncementEmails(id: string, revision: number) {
const session = await requireRole([UserRole.ADMIN]);
if (
!z.string().min(1).max(64).safeParse(id).success ||
!Number.isSafeInteger(revision) ||
revision < 1
)
return { error: "Ungültiger Beitrag." };
const actor = {
id: session.user.id,
kitaId: session.user.kitaId!,
sessionVersion: session.user.sessionVersion,
};
if (
!(
await rateLimit(
`announcement-retry:${actor.kitaId}:${actor.id}`,
20,
3600,
)
).allowed
)
return {
error: "Zu viele Versandversuche. Bitte versuche es später erneut.",
};
try {
const queued = await serializableTransaction(async (tx) => {
await assertAnnouncementAdmin(tx, actor);
await assertAnnouncementEditable(tx, actor.kitaId, id, revision);
return (
await tx.announcementEmail.updateMany({
where: {
kitaId: actor.kitaId,
announcementId: id,
revision,
sentAt: null,
skippedAt: null,
failed: true,
},
data: { nextAttemptAt: new Date(), failed: false },
})
).count;
});
if (queued) startAnnouncementDelivery(actor.kitaId, id);
revalidateAnnouncements();
return { success: true, queued };
} catch (error) {
return {
error: publicActionError(
error,
"Versand konnte nicht erneut gestartet werden.",
),
};
}
}
export async function markAnnouncementRead(
announcementId: string,
revision = 1,
) {
const session = await requireKitaSession();
const announcement = await prisma.announcement.findFirst({
if (!Number.isSafeInteger(revision) || revision < 1)
return { error: "Ungültige Beitragsversion." };
try {
await serializableTransaction(async (tx) => {
await lockAnnouncement(tx, session.user.kitaId, announcementId);
const announcement = await tx.announcement.findFirst({
where: {
id: announcementId,
revision,
kitaId: session.user.kitaId,
audience: { in: visibleAnnouncementAudiences(session.user) },
},
select: { id: true },
});
if (!announcement) {
return { error: "Ankündigung wurde nicht gefunden." };
}
await prisma.announcementRead.upsert({
if (!announcement)
throw new UserFacingError(
"Der Beitrag wurde geändert oder ist nicht verfügbar. Bitte lade die Seite neu.",
);
await tx.announcementRead.upsert({
where: {
userId_announcementId: {
userId: session.user.id,
announcementId: announcement.id,
},
userId_announcementId: { userId: session.user.id, announcementId },
},
create: {
kitaId: session.user.kitaId,
userId: session.user.id,
announcementId: announcement.id,
},
update: {
readAt: new Date(),
announcementId,
},
update: { readAt: new Date() },
});
revalidatePath("/dashboard");
revalidatePath("/dashboard/mitteilungen");
});
} catch (error) {
return {
error: publicActionError(
error,
"Lesestatus konnte nicht gespeichert werden.",
),
};
}
revalidateAnnouncements();
return { success: true };
}
+51 -14
View File
@@ -7,6 +7,12 @@ import { AuditTargetType, UserRole } from "@prisma/client";
import { z } from "zod";
import { requireRole } from "@/lib/auth-utils";
import {
assertKitaAdminRemains,
lockKitaAccounts,
} from "@/lib/kita-admin-guard";
import { serializableTransaction } from "@/lib/transaction";
import { UserFacingError } from "@/lib/action-error";
import { prisma } from "@/lib/prisma";
const deleteKitaSchema = z.object({
@@ -28,7 +34,8 @@ type ActionResult = {
async function requireSuperadminActor() {
const session = await requireRole([UserRole.SUPERADMIN]);
if (Date.now() - session.user.authenticatedAt > 15 * 60_000) redirect("/login?reauth=1&callbackUrl=/admin");
if (Date.now() - session.user.authenticatedAt > 15 * 60_000)
redirect("/login?reauth=1&callbackUrl=/admin");
return {
id: session.user.id,
email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
@@ -96,7 +103,10 @@ export async function deleteKita(
const counts = await getKitaDeleteCounts(kita.id);
const attachmentIds = await prisma.$transaction(async (tx) => {
const attachments = await tx.attachment.findMany({ where: { announcement: { kitaId: kita.id } }, select: { id: true } });
const attachments = await tx.attachment.findMany({
where: { announcement: { kitaId: kita.id } },
select: { id: true },
});
await tx.auditLog.create({
data: {
actorUserId: actor.id,
@@ -111,7 +121,7 @@ export async function deleteKita(
});
await tx.kita.delete({ where: { id: kita.id } });
return attachments.map(item => item.id);
return attachments.map((item) => item.id);
});
await removeAttachmentFiles(attachmentIds);
@@ -156,13 +166,19 @@ async function findMutableTenantUser(userId: string, actorId: string) {
return { error: "Benutzer wurde nicht gefunden." } as const;
}
if (user.id === actorId) {
return { error: "Das eigene Superadmin-Konto kann hier nicht geändert werden." } as const;
return {
error: "Das eigene Superadmin-Konto kann hier nicht geändert werden.",
} as const;
}
if (user.role === UserRole.SUPERADMIN) {
return { error: "Andere Superadmins werden in V1 nicht verwaltet." } as const;
return {
error: "Andere Superadmins werden in V1 nicht verwaltet.",
} as const;
}
if (!user.kitaId) {
return { error: "Nur tenant-gebundene Benutzer können verwaltet werden." } as const;
return {
error: "Nur tenant-gebundene Benutzer können verwaltet werden.",
} as const;
}
return { user } as const;
@@ -188,8 +204,15 @@ export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
};
}
const attachmentIds = await prisma.$transaction(async (tx) => {
const attachments = await tx.attachment.findMany({ where: { announcement: { authorId: user.id } }, select: { id: true } });
let attachmentIds: string[];
try {
attachmentIds = await serializableTransaction(async (tx) => {
await lockKitaAccounts(tx, user.kitaId!);
await assertKitaAdminRemains(tx, user.kitaId!, [user.id]);
const attachments = await tx.attachment.findMany({
where: { announcement: { authorId: user.id } },
select: { id: true },
});
await tx.verificationToken.deleteMany({
where: { identifier: user.id },
});
@@ -213,9 +236,12 @@ export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
},
});
await tx.user.delete({ where: { id: user.id } });
return attachments.map(item => item.id);
return attachments.map((item) => item.id);
});
} catch (error) {
if (!(error instanceof UserFacingError)) throw error;
return { ok: false, error: error.message };
}
await removeAttachmentFiles(attachmentIds);
revalidatePath("/admin");
revalidatePath(`/admin/kitas/${user.kitaId}`);
@@ -245,10 +271,19 @@ async function updateUserSupportState(
const { user } = lookup;
await prisma.$transaction(async (tx) => {
try {
await serializableTransaction(async (tx) => {
await lockKitaAccounts(tx, user.kitaId!);
if (action === "USER_LOCK")
await assertKitaAdminRemains(tx, user.kitaId!, [user.id]);
await tx.user.update({
where: { id: user.id },
data: { ...data, ...(action === "USER_LOCK" ? { sessionVersion: { increment: 1 } } : {}) },
data: {
...data,
...(action === "USER_LOCK"
? { sessionVersion: { increment: 1 } }
: {}),
},
});
await tx.auditLog.create({
data: {
@@ -267,7 +302,10 @@ async function updateUserSupportState(
},
});
});
} catch (error) {
if (!(error instanceof UserFacingError)) throw error;
return { ok: false, error: error.message };
}
revalidatePath("/admin");
revalidatePath(`/admin/kitas/${user.kitaId}`);
revalidatePath("/admin/audit");
@@ -296,4 +334,3 @@ export async function resetFailedLoginAttempts(
failedLoginAttempts: 0,
});
}
+19 -3
View File
@@ -3,6 +3,8 @@ import { authorizeAlarmJob, runAlarmJob } from "@/lib/alarms/job";
import { runNotdienstNotificationJob } from "@/lib/notdienst-notifications";
import { deliverDueAnnouncementEmails } from "@/lib/announcement-delivery";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
@@ -18,12 +20,26 @@ export async function POST(request: Request) {
{ status },
);
try {
const [alarm, planning] = await Promise.allSettled([runAlarmJob(), runNotdienstNotificationJob()]);
if (alarm.status === "rejected" || planning.status === "rejected") throw new Error("Ein Hintergrundlauf ist fehlgeschlagen.");
const result = { ...alarm.value, planning: planning.value };
const [alarm, planning, announcements] = await Promise.allSettled([
runAlarmJob(),
runNotdienstNotificationJob(),
deliverDueAnnouncementEmails(),
]);
if (
alarm.status === "rejected" ||
planning.status === "rejected" ||
announcements.status === "rejected"
)
throw new Error("Ein Hintergrundlauf ist fehlgeschlagen.");
const result = {
...alarm.value,
planning: planning.value,
announcements: announcements.value,
};
revalidatePath("/dashboard");
revalidatePath("/dashboard/notdienst/alarm");
revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard/admin/news");
return Response.json(result);
} catch {
return Response.json(
+319
View File
@@ -0,0 +1,319 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { format } from "date-fns";
import { ArrowLeft, FileText, Mail } from "lucide-react";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { announcementAudienceLabels } from "@/lib/announcement-access";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { EditNewsForm, RetryNewsEmails } from "../edit-news-form";
export const metadata = { title: "Mitteilung bearbeiten · Kita-Planer" };
export default async function EditNewsPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ page?: string }>;
}) {
const session = await requireRole(["ADMIN"]);
const { id } = await params;
const announcement = await prisma.announcement.findFirst({
where: { id, kitaId: session.user.kitaId! },
include: {
attachments: { select: { id: true, fileName: true, fileUrl: true } },
},
});
if (!announcement) notFound();
const rawPage = Number((await searchParams).page ?? 1);
const page =
Number.isSafeInteger(rawPage) && rawPage > 0
? Math.min(rawPage, 100000)
: 1;
const where = {
kitaId: session.user.kitaId!,
announcementId: id,
revision: announcement.revision,
};
const [statuses, emails, older] = await Promise.all([
prisma.announcementEmail.findMany({
where,
select: { sentAt: true, skippedAt: true, failed: true },
}),
prisma.announcementEmail.findMany({
where,
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
take: 51,
skip: (page - 1) * 50,
select: {
id: true,
sentAt: true,
skippedAt: true,
failed: true,
attempts: true,
leaseUntil: true,
lastError: true,
user: { select: { firstName: true, lastName: true, email: true } },
},
}),
prisma.announcementEmail.findMany({
where: {
kitaId: session.user.kitaId!,
announcementId: id,
revision: { lt: announcement.revision },
},
select: { revision: true, sentAt: true, skippedAt: true, failed: true },
}),
]);
const history = new Map<
number,
{ sent: number; skipped: number; failed: number }
>();
for (const job of older) {
const summary = history.get(job.revision) ?? {
sent: 0,
skipped: 0,
failed: 0,
};
if (job.sentAt) summary.sent++;
else if (job.skippedAt) summary.skipped++;
else if (job.failed) summary.failed++;
history.set(job.revision, summary);
}
const backgroundConfigured =
(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32;
const sent = statuses.filter((job) => job.sentAt).length;
const skipped = statuses.filter((job) => job.skippedAt).length;
const failed = statuses.filter(
(job) => !job.sentAt && !job.skippedAt && job.failed,
).length;
const pending = statuses.length - sent - skipped - failed;
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<Button asChild variant="ghost" size="sm" className="mb-5">
<Link href="/dashboard/admin/news#beitraege">
<ArrowLeft className="h-4 w-4" />
Alle Beiträge
</Link>
</Button>
<header className="mb-7">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-deep)]">
SCHWARZES BRETT · BEITRAG
</p>
<h1 className="mt-2 text-[36px] tracking-[-0.025em]">
Mitteilung{" "}
<em className="font-normal text-[var(--accent-deep)]">bearbeiten</em>
</h1>
<p className="mt-2 text-sm text-[var(--ink-soft)]">
Veröffentlicht am {format(announcement.createdAt, "dd.MM.yyyy HH:mm")}
{announcement.editedAt &&
` · Aktualisiert am ${format(announcement.editedAt, "dd.MM.yyyy HH:mm")}`}
</p>
</header>
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)]">
<Card>
<CardHeader>
<CardTitle>Inhalt korrigieren</CardTitle>
<CardDescription>
Empfängergruppe und bestehende Anhänge bleiben bei einer Korrektur
erhalten.
</CardDescription>
<Badge variant="outline" className="mt-2 w-fit">
{announcementAudienceLabels[announcement.audience]}
</Badge>
</CardHeader>
<CardContent>
<EditNewsForm
key={announcement.revision}
announcement={{
id,
revision: announcement.revision,
title: announcement.title,
content: announcement.content,
}}
/>
{announcement.attachments.length > 0 && (
<div className="mt-6 border-t border-[var(--hairline)] pt-4">
<h2 className="mb-2 text-sm font-medium">Anhänge</h2>
<div className="grid gap-2">
{announcement.attachments.map((a) => (
<a
key={a.id}
href={a.fileUrl}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 break-all text-sm text-[var(--accent-deep)] underline"
>
<FileText className="h-4 w-4 shrink-0" />
{a.fileName}
</a>
))}
</div>
</div>
)}
</CardContent>
</Card>
<Card id="versand">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-4 w-4" />
E-Mail-Versand
</CardTitle>
<CardDescription>
Status der aktuellen Fassung. „Gesendet“ bedeutet, dass der
Maildienst die Nachricht angenommen hat.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex flex-wrap gap-2">
<Badge variant="success">{sent} gesendet</Badge>
<Badge variant="secondary">{pending} ausstehend</Badge>
<Badge variant="outline">{failed} fehlgeschlagen</Badge>
{skipped > 0 && (
<Badge variant="outline">{skipped} übersprungen</Badge>
)}
</div>
{!backgroundConfigured && (
<p className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3 text-sm leading-6">
Automatische Wiederholungen sind noch nicht eingerichtet. Bitte
wende dich an die Hosting-Administration; fehlgeschlagene
E-Mails kannst du hier selbst erneut versuchen.
</p>
)}
{!announcement.emailReady && (
<p className="text-sm text-[var(--ink-soft)]">
Die Anhänge werden noch vorbereitet. Der Versand beginnt erst,
wenn alle Dateien gespeichert sind.
</p>
)}
<p className="text-xs leading-5 text-[var(--ink-soft)]">
Bei eingerichteter Automatik werden fehlgeschlagene Sendungen
erneut versucht. Deaktivierte Konten und Personen, die nicht mehr
zur Empfängergruppe gehören, werden übersprungen.
</p>
{failed > 0 && (
<div className="[&_button]:h-auto [&_button]:whitespace-normal">
<RetryNewsEmails
id={id}
revision={announcement.revision}
failed={failed}
/>
</div>
)}
<Button
asChild
variant="ghost"
size="sm"
className="justify-self-start"
>
<Link href={`/dashboard/admin/news/${id}?page=${page}#versand`}>
Status aktualisieren
</Link>
</Button>
{statuses.length === 0 && (
<p className="rounded-lg bg-[var(--surface-2)] p-4 text-sm leading-6 text-[var(--ink-soft)]">
Für diese Fassung sind keine E-Mails vorgemerkt. Bei Beiträgen
aus der Zeit vor Einführung dieser Übersicht liegt kein
damaliger Versandstatus vor. Über das Formular kannst du die
aktuelle Fassung einmalig versenden.
</p>
)}
<div className="grid gap-2">
{emails.slice(0, 50).map((job) => (
<div
key={job.id}
className="min-w-0 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="text-sm font-medium">
{job.user.firstName} {job.user.lastName}
</p>
<Badge variant={job.sentAt ? "success" : "outline"}>
{job.sentAt
? "Gesendet"
: job.skippedAt
? "Übersprungen"
: job.failed
? "Fehlgeschlagen"
: job.leaseUntil && job.leaseUntil > new Date()
? "Wird versendet"
: "Ausstehend"}
</Badge>
</div>
<p className="mt-1 break-all text-xs text-[var(--ink-muted)]">
{job.user.email}
</p>
{job.sentAt && (
<p className="mt-1 text-xs text-[var(--ink-muted)]">
{format(job.sentAt, "dd.MM.yyyy HH:mm")}
</p>
)}
{job.lastError && (
<p className="mt-2 text-xs leading-5 text-[var(--ink-soft)]">
{job.lastError}
</p>
)}
</div>
))}
</div>
{history.size > 0 && (
<details className="rounded-lg border border-[var(--hairline)] p-3">
<summary className="cursor-pointer text-sm font-medium">
Versand früherer Fassungen
</summary>
<div className="mt-3 grid gap-2">
{[...history]
.sort(([a], [b]) => b - a)
.map(([revision, summary]) => (
<p
key={revision}
className="text-xs leading-5 text-[var(--ink-soft)]"
>
Fassung {revision}: {summary.sent} gesendet ·{" "}
{summary.skipped} übersprungen · {summary.failed}{" "}
fehlgeschlagen
</p>
))}
</div>
<p className="mt-3 text-xs leading-5 text-[var(--ink-muted)]">
Ungesendete E-Mails älterer Fassungen wurden durch die
Korrektur ersetzt.
</p>
</details>
)}
{(page > 1 || emails.length > 50) && (
<nav
aria-label="Empfänger durchblättern"
className="flex flex-wrap justify-between gap-3"
>
<div>
{page > 1 && (
<Button asChild variant="outline" size="sm">
<Link href={`?page=${page - 1}#versand`}>Zurück</Link>
</Button>
)}
</div>
<span className="text-sm">Seite {page}</span>
<div>
{emails.length > 50 && (
<Button asChild variant="outline" size="sm">
<Link href={`?page=${page + 1}#versand`}>Weitere</Link>
</Button>
)}
</div>
</nav>
)}
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,174 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Loader2, RotateCw, Save } from "lucide-react";
import {
updateAnnouncement,
retryAnnouncementEmails,
} from "@/actions/announcements";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { MarkdownContent } from "@/components/markdown-content";
export function EditNewsForm({
announcement,
}: {
announcement: {
id: string;
revision: number;
title: string;
content: string;
};
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [content, setContent] = useState(announcement.content);
const [sendEmail, setSendEmail] = useState(false);
function submit(formData: FormData) {
formData.set("id", announcement.id);
formData.set("revision", String(announcement.revision));
formData.set("sendEmail", sendEmail ? "on" : "off");
startTransition(async () => {
try {
const result = await updateAnnouncement(formData);
if (!("success" in result)) {
toast.error(result.error);
return;
}
toast.success(
result.queued
? `Mitteilung gespeichert. ${result.queued} E-Mails sind zum Versand vorgemerkt.`
: result.changed
? "Mitteilung aktualisiert."
: "Keine Änderungen vorhanden.",
);
setSendEmail(false);
router.refresh();
} catch {
toast.error(
"Mitteilung konnte nicht gespeichert werden. Bitte versuche es erneut.",
);
}
});
}
return (
<form action={submit} className="grid gap-5">
<div className="grid gap-2">
<Label htmlFor="edit-title">Titel</Label>
<Input
id="edit-title"
name="title"
defaultValue={announcement.title}
minLength={3}
maxLength={160}
required
disabled={pending}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-content">Text</Label>
<Textarea
id="edit-content"
name="content"
rows={10}
value={content}
onChange={(event) => setContent(event.target.value)}
minLength={3}
maxLength={20_000}
required
disabled={pending}
/>
<p className="text-xs text-[var(--ink-muted)]">
Markdown ist aktiv. HTML wird nicht interpretiert.
</p>
</div>
<details className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
<summary className="cursor-pointer text-sm font-medium">
Vorschau ansehen
</summary>
<div className="mt-4 text-sm">
<MarkdownContent content={content} />
</div>
</details>
<div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
<div className="flex items-start gap-2">
<Checkbox
id="correction-email"
checked={sendEmail}
disabled={pending}
onCheckedChange={(checked) => setSendEmail(checked === true)}
/>
<Label
htmlFor="correction-email"
className="cursor-pointer leading-5"
>
Aktuelle Fassung auch per E-Mail senden
</Label>
</div>
<p className="mt-2 text-xs leading-5 text-[var(--ink-soft)]">
Bei einer Textänderung erscheint der Beitrag für alle wieder als
ungelesen. Jede Fassung wird je Person höchstens einmal zum Versand
vorgemerkt.
</p>
</div>
<Button type="submit" disabled={pending} className="justify-self-end">
{pending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Änderungen speichern
</Button>
</form>
);
}
export function RetryNewsEmails({
id,
revision,
failed,
}: {
id: string;
revision: number;
failed: number;
}) {
const [pending, startTransition] = useTransition();
const router = useRouter();
return (
<Button
variant="outline"
disabled={pending || failed === 0}
onClick={() =>
startTransition(async () => {
try {
const result = await retryAnnouncementEmails(id, revision);
if (!("success" in result)) {
toast.error(result.error);
return;
}
toast.success(
result.queued
? `${result.queued} fehlgeschlagene E-Mails werden erneut versucht.`
: "Keine fehlgeschlagenen Sendungen vorhanden.",
);
router.refresh();
} catch {
toast.error("Versand konnte nicht gestartet werden.");
}
})
}
>
{pending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCw className="h-4 w-4" />
)}
Fehlgeschlagene erneut versuchen
</Button>
);
}
+23 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useTransition } from "react";
import { useRef, useState, useTransition } from "react";
import { Loader2, Send, UploadCloud } from "lucide-react";
import { toast } from "sonner";
@@ -20,6 +20,7 @@ import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/markdown-content";
export function NewsForm() {
const formRef = useRef<HTMLFormElement>(null);
const [isPending, startTransition] = useTransition();
const [sendEmail, setSendEmail] = useState(false);
const [content, setContent] = useState("");
@@ -30,6 +31,7 @@ export function NewsForm() {
formData.set("sendEmail", "on");
}
try {
const result = await createAnnouncement(formData);
if (result.error) {
toast.error(result.error);
@@ -39,18 +41,33 @@ export function NewsForm() {
if (result.warning) {
toast.warning(result.warning);
} else {
toast.success("Ankündigung veröffentlicht.");
toast.success(
sendEmail
? "Ankündigung veröffentlicht. E-Mails sind zum Versand vorgemerkt."
: "Ankündigung veröffentlicht.",
);
}
formRef.current?.reset();
setContent("");
setSendEmail(false);
} catch {
toast.error(
"Ankündigung konnte nicht veröffentlicht werden. Bitte versuche es erneut.",
);
}
});
}
return (
<form action={handleSubmit} className="grid gap-5">
<form ref={formRef} action={handleSubmit} className="grid gap-5">
<div className="grid gap-2">
<Label htmlFor="news-title">Titel</Label>
<Input
id="news-title"
name="title"
minLength={3}
maxLength={160}
disabled={isPending}
placeholder="Sommerfest: Aufbau und Mitbringliste"
required
/>
@@ -62,6 +79,9 @@ export function NewsForm() {
id="news-content"
name="content"
rows={10}
minLength={3}
maxLength={20_000}
disabled={isPending}
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder={
+122 -29
View File
@@ -1,3 +1,7 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { FileText, Megaphone, Send } from "lucide-react";
@@ -18,7 +22,11 @@ import { NewsForm } from "./news-form";
export const metadata = { title: "Schwarzes Brett · Kita-Planer" };
export default async function AdminNewsPage() {
export default async function AdminNewsPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; page?: string }>;
}) {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
@@ -26,29 +34,46 @@ export default async function AdminNewsPage() {
throw new Error("Kein Mandant zugeordnet.");
}
const announcements = await prisma.announcement.findMany({
where: { kitaId },
const params = await searchParams;
const q = typeof params.q === "string" ? params.q.trim().slice(0, 160) : "";
const parsedPage = Number(params.page ?? 1);
const page =
Number.isSafeInteger(parsedPage) && parsedPage > 0
? Math.min(parsedPage, 100000)
: 1;
const pageSize = 12;
const where = {
kitaId,
...(q
? {
OR: [
{ title: { contains: q, mode: "insensitive" as const } },
{ content: { contains: q, mode: "insensitive" as const } },
],
}
: {}),
};
const [announcements, total] = await Promise.all([
prisma.announcement.findMany({
where,
select: {
id: true,
title: true,
audience: true,
createdAt: true,
attachments: {
select: {
id: true,
fileName: true,
editedAt: true,
revision: true,
_count: { select: { attachments: true } },
author: { select: { firstName: true, lastName: true } },
},
},
author: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: { createdAt: "desc" },
take: 8,
});
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.announcement.count({ where }),
]);
const href = (p: number) =>
`/dashboard/admin/news?${new URLSearchParams({ ...(q ? { q } : {}), page: String(p) })}#beitraege`;
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
@@ -60,7 +85,7 @@ export default async function AdminNewsPage() {
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Meldungen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
veröffentlichen
verwalten
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
@@ -71,12 +96,19 @@ export default async function AdminNewsPage() {
</div>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
<Megaphone className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{announcements.length} zuletzt
{total} {total === 1 ? "Beitrag" : "Beiträge"}
</span>
</header>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
<Card>
<div className="grid gap-6">
<details
open={!q && page === 1}
className="rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-5"
>
<summary className="cursor-pointer text-lg font-semibold">
Neue Ankündigung veröffentlichen
</summary>
<Card className="mt-4 border-0 shadow-none">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Send className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
@@ -91,22 +123,50 @@ export default async function AdminNewsPage() {
<NewsForm />
</CardContent>
</Card>
</details>
<Card className="h-fit">
<Card id="beitraege" className="h-fit">
<CardHeader>
<CardTitle className="text-base">Zuletzt veröffentlicht</CardTitle>
<CardTitle className="text-base">
Veröffentlichte Beiträge
</CardTitle>
<CardDescription>
Die neuesten Meldungen auf dem Schwarzen Brett.
Korrigiere auch ältere Beiträge und prüfe den E-Mail-Versand.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
<form
className="mb-4 flex flex-wrap items-end gap-3"
action="/dashboard/admin/news#beitraege"
>
<div className="grid flex-1 gap-2">
<Label htmlFor="news-search">Beiträge durchsuchen</Label>
<Input
id="news-search"
name="q"
defaultValue={q}
maxLength={160}
placeholder="Titel oder Text"
/>
</div>
<Button type="submit" variant="outline">
Suchen
</Button>
{q && (
<Button asChild variant="ghost">
<Link href="/dashboard/admin/news#beitraege">
Alle Beiträge
</Link>
</Button>
)}
</form>
{announcements.length === 0 ? (
<div className="flex max-h-[280px] min-h-[220px] flex-col items-center justify-center rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-6 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Megaphone className="h-6 w-6 [stroke-width:1.7]" />
</div>
<p className="text-sm font-medium text-[var(--ink)]">
Noch keine Ankündigungen
Keine Beiträge gefunden
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Veröffentlichte Meldungen erscheinen hier.
@@ -119,13 +179,13 @@ export default async function AdminNewsPage() {
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
>
<div className="mb-2 flex items-start justify-between gap-2">
<p className="font-medium leading-snug text-[var(--ink)]">
<p className="min-w-0 break-words font-medium leading-snug text-[var(--ink)]">
{announcement.title}
</p>
{announcement.attachments.length > 0 && (
{announcement._count.attachments > 0 && (
<Badge variant="secondary" className="shrink-0">
<FileText className="h-3 w-3 [stroke-width:1.7]" />
{announcement.attachments.length}
{announcement._count.attachments}
</Badge>
)}
</div>
@@ -139,9 +199,42 @@ export default async function AdminNewsPage() {
· {announcement.author.firstName}{" "}
{announcement.author.lastName}
</p>
{announcement.editedAt && (
<p className="mt-1 text-xs text-[var(--accent-deep)]">
Aktualisiert am{" "}
{format(announcement.editedAt, "dd.MM.yyyy HH:mm")}
</p>
)}
<Button asChild variant="outline" size="sm" className="mt-3">
<Link href={`/dashboard/admin/news/${announcement.id}`}>
Bearbeiten &amp; Versand
</Link>
</Button>
</div>
))
)}
<nav
aria-label="Beiträge durchblättern"
className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-[var(--hairline)] pt-4"
>
<div>
{page > 1 && (
<Button asChild variant="outline" size="sm">
<Link href={href(page - 1)}>Neuere Beiträge</Link>
</Button>
)}
</div>
<span className="text-sm text-[var(--ink-soft)]">
Seite {page} · {total} {total === 1 ? "Beitrag" : "Beiträge"}
</span>
<div>
{page * pageSize < total && (
<Button asChild variant="outline" size="sm">
<Link href={href(page + 1)}>Ältere Beiträge</Link>
</Button>
)}
</div>
</nav>
</CardContent>
</Card>
</div>
@@ -1,5 +1,9 @@
"use client";
import {
RoleChangeButton,
type RolePerson,
} from "../../zugriffsrechte/role-change-button";
import { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -47,6 +51,7 @@ type EducatorDto = {
email: string | null;
verified: boolean;
inviteFailed: boolean;
account: RolePerson | null;
};
function AccessFields({
@@ -119,9 +124,11 @@ function AccessFields({
}
export function ErzieherList({
currentUserId,
educators,
today,
}: {
currentUserId: string;
educators: EducatorDto[];
today: string;
}) {
@@ -377,6 +384,14 @@ export function ErzieherList({
</Badge>
</TableCell>
<TableCell className="pr-6 text-right">
{ed.account ? (
<span className="mr-2 inline-block">
<RoleChangeButton
person={ed.account}
ownAccount={ed.account.id === currentUserId}
/>
</span>
) : null}
<Button
variant="outline"
size="sm"
+19 -1
View File
@@ -21,7 +21,15 @@ export default async function ErzieherPage() {
staffProfile: {
select: {
id: true,
user: { select: { email: true, emailVerifiedAt: true } },
user: {
select: {
id: true,
role: true,
familyId: true,
email: true,
emailVerifiedAt: true,
},
},
emails: {
where: { inviteToken: { not: null }, skippedAt: null },
orderBy: { createdAt: "desc" },
@@ -65,10 +73,20 @@ export default async function ErzieherPage() {
</header>
<ErzieherList
currentUserId={session.user.id}
today={todayKey()}
educators={educators.map(({ staffProfile, ...educator }) => ({
...educator,
hasProfile: !!staffProfile,
account: staffProfile?.user
? {
id: staffProfile.user.id,
name: `${educator.firstName} ${educator.lastName}`,
role: staffProfile.user.role,
familyId: staffProfile.user.familyId,
hasStaffProfile: true,
}
: null,
email: staffProfile?.user?.email ?? null,
verified: !!staffProfile?.user?.emailVerifiedAt,
inviteFailed: !!staffProfile?.emails[0]?.failed,
@@ -1,5 +1,8 @@
"use client";
import type { UserRole } from "@prisma/client";
import { RoleChangeButton } from "../../zugriffsrechte/role-change-button";
import { RoleBadge } from "@/components/ui/role-badge";
import { useState, useTransition } from "react";
import { LayoutGrid, Mail, Search, Table, Users } from "lucide-react";
import { toast } from "sonner";
@@ -29,6 +32,8 @@ interface FamilyUser {
email: string;
emailVerifiedAt: Date | null;
invitation: FamilyInvitationView;
role: UserRole;
staffProfile: { id: string } | null;
dutyAssignments: {
id: string;
dutyId: string;
@@ -60,12 +65,14 @@ interface DutyData {
}
interface FamiliesListClientProps {
currentUserId: string;
initialFamilies: FamilyData[];
allDuties: DutyData[];
canManageDuties: boolean;
}
export function FamiliesListClient({
currentUserId,
initialFamilies,
allDuties,
canManageDuties,
@@ -85,7 +92,7 @@ export function FamiliesListClient({
(user) =>
user.firstName.toLowerCase().includes(q) ||
user.lastName.toLowerCase().includes(q) ||
user.email.toLowerCase().includes(q)
user.email.toLowerCase().includes(q),
);
if (matchesParent) return true;
@@ -93,20 +100,20 @@ export function FamiliesListClient({
const matchesChild = family.children.some(
(child) =>
child.firstName.toLowerCase().includes(q) ||
child.lastName.toLowerCase().includes(q)
child.lastName.toLowerCase().includes(q),
);
return matchesChild;
});
const childCount = filteredFamilies.reduce(
(acc, family) => acc + family.children.length,
0
0,
);
const pendingInviteCount = filteredFamilies.reduce(
(acc, family) =>
acc + family.users.filter((user) => !user.emailVerifiedAt).length,
0
0,
);
return (
@@ -123,14 +130,17 @@ export function FamiliesListClient({
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort pflegen.
Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort
pflegen.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setViewMode(viewMode === "table" ? "cards" : "table")}
onClick={() =>
setViewMode(viewMode === "table" ? "cards" : "table")
}
className="flex items-center gap-1.5"
>
{viewMode === "table" ? (
@@ -152,7 +162,9 @@ export function FamiliesListClient({
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{filteredFamilies.length}</span>
<span className="text-[var(--ink)] tabular-nums">
{filteredFamilies.length}
</span>
Familien
</span>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
@@ -160,7 +172,9 @@ export function FamiliesListClient({
Kinder
</span>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{pendingInviteCount}</span>
<span className="text-[var(--ink)] tabular-nums">
{pendingInviteCount}
</span>
Offene Einladungen
</span>
</div>
@@ -196,7 +210,8 @@ export function FamiliesListClient({
<div>
<CardTitle>Haushalte</CardTitle>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
{filteredFamilies.length} {filteredFamilies.length === 1 ? "Familie" : "Familien"} ·{" "}
{filteredFamilies.length}{" "}
{filteredFamilies.length === 1 ? "Familie" : "Familien"} ·{" "}
{childCount} Kinder · {pendingInviteCount} offene Einladungen
</p>
</div>
@@ -218,7 +233,7 @@ export function FamiliesListClient({
<TableBody>
{filteredFamilies.map((family) => {
const hasPendingInvite = family.users.some(
(user) => !user.emailVerifiedAt
(user) => !user.emailVerifiedAt,
);
return (
@@ -250,10 +265,29 @@ export function FamiliesListClient({
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{parent.email}
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
{!parent.emailVerifiedAt && (
<ResendInviteButton userId={parent.id} />
)}
</span>
</div>
<InvitationStatus userId={parent.id} name={`${parent.firstName} ${parent.lastName}`} invitation={parent.invitation} />
<InvitationStatus
userId={parent.id}
name={`${parent.firstName} ${parent.lastName}`}
invitation={parent.invitation}
/>
<div className="mt-2 flex flex-wrap items-center gap-2">
<RoleBadge role={parent.role} />
<RoleChangeButton
ownAccount={parent.id === currentUserId}
person={{
id: parent.id,
name: `${parent.firstName} ${parent.lastName}`,
role: parent.role,
familyId: family.id,
hasStaffProfile: !!parent.staffProfile,
}}
/>
</div>
</div>
))}
</div>
@@ -277,7 +311,9 @@ export function FamiliesListClient({
)}
</TableCell>
<TableCell>
<Badge variant={hasPendingInvite ? "warning" : "success"}>
<Badge
variant={hasPendingInvite ? "warning" : "success"}
>
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge>
</TableCell>
@@ -323,7 +359,7 @@ export function FamiliesListClient({
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredFamilies.map((family) => {
const hasPendingInvite = family.users.some(
(user) => !user.emailVerifiedAt
(user) => !user.emailVerifiedAt,
);
return (
<Card key={family.id} className="flex flex-col h-full">
@@ -333,9 +369,12 @@ export function FamiliesListClient({
{family.name.slice(0, 1)}
</div>
<div>
<CardTitle className="text-lg font-semibold">{family.name}</CardTitle>
<CardTitle className="text-lg font-semibold">
{family.name}
</CardTitle>
<p className="text-xs text-[var(--ink-muted)]">
{family.users.length} Elternteil{family.users.length === 1 ? "" : "e"}
{family.users.length} Elternteil
{family.users.length === 1 ? "" : "e"}
</p>
</div>
</div>
@@ -359,10 +398,29 @@ export function FamiliesListClient({
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{parent.email}
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
{!parent.emailVerifiedAt && (
<ResendInviteButton userId={parent.id} />
)}
</span>
</div>
<InvitationStatus userId={parent.id} name={`${parent.firstName} ${parent.lastName}`} invitation={parent.invitation} />
<InvitationStatus
userId={parent.id}
name={`${parent.firstName} ${parent.lastName}`}
invitation={parent.invitation}
/>
<div className="mt-2 flex flex-wrap items-center gap-2">
<RoleBadge role={parent.role} />
<RoleChangeButton
ownAccount={parent.id === currentUserId}
person={{
id: parent.id,
name: `${parent.firstName} ${parent.lastName}`,
role: parent.role,
familyId: family.id,
hasStaffProfile: !!parent.staffProfile,
}}
/>
</div>
</div>
))}
</div>
@@ -430,8 +488,21 @@ export function FamiliesListClient({
function ResendInviteButton({ userId }: { userId: string }) {
const [pending, startTransition] = useTransition();
return <Button type="button" size="sm" variant="ghost" disabled={pending} onClick={() => startTransition(async () => {
return (
<Button
type="button"
size="sm"
variant="ghost"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await resendParentInvite(userId);
if (result.error) toast.error(result.error); else toast.success("Einladung erneut gesendet.");
})}>{pending ? "Wird gesendet…" : "Einladung erneut senden"}</Button>;
if (result.error) toast.error(result.error);
else toast.success("Einladung erneut gesendet.");
})
}
>
{pending ? "Wird gesendet…" : "Einladung erneut senden"}
</Button>
);
}
+100 -21
View File
@@ -11,6 +11,10 @@ import {
queueFamilyInvitation,
resendFamilyInvitation,
} from "@/lib/family-invitations";
import {
assertKitaAdminRemains,
lockKitaAccounts,
} from "@/lib/kita-admin-guard";
import { prisma } from "@/lib/prisma";
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
import { UserFacingError, publicActionError } from "@/lib/action-error";
@@ -149,7 +153,12 @@ export async function addFamilyAction(
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
}
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { errors: { _form: ["Zu viele Einladungen. Bitte versuche es später erneut."] } };
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed)
return {
errors: {
_form: ["Zu viele Einladungen. Bitte versuche es später erneut."],
},
};
const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData));
if (!parsedFamily.success) {
@@ -310,8 +319,11 @@ export async function updateFamilyAction(
}
const removedChildIdSet = new Set(removedChildIds);
const activeChildren = children.filter((child) => !removedChildIdSet.has(child.id));
if (activeChildren.length + newChildren.length > 20) return { error: "Maximal 20 Kinder pro Familie." };
const activeChildren = children.filter(
(child) => !removedChildIdSet.has(child.id),
);
if (activeChildren.length + newChildren.length > 20)
return { error: "Maximal 20 Kinder pro Familie." };
if (activeChildren.length + newChildren.length === 0) {
return { error: "Eine Familie benötigt mindestens ein Kind." };
}
@@ -341,13 +353,26 @@ export async function updateFamilyAction(
return { error: "Familie wurde nicht gefunden." };
}
const needsEmail = newParents.length > 0 || parents.some(parent => existingFamily.users.find(user => user.id === parent.id)?.email !== parent.email);
const needsEmail =
newParents.length > 0 ||
parents.some(
(parent) =>
existingFamily.users.find((user) => user.id === parent.id)?.email !==
parent.email,
);
if (needsEmail) {
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { error: "Zu viele Einladungen. Bitte versuche es später erneut." };
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed)
return {
error: "Zu viele Einladungen. Bitte versuche es später erneut.",
};
}
const existingParentIds = new Set(existingFamily.users.map((user) => user.id));
const existingChildIds = new Set(existingFamily.children.map((child) => child.id));
const existingParentIds = new Set(
existingFamily.users.map((user) => user.id),
);
const existingChildIds = new Set(
existingFamily.children.map((child) => child.id),
);
if (!parents.every((parent) => existingParentIds.has(parent.id))) {
return {
@@ -367,23 +392,51 @@ export async function updateFamilyAction(
return { error: "Pro Familie sind maximal zwei Elternteile vorgesehen." };
}
await prisma.$transaction(async (tx) => {
await prisma.$transaction(
async (tx) => {
await lockKitaAccounts(tx, kitaId);
const changedEmails = parents
.filter(
(parent) =>
existingFamily.users.find((user) => user.id === parent.id)
?.email !== parent.email,
)
.map((parent) => parent.id);
await assertKitaAdminRemains(tx, kitaId, changedEmails);
await assertChildrenRemovable(tx, kitaId, familyId, removedChildIds);
const removed = await tx.child.count({ where: { kitaId, familyId, id: { in: removedChildIds } } });
if (removed !== removedChildIds.length) throw new UserFacingError("Mindestens ein Kind gehört nicht zu dieser Familie.");
const removed = await tx.child.count({
where: { kitaId, familyId, id: { in: removedChildIds } },
});
if (removed !== removedChildIds.length)
throw new UserFacingError(
"Mindestens ein Kind gehört nicht zu dieser Familie.",
);
await tx.family.update({
where: { id: familyId },
data: { name: familyName },
});
for (const parent of parents) {
const current = await tx.user.findFirst({ where: { id: parent.id, kitaId, familyId }, select: { email: true } });
const current = await tx.user.findFirst({
where: { id: parent.id, kitaId, familyId },
select: { email: true },
});
if (!current) throw new Error("Parent assignment changed");
const emailChanged = current.email !== parent.email;
await tx.user.update({
where: { id: parent.id, kitaId, familyId },
data: { firstName: parent.firstName, lastName: parent.lastName, email: parent.email,
...(emailChanged ? { passwordHash: "", emailVerifiedAt: null, sessionVersion: { increment: 1 } } : {}) },
data: {
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
...(emailChanged
? {
passwordHash: "",
emailVerifiedAt: null,
sessionVersion: { increment: 1 },
}
: {}),
},
});
if (emailChanged) {
const invite = await queueFamilyInvitation(
@@ -439,7 +492,9 @@ export async function updateFamilyAction(
},
});
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
@@ -448,8 +503,14 @@ export async function updateFamilyAction(
return { error: "Mindestens eine E-Mail-Adresse existiert bereits." };
}
if (!(error instanceof UserFacingError)) console.error("Fehler beim Aktualisieren der Familie:", error);
return { error: publicActionError(error, "Familie konnte nicht aktualisiert werden.") };
if (!(error instanceof UserFacingError))
console.error("Fehler beim Aktualisieren der Familie:", error);
return {
error: publicActionError(
error,
"Familie konnte nicht aktualisiert werden.",
),
};
}
const warning = await sendInviteJobs(inviteJobs);
@@ -464,15 +525,33 @@ 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)
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)
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)
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 },
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." };
+29 -7
View File
@@ -16,7 +16,12 @@ import {
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { createDuty, assignDuty, removeDutyAssignment, deleteDuty } from "./duty-actions";
import {
createDuty,
assignDuty,
removeDutyAssignment,
deleteDuty,
} from "./duty-actions";
type DutyWithAssignments = {
id: string;
@@ -100,17 +105,25 @@ export function DutyManager({
};
const unassignedDuties = allDuties.filter(
(d) => !userAssignments.some((a) => a.dutyId === d.id)
(d) => !userAssignments.some((a) => a.dutyId === d.id),
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1">
<Button
variant="ghost"
size="sm"
className="h-8 gap-1"
aria-label={`Ämter für ${user.firstName} ${user.lastName}`}
>
<Shield className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Ämter</span>
<span>Ämter: {user.firstName}</span>
{userAssignments.length > 0 && (
<Badge variant="secondary" className="ml-1 h-4 px-1 py-0 tabular-nums">
<Badge
variant="secondary"
className="ml-1 h-4 px-1 py-0 tabular-nums"
>
{userAssignments.length}
</Badge>
)}
@@ -137,7 +150,11 @@ export function DutyManager({
) : (
<div className="flex flex-wrap gap-2">
{userAssignments.map((assignment) => (
<Badge key={assignment.id} variant="default" className="gap-1 pr-1">
<Badge
key={assignment.id}
variant="default"
className="gap-1 pr-1"
>
{assignment.duty.name}
<button
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-[oklch(0.98_0.005_45_/_0.18)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[oklch(0.98_0.005_45)]"
@@ -199,7 +216,12 @@ export function DutyManager({
Neues Amt anlegen
</h4>
<form action={handleCreateDuty} className="flex gap-2">
<Input name="name" placeholder="z.B. Wäschedienst" className="h-8" required />
<Input
name="name"
placeholder="z.B. Wäschedienst"
className="h-8"
required
/>
<Button size="sm" className="h-8" disabled={isPending}>
Erstellen
</Button>
+14 -2
View File
@@ -32,9 +32,17 @@ export default async function FamiliesPage() {
firstName: true,
lastName: true,
email: true,
role: true,
staffProfile: { select: { id: true } },
emailVerifiedAt: true,
familyInvitation: {
select: { expiresAt: true, sentAt: true, failed: true, leaseUntil: true, status: true },
select: {
expiresAt: true,
sentAt: true,
failed: true,
leaseUntil: true,
status: true,
},
},
dutyAssignments: {
select: {
@@ -77,11 +85,15 @@ export default async function FamiliesPage() {
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<FamiliesListClient
currentUserId={session.user.id}
initialFamilies={families.map((family) => ({
...family,
users: family.users.map(({ familyInvitation, ...parent }) => ({
...parent,
invitation: familyInvitationView(parent.emailVerifiedAt, familyInvitation),
invitation: familyInvitationView(
parent.emailVerifiedAt,
familyInvitation,
),
})),
}))}
allDuties={allDuties}
@@ -1,10 +1,7 @@
"use client";
import { useState, useMemo, useTransition } from "react";
import {
TerminType,
TerminStatus,
} from "@prisma/client";
import { TerminType, TerminStatus } from "@prisma/client";
import {
format,
startOfMonth,
@@ -30,7 +27,7 @@ import {
WashingMachine,
Trash2,
} from "lucide-react";
import { RsvpDialog, RsvpForm } from "@/components/rsvp-form";
import { RsvpForm } from "@/components/rsvp-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -42,7 +39,6 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { toast } from "sonner";
@@ -64,12 +60,32 @@ import {
const categories = [
{ label: "Schließtag", type: TerminType.SCHLIESSTAG, color: "var(--danger)" },
{ label: "Teamtag", type: TerminType.TEAMTAG, color: "var(--danger)" },
{ label: "Elternabend", type: TerminType.ELTERNABEND, color: "oklch(0.50 0.10 280)" },
{
label: "Elternabend",
type: TerminType.ELTERNABEND,
color: "oklch(0.50 0.10 280)",
},
{ label: "Fest", type: TerminType.KITA_FEST, color: "var(--good)" },
{ label: "Geburtstag", type: TerminType.GEBURTSTAG_INTERN, color: "oklch(0.50 0.10 320)" },
{ label: "Mein Elterndienst", type: "mein-elterndienst", color: "oklch(0.60 0.16 160)" },
{ label: "Elterndienst", type: "elterndienst", color: "oklch(0.75 0.08 160)" },
{ label: "Mein Notdienst", type: "mein-notdienst", color: "oklch(0.60 0.18 45)" },
{
label: "Geburtstag",
type: TerminType.GEBURTSTAG_INTERN,
color: "oklch(0.50 0.10 320)",
},
{
label: "Mein Elterndienst",
type: "mein-elterndienst",
color: "oklch(0.60 0.16 160)",
},
{
label: "Elterndienst",
type: "elterndienst",
color: "oklch(0.75 0.08 160)",
},
{
label: "Mein Notdienst",
type: "mein-notdienst",
color: "oklch(0.60 0.18 45)",
},
{ label: "Notdienst", type: "notdienst", color: "oklch(0.75 0.10 50)" },
{ label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" },
];
@@ -102,7 +118,9 @@ export function CalendarOverviewClient({
const [view, setView] = useState<"liste" | "monat" | "agenda">("monat");
const [searchQuery, setSearchQuery] = useState("");
const [activeTypes, setActiveTypes] = useState<Set<string>>(new Set());
const [filterScope, setFilterScope] = useState<"own" | "all">(isAdmin ? "all" : "own");
const [filterScope, setFilterScope] = useState<"own" | "all">(
isAdmin ? "all" : "own",
);
// Month navigation state
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
@@ -152,7 +170,8 @@ export function CalendarOverviewClient({
item.kind === "notdienst" && item.childName
? item.childName.toLowerCase().includes(query)
: false;
if (!titleMatch && !descMatch && !familyMatch && !childMatch) return false;
if (!titleMatch && !descMatch && !familyMatch && !childMatch)
return false;
}
// 2. Category filter
@@ -178,9 +197,21 @@ export function CalendarOverviewClient({
const isEventOnDay = (item: CalendarListItemDto, day: Date) => {
const eventStart = new Date(item.startDate);
const eventEnd = new Date(item.endDate);
const dayTime = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime();
const startTime = new Date(eventStart.getFullYear(), eventStart.getMonth(), eventStart.getDate()).getTime();
const endTime = new Date(eventEnd.getFullYear(), eventEnd.getMonth(), eventEnd.getDate()).getTime();
const dayTime = new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
).getTime();
const startTime = new Date(
eventStart.getFullYear(),
eventStart.getMonth(),
eventStart.getDate(),
).getTime();
const endTime = new Date(
eventEnd.getFullYear(),
eventEnd.getMonth(),
eventEnd.getDate(),
).getTime();
return dayTime >= startTime && dayTime <= endTime;
};
@@ -330,7 +361,7 @@ export function CalendarOverviewClient({
<AgendaView
events={filteredEvents}
isAdmin={isAdmin}
userId={userId}
onOpenDetails={(item) => setSelectedDay(new Date(item.startDate))}
onDeleteTermin={(id, title) => {
setDeleteEventId(id);
setDeleteEventTitle(title);
@@ -339,11 +370,15 @@ export function CalendarOverviewClient({
)}
{/* Day details Dialog for Month View */}
<Dialog open={!!selectedDay} onOpenChange={(open) => !open && setSelectedDay(null)}>
<Dialog
open={!!selectedDay}
onOpenChange={(open) => !open && setSelectedDay(null)}
>
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-xl">
{selectedDay && format(selectedDay, "EEEE, d. MMMM yyyy", { locale: de })}
{selectedDay &&
format(selectedDay, "EEEE, d. MMMM yyyy", { locale: de })}
</DialogTitle>
<DialogDescription>
Übersicht aller Zuteilungen, Feste und Einträge an diesem Tag.
@@ -372,7 +407,14 @@ export function CalendarOverviewClient({
}}
/>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--surface-2)] text-[var(--ink-soft)] ml-2">
<WashingMachine className="h-5 w-5 [stroke-width:1.7]" style={{ color: item.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)" }} />
<WashingMachine
className="h-5 w-5 [stroke-width:1.7]"
style={{
color: item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)",
}}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
@@ -410,7 +452,11 @@ export function CalendarOverviewClient({
{isAdmin && (
<div className="mt-3 flex gap-2">
<a href="/dashboard/admin/dienste">
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2.5"
>
Im Planer bearbeiten
</Button>
</a>
@@ -462,8 +508,14 @@ export function CalendarOverviewClient({
</p>
{isAdmin && (
<div className="mt-3 flex gap-2">
<a href={`/dashboard/notdienst/plan?year=${item.planYear}&month=${item.planMonth}`}>
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
<a
href={`/dashboard/notdienst/plan?year=${item.planYear}&month=${item.planMonth}`}
>
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2.5"
>
Im Planer bearbeiten
</Button>
</a>
@@ -475,7 +527,11 @@ export function CalendarOverviewClient({
target={item}
options={notdienstSwapOptions}
trigger={
<Button size="sm" variant="outline" className="h-7 px-2.5 text-xs">
<Button
size="sm"
variant="outline"
className="h-7 px-2.5 text-xs"
>
Tausch anfragen
</Button>
}
@@ -500,7 +556,8 @@ export function CalendarOverviewClient({
style={{
color: getCategoryColor(item.type),
borderColor: getCategoryColor(item.type) + "44",
backgroundColor: getCategoryColor(item.type) + "11",
backgroundColor:
getCategoryColor(item.type) + "11",
}}
>
{getTypeLabel(item.type)}
@@ -523,10 +580,17 @@ export function CalendarOverviewClient({
</p>
)}
{item.rsvpEvent ? <section className="mt-4 border-t border-[var(--hairline)] pt-4">
<h5 className="mb-3 font-medium">Eure Rückmeldung</h5>
<RsvpForm key={JSON.stringify(item.rsvpEvent)} event={item.rsvpEvent} />
</section> : null}
{item.rsvpEvent ? (
<section className="mt-4 border-t border-[var(--hairline)] pt-4">
<h5 className="mb-3 font-medium">
Eure Rückmeldung
</h5>
<RsvpForm
key={JSON.stringify(item.rsvpEvent)}
event={item.rsvpEvent}
/>
</section>
) : null}
{item.participantInfo && item.showParticipantInfo && (
<div className="mt-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-3 text-xs text-[var(--ink)]">
@@ -534,7 +598,9 @@ export function CalendarOverviewClient({
<AlertTriangle className="h-3.5 w-3.5 text-[var(--warn)]" />
Hinweis für Teilnehmer
</div>
<p className="whitespace-pre-wrap">{item.participantInfo}</p>
<p className="whitespace-pre-wrap">
{item.participantInfo}
</p>
</div>
)}
@@ -582,13 +648,17 @@ export function CalendarOverviewClient({
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deleteEventId} onOpenChange={(open) => !open && setDeleteEventId(null)}>
<Dialog
open={!!deleteEventId}
onOpenChange={(open) => !open && setDeleteEventId(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
<DialogDescription>
Möchtest du den Termin &ldquo;{deleteEventTitle}&rdquo; wirklich löschen? Alle
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
Möchtest du den Termin &ldquo;{deleteEventTitle}&rdquo; wirklich
löschen? Alle zugehörigen Teilnahmen und Mitbringsel-Einträge
gehen verloren.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -642,7 +712,9 @@ function NotdienstSwapRequestRow({
const [isPending, startTransition] = useTransition();
const runAction = (
action: (requestId: string) => Promise<{ success?: boolean; error?: string }>,
action: (
requestId: string,
) => Promise<{ success?: boolean; error?: string }>,
successMessage: string,
) => {
startTransition(async () => {
@@ -659,13 +731,15 @@ function NotdienstSwapRequestRow({
<div className="flex flex-wrap items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--ink)]">
{request.direction === "incoming" ? "Anfrage an dich" : "Deine Anfrage"}
{request.direction === "incoming"
? "Anfrage an dich"
: "Deine Anfrage"}
</p>
<p className="mt-1 text-sm leading-5 text-[var(--ink-soft)]">
{format(request.targetDate, "dd.MM.yyyy", { locale: de })}{" "}
({request.targetFamilyName}, {request.targetChildName}) gegen{" "}
{format(request.offeredDate, "dd.MM.yyyy", { locale: de })}{" "}
({request.offeredFamilyName}, {request.offeredChildName})
{format(request.targetDate, "dd.MM.yyyy", { locale: de })} (
{request.targetFamilyName}, {request.targetChildName}) gegen{" "}
{format(request.offeredDate, "dd.MM.yyyy", { locale: de })} (
{request.offeredFamilyName}, {request.offeredChildName})
</p>
{request.message && (
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
@@ -679,7 +753,10 @@ function NotdienstSwapRequestRow({
<Button
size="sm"
onClick={() =>
runAction(acceptNotdienstSwapRequest, "Tausch wurde angenommen.")
runAction(
acceptNotdienstSwapRequest,
"Tausch wurde angenommen.",
)
}
disabled={isPending}
>
@@ -689,7 +766,10 @@ function NotdienstSwapRequestRow({
size="sm"
variant="ghost"
onClick={() =>
runAction(declineNotdienstSwapRequest, "Tausch wurde abgelehnt.")
runAction(
declineNotdienstSwapRequest,
"Tausch wurde abgelehnt.",
)
}
disabled={isPending}
>
@@ -701,7 +781,10 @@ function NotdienstSwapRequestRow({
size="sm"
variant="ghost"
onClick={() =>
runAction(cancelNotdienstSwapRequest, "Tauschanfrage wurde zurückgezogen.")
runAction(
cancelNotdienstSwapRequest,
"Tauschanfrage wurde zurückgezogen.",
)
}
disabled={isPending}
>
@@ -791,7 +874,9 @@ function MonthView({
onClick={() => onDayClick(day)}
className={[
"min-h-[105px] bg-[var(--surface)] p-2 transition-colors hover:bg-[var(--surface-2)] cursor-pointer flex flex-col justify-between",
!isCurrentMonth ? "bg-[var(--surface-3)]/30 text-[var(--ink-muted)] opacity-60" : "text-[var(--ink)]",
!isCurrentMonth
? "bg-[var(--surface-3)]/30 text-[var(--ink-muted)] opacity-60"
: "text-[var(--ink)]",
].join(" ")}
>
{/* Day number cell top */}
@@ -856,25 +941,33 @@ function MonthView({
function AgendaView({
events,
isAdmin,
userId,
onDeleteTermin,
onOpenDetails,
}: {
events: CalendarListItemDto[];
isAdmin: boolean;
userId: string;
onDeleteTermin: (id: string, title: string) => void;
onOpenDetails: (item: CalendarListItemDto) => void;
}) {
const grouped = useMemo(() => {
const groups = new Map<string, { date: Date; items: CalendarListItemDto[] }>();
const groups = new Map<
string,
{ date: Date; items: CalendarListItemDto[] }
>();
events.forEach((item) => {
const key = format(new Date(item.startDate), "yyyy-MM-dd");
const group = groups.get(key) ?? { date: new Date(item.startDate), items: [] };
const group = groups.get(key) ?? {
date: new Date(item.startDate),
items: [],
};
group.items.push(item);
groups.set(key, group);
});
return Array.from(groups.values()).sort((a, b) => a.date.getTime() - b.date.getTime());
return Array.from(groups.values()).sort(
(a, b) => a.date.getTime() - b.date.getTime(),
);
}, [events]);
if (events.length === 0) {
@@ -896,7 +989,10 @@ function AgendaView({
return (
<div className="space-y-6 pl-4 border-l-2 border-[var(--hairline)]">
{grouped.map((group) => (
<div key={group.date.toString()} className="relative grid grid-cols-[110px_1fr] gap-4 items-start">
<div
key={group.date.toString()}
className="relative grid grid-cols-1 sm:grid-cols-[110px_minmax(0,1fr)] gap-4 items-start"
>
{/* Timeline marker */}
<div className="absolute -left-[21px] top-1.5 h-3.5 w-3.5 rounded-full border-2 border-[var(--accent-deep)] bg-[var(--surface)]" />
@@ -929,7 +1025,7 @@ function AgendaView({
return (
<div
key={item.id}
className="group relative flex items-start justify-between gap-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm transition-shadow hover:shadow-md"
className="group relative flex flex-wrap items-start justify-between gap-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm transition-shadow hover:shadow-md"
>
<div className="flex items-start gap-3 min-w-0">
<div
@@ -965,12 +1061,18 @@ function AgendaView({
{item.isOwn ? "Mein Notdienst" : "Notdienst"}
</Badge>
) : (
<Badge variant="secondary" className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)] text-[10px]">
<Badge
variant="secondary"
className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)] text-[10px]"
>
Elterndienst
</Badge>
)}
{item.kind === "termin" && item.status === TerminStatus.PENDING && (
<Badge variant="warning" className="text-[10px]">Ausstehend</Badge>
{item.kind === "termin" &&
item.status === TerminStatus.PENDING && (
<Badge variant="warning" className="text-[10px]">
Ausstehend
</Badge>
)}
</div>
@@ -989,7 +1091,9 @@ function AgendaView({
new Date(item.endDate),
"dd.MM.yyyy",
)}`}
{item.kind === "termin" && <MapPin className="ml-1 h-3.5 w-3.5" />}
{item.kind === "termin" && (
<MapPin className="ml-1 h-3.5 w-3.5" />
)}
{item.kind === "termin"
? "Kita"
: item.kind === "notdienst"
@@ -1005,7 +1109,14 @@ function AgendaView({
</div>
</div>
{item.kind === "termin" && item.rsvpEvent ? <RsvpDialog event={item.rsvpEvent} /> : null}
<Button
size="sm"
variant="outline"
aria-label={`Details zu ${item.title}`}
onClick={() => onOpenDetails(item)}
>
Details
</Button>
{/* Actions for admins */}
{isAdmin && item.kind === "termin" && (
+8 -4
View File
@@ -26,6 +26,8 @@ import { MarkdownContent } from "@/components/markdown-content";
export type NewsTickerItemDto = {
id: string;
revision: number;
editedAt: string | null;
title: string;
content: string;
excerpt: string;
@@ -53,16 +55,16 @@ export function NewsTicker({
function openAnnouncement(item: NewsTickerItemDto) {
setSelected(item);
if (!item.isUnread || readIds.has(item.id)) return;
if (!item.isUnread || readIds.has(`${item.id}:${item.revision}`)) return;
startTransition(async () => {
try {
const result = await markAnnouncementRead(item.id);
const result = await markAnnouncementRead(item.id, item.revision);
if (result.error) {
toast.error(result.error);
return;
}
setReadIds((current) => new Set(current).add(item.id));
setReadIds((current) => new Set(current).add(`${item.id}:${item.revision}`));
} catch {
toast.error("Lesestatus konnte nicht gespeichert werden.");
}
@@ -101,7 +103,7 @@ export function NewsTicker({
</p>
) : null}
{items.map((item) => {
const isUnread = item.isUnread && !readIds.has(item.id);
const isUnread = item.isUnread && !readIds.has(`${item.id}:${item.revision}`);
return (
<button
key={item.id}
@@ -116,6 +118,7 @@ export function NewsTicker({
</div>
{isUnread && <Badge variant="success">NEU</Badge>}
</div>
{item.editedAt && <p className="mb-2 text-xs font-medium text-[var(--accent-deep)]">Aktualisiert am {item.editedAt}</p>}
<h3 className="line-clamp-2 font-semibold leading-snug text-[var(--ink)]">
{item.title}
</h3>
@@ -146,6 +149,7 @@ export function NewsTicker({
<DialogTitle className="text-2xl">{selected.title}</DialogTitle>
<DialogDescription>
{selected.createdAt} · {selected.authorName}
{selected.editedAt && <span className="block">Aktualisiert am {selected.editedAt}</span>}
</DialogDescription>
</DialogHeader>
+157 -35
View File
@@ -15,7 +15,11 @@ import { getTargetMonthData } from "@/lib/date-utils";
import { loadNotdienstDays } from "@/lib/notdienst-planning";
import { todayKey } from "@/lib/alarms/format";
import { serializableTransaction } from "@/lib/transaction";
import { queueNotdienstPublication, queueNotdienstReminders, deliverDueNotdienstEmails } from "@/lib/notdienst-notifications";
import {
queueNotdienstPublication,
queueNotdienstReminders,
deliverDueNotdienstEmails,
} from "@/lib/notdienst-notifications";
import { sendAppEmail } from "@/lib/mail";
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
import { requireAlarmActor } from "@/lib/alarms/auth";
@@ -43,14 +47,20 @@ export async function generatePlanAction(year?: number, month?: number) {
targetMonth = data.targetMonth;
}
if (!validPlanMonth(targetYear, targetMonth)) return { error: "Ungültiger Planungsmonat." };
if (!validPlanMonth(targetYear, targetMonth))
return { error: "Ungültiger Planungsmonat." };
try {
await serializableTransaction(async (tx) => {
const existingPlan = await tx.notdienstPlan.findUnique({
where: { kitaId_year_month: { kitaId, year: targetYear, month: targetMonth } },
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
});
if (existingPlan) throw new UserFacingError("Ein Plan für diesen Monat existiert bereits.");
if (existingPlan)
throw new UserFacingError(
"Ein Plan für diesen Monat existiert bereits.",
);
const [availabilities, workingDays] = await Promise.all([
tx.notdienstAvailability.findMany({
@@ -69,16 +79,29 @@ export async function generatePlanAction(year?: number, month?: number) {
const familyUsageCount = new Map<string, number>();
const assignments: { childId: string; date: Date }[] = [];
for (const day of workingDays) {
const candidates = availabilities.filter((a) => a.date.getTime() === day.getTime());
const candidates = availabilities.filter(
(a) => a.date.getTime() === day.getTime(),
);
if (!candidates.length) continue;
const minUsage = Math.min(...candidates.map((a) => familyUsageCount.get(a.child.familyId) ?? 0));
const leastUsed = candidates.filter((a) => (familyUsageCount.get(a.child.familyId) ?? 0) === minUsage);
const selected = leastUsed[Math.floor(Math.random() * leastUsed.length)];
const minUsage = Math.min(
...candidates.map((a) => familyUsageCount.get(a.child.familyId) ?? 0),
);
const leastUsed = candidates.filter(
(a) => (familyUsageCount.get(a.child.familyId) ?? 0) === minUsage,
);
const selected =
leastUsed[Math.floor(Math.random() * leastUsed.length)];
assignments.push({ childId: selected.childId, date: day });
familyUsageCount.set(selected.child.familyId, minUsage + 1);
}
const plan = await tx.notdienstPlan.create({
data: { kitaId, year: targetYear, month: targetMonth, status: NotdienstPlanStatus.DRAFT, createdById: session.user.id },
data: {
kitaId,
year: targetYear,
month: targetMonth,
status: NotdienstPlanStatus.DRAFT,
createdById: session.user.id,
},
});
if (assignments.length) {
await tx.notdienstAssignment.createMany({
@@ -91,7 +114,9 @@ export async function generatePlanAction(year?: number, month?: number) {
return { success: true };
} catch (error) {
console.error(error);
return { error: publicActionError(error, "Fehler bei der Generierung des Plans.") };
return {
error: publicActionError(error, "Fehler bei der Generierung des Plans."),
};
}
}
@@ -103,30 +128,55 @@ export async function publishPlanAction(planId: string) {
await serializableTransaction(async (tx) => {
const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId },
include: { assignments: { include: { child: { select: { active: true } } } } },
include: {
assignments: { include: { child: { select: { active: true } } } },
},
});
if (!plan) throw new UserFacingError("Plan nicht gefunden.");
if (plan.status === NotdienstPlanStatus.PUBLISHED) return;
if (plan.status !== NotdienstPlanStatus.DRAFT) throw new UserFacingError("Nur Entwürfe können veröffentlicht werden.");
if (plan.status !== NotdienstPlanStatus.DRAFT)
throw new UserFacingError("Nur Entwürfe können veröffentlicht werden.");
const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
const validDates = new Set(days.map((day) => day.getTime()));
if (plan.assignments.some((a) => !validDates.has(a.date.getTime()) || !a.child.active)) {
throw new UserFacingError("Der Entwurf enthält Einteilungen an geschlossenen Tagen, außerhalb des Planungsmonats oder für inaktive Kinder. Bitte zuerst korrigieren.");
if (
plan.assignments.some(
(a) => !validDates.has(a.date.getTime()) || !a.child.active,
)
) {
throw new UserFacingError(
"Der Entwurf enthält Einteilungen an geschlossenen Tagen, außerhalb des Planungsmonats oder für inaktive Kinder. Bitte zuerst korrigieren.",
);
}
await tx.notdienstPlan.update({
where: { id: planId, kitaId },
data: { status: NotdienstPlanStatus.PUBLISHED, publishedAt: new Date() },
data: {
status: NotdienstPlanStatus.PUBLISHED,
publishedAt: new Date(),
},
});
await queueNotdienstPublication(tx, kitaId, planId, plan.year, plan.month);
await queueNotdienstPublication(
tx,
kitaId,
planId,
plan.year,
plan.month,
);
});
revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard/notdienst");
revalidatePath("/dashboard/kalender");
const delivery = await deliverDueNotdienstEmails({ kitaId, planId }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
const delivery = await deliverDueNotdienstEmails({ kitaId, planId }).catch(
() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }),
);
revalidatePath("/dashboard/notdienst/plan");
return { success: true, ...delivery };
} catch (error) {
return { error: publicActionError(error, "Der Plan konnte nicht veröffentlicht werden.") };
return {
error: publicActionError(
error,
"Der Plan konnte nicht veröffentlicht werden.",
),
};
}
}
@@ -138,7 +188,8 @@ export async function updateAssignmentAction(
const actor = await requireAlarmActor();
const kitaId = actor.kitaId;
if (!dateOnlySchema.safeParse(date).success) return { error: "Ungültiges Datum." };
if (!dateOnlySchema.safeParse(date).success)
return { error: "Ungültiges Datum." };
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
@@ -161,14 +212,27 @@ export async function updateAssignmentAction(
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
}
if (newChildId && (parsedDate.getUTCFullYear() !== plan.year || parsedDate.getUTCMonth() + 1 !== plan.month)) {
throw new UserFacingError("Das Datum gehört nicht zum Planungsmonat.");
if (
newChildId &&
(parsedDate.getUTCFullYear() !== plan.year ||
parsedDate.getUTCMonth() + 1 !== plan.month)
) {
throw new UserFacingError(
"Das Datum gehört nicht zum Planungsmonat.",
);
}
// Clearing obsolete entries remains possible after a new closure.
if (newChildId) {
const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
const days = await loadNotdienstDays(
tx,
kitaId,
plan.year,
plan.month,
);
if (!days.some((day) => day.getTime() === parsedDate.getTime())) {
throw new UserFacingError("An Wochenenden und Schließtagen kann kein Notdienst eingeteilt werden.");
throw new UserFacingError(
"An Wochenenden und Schließtagen kann kein Notdienst eingeteilt werden.",
);
}
}
@@ -189,7 +253,18 @@ export async function updateAssignmentAction(
family: {
include: {
users: {
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
where: {
role: {
in: [
UserRole.ELTERN,
UserRole.ADMIN,
UserRole.KOORDINATOR,
],
},
emailVerifiedAt: { not: null },
disabledAt: null,
privacyPolicyAcceptedAt: { not: null },
},
select: { email: true, firstName: true, lastName: true },
},
},
@@ -217,7 +292,18 @@ export async function updateAssignmentAction(
family: {
include: {
users: {
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
where: {
role: {
in: [
UserRole.ELTERN,
UserRole.ADMIN,
UserRole.KOORDINATOR,
],
},
emailVerifiedAt: { not: null },
disabledAt: null,
privacyPolicyAcceptedAt: { not: null },
},
select: { email: true, firstName: true, lastName: true },
},
},
@@ -392,7 +478,15 @@ export async function updateKitaNotdienstSettingsAction(
return { error: "Kein Mandant zugeordnet." };
}
if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 || !Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) return { error: "Ungültige Einstellungen." };
if (
!Number.isInteger(minPerChild) ||
minPerChild < 1 ||
minPerChild > 31 ||
!Number.isInteger(reminderDays) ||
reminderDays < 0 ||
reminderDays > 31
)
return { error: "Ungültige Einstellungen." };
try {
await prisma.kita.update({
@@ -411,33 +505,61 @@ export async function updateKitaNotdienstSettingsAction(
}
}
export async function sendNotdienstRemindersAction(year: number, month: number) {
export async function sendNotdienstRemindersAction(
year: number,
month: number,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
if (!validPlanMonth(year, month))
return { error: "Ungültiger Planungsmonat." };
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed)
return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
try {
const queued = await queueNotdienstReminders(kitaId, year, month);
const delivery = await deliverDueNotdienstEmails({ kitaId, year, month }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
const delivery = await deliverDueNotdienstEmails({
kitaId,
year,
month,
}).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
revalidatePath("/dashboard/notdienst/plan");
return { success: true, queued, count: delivery.sent, ...delivery };
} catch (error) {
return { error: publicActionError(error, "Die Erinnerungen konnten nicht vorbereitet werden.") };
return {
error: publicActionError(
error,
"Die Erinnerungen konnten nicht vorbereitet werden.",
),
};
}
}
export async function retryNotdienstEmailsAction(year: number, month: number) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
if (!(await rateLimit(`notdienst-mail-retry:${kitaId}`, 3, 3600)).allowed) return { error: "Bitte warte vor einem weiteren Versandversuch." };
if (!validPlanMonth(year, month))
return { error: "Ungültiger Planungsmonat." };
if (!(await rateLimit(`notdienst-mail-retry:${kitaId}`, 3, 3600)).allowed)
return { error: "Bitte warte vor einem weiteren Versandversuch." };
try {
await prisma.notdienstEmail.updateMany({ where: { kitaId, year, month, failed: true, sentAt: null, skippedAt: null }, data: { nextAttemptAt: new Date() } });
await prisma.notdienstEmail.updateMany({
where: {
kitaId,
year,
month,
failed: true,
sentAt: null,
skippedAt: null,
},
data: { nextAttemptAt: new Date() },
});
const delivery = await deliverDueNotdienstEmails({ kitaId, year, month });
revalidatePath("/dashboard/notdienst/plan");
return { success: true, ...delivery };
} catch {
return { error: "Der Versand bleibt gespeichert und wird erneut versucht." };
return {
error: "Der Versand bleibt gespeichert und wird erneut versucht.",
};
}
}
+39 -12
View File
@@ -4,7 +4,11 @@ import { de } from "date-fns/locale";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { formatUtcDateKey, getWorkingDaysOfMonth, getTargetMonthData } from "@/lib/date-utils";
import {
formatUtcDateKey,
getWorkingDaysOfMonth,
getTargetMonthData,
} from "@/lib/date-utils";
import { loadNotdienstDays } from "@/lib/notdienst-planning";
import { getNotdienstEmailStatus } from "@/lib/notdienst-notifications";
import { EmailStatusCard } from "./_components/email-status-card";
@@ -108,8 +112,6 @@ export default async function PlanungsZentralePage({
]);
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
// aber für MVP reichen alle aktiven Kinder.
const allChildrenRaw = await prisma.child.findMany({
where: { kitaId, active: true },
select: {
@@ -146,8 +148,11 @@ export default async function PlanungsZentralePage({
});
const validDateKeys = new Set(workingDays.map(formatUtcDateKey));
const availabilities = rawAvailabilities.filter((a) => validDateKeys.has(formatUtcDateKey(a.date)));
const closureCount = getWorkingDaysOfMonth(targetYear, targetMonth).length - workingDays.length;
const availabilities = rawAvailabilities.filter((a) =>
validDateKeys.has(formatUtcDateKey(a.date)),
);
const closureCount =
getWorkingDaysOfMonth(targetYear, targetMonth).length - workingDays.length;
const availabilityDatesByFamily = new Map<string, Set<string>>();
availabilities.forEach((availability) => {
@@ -186,12 +191,16 @@ export default async function PlanungsZentralePage({
// Wir mappen die Assignments auf die Werktage
// Keep obsolete assignments visible so the coordinator can remove them.
const displayedDays = new Map(workingDays.map((day) => [formatUtcDateKey(day), day]));
const displayedDays = new Map(
workingDays.map((day) => [formatUtcDateKey(day), day]),
);
for (const assignment of plan?.assignments ?? []) {
displayedDays.set(formatUtcDateKey(assignment.date), assignment.date);
}
const activeChildIds = new Set(allChildren.map((child) => child.id));
const daysWithAssignments = Array.from(displayedDays.values()).sort((a, b) => a.getTime() - b.getTime()).map((day) => {
const daysWithAssignments = Array.from(displayedDays.values())
.sort((a, b) => a.getTime() - b.getTime())
.map((day) => {
const assignment = plan?.assignments.find(
(a) => a.date.getTime() === day.getTime(),
);
@@ -199,7 +208,9 @@ export default async function PlanungsZentralePage({
!assignment ||
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
const isInvalid = !validDateKeys.has(formatUtcDateKey(day)) || !!assignment && !activeChildIds.has(assignment.childId);
const isInvalid =
!validDateKeys.has(formatUtcDateKey(day)) ||
(!!assignment && !activeChildIds.has(assignment.childId));
return {
date: day.toISOString(),
isInvalid,
@@ -208,15 +219,24 @@ export default async function PlanungsZentralePage({
isOriginallyUnassigned: !!isOriginallyUnassigned,
activeAlarmId: assignment?.alarms[0]?.id ?? null,
canAlarm:
!!assignment && !isInvalid &&
!!assignment &&
!isInvalid &&
plan?.status === "PUBLISHED" &&
day.toISOString().slice(0, 10) >= todayKey(),
};
});
const mailStatus = await getNotdienstEmailStatus(kitaId, targetYear, targetMonth);
const mailStatus = await getNotdienstEmailStatus(
kitaId,
targetYear,
targetMonth,
);
const target = getTargetMonthData(now);
const remindersOpen = !target.isLocked && target.targetYear === targetYear && target.targetMonth === targetMonth && plan?.status !== "PUBLISHED";
const remindersOpen =
!target.isLocked &&
target.targetYear === targetYear &&
target.targetMonth === targetMonth &&
plan?.status !== "PUBLISHED";
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
@@ -261,7 +281,14 @@ export default async function PlanungsZentralePage({
</div>
</header>
<EmailStatusCard status={mailStatus} year={targetYear} month={targetMonth} remindersOpen={remindersOpen} daysBefore={kita.notdienstReminderDaysBefore} jobConfigured={(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32} />
<EmailStatusCard
status={mailStatus}
year={targetYear}
month={targetMonth}
remindersOpen={remindersOpen}
daysBefore={kita.notdienstReminderDaysBefore}
jobConfigured={(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32}
/>
<PlanView
planId={plan?.id}
+136 -31
View File
@@ -121,8 +121,14 @@ export function PlanView({
if ("error" in result && result.error) {
toast.error(result.error as string);
} else if ("success" in result && result.success) {
if (result.failed || ("deferred" in result && result.deferred)) toast.warning("Plan veröffentlicht. Einige E-Mails stehen noch aus – siehe Versandstatus.");
else toast.success("Plan veröffentlicht. Die Benachrichtigungen wurden vorbereitet.");
if (result.failed || ("deferred" in result && result.deferred))
toast.warning(
"Plan veröffentlicht. Einige E-Mails stehen noch aus – siehe Versandstatus.",
);
else
toast.success(
"Plan veröffentlicht. Die Benachrichtigungen wurden vorbereitet.",
);
}
});
};
@@ -133,8 +139,14 @@ export function PlanView({
if ("error" in result && result.error) {
toast.error(result.error as string);
} else if ("success" in result && result.success) {
if (result.failed || ("deferred" in result && result.deferred)) toast.warning(`${result.count} E-Mails versendet. Offene Nachrichten bleiben gespeichert – siehe Versandstatus.`);
else toast.success(`${result.count} E-Mails versendet. Bereits heute erinnerte Haushalte erhalten keine zweite Erinnerung.`);
if (result.failed || ("deferred" in result && result.deferred))
toast.warning(
`${result.count} E-Mails versendet. Offene Nachrichten bleiben gespeichert – siehe Versandstatus.`,
);
else
toast.success(
`${result.count} E-Mails versendet. Bereits heute erinnerte Haushalte erhalten keine zweite Erinnerung.`,
);
}
});
};
@@ -157,7 +169,11 @@ export function PlanView({
const valueToSet = newChildId === "empty" ? null : newChildId;
startTransition(async () => {
const result = await updateAssignmentAction(planId, date.slice(0, 10), valueToSet);
const result = await updateAssignmentAction(
planId,
date.slice(0, 10),
valueToSet,
);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
@@ -248,7 +264,10 @@ export function PlanView({
{family.childCount === 1 ? "" : "er"}
</p>
</div>
<FamilyStatusPill selectedDays={family.selectedDays.length} requiredDays={family.requiredDays} />
<FamilyStatusPill
selectedDays={family.selectedDays.length}
requiredDays={family.requiredDays}
/>
<div className="pointer-events-none absolute right-6 top-10 z-20 hidden max-w-xs rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-xs leading-5 text-white shadow-lg group-hover:block">
{family.selectedDays.length > 0
? family.selectedDays.map((day) => day.label).join(" · ")
@@ -318,8 +337,8 @@ export function PlanView({
Plan automatisch generieren
</h3>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Verteilt {requiredDays} Notdiensttage fair auf{" "}
die verfügbaren Familien.
Verteilt {requiredDays} Notdiensttage fair auf die verfügbaren
Familien.
</p>
<details className="mt-3 text-xs text-[var(--ink-soft)]">
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
@@ -412,6 +431,7 @@ export function PlanView({
<ManualAssignmentTable
days={days}
allChildren={allChildren}
familyStatuses={familyStatuses}
isDraft={isDraft}
isPending={isPending}
onAssignmentChange={handleAssignmentChange}
@@ -451,7 +471,13 @@ export function PlanView({
);
}
function FamilyStatusPill({ selectedDays, requiredDays }: { selectedDays: number; requiredDays: number }) {
function FamilyStatusPill({
selectedDays,
requiredDays,
}: {
selectedDays: number;
requiredDays: number;
}) {
if (selectedDays === 0) {
return (
<span className="rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]">
@@ -461,7 +487,13 @@ function FamilyStatusPill({ selectedDays, requiredDays }: { selectedDays: number
}
return (
<span className={selectedDays >= requiredDays ? "rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]" : "rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]"}>
<span
className={
selectedDays >= requiredDays
? "rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]"
: "rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]"
}
>
{selectedDays} / {requiredDays} Tage
</span>
);
@@ -552,7 +584,11 @@ function PlanPreviewTable({
{format(dateObj, "EEEE", { locale: de })}
</div>
<div className="text-[var(--ink)]">
{assignedChild ? assignedChild.parentName : day.childId ? "Inaktives Kind" : "Unbesetzt"}
{assignedChild
? assignedChild.parentName
: day.childId
? "Inaktives Kind"
: "Unbesetzt"}
{assignedChild && (
<span className="ml-1 text-[var(--ink-muted)]">
({assignedChild.name})
@@ -576,8 +612,20 @@ function PlanPreviewTable({
</div>
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
<div>
<Badge variant={day.isInvalid ? "warning" : assignedChild ? "success" : "destructive"}>
{day.isInvalid ? "Bitte korrigieren" : assignedChild ? "Besetzt" : "Offen"}
<Badge
variant={
day.isInvalid
? "warning"
: assignedChild
? "success"
: "destructive"
}
>
{day.isInvalid
? "Bitte korrigieren"
: assignedChild
? "Besetzt"
: "Offen"}
</Badge>
</div>
</div>
@@ -588,17 +636,29 @@ function PlanPreviewTable({
);
}
function ManualAssignmentTable({
export function ManualAssignmentTable({
days,
allChildren,
familyStatuses,
isDraft,
isPending,
onAssignmentChange,
}: Pick<PlanViewProps, "days" | "allChildren"> & {
}: Pick<PlanViewProps, "days" | "allChildren" | "familyStatuses"> & {
isDraft: boolean;
isPending: boolean;
onAssignmentChange: (date: string, newChildId: string) => void;
}) {
const availabilityByFamily = useMemo(
() =>
new Map(
familyStatuses.map((family) => [
family.id,
new Set(family.selectedDays.map((day) => day.iso.slice(0, 10))),
]),
),
[familyStatuses],
);
// Map childId to familyId for quick lookup
const childFamilyMap = useMemo(() => {
const map = new Map<string, string>();
@@ -626,7 +686,12 @@ function ManualAssignmentTable({
return (
<Card className="overflow-hidden p-0">
<div className="grid grid-cols-[1fr_2fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
<div className="border-b border-[var(--hairline-soft)] p-4 text-sm leading-6 text-[var(--ink-soft)]">
Verfügbare Familien stehen zuerst. Familien ohne Verfügbarkeit für den
Tag bleiben für abgestimmte Sonderfälle auswählbar. Die Einteilungszahl
gilt für den gesamten Haushalt.
</div>
<div className="hidden sm:grid grid-cols-[1fr_2fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
<div>Datum</div>
<div>Eingeteilte Familie</div>
</div>
@@ -634,17 +699,41 @@ function ManualAssignmentTable({
{days.map((day) => {
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
const dateObj = new Date(yr, mo - 1, dy);
const dateKey = day.date.slice(0, 10);
const available = allChildren.filter((child) =>
availabilityByFamily.get(child.familyId)?.has(dateKey),
);
const unavailable = allChildren.filter(
(child) => !availabilityByFamily.get(child.familyId)?.has(dateKey),
);
const availableFamilies = new Set(
available.map((child) => child.familyId),
).size;
const selectedChild = allChildren.find(
(child) => child.id === day.childId,
);
const selectedAvailable =
!!selectedChild &&
availabilityByFamily.get(selectedChild.familyId)?.has(dateKey);
const option = (child: PlanViewProps["allChildren"][number]) => (
<option key={child.id} value={child.id} disabled={day.isInvalid}>
{child.parentName} ({child.name}) —{" "}
{familyAssignmentCounts.get(child.familyId) ?? 0}x
</option>
);
return (
<div
key={day.date}
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-[var(--surface-2)]"
className="grid gap-3 sm:grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-[var(--surface-2)]"
>
<div className="font-medium text-[var(--ink)]">
{format(dateObj, "EE, dd.MM.", { locale: de })}
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="w-full max-w-sm space-y-2">
<select
aria-label={`Familie für ${format(dateObj, "dd.MM.yyyy")}`}
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
value={day.childId || "empty"}
disabled={isPending || !!day.activeAlarmId}
@@ -653,23 +742,39 @@ function ManualAssignmentTable({
}
>
<option value="empty">-- Unbesetzt --</option>
{day.childId && !allChildren.some((child) => child.id === day.childId) ? (
<option value={day.childId} disabled>Inaktives Kind – bitte Einteilung entfernen</option>
) : null}
{allChildren.map((child) => {
const famId = child.familyId;
const count = famId
? (familyAssignmentCounts.get(famId) ?? 0)
: 0;
return (
<option key={child.id} value={child.id} disabled={day.isInvalid}>
{child.parentName} ({child.name}) — {count}x
{day.childId &&
!allChildren.some((child) => child.id === day.childId) ? (
<option value={day.childId} disabled>
Inaktives Kind – bitte Einteilung entfernen
</option>
);
})}
) : null}
{available.length > 0 ? (
<optgroup label="Verfügbar an diesem Tag">
{available.map(option)}
</optgroup>
) : null}
{unavailable.length > 0 ? (
<optgroup label="Keine Verfügbarkeit für diesen Tag">
{unavailable.map(option)}
</optgroup>
) : null}
</select>
{!day.isInvalid ? (
<p
className={`text-xs leading-5 ${(selectedChild && !selectedAvailable) || availableFamilies === 0 ? "text-[var(--warn)]" : "text-[var(--ink-soft)]"}`}
>
{selectedChild
? selectedAvailable
? "Eingeteilte Familie ist verfügbar."
: "Eingeteilte Familie hat für diesen Tag keine Verfügbarkeit angegeben."
: `${availableFamilies} ${availableFamilies === 1 ? "Familie ist" : "Familien sind"} verfügbar.`}
</p>
) : null}
</div>
{day.isInvalid ? (
<span className="text-xs text-[var(--danger)]">Einteilung nicht mehr gültig – bitte entfernen.</span>
<span className="text-xs text-[var(--danger)]">
Einteilung nicht mehr gültig – bitte entfernen.
</span>
) : null}
{!isDraft && (
<span className="text-[11px] font-semibold text-[oklch(0.60_0.18_45)] bg-[oklch(0.95_0.02_45)] border border-[oklch(0.85_0.05_45)] px-2 py-0.5 rounded-md shrink-0">
+6 -1
View File
@@ -16,7 +16,11 @@ 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 {
absenceDate,
absenceToday,
canManageKitaAbsences,
} from "@/lib/absence-policy";
import { Stat } from "@/components/ui/stat";
import { OnboardingDialog } from "@/components/OnboardingDialog";
import { NewsTicker } from "./news-ticker";
@@ -174,6 +178,7 @@ export default async function DashboardPage() {
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<OnboardingDialog
user={{
id: session.user.id,
phone: onboardingUser.phone,
street: onboardingUser.street,
postalCode: onboardingUser.postalCode,
+137 -33
View File
@@ -4,6 +4,7 @@ import { useId, useState, useTransition, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Dialog,
@@ -32,6 +33,7 @@ export function Field({
min,
max,
step,
autoFocus,
}: {
label: string;
name: string;
@@ -41,6 +43,7 @@ export function Field({
min?: string | number;
max?: string | number;
step?: string | number;
autoFocus?: boolean;
}) {
const id = useId();
return (
@@ -57,6 +60,7 @@ export function Field({
min={min}
max={max}
step={step}
autoFocus={autoFocus}
/>
</div>
);
@@ -123,7 +127,11 @@ function transform(command: string, form: FormData) {
return {
kind: text("kind"),
startDate: text("startDate"),
endDate: text("endDate"),
endDate:
text("kind") === "TIME_OFF" ||
(text("kind") === "VACATION" && form.get("halfDay") === "on")
? text("startDate")
: text("endDate"),
halfDay: form.get("halfDay") === "on",
...(text("kind") === "TIME_OFF" ? { minutes: hours("hours") } : {}),
note: text("note"),
@@ -142,13 +150,19 @@ function StaffForm({
children,
label = "Speichern",
onSuccess,
anotherLabel,
}: {
command: string;
profileId?: string;
extra?: Record<string, unknown>;
children: ReactNode;
label?: string;
onSuccess?: (result: StaffActionResult) => void;
anotherLabel?: string;
onSuccess?: (
result: StaffActionResult,
form: FormData,
another: boolean,
) => void;
}) {
const [pending, start] = useTransition();
const [error, setError] = useState("");
@@ -159,6 +173,10 @@ function StaffForm({
onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const another =
(event.nativeEvent as SubmitEvent).submitter?.getAttribute(
"value",
) === "another";
setError("");
start(async () => {
try {
@@ -173,7 +191,7 @@ function StaffForm({
}
if (result.warning) toast.warning(result.warning);
else toast.success(result.message ?? "Gespeichert.");
onSuccess?.(result);
onSuccess?.(result, form, another);
router.refresh();
} catch (error) {
setError(
@@ -187,10 +205,20 @@ function StaffForm({
>
<fieldset disabled={pending} className="grid gap-4 disabled:opacity-60">
{children}
<div>
<div className="flex flex-wrap gap-2">
<Button type="submit">
{pending ? "Wird gespeichert …" : label}
</Button>
{anotherLabel ? (
<Button
type="submit"
variant="outline"
name="intent"
value="another"
>
{anotherLabel}
</Button>
) : null}
</div>
</fieldset>
{error && (
@@ -333,8 +361,12 @@ export function SetupForm({
export function TimeForm({
profileId,
initial,
defaultDate,
compact = false,
}: {
profileId: string;
defaultDate?: string;
compact?: boolean;
initial?: {
id: string;
date: string;
@@ -345,14 +377,34 @@ export function TimeForm({
};
}) {
const [open, setOpen] = useState(false);
const [date, setDate] = useState(initial?.date ?? defaultDate ?? todayKey());
const [blockVersion, setBlockVersion] = useState(0);
const [continuation, setContinuation] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(next) => {
if (next) {
setDate(initial?.date ?? defaultDate ?? todayKey());
setContinuation(false);
}
setOpen(next);
}}
>
<DialogTrigger asChild>
<Button
variant={initial ? "outline" : "default"}
size={initial ? "sm" : "default"}
variant={initial || compact ? "outline" : "default"}
size={initial || compact ? "sm" : "default"}
disabled={!initial && !!defaultDate && defaultDate > todayKey()}
aria-label={
compact ? `Arbeitszeit für ${defaultDate} erfassen` : undefined
}
>
{initial ? "Bearbeiten" : "+ Arbeitszeit erfassen"}
{initial
? "Bearbeiten"
: compact
? "Zeit erfassen"
: "+ Arbeitszeit erfassen"}
</Button>
</DialogTrigger>
<DialogContent>
@@ -366,31 +418,50 @@ export function TimeForm({
</DialogDescription>
</DialogHeader>
<StaffForm
key={blockVersion}
command="time"
profileId={profileId}
extra={initial ? { id: initial.id } : {}}
label="Arbeitszeit speichern"
onSuccess={() => setOpen(false)}
anotherLabel={
!initial ? "Speichern und weiteren Block erfassen" : undefined
}
onSuccess={(_result, form, another) => {
if (another && !initial) {
setDate(String(form.get("date")));
setContinuation(true);
setBlockVersion((version) => version + 1);
} else setOpen(false);
}}
>
<Field
label="Datum"
name="date"
type="date"
value={initial?.date ?? todayKey()}
value={date}
max={todayKey()}
/>
<div className="grid grid-cols-2 gap-4">
<Field
label="Beginn"
autoFocus={continuation}
name="start"
type="time"
value={minuteClock(initial?.startMinute ?? 480)}
value={
continuation
? undefined
: minuteClock(initial?.startMinute ?? 480)
}
/>
<Field
label="Ende"
name="end"
type="time"
value={minuteClock(initial?.endMinute ?? 960)}
value={
continuation
? undefined
: minuteClock(initial?.endMinute ?? 960)
}
/>
</div>
<Field
@@ -399,7 +470,7 @@ export function TimeForm({
type="number"
min={0}
step={1}
value={initial?.breakMinutes ?? 30}
value={continuation ? 0 : (initial?.breakMinutes ?? 30)}
/>
<Field
label="Notiz (optional)"
@@ -431,9 +502,24 @@ export function LeaveForm({
}) {
const [open, setOpen] = useState(false);
const [kind, setKind] = useState(initial?.kind ?? "VACATION");
const [startDate, setStartDate] = useState(initial?.startDate ?? todayKey());
const [endDate, setEndDate] = useState(initial?.endDate ?? todayKey());
const [halfDay, setHalfDay] = useState(initial?.halfDay ?? false);
const singleDay = kind === "TIME_OFF" || (kind === "VACATION" && halfDay);
const id = useId();
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(next) => {
if (next) {
setKind(initial?.kind ?? "VACATION");
setStartDate(initial?.startDate ?? todayKey());
setEndDate(initial?.endDate ?? todayKey());
setHalfDay(initial?.halfDay ?? false);
}
setOpen(next);
}}
>
<DialogTrigger asChild>
<Button size={initial ? "sm" : "default"} variant="outline">
{initial ? "Ändern" : "+ Abwesenheit beantragen"}
@@ -474,33 +560,51 @@ export function LeaveForm({
{admin && <option value="SICKNESS">Krankheit</option>}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
label="Von"
name="startDate"
type="date"
value={initial?.startDate ?? todayKey()}
/>
<Field
label="Bis"
name="endDate"
type="date"
value={initial?.endDate ?? todayKey()}
/>
</div>
{kind === "VACATION" && (
{kind === "VACATION" ? (
<label className="flex items-center gap-2 text-sm">
<input
name="halfDay"
type="checkbox"
defaultChecked={initial?.halfDay}
/>
Halber Urlaubstag (Von = Bis)
checked={halfDay}
onChange={(event) => setHalfDay(event.target.checked)}
/>{" "}
Halber Urlaubstag
</label>
) : null}
<div className={singleDay ? "grid gap-4" : "grid grid-cols-2 gap-4"}>
<div className="grid gap-1.5">
<Label htmlFor={`${id}-start`}>
{singleDay ? "Datum" : "Von"}
</Label>
<Input
id={`${id}-start`}
name="startDate"
type="date"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
required
/>
</div>
{singleDay ? (
<input type="hidden" name="endDate" value={startDate} />
) : (
<div className="grid gap-1.5">
<Label htmlFor={`${id}-end`}>Bis</Label>
<Input
id={`${id}-end`}
name="endDate"
type="date"
value={endDate}
onChange={(event) => setEndDate(event.target.value)}
required
min={startDate}
/>
</div>
)}
</div>
{kind === "TIME_OFF" && (
<Field
label="Freizeitausgleich in Stunden (Von = Bis)"
label="Freizeitausgleich in Stunden"
name="hours"
type="number"
min={1 / 60}
+22 -1
View File
@@ -284,7 +284,17 @@ export default async function PersonalPage({
</div>
{profile && (
<div className="flex flex-wrap gap-2">
<TimeForm profileId={profile.id} />
<TimeForm
key={`${profile.id}-${start}`}
profileId={profile.id}
defaultDate={
tab === "zeit"
? today >= start && today <= end
? today
: start
: today
}
/>
<LeaveForm profileId={profile.id} admin={admin} />
</div>
)}
@@ -645,6 +655,7 @@ export default async function PersonalPage({
"Ist",
"Urlaub / Krankheit",
"Differenz",
"Erfassen",
].map((s) => (
<th key={s} className="pb-3 pr-4 font-medium">
{s}
@@ -682,6 +693,15 @@ export default async function PersonalPage({
>
{formatMinutes(d.balance)}
</td>
<td className="py-3 pl-3">
{editable && d.date <= today ? (
<TimeForm
profileId={profile.id}
defaultDate={d.date}
compact
/>
) : null}
</td>
</tr>
))}
</tbody>
@@ -696,6 +716,7 @@ export default async function PersonalPage({
<td className="pt-4">
{formatMinutes(totals.balance)}
</td>
<td />
</tr>
</tfoot>
</table>
+105 -82
View File
@@ -11,6 +11,10 @@ import {
parseDateInput,
} from "@/lib/child-birthdate";
import { serializableTransaction } from "@/lib/transaction";
import {
assertKitaAdminRemains,
lockKitaAccounts,
} from "@/lib/kita-admin-guard";
import { prisma } from "@/lib/prisma";
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
import { UserFacingError, publicActionError } from "@/lib/action-error";
@@ -59,7 +63,11 @@ const childSchema = z
});
const familySchema = z.object({
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
familyName: z
.string()
.min(1, "Familienname ist erforderlich.")
.max(120)
.trim(),
});
const contactSchema = z.object({
@@ -88,10 +96,7 @@ const contactSchema = z.object({
// Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück,
// damit der User nicht nur "Ungültige Eingabedaten" sieht.
function firstZodMessage(
error: z.ZodError,
fallback: string,
): string {
function firstZodMessage(error: z.ZodError, fallback: string): string {
const issue = error.issues[0];
return issue?.message || fallback;
}
@@ -121,8 +126,13 @@ export async function createMyChild(rawPayload: unknown) {
}
try {
await serializableTransaction(async tx => {
const count = await tx.child.count({ where: { kitaId: session.user.kitaId, familyId: session.user.familyId! } });
await serializableTransaction(async (tx) => {
const count = await tx.child.count({
where: {
kitaId: session.user.kitaId,
familyId: session.user.familyId!,
},
});
if (count >= 20) throw new Error("Household child limit reached");
await tx.child.create({
data: {
@@ -166,10 +176,17 @@ export async function createMyFamily(rawPayload: unknown) {
});
const attached = await tx.user.updateMany({
where: { id: session.user.id, kitaId: session.user.kitaId, familyId: null, disabledAt: null, sessionVersion: session.user.sessionVersion },
where: {
id: session.user.id,
kitaId: session.user.kitaId,
familyId: null,
disabledAt: null,
sessionVersion: session.user.sessionVersion,
},
data: { familyId: family.id },
});
if (attached.count !== 1) throw new Error("Account already assigned or revoked");
if (attached.count !== 1)
throw new Error("Account already assigned or revoked");
});
revalidatePath("/dashboard/profil");
@@ -178,7 +195,12 @@ export async function createMyFamily(rawPayload: unknown) {
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
if (!(error instanceof Error && error.message === "Account already assigned or revoked")) {
if (
!(
error instanceof Error &&
error.message === "Account already assigned or revoked"
)
) {
console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
}
return { error: "Der Haushalt konnte nicht angelegt werden." };
@@ -249,7 +271,11 @@ export async function updateMyChild(childId: string, rawPayload: unknown) {
}
await prisma.child.update({
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! },
where: {
id: childId,
kitaId: session.user.kitaId,
familyId: session.user.familyId!,
},
data: {
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
@@ -271,15 +297,34 @@ export async function deleteMyChild(childId: string) {
try {
await serializableTransaction(async (tx) => {
if (!session.user.familyId) throw new UserFacingError("Dieses Kind gehört nicht zu deinem Haushalt.");
if (!session.user.familyId)
throw new UserFacingError(
"Dieses Kind gehört nicht zu deinem Haushalt.",
);
const child = await tx.child.findFirst({
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId },
where: {
id: childId,
kitaId: session.user.kitaId,
familyId: session.user.familyId,
},
select: { id: true },
});
if (!child) throw new UserFacingError("Dieses Kind gehört nicht zu deinem Haushalt.");
await assertChildrenRemovable(tx, session.user.kitaId, session.user.familyId, [childId]);
if (!child)
throw new UserFacingError(
"Dieses Kind gehört nicht zu deinem Haushalt.",
);
await assertChildrenRemovable(
tx,
session.user.kitaId,
session.user.familyId,
[childId],
);
await tx.child.delete({
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId },
where: {
id: childId,
kitaId: session.user.kitaId,
familyId: session.user.familyId,
},
});
});
@@ -287,35 +332,32 @@ export async function deleteMyChild(childId: string) {
revalidatePath("/dashboard/families");
return { success: true };
} catch (error) {
if (!(error instanceof UserFacingError)) console.error("Fehler beim Entfernen des Kindes:", error);
return { error: publicActionError(error, "Das Kind konnte nicht entfernt werden.") };
if (!(error instanceof UserFacingError))
console.error("Fehler beim Entfernen des Kindes:", error);
return {
error: publicActionError(error, "Das Kind konnte nicht entfernt werden."),
};
}
}
export async function deleteMyAccount() {
export async function deleteMyAccount(): Promise<{
success?: boolean;
error?: string;
}> {
const session = await requireKitaSession();
try {
const user = await prisma.user.findFirst({
where: {
id: session.user.id,
kitaId: session.user.kitaId,
},
select: {
id: true,
familyId: true,
},
return await serializableTransaction(async (tx) => {
await lockKitaAccounts(tx, session.user.kitaId);
const user = await tx.user.findFirst({
where: { id: session.user.id, kitaId: session.user.kitaId },
select: { id: true, familyId: true },
});
if (!user) {
return { error: "Account wurde nicht gefunden." };
}
if (!user) throw new UserFacingError("Account wurde nicht gefunden.");
await assertKitaAdminRemains(tx, session.user.kitaId, [user.id]);
const today = new Date();
today.setHours(0, 0, 0, 0);
const activeNotdienstAssignments = user.familyId
? await prisma.notdienstAssignment.count({
const assignments = user.familyId
? await tx.notdienstAssignment.count({
where: {
kitaId: session.user.kitaId,
date: { gte: today },
@@ -324,52 +366,33 @@ export async function deleteMyAccount() {
},
})
: 0;
if (activeNotdienstAssignments > 0) {
return {
error:
if (assignments)
throw new UserFacingError(
"Dein Account kann nicht gelöscht werden, solange aktive Notdienst-Verpflichtungen bestehen.",
);
if (user.familyId) {
const family = await tx.family.findFirst({
where: { id: user.familyId, kitaId: session.user.kitaId },
select: { id: true, users: { select: { id: true } } },
});
if (!family)
throw new UserFacingError("Haushalt wurde nicht gefunden.");
if (family.users.length <= 1) {
await tx.family.delete({ where: { id: family.id } });
return { success: true };
}
}
await tx.user.delete({ where: { id: user.id } });
return { success: true };
});
} catch (error) {
if (!(error instanceof UserFacingError))
console.error("Fehler beim Löschen des Accounts:", error);
return {
error: publicActionError(
error,
"Ein Fehler ist beim Löschen aufgetreten.",
),
};
}
if (!user.familyId) {
await prisma.user.delete({
where: { id: user.id },
});
return { success: true };
}
const family = await prisma.family.findFirst({
where: {
id: user.familyId,
kitaId: session.user.kitaId,
},
select: {
id: true,
users: {
select: { id: true },
},
},
});
if (!family) {
return { error: "Haushalt wurde nicht gefunden." };
}
if (family.users.length <= 1) {
await prisma.family.delete({
where: { id: family.id },
});
return { success: true };
}
await prisma.user.delete({
where: { id: user.id },
});
return { success: true };
} catch (error) {
console.error("Fehler beim Löschen des Accounts:", error);
return { error: "Ein Fehler ist beim Löschen aufgetreten." };
}
}
+15 -1
View File
@@ -29,6 +29,7 @@ import { DeleteAccountDialog } from "./_components/delete-account-dialog";
import { MyChildrenManager } from "./_components/my-children-manager";
import { CreateFamilyDialog } from "./_components/create-family-dialog";
import { ContactForm } from "./_components/contact-form";
import { PasswordForm } from "../../passwort-vergessen/password-form";
import { StaffEmailSettings } from "../personal/forms";
export const metadata = { title: "Mein Profil · Kita-Planer" };
@@ -56,6 +57,7 @@ export default async function ProfilPage() {
city: true,
directoryOptInAt: true,
staffDecisionEmails: true,
staffProfile: { select: { id: true } },
createdAt: true,
family: {
select: {
@@ -210,7 +212,19 @@ export default async function ProfilPage() {
</CardContent>
</Card>
{user.role === UserRole.ERZIEHER && (
<Card>
<CardHeader>
<CardTitle>Passwort ändern</CardTitle>
<CardDescription>
Verwalte dein persönliches Anmeldepasswort.
</CardDescription>
</CardHeader>
<CardContent>
<PasswordForm mode="change" />
</CardContent>
</Card>
{user.staffProfile && (
<Card>
<CardHeader>
<CardTitle>E-Mail-Hinweise</CardTitle>
@@ -0,0 +1,37 @@
"use server";
import { revalidatePath } from "next/cache";
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { changeKitaRole } from "@/lib/kita-role-management";
import { publicActionError, UserFacingError } from "@/lib/action-error";
export async function changeKitaRoleAction(
raw: unknown,
): Promise<{
success?: boolean;
error?: string;
changed?: boolean;
ownRoleChanged?: boolean;
}> {
const { user } = await requireRole([UserRole.ADMIN]);
if (!user.kitaId) return { error: "Keine Kita zugeordnet." };
try {
const result = await changeKitaRole(
{ id: user.id, kitaId: user.kitaId, sessionVersion: user.sessionVersion },
raw,
);
revalidatePath("/dashboard", "layout");
revalidatePath("/admin");
return { success: true, ...result };
} catch (error) {
if (!(error instanceof UserFacingError))
console.error("Rolle konnte nicht geändert werden:", error);
return {
error: publicActionError(
error,
"Rolle konnte nicht geändert werden. Bitte erneut versuchen.",
),
};
}
}
+113
View File
@@ -0,0 +1,113 @@
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getKitaRoleHistory } from "@/lib/kita-role-management";
import { kitaRoleLabels } from "@/lib/kita-roles";
import { Card } from "@/components/ui/card";
import { RoleList } from "./role-list";
export const metadata = { title: "Zugriffsrechte · Kita-Planer" };
export default async function KitaRolesPage() {
const { user } = await requireRole([UserRole.ADMIN]);
const kitaId = user.kitaId!;
const [people, history] = await Promise.all([
prisma.user.findMany({
where: { kitaId, role: { not: UserRole.SUPERADMIN } },
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
select: {
id: true,
firstName: true,
lastName: true,
email: true,
role: true,
familyId: true,
disabledAt: true,
lockedUntil: true,
emailVerifiedAt: true,
family: { select: { name: true } },
staffProfile: { select: { id: true } },
},
}),
getKitaRoleHistory(kitaId),
]);
const now = new Date();
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-deep)]">
VERWALTUNG · KITA-KONTEN
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Zugriffsrechte{" "}
<em className="font-normal text-[var(--accent-deep)]">verwalten</em>
</h1>
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Ernenne weitere Kita-Admins oder die Notdienst-Koordination. Rollen
gelten sofort; offene Einladungen müssen weiterhin aktiviert werden.
Mindestens ein aktiver Kita-Admin bleibt erhalten.
</p>
</header>
<RoleList
currentUserId={user.id}
people={people.map((person) => ({
id: person.id,
name: `${person.firstName} ${person.lastName}`,
email: person.email,
role: person.role,
familyId: person.familyId,
familyName: person.family?.name ?? null,
hasStaffProfile: !!person.staffProfile,
status: person.disabledAt
? "Konto deaktiviert"
: person.lockedUntil && person.lockedUntil > now
? "Anmeldung gesperrt"
: !person.emailVerifiedAt
? "Einladung offen"
: "Zugang aktiviert",
}))}
/>
<Card className="mt-7 p-5">
<h2 className="text-lg font-semibold text-[var(--ink)]">
Letzte Rollenänderungen
</h2>
<p className="mt-1 text-sm leading-6 text-[var(--ink-soft)]">
Ämter und Zuständigkeiten verwaltest du weiterhin bei den Familien.
Sie verändern die Zugriffsrechte nicht.
</p>
{history.length ? (
<ul className="mt-4 divide-y divide-[var(--hairline-soft)]">
{history.map((entry) => {
const data = entry.metadata as {
previousRole?: UserRole;
newRole?: UserRole;
} | null;
return (
<li key={entry.id} className="py-3 text-sm leading-6">
<p className="font-medium text-[var(--ink)]">
{entry.targetLabel}:{" "}
{data?.previousRole
? kitaRoleLabels[data.previousRole]
: "—"}{" "}
→ {data?.newRole ? kitaRoleLabels[data.newRole] : "—"}
</p>
<p className="break-words text-xs text-[var(--ink-muted)]">
{new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
timeStyle: "short",
timeZone: "Europe/Berlin",
}).format(entry.createdAt)}{" "}
· {entry.actorEmail}
</p>
</li>
);
})}
</ul>
) : (
<p className="mt-4 text-sm text-[var(--ink-muted)]">
Noch keine Rollenänderungen protokolliert.
</p>
)}
</Card>
</div>
);
}
@@ -0,0 +1,166 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import type { UserRole } from "@prisma/client";
import { ShieldCheck } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { availableKitaRoles, kitaRoleLabels } from "@/lib/kita-roles";
import { changeKitaRoleAction } from "./actions";
const descriptions: Record<string, string> = {
ELTERN: "Eigener Haushalt, Verfügbarkeiten, Diensttausch und Kita-Alltag.",
KOORDINATOR: "Zusätzlich Notdienstpläne, Alarme und Termine koordinieren.",
ADMIN:
"Familien, Team, Zugriffsrechte und alle Kita-Verwaltungsbereiche verwalten.",
ERZIEHER:
"Personalbereich, eigene Arbeitszeit und Anträge sowie Kinder-Abwesenheiten.",
};
export type RolePerson = {
id: string;
name: string;
role: UserRole;
familyId: string | null;
hasStaffProfile: boolean;
};
export function RoleChangeButton({
person,
ownAccount = false,
}: {
person: RolePerson;
ownAccount?: boolean;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [role, setRole] = useState(person.role);
const [error, setError] = useState("");
const [pending, startTransition] = useTransition();
const roles = availableKitaRoles(person);
const submit = () => {
setError("");
startTransition(async () => {
try {
const result = await changeKitaRoleAction({
userId: person.id,
role,
expectedRole: person.role,
});
if (result.error) {
setError(result.error);
return;
}
toast.success("Zugriffsrolle gespeichert.");
setOpen(false);
if (result.ownRoleChanged) router.replace("/dashboard");
router.refresh();
} catch {
setError(
"Die Änderung konnte nicht gespeichert werden. Bitte erneut versuchen.",
);
}
});
};
return (
<>
<Button
variant="outline"
size="sm"
aria-label={`Zugriffsrechte für ${person.name}`}
disabled={person.role === "SUPERADMIN"}
onClick={() => {
setRole(person.role);
setError("");
setOpen(true);
}}
>
<ShieldCheck className="h-3.5 w-3.5" /> Zugriffsrechte
</Button>
<Dialog
open={open}
onOpenChange={(next) => {
if (!pending) setOpen(next);
}}
>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Zugriffsrechte für {person.name}</DialogTitle>
<DialogDescription>
Die Zugriffsrolle bestimmt, welche Bereiche diese Person nutzen
und verwalten kann. Ämter wie „Vorstand“ werden separat vergeben.
</DialogDescription>
</DialogHeader>
<fieldset disabled={pending} className="grid gap-2">
<legend className="mb-2 text-sm font-semibold text-[var(--ink)]">
Zugriffsrolle
</legend>
{roles.map((value) => (
<label
key={value}
className={`flex cursor-pointer items-start gap-3 rounded-xl border p-3 transition-colors ${role === value ? "border-[var(--accent-mid)] bg-[var(--accent-soft)]" : "border-[var(--hairline)] hover:bg-[var(--surface-2)]"}`}
>
<input
type="radio"
name={`role-${person.id}`}
value={value}
checked={role === value}
onChange={() => setRole(value)}
className="mt-1 accent-[var(--accent-deep)]"
/>
<span>
<span className="block text-sm font-semibold text-[var(--ink)]">
{kitaRoleLabels[value]}
</span>
<span className="mt-1 block text-xs leading-5 text-[var(--ink-soft)]">
{descriptions[value]}
</span>
</span>
</label>
))}
</fieldset>
<p className="text-xs leading-5 text-[var(--ink-soft)]">
{person.hasStaffProfile
? "Das Personalprofil mit Arbeitszeiten und Anträgen bleibt erhalten. Mit Kita-Admin-Rechten ist zusätzlich die Personalverwaltung zugänglich."
: person.familyId
? "Haushalt, Kinder und eigene Dienste bleiben erhalten."
: "Dieses Konto hat keinen eigenen Haushalt. Deshalb steht die Elternrolle nicht zur Auswahl."}
</p>
{ownAccount && role !== person.role ? (
<p className="rounded-lg bg-[var(--warn-soft)] p-3 text-sm leading-5 text-[var(--warn)]">
Du änderst deine eigenen Rechte. Nach dem Speichern gelangst du
zur Übersicht; der Zugang zur Verwaltung richtet sich dann nach
der neuen Rolle.
</p>
) : null}
{error ? (
<p role="alert" className="text-sm leading-5 text-[var(--danger)]">
{error}
</p>
) : null}
<DialogFooter>
<Button
variant="outline"
disabled={pending}
onClick={() => setOpen(false)}
>
Abbrechen
</Button>
<Button disabled={pending || role === person.role} onClick={submit}>
{pending ? "Wird gespeichert …" : "Rolle speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Card } from "@/components/ui/card";
import { RoleBadge } from "@/components/ui/role-badge";
import { kitaRoleLabels } from "@/lib/kita-roles";
import { RoleChangeButton, type RolePerson } from "./role-change-button";
type Person = RolePerson & {
email: string;
familyName: string | null;
status: string;
};
export function RoleList({
people,
currentUserId,
}: {
people: Person[];
currentUserId: string;
}) {
const [query, setQuery] = useState("");
const search = query.trim().toLocaleLowerCase("de");
const filtered = people.filter((person) =>
`${person.name} ${person.email} ${person.familyName ?? ""} ${kitaRoleLabels[person.role]}`
.toLocaleLowerCase("de")
.includes(search),
);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<Input
type="search"
aria-label="Kita-Konten durchsuchen"
placeholder="Name, Haushalt oder Rolle suchen …"
value={query}
onChange={(event) => setQuery(event.target.value)}
className="max-w-md"
/>
<span className="text-sm text-[var(--ink-muted)]">
{filtered.length} {filtered.length === 1 ? "Konto" : "Konten"}
</span>
</div>
<Card className="overflow-hidden p-0">
<div className="hidden gap-6 border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] px-5 py-3 text-[11px] font-semibold uppercase tracking-wider text-[var(--ink-muted)] md:grid md:grid-cols-[minmax(0,1fr)_220px_160px]">
<span>Person & Zuordnung</span>
<span>Zugriffsrolle</span>
<span className="text-right">Verwalten</span>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{filtered.map((person) => (
<div
key={person.id}
className="grid items-center gap-4 px-5 py-5 md:grid-cols-[minmax(0,1fr)_220px_160px] md:gap-6"
>
<div className="min-w-0">
<p className="font-semibold text-[var(--ink)]">
{person.name}
{person.id === currentUserId ? (
<span className="ml-2 text-xs font-normal text-[var(--ink-muted)]">
Du
</span>
) : null}
</p>
<p className="mt-1 break-words text-sm text-[var(--ink-soft)]">
{person.email}
</p>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
{[
person.familyName
? `Haushalt ${person.familyName}`
: "Kein eigener Haushalt",
person.hasStaffProfile ? "Personalprofil vorhanden" : null,
person.status,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
<RoleBadge role={person.role} />
<div className="md:justify-self-end">
<RoleChangeButton
person={person}
ownAccount={person.id === currentUserId}
/>
</div>
</div>
))}
{!filtered.length ? (
<p className="p-8 text-center text-sm text-[var(--ink-soft)]">
Keine passenden Kita-Konten gefunden.
</p>
) : null}
</div>
</Card>
</div>
);
}
+16 -17
View File
@@ -1,4 +1,4 @@
import type { Metadata } from "next";
import { publicPageMetadata } from "@/lib/marketing/seo";
import Link from "next/link";
import {
ArrowLeft,
@@ -11,10 +11,11 @@ import {
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
export const metadata: Metadata = {
title: "Datenschutz | Kita-Planer",
description: "Datenschutzhinweise und Platzhalter für Kita-Planer.",
};
export const metadata = publicPageMetadata(
"/datenschutz",
"Datenschutz",
"Datenschutzhinweise zu Konten, Kontaktfreigaben und der Nutzung von Kita-Planer.",
);
const overviewItems = [
{
@@ -77,9 +78,7 @@ export default function DatenschutzPage() {
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
<Icon className="h-5 w-5" />
</div>
<h2 className="font-semibold tracking-tight">
{item.title}
</h2>
<h2 className="font-semibold tracking-tight">{item.title}</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
{item.text}
</p>
@@ -154,9 +153,9 @@ export default function DatenschutzPage() {
<p className="mt-3 leading-7 text-slate-700">
Zugriff auf Daten erhalten nur berechtigte Nutzerinnen und Nutzer
innerhalb der jeweiligen Kita beziehungsweise des jeweiligen
Elternvereins. Für Hosting, E-Mail-Versand oder technische
Dienste können Auftragsverarbeiter eingesetzt werden:
[Hosting-Anbieter], [E-Mail-Anbieter], [weitere Dienstleister].
Elternvereins. Für Hosting, E-Mail-Versand oder technische Dienste
können Auftragsverarbeiter eingesetzt werden: [Hosting-Anbieter],
[E-Mail-Anbieter], [weitere Dienstleister].
</p>
</div>
@@ -180,9 +179,9 @@ export default function DatenschutzPage() {
<p className="mt-3 leading-7 text-slate-700">
Kita-Planer verwendet technisch notwendige Cookies oder
vergleichbare Technologien, um Login-Sitzungen sicher
bereitzustellen. Für die öffentliche Website kann zusätzlich
Umami Analytics eingesetzt werden, um Seitenaufrufe anonymisiert
und ohne Cookies auszuwerten.
bereitzustellen. Für die öffentliche Website kann zusätzlich Umami
Analytics eingesetzt werden, um Seitenaufrufe anonymisiert und
ohne Cookies auszuwerten.
</p>
</div>
@@ -220,9 +219,9 @@ export default function DatenschutzPage() {
</h2>
<p className="mt-3 leading-7 text-slate-700">
Betroffene Personen können sich bei einer
Datenschutzaufsichtsbehörde beschweren. Zuständig ist
insbesondere die Aufsichtsbehörde am Sitz der verantwortlichen
Stelle: [zuständige Datenschutzaufsichtsbehörde].
Datenschutzaufsichtsbehörde beschweren. Zuständig ist insbesondere
die Aufsichtsbehörde am Sitz der verantwortlichen Stelle:
[zuständige Datenschutzaufsichtsbehörde].
</p>
</div>
+35 -34
View File
@@ -1,3 +1,7 @@
import { ScreenshotPreview } from "@/components/marketing/product-screenshots";
import { getProductScreenshot } from "@/lib/marketing/screenshots";
import { StructuredData } from "@/components/marketing/structured-data";
import { featureStructuredData, publicPageMetadata } from "@/lib/marketing/seo";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
@@ -48,30 +52,11 @@ export async function generateMetadata({
};
}
return {
title: feature.seoTitle,
description: feature.seoDescription,
openGraph: {
title: feature.seoTitle,
description: feature.seoDescription,
type: "website",
locale: "de_DE",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Ein Kind spielt mit bunten Holzklötzen.",
},
],
},
twitter: {
card: "summary_large_image",
title: feature.seoTitle,
description: feature.seoDescription,
images: ["/og-image.png"],
},
};
return publicPageMetadata(
`/features/${feature.slug}`,
feature.seoTitle,
feature.seoDescription,
);
}
export default async function FeaturePage({ params }: FeaturePageProps) {
@@ -83,25 +68,34 @@ export default async function FeaturePage({ params }: FeaturePageProps) {
}
const Icon = feature.icon;
const screenshot = getProductScreenshot(slug);
return (
<div className="min-h-screen bg-[#f8faf8] text-slate-950">
<StructuredData data={featureStructuredData(feature)} />
<header className="border-b border-slate-200 bg-white/95">
<div className="mx-auto flex h-20 w-full max-w-7xl items-center justify-between px-5 sm:px-6">
<Link href="/" className="flex items-center gap-3 text-slate-950">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-950 text-white">
<Link
href="/"
className="flex shrink-0 items-center gap-2 text-slate-950 sm:gap-3"
>
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-950 text-white sm:h-9 sm:w-9">
<ShieldCheck className="h-5 w-5" />
</span>
<span className="text-base font-semibold tracking-tight">
<span className="whitespace-nowrap text-sm font-semibold tracking-tight sm:text-base">
Kita-Planer
</span>
</Link>
<nav className="flex items-center gap-2">
<Button asChild variant="ghost">
<nav className="flex items-center gap-1 sm:gap-2">
<Button
asChild
variant="ghost"
className="px-3 text-xs sm:px-4 sm:text-sm"
>
<Link href="/login">Login</Link>
</Button>
<Button asChild>
<Button asChild className="px-3 text-xs sm:px-4 sm:text-sm">
<Link href="/#kontakt">Demo anfragen</Link>
</Button>
</nav>
@@ -178,10 +172,17 @@ export default async function FeaturePage({ params }: FeaturePageProps) {
</div>
</section>
{screenshot && (
<section aria-label={`${feature.shortTitle} in der App`} className="mx-auto w-full max-w-7xl px-5 pt-16 sm:px-6">
<h2 className="mb-6 text-2xl font-semibold tracking-tight">Ein Blick in die Anwendung</h2>
<ScreenshotPreview screenshot={screenshot} />
</section>
)}
<section className="mx-auto grid w-full max-w-7xl gap-6 px-5 py-20 sm:px-6 lg:grid-cols-2">
<Card className="border-slate-200 bg-white">
<CardHeader>
<CardTitle className="text-2xl tracking-tight">
<CardTitle as="h2" className="text-2xl tracking-tight">
{feature.problemTitle}
</CardTitle>
<CardDescription className="text-base leading-7 text-slate-600">
@@ -192,7 +193,7 @@ export default async function FeaturePage({ params }: FeaturePageProps) {
<Card className="border-slate-200 bg-white">
<CardHeader>
<CardTitle className="text-2xl tracking-tight">
<CardTitle as="h2" className="text-2xl tracking-tight">
{feature.solutionTitle}
</CardTitle>
<CardDescription className="text-base leading-7 text-slate-600">
@@ -220,7 +221,7 @@ export default async function FeaturePage({ params }: FeaturePageProps) {
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
<CheckCircle2 className="h-5 w-5" />
</div>
<CardTitle className="text-xl tracking-tight">
<CardTitle as="h3" className="text-xl tracking-tight">
{benefit.title}
</CardTitle>
<CardDescription className="text-sm leading-6 text-slate-600">
@@ -273,7 +274,7 @@ export default async function FeaturePage({ params }: FeaturePageProps) {
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-slate-100 text-slate-700">
<HelpCircle className="h-5 w-5" />
</div>
<CardTitle className="text-lg leading-snug">
<CardTitle as="h3" className="text-lg leading-snug">
{faq.question}
</CardTitle>
<CardDescription className="text-sm leading-6 text-slate-600">
+9 -10
View File
@@ -1,14 +1,15 @@
import type { Metadata } from "next";
import { publicPageMetadata } from "@/lib/marketing/seo";
import Link from "next/link";
import { ArrowLeft, Building2, Mail, MapPin, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
export const metadata: Metadata = {
title: "Impressum | Kita-Planer",
description: "Impressum und Anbieterkennzeichnung von Kita-Planer.",
};
export const metadata = publicPageMetadata(
"/impressum",
"Impressum",
"Impressum und Anbieterkennzeichnung von Kita-Planer.",
);
const contactItems = [
{
@@ -117,8 +118,8 @@ export default function ImpressumPage() {
Umsatzsteuer-ID
</h2>
<p className="mt-3 leading-7 text-slate-700">
Umsatzsteuer-Identifikationsnummer gemäß § 27a
Umsatzsteuergesetz: -
Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz:
-
</p>
</div>
@@ -126,9 +127,7 @@ export default function ImpressumPage() {
<h2 className="text-xl font-semibold tracking-tight">
Verantwortlich für den Inhalt
</h2>
<p className="mt-3 leading-7 text-slate-700">
Torben Indorf
</p>
<p className="mt-3 leading-7 text-slate-700">Torben Indorf</p>
</div>
<div>
+23 -29
View File
@@ -1,6 +1,12 @@
import { getSession } from "@/lib/auth-utils";
import { headers } from "next/headers";
import type { Metadata } from "next";
import {
getPublicSiteOrigin,
marketingDescription,
marketingTitle,
socialImage,
} from "@/lib/marketing/seo";
import { Manrope } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/sonner";
@@ -13,36 +19,24 @@ const manrope = Manrope({
});
export const metadata: Metadata = {
metadataBase: new URL(
process.env.NEXT_PUBLIC_SITE_URL ?? "https://mein-kitaplaner.de",
),
title: {
default: "Der digitale Kita-Planer für Elternvereine",
template: "%s | Kita-Planer",
},
description:
"Kita-Planer bündelt Dienste, Termine, Notdienste, Abwesenheiten und sichere Kommunikation für Elternvereine an einem Ort.",
metadataBase: new URL(getPublicSiteOrigin()),
title: { default: marketingTitle, template: "%s | Kita-Planer" },
description: marketingDescription,
// Public marketing pages opt in explicitly; account and internal pages stay out.
robots: { index: false, follow: false },
openGraph: {
title: "Der digitale Kita-Planer für Elternvereine",
description:
"Organisiert Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.",
title: marketingTitle,
description: marketingDescription,
type: "website",
locale: "de_DE",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Ein Kind spielt mit bunten Holzklötzen.",
},
],
siteName: "Kita-Planer",
images: [socialImage],
},
twitter: {
card: "summary_large_image",
title: "Der digitale Kita-Planer für Elternvereine",
description:
"Organisiert Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.",
images: ["/og-image.png"],
title: marketingTitle,
description: marketingDescription,
images: [{ url: socialImage.url, alt: socialImage.alt }],
},
};
@@ -51,13 +45,13 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const [requestHeaders, session] = await Promise.all([headers(), getSession()]);
const [requestHeaders, session] = await Promise.all([
headers(),
getSession(),
]);
const nonce = requestHeaders.get("x-nonce") ?? undefined;
return (
<html
lang="de"
className={`${manrope.variable} h-full antialiased`}
>
<html lang="de" className={`${manrope.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col">
{children}
<UmamiAnalytics nonce={nonce} enabled={!session?.user?.id} />
+15 -2
View File
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { useActionState, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -23,7 +24,10 @@ export function LoginForm({
devParentEmail?: string;
devParentPassword?: string;
}) {
const [state, formAction, pending] = useActionState(loginAction, initialState);
const [state, formAction, pending] = useActionState(
loginAction,
initialState,
);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -67,6 +71,13 @@ export function LoginForm({
)}
</div>
<Link
href="/passwort-vergessen"
className="inline-block text-sm text-[var(--accent-deep)] underline underline-offset-4"
>
Passwort vergessen?
</Link>
{state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{state.errors._form[0]}
@@ -79,7 +90,9 @@ export function LoginForm({
{showDevHelpers && (
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/40 p-3 space-y-2 text-xs">
<p className="text-center font-medium text-muted-foreground">Dev-Schnellzugriff (nur lokal):</p>
<p className="text-center font-medium text-muted-foreground">
Dev-Schnellzugriff (nur lokal):
</p>
<div className="grid grid-cols-2 gap-2">
{devAdminEmail && (
<Button
+24 -4
View File
@@ -17,7 +17,11 @@ import { LoginForm } from "./login-form";
export const metadata = { title: "Anmelden · Kita-Planer" };
type LoginPageProps = {
searchParams?: Promise<{ callbackUrl?: string | string[]; reauth?: string }>;
searchParams?: Promise<{
callbackUrl?: string | string[];
reauth?: string;
passwordChanged?: string;
}>;
};
export default async function LoginPage({ searchParams }: LoginPageProps) {
@@ -31,9 +35,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
const isDev = process.env.NODE_ENV === "development";
const devAdminEmail = isDev ? process.env.DEV_LOGIN_ADMIN_EMAIL : undefined;
const devAdminPassword = isDev ? process.env.DEV_LOGIN_ADMIN_PASSWORD : undefined;
const devAdminPassword = isDev
? process.env.DEV_LOGIN_ADMIN_PASSWORD
: undefined;
const devParentEmail = isDev ? process.env.DEV_LOGIN_PARENT_EMAIL : undefined;
const devParentPassword = isDev ? process.env.DEV_LOGIN_PARENT_PASSWORD : undefined;
const devParentPassword = isDev
? process.env.DEV_LOGIN_PARENT_PASSWORD
: undefined;
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
@@ -43,6 +51,15 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
<CardDescription>Willkommen zurück.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{resolvedSearchParams?.passwordChanged === "1" ? (
<p
role="status"
className="rounded-lg bg-[var(--good-soft)] p-3 text-sm text-[var(--ink)]"
>
Dein Passwort wurde geändert. Melde dich mit dem neuen Passwort
an.
</p>
) : null}
<LoginForm
callbackUrl={callbackUrl}
devAdminEmail={devAdminEmail}
@@ -52,7 +69,10 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
/>
<p className="text-center text-sm text-muted-foreground">
Noch keinen Account?{" "}
<Link href="/register" className="font-medium text-foreground underline">
<Link
href="/register"
className="font-medium text-foreground underline"
>
Kita registrieren
</Link>
</p>
+52
View File
@@ -0,0 +1,52 @@
import Link from "next/link";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { isPasswordResetValid } from "@/lib/password-management";
import { PasswordForm } from "../../passwort-vergessen/password-form";
export const metadata = {
title: "Neues Passwort · Kita-Planer",
robots: { index: false, follow: false },
};
export default async function ResetPasswordPage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
const valid = await isPasswordResetValid(token);
return (
<div className="flex min-h-screen items-center justify-center bg-[var(--bg)] px-5 py-12">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>
{valid
? "Neues Passwort festlegen"
: "Dieser Link ist nicht mehr gültig"}
</CardTitle>
<CardDescription>
{valid
? "Wähle ein neues Passwort für dein Kita-Planer-Konto."
: "Der Link ist abgelaufen, wurde bereits verwendet oder ist ungültig. Du kannst einen neuen Link anfordern."}
</CardDescription>
</CardHeader>
<CardContent>
{valid ? (
<PasswordForm mode="reset" token={token} />
) : (
<Link
href="/passwort-vergessen"
className="text-sm font-semibold text-[var(--accent-deep)] underline underline-offset-4"
>
Neuen Link anfordern
</Link>
)}
</CardContent>
</Card>
</div>
);
}
+71 -205
View File
@@ -1,12 +1,9 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import {
ArrowRight,
Bell,
CheckCircle2,
ClipboardList,
Clock,
DatabaseZap,
FileStack,
Fingerprint,
@@ -16,7 +13,6 @@ import {
MessageSquareText,
MessagesSquare,
PhoneCall,
Search,
Route,
ShieldCheck,
Table2,
@@ -35,38 +31,23 @@ import {
CardTitle,
} from "@/components/ui/card";
import { marketingFeatures } from "@/lib/marketing/features";
import { StructuredData } from "@/components/marketing/structured-data";
import {
landingStructuredData,
marketingDescription,
marketingTitle,
publicPageMetadata,
} from "@/lib/marketing/seo";
import { ProductScreenshots, ScreenshotPreview } from "@/components/marketing/product-screenshots";
import { getProductScreenshot } from "@/lib/marketing/screenshots";
import { ProductVideo } from "@/components/marketing/product-video";
import { ContactForm } from "./contact-form";
export const metadata: Metadata = {
title: "Kita-Planer für Elterninitiativen und Kita-Vereine",
description:
"Die einfache Plattform für Elterninitiativen, freie Kitas und Kita-Vereine: organisiert Dienste, Termine, Krankmeldungen und offizielle Kommunikation an einem Ort.",
alternates: {
canonical: "/",
},
openGraph: {
title: "Kita-Planer für Elterninitiativen und Kita-Vereine",
description:
"Weniger Chat-Chaos, weniger Excel-Listen, mehr Ruhe im ehrenamtlichen Kita-Alltag.",
type: "website",
locale: "de_DE",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Ein Kind spielt mit bunten Holzklötzen.",
},
],
},
twitter: {
card: "summary_large_image",
title: "Kita-Planer für Elterninitiativen und Kita-Vereine",
description:
"Organisiert Dienste, Termine, Krankmeldungen und offizielle Kommunikation an einem Ort.",
images: ["/og-image.png"],
},
};
export const metadata = publicPageMetadata(
"/",
marketingTitle,
marketingDescription,
);
const heroImage =
"https://images.unsplash.com/photo-1503454537195-1dcabb73ffb9?auto=format&fit=crop&w=2400&q=86";
@@ -74,13 +55,13 @@ const heroImage =
const privacyPoints = [
{
icon: DatabaseZap,
title: "Strikt isolierte Mandanten",
text: "Jede Kita arbeitet technisch streng getrennt pro Kita. Rollen und Datenabfragen bleiben konsequent tenant-isoliert.",
title: "Getrennte Daten je Kita",
text: "Die Daten eurer Kita sind von anderen Einrichtungen getrennt. Zugriff erhalten nur die berechtigten Konten eurer Kita.",
},
{
icon: Fingerprint,
title: "Kontaktfreigabe per Opt-in",
text: "Adressbuchdaten erscheinen nur nach aktivem Opt-In pro Elternteil. Nicht freigegebene Kontaktdaten verlassen den Server nicht.",
text: "Jeder Elternteil entscheidet über die Freigabe der eigenen Kontaktdaten. Im internen Adressbuch erscheinen nur freigegebene Angaben.",
},
{
icon: Trash2,
@@ -90,23 +71,23 @@ const privacyPoints = [
{
icon: LockKeyhole,
title: "Geschützte Anhänge",
text: "Dokumente vom Schwarzen Brett liegen nicht öffentlich und werden nur nach Session- und Kita-Prüfung ausgeliefert.",
text: "Anhänge sind nur nach Anmeldung und für die gewählte Empfängergruppe eurer Kita abrufbar.",
},
{
icon: UserCog,
title: "Rollenrechte pro Kita",
text: "Vorstand, Koordination, Eltern und Team sehen nur die Bereiche, die zu ihrer Aufgabe und Kita-Zugehörigkeit passen.",
text: "Kita-Admins vergeben Rechte für Verwaltung, Notdienst-Koordination, Eltern und Team. Ämter wie Vorstand sind von Zugriffsrechten getrennt.",
},
{
icon: FileStack,
title: "Klare Datenpfade",
text: "Löschungen sind bewusst vorgesehen; Fragen zu Export und Übergabe werden im Setup sauber geklärt.",
title: "Kontozugang selbst verwalten",
text: "Aktivierte Konten können ihr Passwort im Profil ändern oder einen zeitlich begrenzten Wiederherstellungslink anfordern.",
},
];
const proofPoints = [
"Für Elterninitiativen, freie Kitas und Kita-Vereine",
"Dienste, Kalender, Abwesenheiten und offizielle Infos in einem System",
"Dienste, Kalender, Arbeitszeit und offizielle Infos in einem System",
"Gebaut für Vorstände, Eltern, Koordination und Team",
];
@@ -114,13 +95,13 @@ const replacedTools = [
{
icon: MessagesSquare,
title: "WhatsApp für offizielle Infos",
text: "Ankündigungen, Anhänge und Lesestatus gehören in einen verbindlichen Kanal, nicht zwischen spontane Rückfragen.",
text: "Ankündigungen mit Anhängen, sichtbaren Korrekturen und optionalen E-Mails bleiben auch später auffindbar.",
accent: "bg-emerald-50 text-emerald-700 ring-emerald-100",
},
{
icon: Table2,
title: "Excel-Listen für Dienste",
text: "Verfügbarkeiten, Mindesttage und Dienstpläne werden strukturierter als in Tabellen mit Version 17-final-final.",
text: "Verfügbarkeiten, Erinnerungen und veröffentlichte Dienstpläne bleiben nachvollziehbar. Bei Bedarf lässt sich die Einteilung manuell anpassen.",
accent: "bg-lime-50 text-lime-700 ring-lime-100",
},
{
@@ -147,17 +128,22 @@ const audienceRoles = [
{
icon: UserCog,
title: "Vorstand & Admin",
text: "Kita, Familien, Termine, Einladungen und Rechte verwalten, ohne dass alles an einer privaten Ablage hängt.",
text: "Familien einladen, Zugriffsrechte vergeben, Mitteilungen korrigieren und Versandfehler gezielt erneut versuchen.",
},
{
icon: UsersRound,
title: "Eltern",
text: "Termine sehen, Kinder krankmelden, Verfügbarkeiten eintragen und freigegebene Kontakte finden.",
text: "Verfügbarkeiten eintragen, zu Terminen zusagen, Mitbringbeiträge verwalten und zukünftige Abwesenheiten korrigieren.",
},
{
icon: HeartHandshake,
title: "ErzieherInnen & Koordination",
text: "Tagesüberblick, Abwesenheiten, Dienste und relevante Informationen an einem Ort sehen.",
title: "Kita-Team",
text: "Arbeitszeit erfassen, Wochen einreichen und Urlaub oder Freizeitausgleich beantragen. Abwesenheiten und Teammitteilungen bleiben im Blick.",
},
{
icon: HeartHandshake,
title: "Notdienst-Koordination",
text: "Verfügbarkeiten prüfen, Monatspläne veröffentlichen und bei Ausfällen mit Rückmeldungen aus den Haushalten weiterplanen.",
},
];
@@ -190,28 +176,32 @@ const onboardingSteps = [
export default function LandingPage() {
return (
<div className="min-h-screen bg-[#f8faf8] text-slate-950">
<StructuredData data={landingStructuredData(marketingFeatures)} />
<header className="absolute left-0 right-0 top-0 z-20">
<div className="mx-auto flex min-h-20 w-full max-w-7xl items-center justify-between gap-3 px-5 py-4 sm:px-6">
<Link href="/" className="flex items-center gap-3 text-white">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/12 ring-1 ring-white/30">
<Link
href="/"
className="flex shrink-0 items-center gap-2 text-white sm:gap-3"
>
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-white/12 ring-1 ring-white/30 sm:h-9 sm:w-9">
<ShieldCheck className="h-5 w-5" />
</span>
<span className="text-base font-semibold tracking-tight">
<span className="whitespace-nowrap text-sm font-semibold tracking-tight sm:text-base">
Kita-Planer
</span>
</Link>
<nav className="flex flex-wrap items-center justify-end gap-2">
<nav className="flex flex-wrap items-center justify-end gap-1 sm:gap-2">
<Button
asChild
variant="ghost"
className="text-white hover:bg-white/10 hover:text-white"
className="px-3 text-xs text-white hover:bg-white/10 hover:text-white sm:px-4 sm:text-sm"
>
<Link href="/login">Login</Link>
</Button>
<Button
asChild
className="bg-white text-slate-950 shadow-sm hover:bg-white/90"
className="bg-white px-3 text-xs text-slate-950 shadow-sm hover:bg-white/90 sm:px-4 sm:text-sm"
>
<Link href="/#kontakt">Demo anfragen</Link>
</Button>
@@ -245,8 +235,8 @@ export default function LandingPage() {
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-white/82 sm:text-xl">
Für Elterninitiativen, freie Kitas und Kita-Vereine, die
Dienste, Termine, Krankmeldungen und offizielle Kommunikation
ehrenamtlich organisieren.
Dienste, Termine, Abwesenheiten, Arbeitszeit und offizielle
Kommunikation gemeinsam organisieren.
</p>
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
@@ -255,10 +245,10 @@ export default function LandingPage() {
size="lg"
className="h-12 bg-white px-6 text-slate-950 hover:bg-white/90"
>
<Link href="/#kontakt">
Demo anfragen
<a href="#produkt-video">
Video ansehen
<ArrowRight className="h-4 w-4" />
</Link>
</a>
</Button>
<Button
asChild
@@ -282,6 +272,8 @@ export default function LandingPage() {
</div>
</section>
<ProductVideo />
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6 lg:py-24">
<div className="mb-10 grid gap-5 lg:grid-cols-[0.78fr_1fr] lg:items-end">
<div>
@@ -294,13 +286,14 @@ export default function LandingPage() {
</h2>
</div>
<p className="max-w-2xl text-sm leading-6 text-slate-600 lg:justify-self-end">
Der Notdienst-Planer sammelt Verfügbarkeiten, zeigt offene
Familien und macht aus losen Antworten einen veröffentlichbaren
Monatsplan.
Entdeckt die sechs Bereiche direkt in der App: vom Notdienst
über Kalender und Mitteilungen bis zur Arbeitszeit. Die Ansichten
zeigen Beispieldaten und lassen sich für einen genaueren Blick
vergrößern.
</p>
</div>
<ProductBrowserMock />
<ProductScreenshots />
</section>
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6 lg:py-24">
@@ -386,11 +379,14 @@ export default function LandingPage() {
</div>
</section>
<section className="mx-auto w-full max-w-7xl px-5 py-24 sm:px-6">
<section
id="funktionen"
className="mx-auto w-full max-w-7xl px-5 py-24 sm:px-6"
>
<div className="mb-12 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
Die 5 Säulen
Die {marketingFeatures.length} Bereiche
</p>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
Plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam
@@ -405,15 +401,12 @@ export default function LandingPage() {
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-6">
{marketingFeatures.map((feature, index) => {
{marketingFeatures.map((feature) => {
const Icon = feature.icon;
const isWide = index > 2;
return (
<Card
key={feature.title}
className={`group border-slate-200 bg-white/90 shadow-sm transition duration-200 hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg ${
isWide ? "xl:col-span-3" : "xl:col-span-2"
}`}
className="group border-slate-200 bg-white/90 shadow-sm transition duration-200 hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg xl:col-span-2"
>
<CardHeader className="space-y-5">
<div className="flex items-start justify-between gap-4">
@@ -440,7 +433,10 @@ export default function LandingPage() {
variant="link"
className="mt-4 h-auto p-0 text-slate-950"
>
<Link href={`/features/${feature.slug}`}>
<Link
href={`/features/${feature.slug}`}
aria-label={`Mehr über ${feature.shortTitle} erfahren`}
>
Mehr erfahren
<ArrowRight className="h-4 w-4" />
</Link>
@@ -448,7 +444,7 @@ export default function LandingPage() {
</div>
</CardHeader>
<CardContent className="pt-0">
<FeaturePreview slug={feature.slug} />
<ScreenshotPreview screenshot={getProductScreenshot(feature.slug)!} compact />
</CardContent>
</Card>
);
@@ -508,9 +504,9 @@ export default function LandingPage() {
</h2>
<p className="mt-5 max-w-xl text-base leading-7 text-slate-300">
Kita-Vereine verwalten besonders sensible Daten. Deshalb ist
Kita-Planer so gebaut, dass Sichtbarkeit, Rollenrechte,
Löschung und Mandantentrennung nicht vom guten Willen einzelner
Chats abhängen.
Kita-Planer so gebaut, dass Sichtbarkeit, Rollenrechte, Löschung
und Mandantentrennung nicht vom guten Willen einzelner Chats
abhängen.
</p>
</div>
@@ -634,133 +630,3 @@ export default function LandingPage() {
</div>
);
}
function ProductBrowserMock() {
return (
<div className="overflow-hidden rounded-2xl border border-emerald-100 bg-white shadow-[0_24px_70px_rgba(15,23,42,0.12)]">
<div className="flex items-center justify-between border-b border-slate-200 bg-[#fbf8ef] px-4 py-3">
<div className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full bg-red-300" />
<span className="h-3 w-3 rounded-full bg-amber-300" />
<span className="h-3 w-3 rounded-full bg-emerald-300" />
</div>
<div className="rounded-full border border-slate-200 bg-white px-4 py-1 text-xs font-medium text-slate-500">
app.kita-planer.de/dashboard/notdienst
</div>
<div className="w-16" />
</div>
<div className="bg-[#fbf8ef] p-2 sm:p-4">
<Image
src="/landing/notdienst-calendar-linen.png"
alt="Notdienst-Kalender im Kita-Planer mit ausgewählten Tagen im Juni 2026"
width={2262}
height={1640}
sizes="(min-width: 1280px) 1184px, calc(100vw - 40px)"
className="h-auto w-full rounded-xl border border-slate-200 bg-[#fbf8ef]"
/>
</div>
</div>
);
}
function FeaturePreview({ slug }: { slug: string }) {
if (slug === "notdienst-planer") {
return (
<div className="rounded-xl border border-red-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center justify-between text-xs">
<span className="font-semibold text-slate-700">Juni</span>
<span className="rounded-full bg-red-50 px-2 py-1 text-red-700">
12/16
</span>
</div>
<div className="grid grid-cols-5 gap-1.5">
{["", "bg-red-100", "", "bg-red-100", "", "bg-red-100", "", "", "bg-red-100", ""].map(
(cell, index) => (
<span
key={index}
className={`h-7 rounded-md border border-red-100 bg-white ${cell}`}
/>
),
)}
</div>
</div>
);
}
if (slug === "kita-kalender") {
return (
<div className="rounded-xl border border-sky-100 bg-[#fbf8ef] p-3">
{[
["10", "Schließtag", "bg-red-400"],
["18", "Elternabend", "bg-indigo-400"],
["29", "Sommerfest", "bg-emerald-500"],
].map(([date, title, color]) => (
<div
key={title}
className="mb-2 last:mb-0 flex items-center gap-3 rounded-lg bg-white px-3 py-2 text-xs"
>
<span className="font-semibold tabular-nums text-slate-950">
{date}
</span>
<span className={`h-5 w-1 rounded-full ${color}`} />
<span className="text-slate-600">{title}</span>
</div>
))}
</div>
);
}
if (slug === "eltern-adressbuch") {
return (
<div className="rounded-xl border border-emerald-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs text-slate-400">
<Search className="h-3.5 w-3.5" />
Familie suchen
</div>
{["Meier", "Mustermann"].map((name) => (
<div key={name} className="mb-2 last:mb-0 flex items-center gap-2">
<span className="h-7 w-7 rounded-lg bg-emerald-50" />
<span className="h-2 flex-1 rounded-full bg-slate-200" />
<span className="h-2 w-10 rounded-full bg-emerald-100" />
</div>
))}
</div>
);
}
if (slug === "krankmeldung") {
return (
<div className="rounded-xl border border-amber-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center gap-2 text-xs font-semibold text-slate-700">
<Clock className="h-4 w-4 text-amber-700" />
Heute fehlen 2 Kinder
</div>
{["Kai · krank", "Mia · Arzttermin"].map((item) => (
<div
key={item}
className="mb-2 last:mb-0 rounded-lg bg-white px-3 py-2 text-xs text-slate-600"
>
{item}
</div>
))}
</div>
);
}
return (
<div className="rounded-xl border border-indigo-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center gap-2 text-xs font-semibold text-slate-700">
<Bell className="h-4 w-4 text-indigo-700" />
Neue Mitteilung
</div>
<div className="space-y-2">
<span className="block h-2.5 w-4/5 rounded-full bg-slate-200" />
<span className="block h-2.5 w-3/5 rounded-full bg-slate-200" />
<span className="inline-flex rounded-full bg-indigo-50 px-2 py-1 text-xs text-indigo-700">
PDF angehängt
</span>
</div>
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
"use server";
import { z } from "zod";
import { after } from "next/server";
import { limitPublicAction } from "@/lib/rate-limit";
import {
requestPasswordReset,
passwordResetMessage,
resetPassword,
changeOwnPassword,
} from "@/lib/password-management";
import { hashToken } from "@/lib/tokens";
import { requireSession } from "@/lib/auth-utils";
import { publicActionError } from "@/lib/action-error";
export type PasswordActionState = {
error?: string;
message?: string;
success?: boolean;
};
export async function requestResetAction(
_previous: PasswordActionState,
form: FormData,
): Promise<PasswordActionState> {
const email = z
.string()
.trim()
.max(254)
.email()
.toLowerCase()
.safeParse(form.get("email"));
if (!email.success)
return { error: "Bitte gib eine gültige E-Mail-Adresse ein." };
try {
if (await limitPublicAction("password-reset-request", email.data, 3, 3600))
after(async () => {
try {
await requestPasswordReset(email.data);
} catch {
console.error(
"Passwort-Wiederherstellung konnte nicht vorbereitet werden.",
);
}
});
} catch {
console.error(
"Passwort-Wiederherstellung konnte nicht vorbereitet werden.",
);
}
return { success: true, message: passwordResetMessage };
}
export async function finishResetAction(
_previous: PasswordActionState,
form: FormData,
): Promise<PasswordActionState> {
const token = form.get("token");
if (typeof token !== "string" || !/^[a-f0-9]{64}$/i.test(token))
return {
error: "Dieser Link ist ungültig. Bitte fordere einen neuen Link an.",
};
try {
if (
!(await limitPublicAction(
"password-reset-complete",
hashToken(token),
5,
900,
))
)
return { error: "Zu viele Versuche. Bitte versuche es später erneut." };
await resetPassword({
token,
password: form.get("password"),
confirmation: form.get("confirmation"),
});
return {
success: true,
message:
"Dein Passwort wurde geändert. Melde dich mit deinem neuen Passwort an.",
};
} catch (error) {
return {
error: publicActionError(
error,
"Das Passwort konnte nicht geändert werden. Bitte versuche es erneut.",
),
};
}
}
export async function changePasswordAction(
_previous: PasswordActionState,
form: FormData,
): Promise<PasswordActionState> {
const { user } = await requireSession();
try {
if (!(await limitPublicAction("password-change", user.id, 5, 900)))
return { error: "Zu viele Versuche. Bitte versuche es später erneut." };
await changeOwnPassword(
{ id: user.id, sessionVersion: user.sessionVersion },
{
currentPassword: form.get("currentPassword"),
password: form.get("password"),
confirmation: form.get("confirmation"),
},
);
return {
success: true,
message: "Dein Passwort wurde geändert. Bitte melde dich erneut an.",
};
} catch (error) {
return {
error: publicActionError(
error,
"Das Passwort konnte nicht geändert werden. Bitte versuche es erneut.",
),
};
}
}
+37
View File
@@ -0,0 +1,37 @@
import Link from "next/link";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { PasswordForm } from "./password-form";
export const metadata = {
title: "Passwort vergessen · Kita-Planer",
robots: { index: false, follow: false },
};
export default function ForgotPasswordPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-[var(--bg)] px-5 py-12">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Passwort vergessen?</CardTitle>
<CardDescription>
Fordere einen persönlichen Link an, um ein neues Passwort
festzulegen. Der Link ist 30 Minuten gültig.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<PasswordForm mode="request" />
<Link
href="/login"
className="inline-block text-sm text-[var(--accent-deep)] underline underline-offset-4"
>
Zurück zur Anmeldung
</Link>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,137 @@
"use client";
import Link from "next/link";
import { useActionState, useEffect, useId } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
requestResetAction,
finishResetAction,
changePasswordAction,
type PasswordActionState,
} from "./actions";
export function PasswordForm({
mode,
token,
}: {
mode: "request" | "reset" | "change";
token?: string;
}) {
const action =
mode === "request"
? requestResetAction
: mode === "reset"
? finishResetAction
: changePasswordAction;
const [state, formAction, pending] = useActionState(
action,
{} as PasswordActionState,
);
const id = useId();
useEffect(() => {
if (mode === "change" && state.success)
window.location.replace(
new URL("/login?passwordChanged=1", window.location.origin).href,
);
}, [mode, state.success]);
if (state.success)
return (
<div className="space-y-4">
<p
role="status"
className="rounded-xl border border-[var(--hairline)] bg-[var(--good-soft)] p-4 text-sm leading-6 text-[var(--ink)]"
>
{state.message}
</p>
{mode !== "request" ? (
<Link
href="/login"
className="text-sm font-semibold text-[var(--accent-deep)] underline underline-offset-4"
>
Zur Anmeldung
</Link>
) : null}
</div>
);
return (
<form action={formAction} className="space-y-4">
{token ? <input type="hidden" name="token" value={token} /> : null}
{mode === "request" ? (
<div className="space-y-2">
<Label htmlFor={`${id}-email`}>E-Mail-Adresse</Label>
<Input
id={`${id}-email`}
name="email"
type="email"
autoComplete="email"
maxLength={254}
required
disabled={pending}
/>
</div>
) : (
<>
{mode === "change" ? (
<div className="space-y-2">
<Label htmlFor={`${id}-current`}>Aktuelles Passwort</Label>
<Input
id={`${id}-current`}
name="currentPassword"
type="password"
autoComplete="current-password"
required
disabled={pending}
/>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor={`${id}-password`}>Neues Passwort</Label>
<Input
id={`${id}-password`}
name="password"
type="password"
autoComplete="new-password"
minLength={8}
required
disabled={pending}
/>
<p className="text-xs leading-5 text-[var(--ink-soft)]">
Mindestens 8 Zeichen. Verwende ein eigenes Passwort für den
Kita-Planer.
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-confirmation`}>
Neues Passwort wiederholen
</Label>
<Input
id={`${id}-confirmation`}
name="confirmation"
type="password"
autoComplete="new-password"
minLength={8}
required
disabled={pending}
/>
</div>
<p className="text-xs leading-5 text-[var(--ink-soft)]">
Nach dem Speichern werden alle bisherigen Anmeldungen beendet.
</p>
</>
)}
{state.error ? (
<p role="alert" className="text-sm leading-6 text-[var(--danger)]">
{state.error}
</p>
) : null}
<Button type="submit" className="w-full sm:w-auto" disabled={pending}>
{pending
? "Wird verarbeitet …"
: mode === "request"
? "Link anfordern"
: "Passwort speichern"}
</Button>
</form>
);
}
+8 -10
View File
@@ -1,28 +1,26 @@
import type { MetadataRoute } from "next";
function getSiteUrl() {
return process.env.NEXT_PUBLIC_SITE_URL ?? "https://mein-kitaplaner.de";
}
import { getPublicSiteOrigin, publicUrl } from "@/lib/marketing/seo";
export default function robots(): MetadataRoute.Robots {
const siteUrl = getSiteUrl();
const siteUrl = getPublicSiteOrigin();
return {
rules: {
userAgent: "*",
allow: ["/", "/features/", "/impressum", "/datenschutz"],
// Login/registration/recovery forms stay crawlable so bots can read noindex.
disallow: [
"/api/",
"/dashboard/",
"/dashboard",
"/admin",
"/invite/",
"/alert/",
"/login",
"/register",
"/onboarding/",
"/forbidden",
"/onboarding",
"/neues-passwort/",
],
},
sitemap: `${siteUrl}/sitemap.xml`,
sitemap: publicUrl("/sitemap.xml"),
host: siteUrl,
};
}
+8 -19
View File
@@ -2,36 +2,25 @@ import type { MetadataRoute } from "next";
import { marketingFeatures } from "@/lib/marketing/features";
function getSiteUrl() {
return process.env.NEXT_PUBLIC_SITE_URL ?? "https://mein-kitaplaner.de";
}
import { marketingLastModified, publicUrl } from "@/lib/marketing/seo";
export default function sitemap(): MetadataRoute.Sitemap {
const siteUrl = getSiteUrl();
const lastModified = new Date();
return [
{
url: siteUrl,
lastModified,
changeFrequency: "weekly",
url: publicUrl(),
lastModified: marketingLastModified,
changeFrequency: "monthly",
priority: 1,
},
{ url: publicUrl("/impressum"), changeFrequency: "yearly", priority: 0.2 },
{
url: `${siteUrl}/impressum`,
lastModified,
changeFrequency: "yearly",
priority: 0.2,
},
{
url: `${siteUrl}/datenschutz`,
lastModified,
url: publicUrl("/datenschutz"),
changeFrequency: "yearly",
priority: 0.2,
},
...marketingFeatures.map((feature) => ({
url: `${siteUrl}/features/${feature.slug}`,
lastModified,
url: publicUrl(`/features/${feature.slug}`),
lastModified: marketingLastModified,
changeFrequency: "monthly" as const,
priority: 0.8,
})),
+20 -11
View File
@@ -31,6 +31,7 @@ type UserRoleDto =
type OnboardingDialogProps = {
user: {
id: string;
phone: string | null;
street: string | null;
postalCode: string | null;
@@ -43,7 +44,7 @@ type OnboardingDialogProps = {
} | null;
};
const HIDE_ONBOARDING_DIALOG_KEY = "hideOnboardingDialog";
const onboardingKey = (userId: string) => `hideOnboardingDialog:${userId}`;
export function OnboardingDialog({ user, family }: OnboardingDialogProps) {
const [open, setOpen] = useState(false);
@@ -79,14 +80,18 @@ export function OnboardingDialog({ user, family }: OnboardingDialogProps) {
}, [family?.childrenCount, user]);
useEffect(() => {
if (status.isComplete) return;
const hidden = localStorage.getItem(HIDE_ONBOARDING_DIALOG_KEY);
if (hidden !== "true") {
const timer = window.setTimeout(() => setOpen(true), 0);
return () => window.clearTimeout(timer);
let hidden: string | null = null;
try {
hidden = localStorage.getItem(onboardingKey(user.id));
} catch {
/* Unavailable browser storage does not prevent using the app. */
}
}, [status.isComplete]);
const timer = window.setTimeout(
() => setOpen(!status.isComplete && hidden !== "true"),
0,
);
return () => window.clearTimeout(timer);
}, [status.isComplete, user.id]);
if (status.isComplete) {
return null;
@@ -97,7 +102,11 @@ export function OnboardingDialog({ user, family }: OnboardingDialogProps) {
: "Haushalt & Kinder einrichten";
function hidePermanently() {
localStorage.setItem(HIDE_ONBOARDING_DIALOG_KEY, "true");
try {
localStorage.setItem(onboardingKey(user.id), "true");
} catch {
/* Close for this visit if storage is unavailable. */
}
setOpen(false);
}
@@ -113,8 +122,8 @@ export function OnboardingDialog({ user, family }: OnboardingDialogProps) {
Dein Kita-Profil ist fast bereit
</DialogTitle>
<DialogDescription className="text-emerald-50/80">
Vervollständige die wichtigsten Daten, damit Notdienst,
Adressbuch und Familienverwaltung reibungslos funktionieren.
Vervollständige die wichtigsten Daten, damit Notdienst, Adressbuch
und Familienverwaltung reibungslos funktionieren.
</DialogDescription>
</DialogHeader>
</div>
+9
View File
@@ -14,6 +14,7 @@ import {
Megaphone,
Clock3,
BellRing,
ShieldCheck,
} from "lucide-react";
import { NavItem } from "@/components/ui/nav-item";
@@ -21,6 +22,14 @@ import { NavItem } from "@/components/ui/nav-item";
import { UserRole } from "@prisma/client";
const navItems = [
{
section: "Verwaltung",
href: "/dashboard/zugriffsrechte",
label: "Zugriffsrechte",
icon: ShieldCheck,
exact: true,
allowedRoles: [UserRole.ADMIN],
},
{
section: "Heute",
href: "/dashboard/notdienst/alarm",
@@ -0,0 +1,125 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { Maximize2 } from "lucide-react";
import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog";
import { productScreenshots, type ProductScreenshot } from "@/lib/marketing/screenshots";
import { cn } from "@/lib/utils";
export function ScreenshotPreview({
screenshot,
compact = false,
}: {
screenshot: ProductScreenshot;
compact?: boolean;
}) {
const [fullSize, setFullSize] = useState(false);
return (
<Dialog onOpenChange={() => setFullSize(false)}>
<figure>
<DialogTrigger asChild>
<button
type="button"
aria-label={`${screenshot.label}: Screenshot vergrößern`}
className="group block w-full overflow-hidden rounded-xl border border-slate-200 bg-[#f8f5ec] text-left shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700 focus-visible:ring-offset-4"
>
<span className="relative block aspect-[8/5]">
<Image
src={screenshot.src}
alt={screenshot.alt}
fill
sizes={compact
? "(min-width: 1024px) 380px, (min-width: 640px) 50vw, calc(100vw - 64px)"
: "(min-width: 1280px) 1232px, calc(100vw - 48px)"}
className="object-contain"
/>
</span>
<span className="flex items-center justify-between gap-3 border-t border-slate-200 bg-white px-4 py-3 text-xs text-slate-600">
<span>{compact ? "Ansicht mit Beispieldaten" : screenshot.role}</span>
<span className="flex shrink-0 items-center gap-1.5 font-medium text-emerald-800">
<Maximize2 className="h-3.5 w-3.5" aria-hidden="true" />
Vergrößern
</span>
</span>
</button>
</DialogTrigger>
{!compact && (
<figcaption className="mt-4 flex flex-col gap-1 text-sm leading-6 text-slate-600 sm:flex-row sm:justify-between sm:gap-6">
<span>{screenshot.caption}</span>
<span className="shrink-0 text-xs text-slate-500">Echte App-Ansicht mit Beispieldaten</span>
</figcaption>
)}
</figure>
<DialogContent className="w-[calc(100vw-24px)] max-w-[1660px] max-h-[calc(100dvh-24px)] grid-rows-[auto_minmax(0,1fr)] gap-3 rounded-xl p-4 sm:p-6">
<DialogHeader className="pr-6 text-left">
<DialogTitle>{screenshot.label} · {screenshot.role}</DialogTitle>
<DialogDescription>
{fullSize
? "Beispieldaten. Das Bild lässt sich horizontal und vertikal scrollen."
: "Echte App-Ansicht mit Beispieldaten. Für lesbare Details die Originalgröße auswählen."}
</DialogDescription>
<button
type="button"
onClick={() => setFullSize((value) => !value)}
className="mt-2 self-start rounded-lg border border-slate-200 px-3 py-2 text-xs font-medium text-emerald-800 hover:bg-emerald-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700"
>
{fullSize ? "An Fenster anpassen" : "Originalgröße anzeigen"}
</button>
</DialogHeader>
<div
tabIndex={0}
role="region"
aria-label={`Vergrößerter Screenshot: ${screenshot.label}`}
className="min-h-0 max-h-[72dvh] overflow-auto rounded-lg border border-slate-200 bg-[#f8f5ec] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700"
>
<Image
src={screenshot.src}
alt={screenshot.alt}
width={screenshot.width}
height={screenshot.height}
sizes={`${screenshot.width}px`}
className={cn("block h-auto", fullSize ? "max-w-none" : "w-full")}
style={fullSize ? { width: screenshot.width } : undefined}
/>
</div>
</DialogContent>
</Dialog>
);
}
export function ProductScreenshots() {
const [selectedSlug, setSelectedSlug] = useState(productScreenshots[0].slug);
const selected = productScreenshots.find((screenshot) => screenshot.slug === selectedSlug)!;
return (
<div id="app-ansichten" className="scroll-mt-24">
<div role="group" aria-label="App-Ansicht auswählen" className="mb-5 flex flex-wrap gap-2">
{productScreenshots.map((screenshot) => (
<button
key={screenshot.slug}
type="button"
aria-pressed={selectedSlug === screenshot.slug}
aria-controls="app-screenshot"
onClick={() => setSelectedSlug(screenshot.slug)}
className={cn(
"rounded-full border px-4 py-2.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700 focus-visible:ring-offset-2",
selectedSlug === screenshot.slug
? "border-slate-950 bg-slate-950 text-white"
: "border-slate-200 bg-white text-slate-600 hover:border-emerald-700 hover:text-emerald-800",
)}
>
{screenshot.label}
</button>
))}
</div>
<div id="app-screenshot">
<ScreenshotPreview key={selected.slug} screenshot={selected} />
</div>
</div>
);
}
@@ -0,0 +1,53 @@
import storyboard from "../../../scripts/product-video/storyboard.json";
export function ProductVideo() {
return (
<section id="produkt-video" aria-labelledby="produkt-video-heading" className="mx-auto w-full max-w-7xl scroll-mt-8 px-5 pt-16 pb-4 sm:px-6 lg:pt-24">
<div className="mb-8 grid gap-5 lg:grid-cols-[0.85fr_1.15fr] lg:items-end">
<div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">In Ruhe kennenlernen</p>
<h2 id="produkt-video-heading" className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">Kita-Planer in zwei Minuten.</h2>
</div>
<p className="max-w-2xl text-sm leading-6 text-slate-600 lg:justify-self-end">
Ein Rundgang durch die sechs Bereiche mit echten App-Ansichten und Beispieldaten.
Deutsche Untertitel und markierte Hinweise erklären die Abläufe – ohne Ton und ohne Gesprächstermin.
</p>
</div>
<video
controls
playsInline
preload="none"
width={1920}
height={1080}
poster="/landing/video/kita-planer-poster.jpg"
aria-label="Kita-Planer: zweiminütiger Rundgang mit deutschen Untertiteln und visuellen Hinweisen"
className="aspect-video h-auto w-full rounded-2xl border border-slate-200 bg-[#f7f4ed] shadow-sm [&::cue]:text-[clamp(12px,1.8vw,24px)]"
>
<source src="/landing/video/kita-planer-rundgang-web.mp4" type="video/mp4" />
<track src="/landing/video/kita-planer-untertitel.vtt" kind="captions" srcLang="de" label="Deutsch" default />
<track src="/landing/video/kita-planer-kapitel.vtt" kind="chapters" srcLang="de" label="Kapitel" />
Dein Browser unterstützt die Videoanzeige nicht. Das Video kannst du über den Link unten herunterladen.
</video>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs leading-6 text-slate-500">
<p>2:00 Min. · Ohne Ton · Deutsche Untertitel</p>
<div className="flex flex-wrap gap-x-5 gap-y-1">
<a href="/landing/video/kita-planer-rundgang.mp4" download className="font-medium text-emerald-800 underline-offset-4 hover:underline">Video herunterladen</a>
<a href="/landing/video/kita-planer-untertitel.vtt" download className="text-emerald-800 underline-offset-4 hover:underline">Untertitel als Datei</a>
</div>
</div>
<details className="mt-5 rounded-xl border border-slate-200 bg-white px-5 py-4 text-sm text-slate-600">
<summary className="cursor-pointer font-medium text-slate-800">Videoinhalt als Text lesen</summary>
<div className="mt-5 grid gap-5 md:grid-cols-2">
{storyboard.chapters.map((chapter, index) => (
<div key={chapter.title}>
<h3 className="font-semibold text-slate-800">{chapter.title}</h3>
<div className="mt-2 space-y-2 leading-6">
{storyboard.scenes.filter((scene) => scene.chapter === index).map((scene) => <p key={scene.title.join(" ")}>{scene.subtitle.join(" ")}</p>)}
</div>
</div>
))}
</div>
</details>
</section>
);
}
@@ -0,0 +1,13 @@
import { headers } from "next/headers";
import { serializeStructuredData } from "@/lib/marketing/seo";
export async function StructuredData({ data }: { data: unknown }) {
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<script
type="application/ld+json"
nonce={nonce}
dangerouslySetInnerHTML={{ __html: serializeStructuredData(data) }}
/>
);
}
+3 -9
View File
@@ -1,5 +1,7 @@
import type { UserRole } from "@prisma/client";
import { kitaRoleLabels } from "@/lib/kita-roles";
import { cn } from "@/lib/utils";
type RoleBadgeProps = {
@@ -7,14 +9,6 @@ type RoleBadgeProps = {
className?: string;
};
const roleLabels: Record<string, string> = {
SUPERADMIN: "Admin",
ADMIN: "Vorstand",
KOORDINATOR: "Koordinator",
ELTERN: "Eltern",
ERZIEHER: "Erzieher",
};
export function RoleBadge({ role, className }: RoleBadgeProps) {
return (
<span
@@ -23,7 +17,7 @@ export function RoleBadge({ role, className }: RoleBadgeProps) {
className,
)}
>
{roleLabels[role] ?? role}
{kitaRoleLabels[role]}
</span>
);
}
+4 -3
View File
@@ -17,20 +17,21 @@ type NewsEmailProps = {
title: string;
content: string;
dashboardUrl: string;
updated?: boolean;
};
export function NewsEmail({ title, content, dashboardUrl }: NewsEmailProps) {
export function NewsEmail({ title, content, dashboardUrl, updated = false }: NewsEmailProps) {
const excerpt =
content.length > 420 ? `${content.slice(0, 420).trim()}...` : content;
return (
<Html lang="de">
<Head />
<Preview>Neue Kita-Ankündigung: {title}</Preview>
<Preview>{updated ? "Aktualisierte" : "Neue"} Kita-Ankündigung: {title}</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Section style={styles.header}>
<Text style={styles.kicker}>Schwarzes Brett</Text>
<Text style={styles.kicker}>{updated ? "Schwarzes Brett · Aktualisiert" : "Schwarzes Brett"}</Text>
<Heading style={styles.heading}>{title}</Heading>
</Section>
+58
View File
@@ -0,0 +1,58 @@
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Preview,
Text,
} from "@react-email/components";
import { baseBodyStyle, colors } from "./_styles";
export function PasswordResetEmail({
name,
resetLink,
}: {
name: string;
resetLink: string;
}) {
return (
<Html lang="de">
<Head />
<Preview>Dein Link für ein neues Kita-Planer-Passwort</Preview>
<Body style={baseBodyStyle}>
<Container
style={{
maxWidth: "520px",
padding: "32px",
backgroundColor: colors.cardBg,
}}
>
<Heading>Neues Passwort festlegen</Heading>
<Text>
Hallo {name}, du hast einen Link angefordert, um dein
Kita-Planer-Passwort zurückzusetzen.
</Text>
<Text>
Der Link ist 30 Minuten gültig und kann einmal verwendet werden.
Wenn du diese Anfrage nicht gestellt hast, kannst du die Nachricht
ignorieren. Dein Passwort bleibt dann unverändert.
</Text>
<Button
href={resetLink}
style={{
backgroundColor: colors.brandGreenAccent,
color: "#ffffff",
padding: "12px 18px",
borderRadius: "8px",
}}
>
Neues Passwort festlegen
</Button>
<Text>Falls der Button nicht funktioniert: {resetLink}</Text>
</Container>
</Body>
</Html>
);
}
+18 -3
View File
@@ -39,14 +39,29 @@ export async function deliverAlarmEmails(
include: { user: true, alarm: { include: { kita: true } } },
});
const { user, alarm } = job;
let eligible = user.kitaId === alarm.kitaId && !user.disabledAt && !!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt && (!user.lockedUntil || user.lockedUntil <= now);
let eligible =
user.kitaId === alarm.kitaId &&
!user.disabledAt &&
!!user.emailVerifiedAt &&
!!user.privacyPolicyAcceptedAt &&
(!user.lockedUntil || user.lockedUntil <= now);
if (job.kind === "MANAGER") eligible &&= managerRoles.includes(user.role);
else
eligible &&= user.role === "ELTERN" && user.familyId === alarm.familyId;
eligible &&=
["ELTERN", "ADMIN", "KOORDINATOR"].includes(user.role) &&
user.familyId === alarm.familyId;
if (job.kind === "REQUEST") {
const token = job.path.split("/").pop();
const recipient = await prisma.notdienstAlarmRecipient.findFirst({
where: { alarmId: alarm.id, userId: user.id, sessionVersion: user.sessionVersion, OR: [{ token: hashToken(token ?? "") }, ...(token?.length === 36 ? [{ token }] : [])] },
where: {
alarmId: alarm.id,
userId: user.id,
sessionVersion: user.sessionVersion,
OR: [
{ token: hashToken(token ?? "") },
...(token?.length === 36 ? [{ token }] : []),
],
},
});
eligible &&=
!!recipient && alarm.status === "PENDING" && alarm.expiresAt > now;
+52 -16
View File
@@ -114,22 +114,30 @@ async function queue(
const users =
audience === "managers"
? await tx.user.findMany({
where: { kitaId: alarm.kitaId, role: { in: managerRoles }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
where: {
kitaId: alarm.kitaId,
role: { in: managerRoles },
emailVerifiedAt: { not: null },
disabledAt: null,
privacyPolicyAcceptedAt: { not: null },
},
})
: await tx.user.findMany({
where: {
kitaId: alarm.kitaId,
familyId: alarm.familyId,
role: "ELTERN",
emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null },
role: { in: ["ELTERN", "ADMIN", "KOORDINATOR"] },
emailVerifiedAt: { not: null },
disabledAt: null,
privacyPolicyAcceptedAt: { not: null },
alarmRecipients: { some: { alarmId: alarm.id } },
},
});
const jobs: string[] = [];
for (const user of users) {
const recipient =
kind === "REQUEST" ? requestTokens?.get(user.id) : null;
if (kind === "REQUEST" && !recipient) throw new Error("Request token missing");
const recipient = kind === "REQUEST" ? requestTokens?.get(user.id) : null;
if (kind === "REQUEST" && !recipient)
throw new Error("Request token missing");
await tx.notdienstAlarmNotification.create({
data: { alarmId: alarm.id, userId: user.id, eventKey, message },
});
@@ -204,7 +212,15 @@ export async function executeAlarmCommand(
include: {
family: {
include: {
users: { where: { role: "ELTERN", kitaId: actor.kitaId, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } } },
users: {
where: {
role: { in: ["ELTERN", "ADMIN", "KOORDINATOR"] },
kitaId: actor.kitaId,
emailVerifiedAt: { not: null },
disabledAt: null,
privacyPolicyAcceptedAt: { not: null },
},
},
},
},
},
@@ -221,10 +237,14 @@ export async function executeAlarmCommand(
if (!assignment.kita.notdienstModuleEnabled)
throw new UserFacingError("Notdienst ist für diese Kita deaktiviert.");
if (assignment.plan.status !== "PUBLISHED")
throw new UserFacingError("Bitte den Notdienstplan zuerst veröffentlichen.");
throw new UserFacingError(
"Bitte den Notdienstplan zuerst veröffentlichen.",
);
const date = dateKey(assignment.date);
if (date < todayKey(now))
throw new UserFacingError("Vergangene Notdienste können nicht alarmiert werden.");
throw new UserFacingError(
"Vergangene Notdienste können nicht alarmiert werden.",
);
const expiresAt = endOfAlarm(date, input.wholeDay, input.endMinute);
if (expiresAt <= now)
throw new UserFacingError("Dieser Einsatz ist bereits beendet.");
@@ -245,7 +265,9 @@ export async function executeAlarmCommand(
"Für diese Einteilung besteht bereits ein aktiver Alarm.",
);
const parents = assignment.child.family.users;
const requestTokens = new Map(parents.map(parent => [parent.id, newToken()]));
const requestTokens = new Map(
parents.map((parent) => [parent.id, newToken()]),
);
if (!parents.length)
throw new UserFacingError(
"Für diesen Haushalt sind keine Elternkonten hinterlegt.",
@@ -353,7 +375,9 @@ export async function executeAlarmCommand(
);
const reason = note.parse(data.reason);
if (command === "approveLate" && alarm.status !== "LATE_CONFIRMATION")
throw new UserFacingError("Es liegt keine verspätete Zusage zur Freigabe vor.");
throw new UserFacingError(
"Es liegt keine verspätete Zusage zur Freigabe vor.",
);
const approve =
command === "approveLate" || z.boolean().parse(data.approve);
const updated = await tx.notdienstAlarm.update({
@@ -401,7 +425,12 @@ export async function executeAlarmCommand(
async function recipientForToken(tx: Tx, token: string) {
const recipient = await tx.notdienstAlarmRecipient.findFirst({
where: { OR: [{ token: hashToken(token) }, ...(token.length === 36 ? [{ token }] : [])] },
where: {
OR: [
{ token: hashToken(token) },
...(token.length === 36 ? [{ token }] : []),
],
},
include: {
user: true,
alarm: { include: { kita: { select: { name: true } } } },
@@ -409,9 +438,11 @@ async function recipientForToken(tx: Tx, token: string) {
});
if (
!recipient ||
recipient.user.role !== "ELTERN" ||
!["ELTERN", "ADMIN", "KOORDINATOR"].includes(recipient.user.role) ||
recipient.sessionVersion !== recipient.user.sessionVersion ||
!!recipient.user.disabledAt || !recipient.user.emailVerifiedAt || !recipient.user.privacyPolicyAcceptedAt ||
!!recipient.user.disabledAt ||
!recipient.user.emailVerifiedAt ||
!recipient.user.privacyPolicyAcceptedAt ||
recipient.alarm.expiresAt <= new Date() ||
(recipient.user.lockedUntil && recipient.user.lockedUntil > new Date()) ||
recipient.user.kitaId !== recipient.alarm.kitaId ||
@@ -423,7 +454,12 @@ async function recipientForToken(tx: Tx, token: string) {
export async function getPublicAlarm(token: string) {
if (!tokenSchema.safeParse(token).success) return null;
const legacy = await prisma.notdienstAlert.findFirst({
where: { OR: [{ confirmationToken: hashToken(token) }, ...(token.length === 36 ? [{ confirmationToken: token }] : [])] },
where: {
OR: [
{ confirmationToken: hashToken(token) },
...(token.length === 36 ? [{ confirmationToken: token }] : []),
],
},
select: { alarmId: true, assignmentId: true },
});
if (legacy && !legacy.alarmId)
@@ -591,7 +627,7 @@ export async function importLegacyAlarms(
const old = assignment.alerts;
const valid = old.filter(
(a) =>
a.parentUser.role === "ELTERN" &&
["ELTERN", "ADMIN", "KOORDINATOR"].includes(a.parentUser.role) &&
a.parentUser.familyId === assignment.child.familyId &&
a.parentUser.kitaId === assignment.kitaId,
);
+14 -6
View File
@@ -1,4 +1,4 @@
import { AnnouncementAudience, UserRole } from "@prisma/client";
import { AnnouncementAudience, UserRole, type Prisma } from "@prisma/client";
// Admins need access to all publications for management. Other roles only
// receive their audience; a parent with a coordination role is still a parent.
@@ -19,15 +19,23 @@ export function visibleAnnouncementAudiences(user: {
return audiences;
}
export function announcementRecipientWhere(audience: AnnouncementAudience) {
export function announcementRecipientWhere(
audience: AnnouncementAudience,
): Prisma.UserWhereInput {
const parents = {
role: { not: UserRole.ERZIEHER },
role: { in: [UserRole.ELTERN, UserRole.KOORDINATOR, UserRole.ADMIN] },
OR: [{ familyId: { not: null } }, { role: UserRole.ELTERN }],
};
// A promoted staff account keeps its team membership through the profile.
const staff = {
OR: [
{ role: UserRole.ERZIEHER },
{ role: UserRole.ADMIN, staffProfile: { isNot: null } },
],
};
if (audience === AnnouncementAudience.ELTERN) return parents;
if (audience === AnnouncementAudience.ERZIEHER)
return { role: UserRole.ERZIEHER };
return { OR: [parents, { role: UserRole.ERZIEHER }] };
if (audience === AnnouncementAudience.ERZIEHER) return staff;
return { OR: [parents, staff] };
}
export const announcementAudienceLabels: Record<AnnouncementAudience, string> =
+286
View File
@@ -0,0 +1,286 @@
import "server-only";
import { stat } from "node:fs/promises";
import { attachmentPath } from "@/lib/attachments";
import { randomUUID } from "node:crypto";
import { createElement } from "react";
import type { Announcement, Prisma } from "@prisma/client";
import { NewsEmail } from "@/emails/NewsEmail";
import { announcementRecipientWhere } from "@/lib/announcement-access";
import { prisma } from "@/lib/prisma";
import { sendAppEmail } from "@/lib/mail";
import { UserFacingError } from "@/lib/action-error";
export async function lockAnnouncement(
tx: Prisma.TransactionClient,
kitaId: string,
id: string,
) {
await tx.$queryRaw`SELECT id FROM announcements WHERE id = ${id} AND "kitaId" = ${kitaId} FOR UPDATE`;
}
export async function queueAnnouncementEmails(
tx: Prisma.TransactionClient,
announcement: Pick<Announcement, "id" | "kitaId" | "audience" | "revision">,
now = new Date(),
) {
const users = await tx.user.findMany({
where: {
kitaId: announcement.kitaId,
disabledAt: null,
emailVerifiedAt: { not: null },
privacyPolicyAcceptedAt: { not: null },
AND: [
announcementRecipientWhere(announcement.audience),
{ OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }] },
],
},
select: { id: true },
});
if (!users.length) return 0;
return (
await tx.announcementEmail.createMany({
data: users.map((user) => ({
id: randomUUID(),
kitaId: announcement.kitaId,
announcementId: announcement.id,
revision: announcement.revision,
userId: user.id,
nextAttemptAt: now,
})),
skipDuplicates: true,
})
).count;
}
type Scope = { kitaId?: string; announcementId?: string; revision?: number };
export async function deliverDueAnnouncementEmails(
scope: Scope = {},
send = sendAppEmail,
now = new Date(),
) {
// Recover a crash between completed file writes and the final readiness update.
// Partial or missing files never release a message for sending.
let preparationCursor: string | undefined;
for (;;) {
const unfinished = await prisma.announcement.findMany({
where: {
kitaId: scope.kitaId,
id: scope.announcementId,
revision: scope.revision,
emailReady: false,
createdAt: { lte: new Date(now.getTime() - 5 * 60_000) },
...(preparationCursor
? { AND: { id: { gt: preparationCursor } } }
: {}),
},
select: {
id: true,
attachments: { select: { id: true, sizeBytes: true } },
},
orderBy: { id: "asc" },
take: 12,
});
for (const row of unfinished) {
const complete = await Promise.all(
row.attachments.map(async (file) => {
try {
return (
file.sizeBytes !== null &&
(await stat(attachmentPath(file.id))).size === file.sizeBytes
);
} catch {
return false;
}
}),
);
if (complete.every(Boolean))
await prisma.announcement.updateMany({
where: { id: row.id, kitaId: scope.kitaId, emailReady: false },
data: { emailReady: true },
});
}
if (unfinished.length < 12) break;
preparationCursor = unfinished[unfinished.length - 1].id;
}
const jobs = await prisma.announcementEmail.findMany({
where: {
...scope,
sentAt: null,
skippedAt: null,
nextAttemptAt: { lte: now },
announcement: { emailReady: true },
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
},
select: { id: true, kitaId: true, announcementId: true },
orderBy: [{ nextAttemptAt: "asc" }, { createdAt: "asc" }, { id: "asc" }],
take: 12,
});
const result = { sent: 0, failed: 0, skipped: 0 };
const deliver = async (candidate: (typeof jobs)[number]) => {
const claimToken = randomUUID();
// Edits and claims share the publication lock. An active send blocks editing
// until it finishes, so a retried version always has the same message body.
// ReadCommitted plus the row lock and atomic claim is sufficient here;
// recipient updates must not cause serialization conflicts for other jobs.
const claimed = await prisma.$transaction(
async (tx) => {
await lockAnnouncement(tx, candidate.kitaId, candidate.announcementId);
return tx.announcementEmail.updateMany({
where: {
id: candidate.id,
sentAt: null,
skippedAt: null,
nextAttemptAt: { lte: now },
announcement: { emailReady: true },
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
},
data: {
claimToken,
leaseUntil: new Date(now.getTime() + 5 * 60_000),
attempts: { increment: 1 },
},
});
},
{ isolationLevel: "ReadCommitted" },
);
if (!claimed.count) return;
const finish = (data: Prisma.AnnouncementEmailUpdateManyMutationInput) =>
prisma.announcementEmail.updateMany({
where: { id: candidate.id, claimToken },
data: { ...data, claimToken: null, leaseUntil: null },
});
try {
const job = await prisma.announcementEmail.findUniqueOrThrow({
where: { id: candidate.id },
include: { announcement: true, user: true },
});
const eligible = await prisma.user.count({
where: {
id: job.userId,
kitaId: job.kitaId,
disabledAt: null,
emailVerifiedAt: { not: null },
privacyPolicyAcceptedAt: { not: null },
AND: [
announcementRecipientWhere(job.announcement.audience),
{ OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }] },
],
},
});
if (
!eligible ||
job.announcement.revision !== job.revision ||
job.announcement.kitaId !== job.kitaId
) {
await finish({
skippedAt: now,
failed: false,
lastError: "Empfänger oder Beitragsversion nicht mehr gültig.",
});
result.skipped++;
return;
}
const base =
process.env.NEXT_PUBLIC_SITE_URL ||
process.env.AUTH_URL ||
process.env.NEXTAUTH_URL;
if (!base) throw new Error("App-Adresse fehlt.");
const site = new URL(base);
if (
!["http:", "https:"].includes(site.protocol) ||
site.username ||
site.password
)
throw new Error("App-Adresse ungültig.");
const response = await send({
to: job.user.email,
idempotencyKey: `announcement-email/${job.id}`,
subject: `${job.revision > 1 ? "Aktualisierte" : "Neue"} Kita-Ankündigung: ${job.announcement.title}`,
react: createElement(NewsEmail, {
title: job.announcement.title,
content: job.announcement.content,
updated: job.revision > 1,
dashboardUrl: new URL(
"/dashboard/mitteilungen",
site.origin,
).toString(),
}),
});
if (!response.success) throw new Error(response.error);
await finish({ sentAt: now, failed: false, lastError: null });
result.sent++;
} catch {
const job = await prisma.announcementEmail.findUnique({
where: { id: candidate.id },
select: { attempts: true },
});
const delay = Math.min(
6 * 60 * 60_000,
60_000 * 2 ** Math.min(job?.attempts ?? 1, 9),
);
await finish({
failed: true,
lastError:
"E-Mail-Versand fehlgeschlagen. Ein neuer Versuch folgt automatisch.",
nextAttemptAt: new Date(now.getTime() + delay),
});
result.failed++;
}
};
for (let i = 0; i < jobs.length; i += 4)
await Promise.all(jobs.slice(i, i + 4).map(deliver));
return result;
}
export type AnnouncementActor = {
id: string;
kitaId: string;
sessionVersion: number;
};
export async function assertAnnouncementAdmin(
tx: Prisma.TransactionClient,
actor: AnnouncementActor,
) {
const active = await tx.user.findFirst({
where: {
id: actor.id,
kitaId: actor.kitaId,
sessionVersion: actor.sessionVersion,
role: "ADMIN",
disabledAt: null,
emailVerifiedAt: { not: null },
privacyPolicyAcceptedAt: { not: null },
OR: [{ lockedUntil: null }, { lockedUntil: { lte: new Date() } }],
},
select: { id: true },
});
if (!active)
throw new UserFacingError(
"Du darfst Mitteilungen nicht mehr verwalten. Bitte melde dich erneut an.",
);
}
export async function assertAnnouncementEditable(
tx: Prisma.TransactionClient,
kitaId: string,
id: string,
revision: number,
) {
await lockAnnouncement(tx, kitaId, id);
const announcement = await tx.announcement.findFirst({
where: { id, kitaId, revision, emailReady: true },
});
if (!announcement)
throw new UserFacingError(
"Der Beitrag wurde inzwischen geändert oder ist nicht verfügbar. Bitte lade die Seite neu.",
);
if (
await tx.announcementEmail.count({
where: { kitaId, announcementId: id, leaseUntil: { gt: new Date() } },
})
)
throw new UserFacingError(
"Für diesen Beitrag läuft gerade der E-Mail-Versand. Bitte versuche es gleich erneut.",
);
return announcement;
}
+5 -1
View File
@@ -19,18 +19,22 @@ export async function getAnnouncements(
title: true,
content: true,
createdAt: true,
revision: true,
editedAt: true,
author: { select: { firstName: true, lastName: true } },
reads: { where: { userId: session.user.id }, select: { id: true } },
attachments: {
select: { id: true, fileName: true, fileUrl: true, fileType: true },
},
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: options.take,
skip: options.skip,
});
return rows.map((row) => ({
id: row.id,
revision: row.revision,
editedAt: row.editedAt ? format(row.editedAt, "dd.MM.yyyy HH:mm", { locale: de }) : null,
title: row.title,
content: row.content,
excerpt: row.content
+41
View File
@@ -0,0 +1,41 @@
import type { Prisma } from "@prisma/client";
import { UserFacingError } from "@/lib/action-error";
// Serialize role changes, account deletion and support locks for the same Kita.
export async function lockKitaAccounts(
tx: Prisma.TransactionClient,
kitaId: string,
) {
const rows = await tx.$queryRaw<
{ id: string }[]
>`SELECT id FROM kitas WHERE id = ${kitaId} FOR UPDATE`;
if (!rows.length) throw new UserFacingError("Kita wurde nicht gefunden.");
}
export async function assertKitaAdminRemains(
tx: Prisma.TransactionClient,
kitaId: string,
excludedUserIds: string[],
) {
const removesAdmin = await tx.user.count({
where: { kitaId, id: { in: excludedUserIds }, role: "ADMIN" },
});
if (!removesAdmin) return;
const now = new Date();
const remaining = await tx.user.count({
where: {
kitaId,
id: { notIn: excludedUserIds },
role: "ADMIN",
disabledAt: null,
emailVerifiedAt: { not: null },
privacyPolicyAcceptedAt: { not: null },
passwordHash: { not: "" },
OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }],
},
});
if (!remaining)
throw new UserFacingError(
"Mindestens ein aktiver Kita-Admin muss erhalten bleiben. Ernenne zuerst eine weitere Person mit aktiviertem Zugang und Datenschutzzustimmung.",
);
}
+116
View File
@@ -0,0 +1,116 @@
import "server-only";
import { AuditTargetType } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { serializableTransaction } from "@/lib/transaction";
import { UserFacingError } from "@/lib/action-error";
import {
assertKitaAdminRemains,
lockKitaAccounts,
} from "@/lib/kita-admin-guard";
import { availableKitaRoles } from "@/lib/kita-roles";
const inputSchema = z.object({
userId: z.string().min(1).max(64),
role: z.enum(["ADMIN", "KOORDINATOR", "ELTERN", "ERZIEHER"]),
expectedRole: z.enum(["ADMIN", "KOORDINATOR", "ELTERN", "ERZIEHER"]),
});
type Actor = { id: string; kitaId: string; sessionVersion: number };
export async function changeKitaRole(actor: Actor, raw: unknown) {
const input = inputSchema.safeParse(raw);
if (!input.success) throw new UserFacingError("Ungültige Rollenänderung.");
return serializableTransaction(async (tx) => {
await lockKitaAccounts(tx, actor.kitaId);
const currentActor = await tx.user.findFirst({
where: {
id: actor.id,
kitaId: actor.kitaId,
role: "ADMIN",
sessionVersion: actor.sessionVersion,
disabledAt: null,
emailVerifiedAt: { not: null },
privacyPolicyAcceptedAt: { not: null },
OR: [{ lockedUntil: null }, { lockedUntil: { lte: new Date() } }],
},
select: { id: true, email: true },
});
if (!currentActor)
throw new UserFacingError(
"Du darfst die Zugriffsrechte nicht mehr ändern. Bitte lade die Seite neu.",
);
const person = await tx.user.findFirst({
where: { id: input.data.userId, kitaId: actor.kitaId },
select: {
id: true,
firstName: true,
lastName: true,
email: true,
role: true,
familyId: true,
staffProfile: { select: { id: true, kitaId: true } },
},
});
if (!person || person.role === "SUPERADMIN")
throw new UserFacingError(
"Dieses Kita-Konto kann hier nicht geändert werden.",
);
if (person.role !== input.data.expectedRole)
throw new UserFacingError(
"Die Rolle wurde inzwischen geändert. Bitte lade die Seite neu.",
);
if (person.staffProfile && person.staffProfile.kitaId !== actor.kitaId)
throw new UserFacingError(
"Das Personalprofil gehört nicht zu dieser Kita.",
);
if (
!availableKitaRoles({
familyId: person.familyId,
hasStaffProfile: !!person.staffProfile,
}).includes(input.data.role)
)
throw new UserFacingError(
person.staffProfile
? "Ein Personalprofil benötigt die Rolle Fachkraft oder Kita-Admin."
: "Eltern benötigen einen Haushalt; die Fachkraftrolle benötigt ein Personalprofil.",
);
if (person.role === input.data.role)
return { changed: false, ownRoleChanged: false };
if (person.role === "ADMIN")
await assertKitaAdminRemains(tx, actor.kitaId, [person.id]);
await tx.user.update({
where: { id: person.id, kitaId: actor.kitaId },
data: { role: input.data.role },
});
await tx.auditLog.create({
data: {
actorUserId: currentActor.id,
actorEmail: currentActor.email,
action: "USER_ROLE_CHANGE",
targetType: AuditTargetType.USER,
targetId: person.id,
targetLabel: `${person.firstName} ${person.lastName}`,
targetKitaId: actor.kitaId,
metadata: { previousRole: person.role, newRole: input.data.role },
},
});
// Auth reloads the role from the database on every request. Keeping sessionVersion
// preserves already-issued household alarm links and existing login sessions.
return { changed: true, ownRoleChanged: person.id === actor.id };
});
}
export async function getKitaRoleHistory(kitaId: string) {
return prisma.auditLog.findMany({
where: { targetKitaId: kitaId, action: "USER_ROLE_CHANGE" },
orderBy: { createdAt: "desc" },
take: 15,
select: {
id: true,
targetLabel: true,
actorEmail: true,
metadata: true,
createdAt: true,
},
});
}
+19
View File
@@ -0,0 +1,19 @@
import type { UserRole } from "@prisma/client";
export const kitaRoleLabels: Record<UserRole, string> = {
ADMIN: "Kita-Admin",
KOORDINATOR: "Notdienst-Koordination",
ELTERN: "Eltern",
ERZIEHER: "Fachkraft",
SUPERADMIN: "Systemadmin",
};
export function availableKitaRoles(person: {
familyId: string | null;
hasStaffProfile: boolean;
}): UserRole[] {
if (person.hasStaffProfile) return ["ERZIEHER", "ADMIN"];
return person.familyId
? ["ELTERN", "KOORDINATOR", "ADMIN"]
: ["KOORDINATOR", "ADMIN"];
}
+103 -35
View File
@@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react";
import {
CalendarDays,
Clock3,
Megaphone,
ShieldAlert,
Stethoscope,
@@ -43,7 +44,7 @@ export const marketingFeatures: MarketingFeature[] = [
title: "Fairer Notdienst-Planer",
shortTitle: "Notdienst-Planer",
description:
"Automatische Generierung, gerechte Verteilung und 1-Klick-Alarmierung, wenn morgens Ersatz gebraucht wird.",
"Verfügbarkeiten sammeln, Familien erinnern, Monatspläne veröffentlichen und bei Ausfällen Rückmeldungen erhalten.",
accent: "bg-red-50 text-red-700 ring-red-100",
eyebrow: "Notdienst ohne Excel-Lotterie",
heroTitle: "Faire Notdienstplanung für Elterninitiativen.",
@@ -57,11 +58,11 @@ export const marketingFeatures: MarketingFeature[] = [
"In vielen Elterninitiativen hängt der Notdienst an Tabellen, Bauchgefühl und der Hoffnung, dass irgendwer im Chat rechtzeitig reagiert. Das ist für Vorstände anstrengend und für Eltern oft schwer nachvollziehbar.",
solutionTitle: "Verfügbarkeiten einsammeln, Plan erzeugen, reagieren",
solutionText:
"Kita-Planer bündelt die Verfügbarkeiten der Familien, berücksichtigt Mindesttage pro Kind und unterstützt Koordinatoren dabei, den Monatsplan zu erstellen und bei Ausfällen schneller Ersatz zu finden.",
"Kita-Planer berücksichtigt Verfügbarkeiten und Mindesttage pro Kind, unterstützt die manuelle Einteilung und veröffentlicht den Monatsplan mit E-Mail-Benachrichtigung. Bei Ausfällen können Haushalte über persönliche Links zu- oder absagen.",
benefits: [
{
title: "Verfügbarkeiten pro Familie",
text: "Eltern geben digital an, an welchen Tagen sie unterstützen können. Der Vorstand arbeitet mit strukturierten Daten statt verstreuten Nachrichten.",
text: "Eltern hinterlegen mögliche Tage. Die Planung zählt Geschwister gemeinsam als Haushalt und zeigt, ob die erforderliche Auswahl vollständig ist.",
},
{
title: "Nachvollziehbare Verteilung",
@@ -69,11 +70,19 @@ export const marketingFeatures: MarketingFeature[] = [
},
{
title: "Planung für Koordinatoren",
text: "Koordinatoren sehen den Zielmonat, können Pläne erzeugen und bei Bedarf manuell nachsteuern.",
text: "Koordinatoren erzeugen Planvorschläge und passen sie an. Verfügbare Haushalte stehen bei manuellen Einteilungen zuerst; abweichende Einteilungen werden gekennzeichnet.",
},
{
title: "Schneller reagieren",
text: "Wenn morgens Ersatz nötig ist, hilft eine strukturierte Übersicht deutlich mehr als ein hektischer Chat.",
text: "Persönliche Alarmlinks ermöglichen Zu- oder Absagen. Verantwortliche behalten offene, bestätigte und verspätete Rückmeldungen im Blick.",
},
{
title: "Erinnern und veröffentlichen",
text: "Fehlende Verfügbarkeiten können per E-Mail erinnert werden. Mit der Veröffentlichung erhalten die eingeteilten Haushaltskonten ihre Termine.",
},
{
title: "Dienste gemeinsam tauschen",
text: "Eltern können einen Tausch anfragen. Eine bestätigte Anfrage tauscht die beiden Dienste; offene Anfragen bleiben sichtbar.",
},
],
trustTitle: "Gemacht für sensible Kita-Abläufe",
@@ -103,7 +112,7 @@ export const marketingFeatures: MarketingFeature[] = [
title: "Smarter Kalender",
shortTitle: "Kita-Kalender",
description:
"Feste, Termine, Raumbuchungen und digitale Mitbring-Listen in einem verbindlichen Kalender für alle Familien.",
"Feste, Termine, Rückmeldungen und Mitbringlisten mit gemeinsamen Details in Monats-, Wochen-, Tages- und Agendaansicht.",
accent: "bg-sky-50 text-sky-700 ring-sky-100",
eyebrow: "Termine, Dienste und Mitbringlisten",
heroTitle: "Ein Kita-Kalender, den Eltern wirklich nutzen.",
@@ -111,7 +120,7 @@ export const marketingFeatures: MarketingFeature[] = [
"Alle wichtigen Termine, Dienstzuordnungen und Mitbringlisten an einem Ort, damit niemand nach der richtigen Nachricht im Chat suchen muss.",
seoTitle: "Kita-Kalender für Elternvereine",
seoDescription:
"Ein gemeinsamer Kalender für Elterninitiativen: Termine, Anfragen, Dienste und Mitbringlisten übersichtlich organisieren.",
"Kita-Termine, Zusagen je Kind oder Haushalt und Mitbringlisten organisieren. Gemeinsame Details in Kalender und Agenda für Elterninitiativen.",
problemTitle: "Termine verschwinden zu schnell",
problemText:
"Elternabende, Feste, Schließtage, Dienste und Mitbringlisten landen oft in unterschiedlichen Kanälen. Wer später nachschaut, findet nicht mehr sicher die aktuelle Information.",
@@ -121,19 +130,19 @@ export const marketingFeatures: MarketingFeature[] = [
benefits: [
{
title: "Termine im Überblick",
text: "Feste, Schließtage, Elternabende und andere Ereignisse werden zentral sichtbar.",
text: "Monats-, Wochen-, Tages- und Agendaansicht zeigen die gleichen Termindetails. Über die Suche lassen sich passende Ereignisse finden.",
},
{
title: "Anfragen statt Nebenabsprachen",
text: "Eltern können Terminwünsche einreichen, die von Verantwortlichen geprüft werden.",
title: "Zusagen je Kind oder Haushalt",
text: "Je nach Termin antworten Eltern für einzelne Kinder oder ihren Haushalt. Gemeinsam betreuende Eltern sehen denselben Antwortstand.",
},
{
title: "Mitbringlisten direkt am Termin",
text: "Was gebraucht wird, steht dort, wo Eltern es erwarten: am passenden Termin.",
text: "Familien tragen ihren Beitrag direkt am Termin ein und können eigene Mitbring-Einträge ändern oder zurücknehmen.",
},
{
title: "Dienste sichtbar machen",
text: "Geplante Dienste werden im Kontext der kommenden Termine und Aufgaben angezeigt.",
title: "Termine vorschlagen und prüfen",
text: "Eltern reichen Terminanfragen ein. Berechtigte Personen prüfen sie; Dienste bleiben ebenfalls im Kalender sichtbar.",
},
],
trustTitle: "Verbindlichkeit statt Chat-Suche",
@@ -148,7 +157,7 @@ export const marketingFeatures: MarketingFeature[] = [
{
question: "Sind Mitbringlisten enthalten?",
answer:
"Ja. Termine können mit digitalen Mitbringlisten verbunden werden.",
"Ja. Eltern können Beiträge eintragen und eigene Einträge bearbeiten oder zurücknehmen. Die Liste ist auch über die Agenda erreichbar.",
},
{
question: "Wer sieht welche Termine?",
@@ -163,7 +172,7 @@ export const marketingFeatures: MarketingFeature[] = [
title: "Sicheres Adressbuch",
shortTitle: "Eltern-Adressbuch",
description:
"Haushaltsbasiert, DSGVO-konform gedacht und mit strengem Opt-In: Kontaktdaten werden nur sichtbar, wenn Eltern zustimmen.",
"Familien und Kinder gemeinsam finden. E-Mail, Telefon und Adresse eines Elternteils erscheinen nur nach dessen aktiver Freigabe.",
accent: "bg-emerald-50 text-emerald-700 ring-emerald-100",
eyebrow: "Kontakte nur mit Zustimmung",
heroTitle: "Ein Eltern-Adressbuch mit klarer Freigabe.",
@@ -202,8 +211,7 @@ export const marketingFeatures: MarketingFeature[] = [
faqs: [
{
question: "Sind alle Eltern automatisch sichtbar?",
answer:
"Nein. Kontaktdaten erscheinen nur nach aktiver Freigabe.",
answer: "Nein. Kontaktdaten erscheinen nur nach aktiver Freigabe.",
},
{
question: "Kann eine Familie teilweise sichtbar sein?",
@@ -253,8 +261,8 @@ export const marketingFeatures: MarketingFeature[] = [
text: "Zeiträume und Hinweise sind dokumentiert, statt in einzelnen Chatnachrichten zu verschwinden.",
},
{
title: "Passt zum Familienprofil",
text: "Abwesenheiten sind mit den Kindern und der jeweiligen Kita-Zugehörigkeit verbunden.",
title: "Künftige Meldungen korrigieren",
text: "Eltern können Meldungen für zukünftige Tage selbst bearbeiten oder zurücknehmen. Laufende Meldungen verwaltet die Kita.",
},
],
trustTitle: "Sensible Informationen geschützt behandeln",
@@ -262,9 +270,9 @@ export const marketingFeatures: MarketingFeature[] = [
"Abwesenheiten sind personenbezogene Informationen. Kita-Planer behandelt sie innerhalb des geschützten Kita-Bereichs und nicht als öffentliche Nachricht.",
faqs: [
{
question: "Können Eltern mehrere Tage eintragen?",
question: "Können Eltern Meldungen später ändern?",
answer:
"Ja. Abwesenheiten können für einen Zeitraum erfasst werden.",
"Ja, bei zukünftigen Tagen können Eltern eigene Meldungen bearbeiten oder zurücknehmen. Die Kita verwaltet laufende Meldungen.",
},
{
question: "Sieht das Kita-Team die Meldungen sofort?",
@@ -284,38 +292,38 @@ export const marketingFeatures: MarketingFeature[] = [
title: "Schwarzes Brett",
shortTitle: "Schwarzes Brett",
description:
"Offizielle Ankündigungen mit Dateianhängen, Lesebestätigung und optionalem E-Mail-Push statt verstreuter Chatposts.",
"Ankündigungen mit geschützten Anhängen korrigieren, ältere Beiträge finden und den optionalen E-Mail-Versand je Person nachvollziehen.",
accent: "bg-indigo-50 text-indigo-700 ring-indigo-100",
badge: "Neu",
eyebrow: "Verbindliche Kita-Kommunikation",
heroTitle: "Ein digitales Schwarzes Brett für offizielle Nachrichten.",
heroDescription:
"Vorstände veröffentlichen Ankündigungen, hängen Dokumente an und erreichen Eltern über einen klaren Kanal statt über mehrere Chatgruppen.",
"Kita-Admins veröffentlichen Nachrichten für Eltern, Team oder beide Gruppen. Korrekturen erscheinen sichtbar aktualisiert und wieder ungelesen; ältere Beiträge bleiben auffindbar.",
seoTitle: "Digitales Schwarzes Brett für Kitas",
seoDescription:
"Offizielle Ankündigungen für Elternvereine: Nachrichten, Anhänge, Lesestatus und optionaler E-Mail-Push in einem geschützten System.",
"Kita-Mitteilungen mit Anhängen veröffentlichen und korrigieren. Ungelesene Beiträge erkennen, E-Mail-Status prüfen und Versandfehler erneut versuchen.",
problemTitle: "Wichtige Infos gehen im Chat unter",
problemText:
"Zwischen Fotos, Rückfragen und spontanen Abstimmungen verlieren offizielle Ankündigungen schnell ihre Sichtbarkeit.",
solutionTitle: "Ein Kanal für das, was verbindlich sein soll",
solutionText:
"Kita-Planer trennt offizielle Nachrichten vom Chatrauschen. Ankündigungen können Anhänge enthalten und sind für die passende Kita geschützt abrufbar.",
"Nachrichten und Anhänge bleiben im geschützten Kita-Bereich. Admins können auch ältere Beiträge durchsuchen und korrigieren; der Versandstatus wird getrennt vom Inhalt verwaltet.",
benefits: [
{
title: "Offizielle Ankündigungen",
text: "Vorstände und Admins veröffentlichen zentrale Nachrichten an Eltern oder Team.",
text: "Kita-Admins wählen Eltern, Team oder beide Gruppen. Diese Auswahl bestimmt auch, wer Beitrag und Anhänge in der App sehen darf.",
},
{
title: "Geschützte Anhänge",
text: "PDF, JPG und PNG werden nicht öffentlich ausgeliefert, sondern nach Session und Kita geprüft.",
text: "PDF, JPG und PNG bleiben geschützt: bis zu fünf Dateien, je zehn MB und insgesamt 25 MB.",
},
{
title: "Lesestatus im Blick",
text: "Das System kann sichtbar machen, welche Ankündigungen für Nutzer noch ungelesen sind.",
title: "Korrekturen bleiben sichtbar",
text: "Geänderte Texte erhalten einen Aktualisierungszeitpunkt und erscheinen für Leser wieder ungelesen. Auch ältere Korrekturen rücken nach vorne.",
},
{
title: "Optional per E-Mail",
text: "Wichtige Meldungen können zusätzlich per E-Mail angestoßen werden.",
title: "Versand je Person prüfen",
text: "E-Mails sind optional. Erfolgreiche, ausstehende und fehlgeschlagene Sendungen werden getrennt angezeigt; Fehler können gezielt erneut versucht werden.",
},
],
trustTitle: "Nicht öffentlich, nicht im Chat verloren",
@@ -325,17 +333,77 @@ export const marketingFeatures: MarketingFeature[] = [
{
question: "Welche Dateitypen sind möglich?",
answer:
"Aktuell sind PDF, JPG und PNG vorgesehen.",
"PDF, JPG und PNG sind möglich: bis zu fünf Anhänge, je zehn MB und insgesamt 25 MB.",
},
{
question: "Sind Anhänge öffentlich erreichbar?",
answer:
"Nein. Anhänge werden nur nach Session- und Kita-Prüfung ausgeliefert.",
"Nein. Anhänge sind nur nach Anmeldung und für die gewählte Empfängergruppe eurer Kita abrufbar.",
},
{
question: "Kann eine Ankündigung per E-Mail versendet werden?",
question: "Kann ich Fehler im Text oder beim Mailversand korrigieren?",
answer:
"Ja. Für wichtige Meldungen ist optional ein E-Mail-Push vorgesehen.",
"Ja. Admins können Titel und Text bearbeiten und die aktuelle Fassung ausdrücklich per E-Mail senden. Fehlgeschlagene Sendungen lassen sich unabhängig davon erneut versuchen; bereits erfolgreiche E-Mails werden dabei ausgelassen.",
},
],
},
{
slug: "personal-arbeitszeit",
icon: Clock3,
title: "Personal & Arbeitszeit",
shortTitle: "Personal und Arbeitszeit",
description:
"Arbeitszeit mit Pausen erfassen, Wochen zur Freigabe einreichen und Urlaub, Freizeitausgleich sowie das bestätigte Zeitkonto im Blick behalten.",
accent: "bg-teal-50 text-teal-700 ring-teal-100",
eyebrow: "Arbeitszeit, Urlaub und Zeitkonto",
heroTitle: "Mehr Überblick über die Arbeitszeit im Kita-Team.",
heroDescription:
"Fachkräfte erfassen ihre Arbeitsblöcke und stellen Anträge. Kita-Admins prüfen Wochen, genehmigen Urlaub und führen nachvollziehbare Zeitkonten.",
seoTitle: "Arbeitszeit und Urlaub für Kita-Teams",
seoDescription:
"Kita-Arbeitszeit mit Pausen erfassen, Wochen freigeben und Urlaub oder Freizeitausgleich beantragen. Zeitkonto und Resturlaub nachvollziehbar verwalten.",
problemTitle: "Arbeitszeit und Urlaub landen oft in mehreren Listen",
problemText:
"Einzelne Stundenlisten, Urlaubsanfragen und manuell berechnete Salden machen es schwer, einen gemeinsamen und bestätigten Stand zu behalten.",
solutionTitle: "Erfassung, Antrag und Freigabe gemeinsam organisieren",
solutionText:
"Arbeitsblöcke und Pausen gehören zur jeweiligen Woche. Anträge und Freigaben bleiben mit dem Personalprofil verknüpft; bestätigte Wochen und begründete Korrekturen führen zum Zeitkonto.",
benefits: [
{
title: "Mehrere Arbeitsblöcke je Tag",
text: "Fachkräfte erfassen Beginn, Ende und Pause. Weitere Blöcke lassen sich für denselben ausgewählten Tag direkt ergänzen.",
},
{
title: "Wochen prüfen und freigeben",
text: "Fachkräfte reichen die Woche ein. Admins können sie prüfen, freigeben oder zur Korrektur zurückgeben.",
},
{
title: "Urlaub und Freizeitausgleich",
text: "Ganze und einzelne halbe Urlaubstage sowie Freizeitausgleich können beantragt werden. Nur vorgesehene Arbeitstage verbrauchen Urlaub.",
},
{
title: "Bestätigtes Zeitkonto",
text: "Genehmigte Wochen, Anfangssaldo und wirksame manuelle Korrekturen bilden das bestätigte Konto. Offene Wochen bleiben als vorläufig erkennbar.",
},
],
trustTitle: "Persönliche Zeiten mit klaren Zuständigkeiten",
trustText:
"Fachkräfte sehen ihr eigenes Profil und ihre Zeiten. Kita-Admins verwalten die Personalprofile ihrer Einrichtung; Zugriffsrechte und bestehende Profile bleiben miteinander verbunden.",
faqs: [
{
question: "Sind mehrere Arbeitsblöcke am selben Tag möglich?",
answer:
"Ja. Zum Beispiel lassen sich ein Vormittags- und ein Nachmittagsblock getrennt erfassen, jeweils mit passender Pause.",
},
{
question: "Kann ich einen halben Urlaubstag beantragen?",
answer:
"Ja. Ein halber Urlaubstag bezieht sich auf ein einzelnes Datum. Auch für Freizeitausgleich wird ein Tag ausgewählt.",
},
{
question: "Zählt eine offene Woche bereits zum bestätigten Zeitkonto?",
answer:
"Nein. Erst genehmigte Wochen zählen zusammen mit Anfangssaldo und wirksamen Korrekturen zum bestätigten Konto.",
},
],
},
+54
View File
@@ -0,0 +1,54 @@
export type ProductScreenshot = {
slug: string;
label: string;
role: string;
caption: string;
alt: string;
src: string;
width: number;
height: number;
};
/** Real app captures with synthetic data; see docs/product-screenshots.md. */
export const productScreenshots: readonly ProductScreenshot[] = [
{
slug: "notdienst-planer", label: "Notdienst", role: "Elternansicht",
caption: "Verfügbare Tage markieren und Kita-Termine direkt bei der Auswahl sehen.",
alt: "Notdienst-Kalender für November 2026 mit acht ausgewählten Tagen und einem gesperrten Teamtag",
src: "/landing/screenshots/notdienst.jpg", width: 1600, height: 1000,
},
{
slug: "kita-kalender", label: "Kalender", role: "Elternansicht",
caption: "Elternabend, Herbstfest, Ausflüge und Schließtage im gemeinsamen Monatskalender.",
alt: "Monatskalender für Oktober 2026 mit Elterncafé, Wald-Ausflug, Elternabend, Herbstfest und Teamtag",
src: "/landing/screenshots/kalender.jpg", width: 1600, height: 1000,
},
{
slug: "eltern-adressbuch", label: "Adressbuch", role: "Elternansicht",
caption: "Haushalte, Kinder und ausdrücklich freigegebene Kontakte zusammen finden.",
alt: "Internes Adressbuch mit Familien, Kindern und per Opt-in freigegebenen E-Mail-Adressen",
src: "/landing/screenshots/adressbuch.jpg", width: 1585, height: 991,
},
{
slug: "krankmeldung", label: "Abwesenheiten", role: "Elternansicht",
caption: "Laufende Meldungen sehen und zukünftige Abwesenheiten bearbeiten oder zurücknehmen.",
alt: "Abwesenheitsübersicht mit einer laufenden Krankmeldung und einer zukünftigen Urlaubsmeldung samt Bearbeiten- und Zurücknehmen-Schaltflächen",
src: "/landing/screenshots/abwesenheiten.jpg", width: 1600, height: 1000,
},
{
slug: "schwarzes-brett", label: "Mitteilungen", role: "Elternansicht",
caption: "Offizielle Nachrichten lesen – mit formatiertem Inhalt und sichtbarem Aktualisierungshinweis.",
alt: "Geöffnete Herbstfest-Mitteilung mit formatiertem Text und Datum der Aktualisierung vor dem Schwarzen Brett",
src: "/landing/screenshots/mitteilungen.jpg", width: 1600, height: 1000,
},
{
slug: "personal-arbeitszeit", label: "Arbeitszeit", role: "Ansicht für Fachkräfte",
caption: "Erfasste Stunden, Wochensumme, bestätigtes Zeitkonto und verfügbaren Urlaub im Blick behalten.",
alt: "Arbeitszeitübersicht einer Fachkraft mit 35 erfassten Wochenstunden, Zeitkonto und Urlaubsstand",
src: "/landing/screenshots/personal.jpg", width: 1585, height: 1268,
},
];
export function getProductScreenshot(slug: string) {
return productScreenshots.find((screenshot) => screenshot.slug === slug);
}
+166
View File
@@ -0,0 +1,166 @@
import type { Metadata } from "next";
// Update when the public product copy changes, never on each request or build.
export const marketingLastModified = "2026-10-14";
export const marketingTitle =
"Kita-Planer für Elterninitiativen und Kita-Vereine";
export const marketingDescription =
"Organisiert Notdienste, Termine, Abwesenheiten, Arbeitszeit und Kita-Mitteilungen mit klaren Rollen, geschützten Anhängen und optionalen E-Mails.";
export const socialImage = {
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Bunte Spielklötze mit Tiermotiven für den Kita-Alltag.",
};
export function getPublicSiteOrigin() {
const site = new URL(
process.env.NEXT_PUBLIC_SITE_URL?.trim() || "https://mein-kitaplaner.de",
);
if (
!["https:", "http:"].includes(site.protocol) ||
site.username ||
site.password
)
throw new Error(
"NEXT_PUBLIC_SITE_URL muss eine öffentliche HTTP(S)-Adresse ohne Zugangsdaten sein.",
);
return site.origin;
}
export function publicUrl(path = "/") {
if (!path.startsWith("/") || path.startsWith("//"))
throw new Error("Ungültiger öffentlicher Seitenpfad.");
const origin = getPublicSiteOrigin();
const url = new URL(path, origin);
if (url.origin !== origin)
throw new Error("Ungültiger öffentlicher Seitenpfad.");
return url.toString();
}
export function publicPageMetadata(
path: string,
title: string,
description: string,
): Metadata {
return {
title: {
absolute: title.includes("Kita-Planer")
? title
: `${title} | Kita-Planer`,
},
description,
alternates: { canonical: publicUrl(path) },
robots: {
index: true,
follow: true,
googleBot: { index: true, follow: true, "max-image-preview": "large" },
},
openGraph: {
title,
description,
url: publicUrl(path),
siteName: "Kita-Planer",
type: "website",
locale: "de_DE",
images: [socialImage],
},
twitter: {
card: "summary_large_image",
title,
description,
images: [{ url: socialImage.url, alt: socialImage.alt }],
},
};
}
export function websiteStructuredData() {
return {
"@type": "WebSite",
"@id": publicUrl("/#website"),
name: "Kita-Planer",
url: publicUrl(),
inLanguage: "de-DE",
};
}
export function landingStructuredData(
features: { title: string; description: string }[],
) {
return {
"@context": "https://schema.org",
"@graph": [
websiteStructuredData(),
{
"@type": "WebPage",
"@id": publicUrl("/#webpage"),
url: publicUrl(),
name: marketingTitle,
description: marketingDescription,
inLanguage: "de-DE",
isPartOf: { "@id": publicUrl("/#website") },
about: { "@id": publicUrl("/#software") },
},
{
"@type": "SoftwareApplication",
"@id": publicUrl("/#software"),
name: "Kita-Planer",
url: publicUrl(),
description: marketingDescription,
applicationCategory: "BusinessApplication",
operatingSystem: "Webbrowser",
inLanguage: "de-DE",
featureList: features.map(
(feature) => `${feature.title}: ${feature.description}`,
),
},
],
};
}
export function featureStructuredData(feature: {
slug: string;
shortTitle: string;
seoTitle: string;
seoDescription: string;
}) {
const path = `/features/${feature.slug}`;
return {
"@context": "https://schema.org",
"@graph": [
websiteStructuredData(),
{
"@type": "WebPage",
"@id": publicUrl(`${path}#webpage`),
url: publicUrl(path),
name: feature.seoTitle,
description: feature.seoDescription,
inLanguage: "de-DE",
isPartOf: { "@id": publicUrl("/#website") },
breadcrumb: { "@id": publicUrl(`${path}#breadcrumb`) },
},
{
"@type": "BreadcrumbList",
"@id": publicUrl(`${path}#breadcrumb`),
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Kita-Planer",
item: publicUrl(),
},
{
"@type": "ListItem",
position: 2,
name: feature.shortTitle,
item: publicUrl(path),
},
],
},
],
};
}
export function serializeStructuredData(data: unknown) {
return JSON.stringify(data).replace(/</g, "\\u003c");
}
+260
View File
@@ -0,0 +1,260 @@
import { createElement } from "react";
import { compare, hash } from "bcryptjs";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { serializableTransaction } from "@/lib/transaction";
import { hashToken, newToken } from "@/lib/tokens";
import { passwordSchema } from "@/lib/password";
import { sendAppEmail } from "@/lib/mail";
import { PasswordResetEmail } from "@/emails/PasswordResetEmail";
import { UserFacingError } from "@/lib/action-error";
const RESET_PREFIX = "password-reset:";
const resetScope = (userId: string) =>
`${RESET_PREFIX}${Buffer.from(userId).toString("base64url")}:`;
function resetIdentity(identifier: string) {
const match = /^password-reset:([a-zA-Z0-9_-]+):(\d+)$/.exec(identifier);
if (!match) return null;
const sessionVersion = Number(match[2]);
if (!Number.isSafeInteger(sessionVersion)) return null;
return {
id: Buffer.from(match[1], "base64url").toString("utf8"),
sessionVersion,
};
}
const tokenSchema = z.string().regex(/^[a-f0-9]{64}$/i);
export const passwordResetMessage =
"Wenn für diese E-Mail-Adresse ein aktiviertes Konto besteht, erhältst du einen Link für ein neues Passwort. Prüfe auch deinen Spamordner.";
const newPasswordSchema = z
.object({ password: passwordSchema, confirmation: z.string() })
.refine((input) => input.password === input.confirmation, {
message: "Die neuen Passwörter stimmen nicht überein.",
path: ["confirmation"],
});
const resetSchema = newPasswordSchema.and(z.object({ token: tokenSchema }));
const invalidLink = () =>
new UserFacingError(
"Dieser Link ist ungültig, abgelaufen oder wurde bereits verwendet. Bitte fordere einen neuen Link an.",
);
function siteOrigin() {
const base =
process.env.NEXT_PUBLIC_SITE_URL ||
process.env.AUTH_URL ||
process.env.NEXTAUTH_URL;
if (!base && process.env.NODE_ENV !== "development")
throw new Error("App-Adresse fehlt.");
const url = new URL(base || "http://localhost:3000");
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password
)
throw new Error("Ungültige App-Adresse.");
return url.origin;
}
export async function requestPasswordReset(email: string, send = sendAppEmail) {
const parsed = z
.string()
.trim()
.max(254)
.email()
.toLowerCase()
.safeParse(email);
if (!parsed.success) return;
const resetToken = newToken();
const tokenHash = hashToken(resetToken);
const job = await serializableTransaction(async (tx) => {
const user = await tx.user.findUnique({ where: { email: parsed.data } });
if (!user || user.disabledAt || !user.emailVerifiedAt || !user.passwordHash)
return null;
await tx.$queryRaw`SELECT id FROM users WHERE id = ${user.id} FOR UPDATE`;
const fresh = await tx.user.findUniqueOrThrow({ where: { id: user.id } });
if (
fresh.email !== parsed.data ||
fresh.disabledAt ||
!fresh.emailVerifiedAt ||
!fresh.passwordHash
)
return null;
const identifier = resetScope(user.id) + fresh.sessionVersion;
await tx.verificationToken.deleteMany({
where: { identifier: { startsWith: resetScope(user.id) } },
});
await tx.verificationToken.create({
data: {
identifier,
token: tokenHash,
expires: new Date(Date.now() + 30 * 60_000),
},
});
return {
id: user.id,
email: fresh.email,
name: fresh.firstName,
identifier,
};
});
if (!job) return;
try {
const result = await send({
to: job.email,
subject: "Kita-Planer: Neues Passwort festlegen",
react: createElement(PasswordResetEmail, {
name: job.name,
resetLink: `${siteOrigin()}/neues-passwort/${resetToken}`,
}),
idempotencyKey: `password-reset/${tokenHash}`,
});
if (result.success) return;
} catch {
/* Return the same public response, including transport failure. */
}
await prisma.verificationToken.deleteMany({
where: { identifier: job.identifier, token: tokenHash },
});
}
export async function isPasswordResetValid(token: string) {
if (!tokenSchema.safeParse(token).success) return false;
const entry = await prisma.verificationToken.findUnique({
where: { token: hashToken(token) },
});
if (!entry || !resetIdentity(entry.identifier) || entry.expires <= new Date())
return false;
return !!(await prisma.user.findFirst({
where: {
...resetIdentity(entry.identifier)!,
disabledAt: null,
emailVerifiedAt: { not: null },
passwordHash: { not: "" },
},
select: { id: true },
}));
}
export async function resetPassword(raw: unknown) {
const input = resetSchema.safeParse(raw);
if (!input.success)
throw new UserFacingError(
input.error.issues[0]?.message ?? "Bitte die Eingaben prüfen.",
);
if (!(await isPasswordResetValid(input.data.token))) throw invalidLink();
const passwordHash = await hash(input.data.password, 12);
await serializableTransaction(async (tx) => {
const tokenHash = hashToken(input.data.token);
const entry = await tx.verificationToken.findUnique({
where: { token: tokenHash },
});
if (
!entry ||
!resetIdentity(entry.identifier) ||
entry.expires <= new Date()
)
throw invalidLink();
const identity = resetIdentity(entry.identifier)!;
const userId = identity.id;
await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`;
const user = await tx.user.findFirst({
where: {
...identity,
disabledAt: null,
emailVerifiedAt: { not: null },
passwordHash: { not: "" },
},
});
if (!user) throw invalidLink();
const consumed = await tx.verificationToken.deleteMany({
where: {
identifier: entry.identifier,
token: tokenHash,
expires: { gt: new Date() },
},
});
if (consumed.count !== 1) throw invalidLink();
await tx.user.update({
where: { id: userId },
data: {
passwordHash,
sessionVersion: { increment: 1 },
failedLoginAttempts: 0,
lockedUntil: null,
},
});
await tx.verificationToken.deleteMany({
where: {
OR: [
{ identifier: userId },
{ identifier: { startsWith: resetScope(userId) } },
],
},
});
});
}
export async function changeOwnPassword(
actor: { id: string; sessionVersion: number },
raw: unknown,
) {
const schema = newPasswordSchema.and(
z.object({
currentPassword: z
.string()
.min(1)
.refine(
(value) => Buffer.byteLength(value, "utf8") <= 72,
"Aktuelles Passwort ist ungültig.",
),
}),
);
const input = schema.safeParse(raw);
if (!input.success)
throw new UserFacingError(
input.error.issues[0]?.message ?? "Bitte die Eingaben prüfen.",
);
const user = await prisma.user.findFirst({
where: {
id: actor.id,
sessionVersion: actor.sessionVersion,
disabledAt: null,
emailVerifiedAt: { not: null },
},
});
if (!user || !(await compare(input.data.currentPassword, user.passwordHash)))
throw new UserFacingError(
"Das aktuelle Passwort ist nicht korrekt. Bitte melde dich bei Bedarf erneut an.",
);
if (await compare(input.data.password, user.passwordHash))
throw new UserFacingError("Bitte wähle ein anderes neues Passwort.");
const passwordHash = await hash(input.data.password, 12);
await serializableTransaction(async (tx) => {
const changed = await tx.user.updateMany({
where: {
id: user.id,
passwordHash: user.passwordHash,
sessionVersion: actor.sessionVersion,
disabledAt: null,
emailVerifiedAt: { not: null },
},
data: {
passwordHash,
sessionVersion: { increment: 1 },
failedLoginAttempts: 0,
lockedUntil: null,
},
});
if (changed.count !== 1)
throw new UserFacingError(
"Das Konto wurde inzwischen geändert. Bitte melde dich erneut an.",
);
await tx.verificationToken.deleteMany({
where: {
OR: [
{ identifier: user.id },
{ identifier: { startsWith: resetScope(user.id) } },
],
},
});
});
}
+8 -4
View File
@@ -40,25 +40,29 @@ export async function deliverStaffEmails(ids: string[], send = sendAppEmail) {
},
});
const { user, profile } = job;
let eligible = user.kitaId === profile.kitaId && !user.disabledAt && (!user.lockedUntil || user.lockedUntil <= now);
let eligible =
user.kitaId === profile.kitaId &&
!user.disabledAt &&
(!user.lockedUntil || user.lockedUntil <= now);
if (job.inviteToken) {
const token = await prisma.verificationToken.findUnique({
where: { token: hashToken(job.inviteToken) },
});
eligible &&=
profile.userId === user.id &&
user.role === "ERZIEHER" &&
["ERZIEHER", "ADMIN"].includes(user.role) &&
!user.emailVerifiedAt &&
!!token &&
token.identifier === user.id &&
token.expires > now;
} else {
eligible &&=
!!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt &&
!!user.emailVerifiedAt &&
!!user.privacyPolicyAcceptedAt &&
(!user.lockedUntil || user.lockedUntil <= now) &&
(job.toAdmins
? user.role === "ADMIN"
: user.role === "ERZIEHER" &&
: ["ERZIEHER", "ADMIN"].includes(user.role) &&
profile.userId === user.id &&
user.staffDecisionEmails);
}
+46 -15
View File
@@ -81,7 +81,11 @@ async function inviteStaff(
if (
existingUser &&
(existingUser.kitaId !== actor.kitaId ||
existingUser.role !== UserRole.ERZIEHER ||
!(
existingUser.role === UserRole.ERZIEHER ||
(existingUser.role === UserRole.ADMIN &&
existingUser.id === profile.userId)
) ||
(await tx.staffProfile.count({
where: { userId: existingUser.id, id: { not: profile.id } },
})))
@@ -205,7 +209,9 @@ export function assertStaffAccess(
throw new UserFacingError("Kein Zugriff auf dieses Personalprofil.");
if (adminOnly) requireAdmin(actor);
else if (actor.role !== UserRole.ADMIN && profile.userId !== actor.id)
throw new UserFacingError("Du kannst nur dein eigenes Personalprofil verwalten.");
throw new UserFacingError(
"Du kannst nur dein eigenes Personalprofil verwalten.",
);
}
export async function getStaffProfile(actor: StaffActor, profileId?: string) {
@@ -419,7 +425,9 @@ export async function executeStaffCommand(
if (
profile.user &&
(profile.user.kitaId !== actor.kitaId ||
profile.user.role !== UserRole.ERZIEHER)
!([UserRole.ERZIEHER, UserRole.ADMIN] as UserRole[]).includes(
profile.user.role,
))
)
throw new UserFacingError(
"Der verknüpfte Account gehört nicht zum Fachpersonal dieser Kita.",
@@ -440,7 +448,11 @@ export async function executeStaffCommand(
return { ...invitation, educatorId: educator.id };
}
if (command === "emailPreference") {
if (actor.role !== UserRole.ERZIEHER)
if (
!(await tx.staffProfile.count({
where: { userId: actor.id, kitaId: actor.kitaId },
}))
)
throw new UserFacingError(
"Diese Einstellung ist für Fachkräfte vorgesehen.",
);
@@ -534,7 +546,9 @@ export async function executeStaffCommand(
input.employmentEnd &&
input.employmentEnd < input.employmentStart
)
throw new UserFacingError("Das Beschäftigungsende liegt vor dem Beginn.");
throw new UserFacingError(
"Das Beschäftigungsende liegt vor dem Beginn.",
);
if (
input.trackingStart < input.employmentStart ||
(input.employmentEnd && input.trackingStart > input.employmentEnd)
@@ -545,7 +559,8 @@ export async function executeStaffCommand(
const educator = await tx.educator.findFirst({
where: { id: input.educatorId, kitaId: actor.kitaId },
});
if (!educator) throw new UserFacingError("Fachkraft nicht gefunden.");
if (!educator)
throw new UserFacingError("Fachkraft nicht gefunden.");
const existing = await tx.staffProfile.findFirst({
where: { educatorId: educator.id, kitaId: actor.kitaId },
include: profileInclude,
@@ -591,7 +606,8 @@ export async function executeStaffCommand(
where: { id: profileId, kitaId: actor.kitaId },
include: profileInclude,
});
if (!profile) throw new UserFacingError("Personalprofil nicht gefunden.");
if (!profile)
throw new UserFacingError("Personalprofil nicht gefunden.");
assertStaffAccess(actor, profile);
const holidays = new Set(
(
@@ -631,7 +647,10 @@ export async function executeStaffCommand(
for (const user of recipients) {
if (
!toAdmins &&
(user.role !== UserRole.ERZIEHER || !user.staffDecisionEmails)
(!(
[UserRole.ERZIEHER, UserRole.ADMIN] as UserRole[]
).includes(user.role) ||
!user.staffDecisionEmails)
)
continue;
const job = await tx.staffEmail.create({
@@ -719,7 +738,9 @@ export async function executeStaffCommand(
input.employmentEnd &&
input.employmentEnd < dateKey(profile.employmentStart)
)
throw new UserFacingError("Das Beschäftigungsende liegt vor dem Beginn.");
throw new UserFacingError(
"Das Beschäftigungsende liegt vor dem Beginn.",
);
if (
input.employmentEnd &&
(input.employmentEnd < dateKey(profile.trackingStart) ||
@@ -786,7 +807,9 @@ export async function executeStaffCommand(
(profile.employmentEnd &&
input.date > dateKey(profile.employmentEnd))
)
throw new UserFacingError("Der Tag liegt außerhalb der Beschäftigung.");
throw new UserFacingError(
"Der Tag liegt außerhalb der Beschäftigung.",
);
if (input.remove)
await tx.staffDayOverride.deleteMany({
where: { profileId, date: dateOnly(input.date) },
@@ -859,7 +882,9 @@ export async function executeStaffCommand(
})
.parse(data);
if (input.date < dateKey(profile.trackingStart))
throw new UserFacingError("Korrekturen müssen ab Erfassungsbeginn liegen.");
throw new UserFacingError(
"Korrekturen müssen ab Erfassungsbeginn liegen.",
);
await tx.staffAdjustment.create({
data: {
profileId,
@@ -887,7 +912,8 @@ export async function executeStaffCommand(
throw new UserFacingError("Zeiteintrag nicht gefunden.");
if (existing) ensureDayEditable(profile, dateKey(existing.date));
if (command === "deleteTime") {
if (!existing) throw new UserFacingError("Zeiteintrag nicht gefunden.");
if (!existing)
throw new UserFacingError("Zeiteintrag nicht gefunden.");
await tx.staffTimeEntry.delete({ where: { id: existing.id } });
await history(
"Arbeitszeit gelöscht",
@@ -1135,7 +1161,8 @@ export async function executeStaffCommand(
const existing = data.id
? profile.leaves.find((l) => l.id === idSchema.parse(data.id))
: undefined;
if (data.id && !existing) throw new UserFacingError("Antrag nicht gefunden.");
if (data.id && !existing)
throw new UserFacingError("Antrag nicht gefunden.");
if (command === "leaveCancel") {
if (
!existing ||
@@ -1182,7 +1209,9 @@ export async function executeStaffCommand(
!existing ||
!["PENDING", "CANCEL_REQUESTED"].includes(existing.status)
)
throw new UserFacingError("Dieser Antrag ist nicht mehr offen.");
throw new UserFacingError(
"Dieser Antrag ist nicht mehr offen.",
);
const cancelling = existing.status === "CANCEL_REQUESTED";
if (input.approve)
ensureRangeEditable(
@@ -1284,7 +1313,9 @@ export async function executeStaffCommand(
.parse(data);
if (input.kind === "SICKNESS") requireAdmin(actor);
if (existing && existing.status !== "PENDING")
throw new UserFacingError("Nur offene Anträge können bearbeitet werden.");
throw new UserFacingError(
"Nur offene Anträge können bearbeitet werden.",
);
if (input.startDate < dateKey(profile.trackingStart))
throw new UserFacingError(
"Abwesenheiten bitte ab Erfassungsbeginn erfassen.",
+4 -1
View File
@@ -127,7 +127,10 @@ export async function proxy(request: NextRequest) {
const response = await routeRequest(request, requestHeaders);
response.headers.set("Content-Security-Policy", csp);
response.headers.set("Referrer-Policy", "no-referrer");
if (request.nextUrl.pathname.startsWith("/alert/") || request.nextUrl.pathname.startsWith("/invite/") || request.nextUrl.pathname.startsWith("/register/verify/") || PROTECTED_PREFIX.some(p => request.nextUrl.pathname.startsWith(p))) {
const pathname = request.nextUrl.pathname;
const privatePage = ["/login", "/register", "/passwort-vergessen", "/forbidden"].includes(pathname) ||
["/alert/", "/invite/", "/register/verify/", "/neues-passwort/", ...PROTECTED_PREFIX].some(prefix => pathname.startsWith(prefix));
if (privatePage) {
response.headers.set("Cache-Control", "private, no-store");
response.headers.set("X-Robots-Tag", "noindex, nofollow");
}
+252
View File
@@ -0,0 +1,252 @@
/* eslint-disable @typescript-eslint/no-require-imports -- Exercise real actions with isolated request and mail boundaries. */
const { test, before, beforeEach, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm');
const ts = require('typescript'); const { randomUUID } = require('node:crypto');
const { PrismaClient } = require('@prisma/client');
const React = require('react'); const { renderToStaticMarkup } = require('react-dom/server');
const { render } = require('@react-email/render');
let prisma, current, f, service, actions, load;
const tenants = [], emails = [], afters = [];
let failedEmail = null;
const sender = async input => { emails.push(input); return input.to === failedEmail ? { success: false, error: 'synthetic failure' } : { success: true }; };
function loader(overrides = {}) {
const cache = new Map();
function load(file) {
file = path.resolve(file);
if (cache.has(file)) return cache.get(file).exports;
const mod = { exports: {} }; cache.set(file, mod);
const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true },
fileName: file,
}).outputText;
const req = spec => {
if (Object.hasOwn(overrides, spec)) return overrides[spec];
if (spec === 'server-only') return {};
if (spec === '@/lib/prisma') return { prisma };
if (spec === 'next/headers') return { headers: async () => new Headers() };
if (spec === 'next/server') return { after: callback => afters.push(callback) };
if (spec === 'next/cache') return { revalidatePath() {} };
if (spec === 'next/navigation') return { redirect(url) { throw new Error(`REDIRECT:${url}`); }, notFound() { throw new Error('NOT_FOUND'); } };
if (spec.startsWith('@/') || spec.startsWith('.')) {
const target = spec.startsWith('@/') ? path.resolve('src', spec.slice(2)) : path.resolve(path.dirname(file), spec);
for (const suffix of ['.ts', '.tsx', '/index.ts']) if (fs.existsSync(target + suffix)) return load(target + suffix);
}
return require(spec);
};
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, { filename: file })(req, mod, mod.exports);
return mod.exports;
}
return load;
}
const authBoundary = { requireKitaSession: async () => ({ user: current }), requireRole: async roles => { assert.ok(roles.includes(current.role)); return { user: current }; } };
before(async () => {
const raw = process.env.TEST_DATABASE_URL;
if (!raw) throw new Error('TEST_DATABASE_URL muss auf die separate lokale kita_announcements_test zeigen.');
const url = new URL(raw); assert.ok(['127.0.0.1','localhost'].includes(url.hostname)); assert.equal(url.pathname, '/kita_announcements_test');
prisma = new PrismaClient({ datasourceUrl: raw }); process.env.AUTH_URL = 'http://127.0.0.1:3014';
load = loader({ '@/lib/auth-utils': authBoundary, '@/lib/mail': { sendAppEmail: sender } });
service = load('src/lib/announcement-delivery.ts'); actions = load('src/actions/announcements.ts');
});
after(async () => { if (prisma) { await prisma.kita.deleteMany({ where: { id: { in: tenants } } }); await prisma.$disconnect(); } });
beforeEach(async () => {
emails.length = 0; afters.length = 0; failedEmail = null;
const kita = await prisma.kita.create({ data: { name: 'Mitteilungen Test', slug: randomUUID() } }); tenants.push(kita.id);
const family = await prisma.family.create({ data: { kitaId: kita.id, name: 'Testhaushalt' } });
f = { kita, family };
const admin = await person('ADMIN', null); const parent = await person('ELTERN', family.id);
f = { ...f, admin, parent }; current = admin;
});
async function person(role = 'ELTERN', familyId = f.family.id, extra = {}) {
return prisma.user.create({ data: { kitaId: f.kita.id, familyId, role, email: `${randomUUID()}@example.invalid`, firstName: 'Test', lastName: role,
passwordHash: 'test', emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date(), ...extra } });
}
function form(data) { const result = new FormData(); for (const [key, value] of Object.entries(data)) result.set(key, value); return result; }
async function publication(sendEmail = true, audience = 'ELTERN') {
assert.equal((await actions.createAnnouncement(form({ title: 'Sommerfest Aufbau', content: 'Am Dienstag treffen wir uns.', audience, sendEmail: sendEmail ? 'on' : 'off' }))).success, true);
return prisma.announcement.findFirstOrThrow({ where: { kitaId: f.kita.id }, orderBy: { createdAt: 'desc' } });
}
const jobs = id => prisma.announcementEmail.findMany({ where: { kitaId: f.kita.id, announcementId: id }, orderBy: { createdAt: 'asc' } });
const edit = (row, data = {}) => actions.updateAnnouncement(form({ id: row.id, revision: String(row.revision), title: row.title, content: 'Aktualisierung: Wir treffen uns am Mittwoch.', sendEmail: 'off', ...data }));
const deliver = (now = new Date()) => service.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, sender, now);
async function flush() { while (afters.length) await afters.shift()(); }
test('Veröffentlichung speichert Versandaufträge vor der Antwort; after sendet einzeln', async () => {
const row = await publication(); assert.equal(emails.length, 0); assert.equal((await jobs(row.id)).length, 1);
await flush(); assert.equal(emails.length, 1); assert.equal(emails[0].to, f.parent.email); assert.ok((await jobs(row.id))[0].sentAt);
});
test('Eine fehlgeschlagene Adresse blockiert andere Empfänger nicht', async () => {
const partner = await person('KOORDINATOR'); const row = await publication(); failedEmail = f.parent.email;
const result = await deliver(); assert.equal(result.sent, 1); assert.equal(result.failed, 1); assert.equal(emails.length, 2);
assert.ok((await jobs(row.id)).find(job => job.userId === partner.id).sentAt);
});
test('Wiederholung verwendet denselben Auftrag und lässt erfolgreiche Sendungen aus', async () => {
await person(); const row = await publication(); failedEmail = f.parent.email; await deliver(); const first = emails.find(mail => mail.to === f.parent.email);
failedEmail = null; assert.equal((await actions.retryAnnouncementEmails(row.id, 1)).queued, 1); await flush();
assert.equal(emails.length, 3); assert.equal(emails[2].idempotencyKey, first.idempotencyKey);
assert.equal((await actions.retryAnnouncementEmails(row.id, 1)).queued, 0); assert.equal((await jobs(row.id)).filter(job => job.sentAt).length, 2);
});
test('Automatische Wiederholung wartet bis zur gespeicherten Frist', async () => {
const row = await publication(); failedEmail = f.parent.email; const now = new Date(); await deliver(now);
const job = (await jobs(row.id))[0]; assert.ok(job.nextAttemptAt > now); failedEmail = null;
assert.equal((await deliver(now)).sent, 0); assert.equal((await deliver(job.nextAttemptAt)).sent, 1);
});
test('Parallele Worker versenden eine Veröffentlichung nur einmal', async () => {
await publication(); await Promise.all([deliver(), deliver()]); assert.equal(emails.length, 1);
});
test('Eine abgelaufene Reservierung wird nach einem Worker-Abbruch übernommen', async () => {
const row = await publication(); await prisma.announcementEmail.update({ where: { id: (await jobs(row.id))[0].id }, data: { claimToken: 'aborted', leaseUntil: new Date(0) } });
assert.equal((await deliver()).sent, 1);
});
test('Neue Fassung verwirft alte ungesendete Aufträge und setzt gelesen zurück', async () => {
const row = await publication(); current = f.parent; assert.equal((await actions.markAnnouncementRead(row.id, 1)).success, true); current = f.admin;
assert.equal((await edit(row)).changed, true); const updated = await prisma.announcement.findUniqueOrThrow({ where: { id: row.id } });
assert.equal(updated.revision, 2); assert.ok(updated.editedAt); assert.equal(updated.audience, row.audience); assert.equal(updated.authorId, row.authorId);
assert.equal(await prisma.announcementRead.count({ where: { announcementId: row.id } }), 0);
assert.ok((await jobs(row.id))[0].skippedAt); await flush(); assert.equal(emails.length, 0);
});
test('Eine alte geöffnete Fassung darf eine Korrektur nicht als gelesen markieren', async () => {
const row = await publication(false); await edit(row); current = f.parent;
assert.ok((await actions.markAnnouncementRead(row.id, 1)).error); assert.equal(await prisma.announcementRead.count({ where: { announcementId: row.id } }), 0);
assert.equal((await actions.markAnnouncementRead(row.id, 2)).success, true);
});
test('Paralleles Lesen und Korrigieren hinterlässt keinen Lesestatus für den alten Text', async () => {
const row = await publication(false);
const reader = loader({ '@/lib/auth-utils': { requireKitaSession: async () => ({ user: f.parent }) } })('src/actions/announcements.ts');
const results = await Promise.all([reader.markAnnouncementRead(row.id, 1), edit(row)]); assert.equal(results[1].success, true);
assert.equal(await prisma.announcementRead.count({ where: { announcementId: row.id } }), 0);
});
test('Veraltete und parallele Bearbeitungen überschreiben keine neuere Korrektur', async () => {
const row = await publication(false); const results = await Promise.all([edit(row), edit(row, { content: 'Eine andere Korrektur.' })]);
assert.equal(results.filter(result => result.success).length, 1); assert.equal(results.filter(result => result.error).length, 1);
assert.ok((await edit(row)).error); assert.equal((await prisma.announcement.findUniqueOrThrow({ where: { id: row.id } })).revision, 2);
});
test('Unveränderter Text erhält Version und Lesestatus; Versand bleibt einmalig', async () => {
const row = await publication(false); current = f.parent; await actions.markAnnouncementRead(row.id, 1); current = f.admin;
const input = { content: row.content, sendEmail: 'on' }; assert.equal((await edit(row, input)).queued, 1); assert.equal((await edit(row, input)).queued, 0);
assert.equal((await prisma.announcement.findUniqueOrThrow({ where: { id: row.id } })).revision, 1); assert.equal(await prisma.announcementRead.count({ where: { announcementId: row.id } }), 1);
});
test('Korrektur-Mail ist ausdrücklich optional und versendet nur die aktuelle Fassung', async () => {
const row = await publication(); await deliver(); assert.equal((await edit(row, { sendEmail: 'on' })).queued, 1); await flush();
assert.equal(emails.length, 2); assert.match(emails[1].subject, /^Aktualisierte/); assert.ok((await render(emails[1].react)).includes('Mittwoch'));
assert.notEqual(emails[0].idempotencyKey, emails[1].idempotencyKey);
});
test('Bereits laufender Versand verhindert Änderungen bis zum Abschluss', async () => {
const row = await publication(); let release; let entered; const started = new Promise(resolve => { entered = resolve; });
const blocked = async input => { entered(); await new Promise(resolve => { release = resolve; }); emails.push(input); return { success: true }; };
const sending = service.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, blocked);
await started; assert.match((await edit(row)).error, /läuft gerade/); release(); await sending; assert.equal((await edit(row)).success, true);
});
test('Rollen- und Sessionänderung entzieht die Bearbeitungsberechtigung auch bei alter Session', async () => {
const row = await publication(false); await prisma.user.update({ where: { id: f.admin.id }, data: { role: 'KOORDINATOR' } }); assert.ok((await edit(row)).error);
await prisma.user.update({ where: { id: f.admin.id }, data: { role: 'ADMIN', sessionVersion: { increment: 1 } } }); assert.ok((await edit(row)).error);
});
test('Fremde Kita kann weder bearbeiten noch Versand wiederholen oder als gelesen markieren', async () => {
const row = await publication(); const other = await prisma.kita.create({ data: { name: 'Fremd', slug: randomUUID() } }); tenants.push(other.id);
current = { ...f.admin, kitaId: other.id }; assert.ok((await edit(row)).error); assert.ok((await actions.retryAnnouncementEmails(row.id, 1)).error); assert.ok((await actions.markAnnouncementRead(row.id, 1)).error);
});
test('Zusammengesetzte Fremdschlüssel verhindern fremde Nutzer und Beiträge', async () => {
const row = await publication(false); const other = await prisma.kita.create({ data: { name: 'Fremd', slug: randomUUID() } }); tenants.push(other.id);
const foreign = await prisma.user.create({ data: { kitaId: other.id, email: `${randomUUID()}@example.invalid`, firstName: 'Fremd', lastName: 'Test', passwordHash: 'test' } });
const base = { kitaId: f.kita.id, announcementId: row.id, userId: f.parent.id, revision: 1 };
await assert.rejects(prisma.announcementEmail.create({ data: { ...base, userId: foreign.id } }), error => error.code === 'P2003');
await assert.rejects(prisma.announcementEmail.create({ data: { ...base, kitaId: other.id, userId: foreign.id } }), error => error.code === 'P2003');
});
test('Empfängergruppe und aktive Konten bestimmen die vorbereiteten Aufträge', async () => {
await person('ERZIEHER', null); await person('ELTERN', f.family.id, { emailVerifiedAt: null }); await person('ELTERN', f.family.id, { disabledAt: new Date() });
await person('ELTERN', f.family.id, { privacyPolicyAcceptedAt: null }); await person('ELTERN', f.family.id, { lockedUntil: new Date(Date.now() + 3600000) });
const parent = await publication(); assert.equal((await jobs(parent.id)).length, 1);
const team = await publication(true, 'ERZIEHER'); assert.equal((await jobs(team.id)).length, 1);
});
test('Berechtigung wird vor Wiederholung erneut geprüft', async () => {
const row = await publication(); await prisma.user.update({ where: { id: f.parent.id }, data: { role: 'ERZIEHER' } });
assert.equal((await deliver()).skipped, 1); assert.equal(emails.length, 0); assert.ok((await jobs(row.id))[0].skippedAt);
});
test('Deaktivierung und widerrufener Consent stoppen vorbereitete Sendungen', async () => {
const second = await person(); await publication(); await prisma.user.update({ where: { id: f.parent.id }, data: { disabledAt: new Date() } });
await prisma.user.update({ where: { id: second.id }, data: { privacyPolicyAcceptedAt: null } }); assert.equal((await deliver()).skipped, 2); assert.equal(emails.length, 0);
});
test('Fehlende App-Adresse bleibt als Fehler wiederholbar gespeichert', async () => {
const row = await publication(); const saved = [process.env.AUTH_URL, process.env.NEXTAUTH_URL, process.env.NEXT_PUBLIC_SITE_URL];
try { delete process.env.AUTH_URL; delete process.env.NEXTAUTH_URL; delete process.env.NEXT_PUBLIC_SITE_URL; assert.equal((await deliver()).failed, 1); assert.ok((await jobs(row.id))[0].failed); assert.equal(emails.length, 0); }
finally { ['AUTH_URL','NEXTAUTH_URL','NEXT_PUBLIC_SITE_URL'].forEach((key, i) => { if (saved[i] === undefined) delete process.env[key]; else process.env[key] = saved[i]; }); }
});
test('Der begrenzte Batch wird in weiteren Läufen vollständig abgearbeitet', async () => {
for (let i = 0; i < 14; i++) await person(); await publication(); assert.equal((await deliver()).sent, 12); assert.equal((await deliver()).sent, 3); assert.equal(emails.length, 15);
});
test('Speicherfehler bei Versandaufträgen macht die Veröffentlichung vollständig rückgängig', async () => {
const local = loader({ '@/lib/auth-utils': authBoundary, '@/lib/announcement-delivery': { queueAnnouncementEmails: async () => { throw new Error('synthetic failure'); } } })('src/actions/announcements.ts');
await assert.rejects(local.createAnnouncement(form({ title: 'Kein Teilzustand', content: 'Kein Teilzustand erlaubt.', audience: 'ELTERN', sendEmail: 'on' })));
assert.equal(await prisma.announcement.count({ where: { kitaId: f.kita.id } }), 0);
});
test('Anhänge sperren Versand bis zum vollständigen Schreiben und bleiben bei Korrektur erhalten', async () => {
let release; const blocked = new Promise(resolve => { release = resolve; });
const local = loader({ '@/lib/auth-utils': authBoundary, 'fs/promises': { mkdir: async () => {}, writeFile: async () => blocked, rm: async () => {} } })('src/actions/announcements.ts');
const data = form({ title: 'Mit Anhang', content: 'Ein Dokument mit Details.', audience: 'ELTERN', sendEmail: 'on' }); data.append('attachments', new File(['%PDF-1.4\ntest\n%%EOF'], 'info.pdf', { type: 'application/pdf' }));
const creating = local.createAnnouncement(data); let row;
for (let attempt = 0; attempt < 100; attempt++) { row = await prisma.announcement.findFirst({ where: { kitaId: f.kita.id }, include: { attachments: true } }); if (row) break; await new Promise(resolve => setTimeout(resolve, 10)); }
assert.ok(row); assert.equal(row.emailReady, false); assert.equal((await deliver()).sent, 0);
release(); assert.equal((await creating).success, true); assert.equal((await edit(row)).success, true);
assert.equal(await prisma.attachment.count({ where: { announcementId: row.id } }), 1); assert.equal((await jobs(row.id))[0].skippedAt !== null, true);
});
test('Fehler beim Schreiben des Anhangs entfernt auch vorbereitete E-Mails', async () => {
const local = loader({ '@/lib/auth-utils': authBoundary, 'fs/promises': { mkdir: async () => {}, writeFile: async () => { throw new Error('synthetic disk failure'); }, rm: async () => {} } })('src/actions/announcements.ts');
const data = form({ title: 'Kaputter Anhang', content: 'Ein Dokument mit Details.', audience: 'ELTERN', sendEmail: 'on' }); data.append('attachments', new File(['%PDF-1.4\ntest\n%%EOF'], 'info.pdf', { type: 'application/pdf' }));
assert.ok((await local.createAnnouncement(data)).error); assert.equal(await prisma.announcement.count({ where: { kitaId: f.kita.id } }), 0); assert.equal(await prisma.announcementEmail.count({ where: { kitaId: f.kita.id } }), 0);
});
test('Verwaltungsübersicht bietet Suche und Pagination für ältere Beiträge', async () => {
await prisma.announcement.createMany({ data: Array.from({ length: 14 }, (_, i) => ({ kitaId: f.kita.id, authorId: f.admin.id, title: `Meldung ${i}`, content: `Beschreibung ${i}`, createdAt: new Date(Date.now() - i * 1000) })) });
const pages = loader({ '@/lib/auth-utils': authBoundary, 'next/link': { __esModule: true, default: ({ href, children, ...props }) => React.createElement('a', { href, ...props }, children) }, './news-form': { NewsForm: () => null } });
const page = pages('src/app/dashboard/admin/news/page.tsx').default;
const first = renderToStaticMarkup(await page({ searchParams: Promise.resolve({}) })); assert.ok(first.includes('Ältere Beiträge')); assert.equal((first.match(/Bearbeiten &amp; Versand/g) || []).length, 12);
const second = renderToStaticMarkup(await page({ searchParams: Promise.resolve({ page: '2' }) })); assert.equal((second.match(/Bearbeiten &amp; Versand/g) || []).length, 2); assert.ok(second.includes('Neuere Beiträge'));
const search = renderToStaticMarkup(await page({ searchParams: Promise.resolve({ q: 'Beschreibung 13' }) })); assert.ok(search.includes('Meldung 13')); assert.ok(!search.includes('Meldung 0')); assert.ok(search.includes('value="Beschreibung 13"'));
});
test('Korrekturen älterer Beiträge erscheinen wieder vorne im ungelesenen Elternfeed', async () => {
const row = await publication(false); await prisma.announcement.update({ where: { id: row.id }, data: { createdAt: new Date('2020-01-01'), updatedAt: new Date('2020-01-01') } });
await prisma.announcement.create({ data: { kitaId: f.kita.id, authorId: f.admin.id, title: 'Neue Meldung', content: 'Neuere Meldung zum Kitaalltag.', audience: 'ELTERN' } });
await edit(row); current = f.parent;
const feed = await load('src/lib/announcements.ts').getAnnouncements({ user: f.parent }, { take: 3 });
assert.equal(feed[0].id, row.id); assert.equal(feed[0].revision, 2); assert.equal(feed[0].isUnread, true); assert.ok(feed[0].editedAt);
});
test('Nach Abbruch vor Freigabe wird ein vollständig geschriebener Anhang wieder aufgenommen', async () => {
const row = await publication(); await prisma.announcement.update({ where: { id: row.id }, data: { emailReady: false, createdAt: new Date(Date.now() - 600000), attachments: { create: { fileName: 'info.pdf', fileUrl: '/unused', fileType: 'application/pdf', sizeBytes: 15 } } } });
const recovered = loader({ 'node:fs/promises': { stat: async () => ({ size: 15 }) } })('src/lib/announcement-delivery.ts');
assert.equal((await recovered.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, sender)).sent, 1); assert.equal((await prisma.announcement.findUniqueOrThrow({ where: { id: row.id } })).emailReady, true);
});
test('Teilweise geschriebene und fehlende Anhänge geben den Versand nicht frei', async () => {
const row = await publication(); await prisma.announcement.update({ where: { id: row.id }, data: { emailReady: false, createdAt: new Date(Date.now() - 600000), attachments: { create: { fileName: 'info.pdf', fileUrl: '/unused', fileType: 'application/pdf', sizeBytes: 15 } } } });
const partial = loader({ 'node:fs/promises': { stat: async () => ({ size: 3 }) } })('src/lib/announcement-delivery.ts');
assert.equal((await partial.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, sender)).sent, 0);
const missing = loader({ 'node:fs/promises': { stat: async () => { throw new Error('missing'); } } })('src/lib/announcement-delivery.ts');
assert.equal((await missing.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, sender)).sent, 0); assert.equal(emails.length, 0);
});
test('Fehlende Anhänge anderer Beiträge verdrängen eine wiederherstellbare Veröffentlichung nicht', async () => {
const old = new Date(Date.now() - 600000);
for (let i = 0; i < 13; i++) await prisma.announcement.create({ data: { id: `${f.kita.id}-unfinished-${i}`, kitaId: f.kita.id, authorId: f.admin.id, title: 'Unfertig', content: 'Ein Anhang fehlt.', emailReady: false, createdAt: old, attachments: { create: { fileName: 'info.pdf', fileUrl: '/unused', fileType: 'application/pdf', sizeBytes: 15 } } } });
const row = await prisma.announcement.create({ data: { id: `${f.kita.id}-zz-complete`, kitaId: f.kita.id, authorId: f.admin.id, title: 'Fertig', content: 'Der Anhang ist vollständig.', audience: 'ELTERN', emailReady: false, createdAt: old, attachments: { create: { fileName: 'info.pdf', fileUrl: '/unused', fileType: 'application/pdf', sizeBytes: 15 } }, emails: { create: { kitaId: f.kita.id, userId: f.parent.id, revision: 1 } } }, include: { attachments: true } });
const recovered = loader({ 'node:fs/promises': { stat: async file => { if (file.endsWith(row.attachments[0].id)) return { size: 15 }; throw new Error('missing'); } } })('src/lib/announcement-delivery.ts');
assert.equal((await recovered.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, sender)).sent, 1);
});
test('Adminrechte erhalten Haushalts- und Teammail-Zugehörigkeit; globale Admins sind keine Empfänger', async () => {
const staff = await person('ADMIN', null);
const educator = await prisma.educator.create({ data: { kitaId: f.kita.id, firstName: 'Test', lastName: 'Fachkraft' } });
await prisma.staffProfile.create({ data: { kitaId: f.kita.id, userId: staff.id, educatorId: educator.id, employmentStart: new Date('2020-01-01'), trackingStart: new Date('2020-01-01') } });
const parentAdmin = await person('ADMIN'); await person('SUPERADMIN');
const parentRow = await publication(true, 'ELTERN'); assert.deepEqual((await jobs(parentRow.id)).map(job => job.userId).sort(), [f.parent.id, parentAdmin.id].sort());
const staffRow = await publication(true, 'ERZIEHER'); assert.deepEqual((await jobs(staffRow.id)).map(job => job.userId), [staff.id]);
await deliver(); assert.ok(emails.some(mail => mail.to === staff.email));
});
test('Drei parallele Worker verarbeiten viele Empfänger ohne Konflikte oder doppelte Sendungen', async () => {
for (let i = 0; i < 23; i++) await person(); const row = await publication();
const slow = async input => { await new Promise(resolve => setTimeout(resolve, 10)); return sender(input); };
await Promise.all(Array.from({ length: 3 }, () => service.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, slow)));
await deliver(); await deliver();
assert.equal(emails.length, 24); assert.equal(new Set(emails.map(mail => mail.to)).size, 24); assert.equal((await jobs(row.id)).filter(job => job.sentAt).length, 24);
});
+599
View File
@@ -0,0 +1,599 @@
/* eslint-disable @typescript-eslint/no-require-imports -- Test real server code with isolated request and transport boundaries. */
const { test, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const ts = require("typescript");
const { PrismaClient } = require("@prisma/client");
const { randomUUID } = require("node:crypto");
let prisma,
current,
f,
change,
changeAction,
remove,
support,
updateFamily,
alarms,
alarmMail,
staff,
staffMail,
assign;
const emails = [];
const tenants = [],
looseUsers = [];
function loader(overrides = {}) {
const cache = new Map();
function load(file) {
file = path.resolve(file);
if (cache.has(file)) return cache.get(file).exports;
const mod = { exports: {} };
cache.set(file, mod);
const code = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.CommonJS,
jsx: ts.JsxEmit.ReactJSX,
esModuleInterop: true,
},
fileName: file,
}).outputText;
const req = (spec) => {
if (Object.hasOwn(overrides, spec)) return overrides[spec];
if (spec === "server-only") return {};
if (spec === "@/lib/prisma") return { prisma };
if (spec === "next/headers")
return { headers: async () => new Headers() };
if (spec === "next/cache") return { revalidatePath() {} };
if (spec === "next/navigation")
return {
redirect(url) {
throw new Error(`REDIRECT:${url}`);
},
notFound() {
throw new Error("NOT_FOUND");
},
};
if (spec.startsWith("@/") || spec.startsWith(".")) {
const target = spec.startsWith("@/")
? path.resolve("src", spec.slice(2))
: path.resolve(path.dirname(file), spec);
for (const suffix of [".ts", ".tsx", "/index.ts"])
if (fs.existsSync(target + suffix)) return load(target + suffix);
}
return require(spec);
};
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, {
filename: file,
})(req, mod, mod.exports);
return mod.exports;
}
return load;
}
before(async () => {
const raw = process.env.TEST_DATABASE_URL;
assert.ok(
raw,
"TEST_DATABASE_URL muss ausdrücklich auf die separate lokale kita_roles_test zeigen.",
);
const url = new URL(raw);
assert.ok(["127.0.0.1", "localhost"].includes(url.hostname));
assert.equal(url.pathname, "/kita_roles_test");
prisma = new PrismaClient({ datasourceUrl: raw });
const load = loader({
"@/lib/auth-utils": {
requireKitaSession: async () => ({ user: current }),
requireRole: async (roles) => {
assert.ok(roles.includes(current.role));
return { user: current };
},
},
"@/lib/mail": {
getAppEmailConfigError: () => null,
sendAppEmail: async (input) => {
emails.push(input);
return { success: true };
},
},
});
change = load("src/lib/kita-role-management.ts").changeKitaRole;
changeAction = load(
"src/app/dashboard/zugriffsrechte/actions.ts",
).changeKitaRoleAction;
remove = load("src/app/dashboard/profil/actions.ts").deleteMyAccount;
support = load("src/app/admin/actions.ts");
updateFamily = load(
"src/app/dashboard/families/actions.ts",
).updateFamilyAction;
alarms = load("src/lib/alarms/service.ts");
alarmMail = load("src/lib/alarms/email.ts");
staff = load("src/lib/staff/service.ts");
staffMail = load("src/lib/staff/email.ts");
assign = load(
"src/app/dashboard/notdienst/plan/actions.ts",
).updateAssignmentAction;
process.env.AUTH_URL = "http://127.0.0.1:3008";
});
async function user(role, extra = {}, kitaId = f.kita.id) {
return prisma.user.create({
data: {
kitaId,
email: `${randomUUID()}@example.invalid`,
firstName: "Test",
lastName: role,
passwordHash: "test-only",
emailVerifiedAt: new Date(),
privacyPolicyAcceptedAt: new Date(),
role,
...extra,
},
});
}
const actor = (u) => ({
id: u.id,
kitaId: u.kitaId,
sessionVersion: u.sessionVersion,
});
const changeTo = (u, role, by = f.admin) =>
change(actor(by), { userId: u.id, role, expectedRole: u.role });
beforeEach(async () => {
const kita = await prisma.kita.create({
data: { name: "Rollen-Test", slug: `roles-${randomUUID()}` },
});
tenants.push(kita.id);
f = { kita };
f.admin = await user("ADMIN");
f.family = await prisma.family.create({
data: { kitaId: kita.id, name: "Beispiel" },
});
f.parent = await user("ELTERN", { familyId: f.family.id });
f.child = await prisma.child.create({
data: {
kitaId: kita.id,
familyId: f.family.id,
firstName: "Kind",
lastName: "Test",
},
});
current = f.admin;
emails.length = 0;
});
after(async () => {
if (!prisma) return;
await prisma.kita.deleteMany({ where: { id: { in: tenants } } });
await prisma.user.deleteMany({ where: { id: { in: looseUsers } } });
await prisma.auditLog.deleteMany({
where: { targetKitaId: { in: tenants } },
});
await prisma.$disconnect();
});
test("Eltern werden Koordination und Kita-Admin; Haushalt und Kind bleiben erhalten", async () => {
assert.equal(
(
await changeAction({
userId: f.parent.id,
expectedRole: "ELTERN",
role: "KOORDINATOR",
})
).success,
true,
);
const coord = await prisma.user.findUniqueOrThrow({
where: { id: f.parent.id },
});
await changeTo(coord, "ADMIN");
const updated = await prisma.user.findUniqueOrThrow({
where: { id: coord.id },
});
assert.equal(updated.familyId, f.family.id);
assert.equal(updated.sessionVersion, coord.sessionVersion);
assert.equal(
await prisma.child.count({ where: { familyId: f.family.id } }),
1,
);
});
test("Rollenänderung protokolliert Akteur, Kita und vorherige/neue Rolle genau einmal", async () => {
await changeTo(f.parent, "ADMIN");
const jobs = await prisma.auditLog.findMany({
where: { targetKitaId: f.kita.id, action: "USER_ROLE_CHANGE" },
});
assert.equal(jobs.length, 1);
assert.equal(jobs[0].actorUserId, f.admin.id);
assert.deepEqual(jobs[0].metadata, {
previousRole: "ELTERN",
newRole: "ADMIN",
});
assert.equal(
(await changeTo({ ...f.parent, role: "ADMIN" }, "ADMIN")).changed,
false,
);
assert.equal(
await prisma.auditLog.count({
where: { targetKitaId: f.kita.id, action: "USER_ROLE_CHANGE" },
}),
1,
);
});
test("Konto ohne Haushalt darf Koordination werden, aber nicht Eltern oder Fachkraft", async () => {
const person = await user("KOORDINATOR");
await assert.rejects(changeTo(person, "ELTERN"), /Haushalt/);
await assert.rejects(changeTo(person, "ERZIEHER"), /Personalprofil/);
assert.equal((await changeTo(person, "ADMIN")).changed, true);
});
test("Superadmin-Rolle kann weder vergeben noch verändert werden", async () => {
await assert.rejects(changeTo(f.parent, "SUPERADMIN"), /Ungültige/);
const root = await user("SUPERADMIN");
await assert.rejects(
change(actor(f.admin), {
userId: root.id,
expectedRole: "ADMIN",
role: "KOORDINATOR",
}),
/nicht geändert/,
);
});
test("Fremde Kita ist bei Zielkonto und Akteur ausgeschlossen", async () => {
const foreign = await prisma.kita.create({
data: { name: "Andere", slug: `roles-${randomUUID()}` },
});
tenants.push(foreign.id);
const outsider = await user("ADMIN", {}, foreign.id);
await assert.rejects(changeTo(outsider, "KOORDINATOR"), /nicht geändert/);
await assert.rejects(
change(
{ ...actor(f.admin), kitaId: foreign.id },
{ userId: outsider.id, expectedRole: "ADMIN", role: "KOORDINATOR" },
),
/nicht mehr ändern/,
);
});
test("Eltern, gesperrte Admins und veraltete Sessions dürfen keine Rollen ändern", async () => {
await assert.rejects(
change(actor(f.parent), {
userId: f.admin.id,
expectedRole: "ADMIN",
role: "KOORDINATOR",
}),
/nicht mehr ändern/,
);
await assert.rejects(
change(
{ ...actor(f.admin), sessionVersion: 9 },
{ userId: f.parent.id, expectedRole: "ELTERN", role: "ADMIN" },
),
/nicht mehr ändern/,
);
await prisma.user.update({
where: { id: f.admin.id },
data: { disabledAt: new Date() },
});
await assert.rejects(changeTo(f.parent, "ADMIN"), /nicht mehr ändern/);
});
test("Veralteter Dialog überschreibt keine zwischenzeitliche Rollenänderung", async () => {
await changeTo(f.parent, "KOORDINATOR");
await assert.rejects(changeTo(f.parent, "ADMIN"), /inzwischen geändert/);
assert.equal(
(await prisma.user.findUnique({ where: { id: f.parent.id } })).role,
"KOORDINATOR",
);
});
test("Letzter Admin kann seine Rolle nicht abgeben", async () => {
await assert.rejects(
changeTo(f.admin, "KOORDINATOR"),
/Mindestens ein aktiver/,
);
});
for (const [label, extra] of [
["offene Einladung", { emailVerifiedAt: null, passwordHash: "" }],
["deaktiviert", { disabledAt: new Date() }],
["gesperrt", { lockedUntil: new Date("2099-01-01") }],
["ohne Zustimmung", { privacyPolicyAcceptedAt: null }],
]) {
test(`Ein weiterer Admin ${label} reicht nicht als Ersatz`, async () => {
await user("ADMIN", extra);
await assert.rejects(
changeTo(f.admin, "KOORDINATOR"),
/Mindestens ein aktiver/,
);
});
}
test("Mit zweitem aktivierten Admin ist eigene Herabstufung möglich und stale Admin-Rechte sind unwirksam", async () => {
await user("ADMIN");
assert.equal((await changeTo(f.admin, "KOORDINATOR")).ownRoleChanged, true);
await assert.rejects(changeTo(f.parent, "ADMIN"), /nicht mehr ändern/);
});
test("Parallele Herabstufung zweier Admins lässt genau einen Admin bestehen", async () => {
const second = await user("ADMIN");
const results = await Promise.allSettled([
changeTo(f.admin, "KOORDINATOR", f.admin),
changeTo(second, "KOORDINATOR", second),
]);
assert.equal(results.filter((r) => r.status === "fulfilled").length, 1);
assert.equal(
await prisma.user.count({ where: { kitaId: f.kita.id, role: "ADMIN" } }),
1,
);
});
test("Eigene Kontolöschung ist für letzten Admin gesperrt", async () => {
assert.match((await remove()).error, /Mindestens ein aktiver/);
assert.ok(await prisma.user.findUnique({ where: { id: f.admin.id } }));
});
test("Eigene Kontolöschung mit zweitem aktivem Admin funktioniert weiterhin", async () => {
await user("ADMIN");
assert.equal((await remove()).success, true);
assert.equal(
await prisma.user.findUnique({ where: { id: f.admin.id } }),
null,
);
});
test("Superadmin kann den letzten Kita-Admin weder löschen noch sperren", async () => {
current = await user("SUPERADMIN", {}, null);
looseUsers.push(current.id);
current.authenticatedAt = Date.now();
assert.match(
(
await support.deleteUser({
userId: f.admin.id,
confirmationEmail: f.admin.email,
})
).error,
/Mindestens ein aktiver/,
);
assert.match(
(await support.lockUser(f.admin.id)).error,
/Mindestens ein aktiver/,
);
});
test("Support darf zusätzlichen Admin sperren und löschen", async () => {
const second = await user("ADMIN");
current = await user("SUPERADMIN", {}, null);
looseUsers.push(current.id);
current.authenticatedAt = Date.now();
assert.equal((await support.lockUser(second.id)).ok, true);
assert.equal(
(
await support.deleteUser({
userId: second.id,
confirmationEmail: second.email,
})
).ok,
true,
);
});
test("Gleichzeitige Admin-Löschung und Herabstufung lässt einen Admin bestehen", async () => {
const second = await user("ADMIN");
const results = await Promise.allSettled([
remove(),
changeTo(second, "KOORDINATOR"),
]);
const successful =
results.filter((r) => r.status === "fulfilled" && r.value.success).length +
results.filter((r) => r.status === "fulfilled" && r.value.changed).length;
assert.equal(successful, 1);
assert.equal(
await prisma.user.count({ where: { kitaId: f.kita.id, role: "ADMIN" } }),
1,
);
});
test("E-Mail-Änderung darf letzten aktivierten Admin nicht deaktivieren", async () => {
await prisma.user.update({
where: { id: f.admin.id },
data: { familyId: f.family.id },
});
const payload = {
familyId: f.family.id,
familyName: f.family.name,
parents: [
f.parent,
{ ...f.admin, email: `${randomUUID()}@example.invalid` },
],
children: [f.child],
};
assert.match((await updateFamily(payload)).error, /Mindestens ein aktiver/);
assert.equal(
(await prisma.user.findUnique({ where: { id: f.admin.id } })).email,
f.admin.email,
);
});
test("Fachkraft wird Admin; Personalprofil bleibt bearbeitbar und eigene Mailpräferenz erhalten", async () => {
const person = await user("ERZIEHER");
const educator = await prisma.educator.create({
data: { kitaId: f.kita.id, firstName: "Team", lastName: "Test" },
});
const profile = await prisma.staffProfile.create({
data: {
kitaId: f.kita.id,
educatorId: educator.id,
userId: person.id,
employmentStart: new Date("2030-01-01"),
trackingStart: new Date("2030-01-01"),
},
});
await assert.rejects(changeTo(person, "KOORDINATOR"), /Personalprofil/);
await changeTo(person, "ADMIN");
await staff.executeStaffCommand(
{ id: person.id, kitaId: f.kita.id, role: "ADMIN" },
{ command: "emailPreference", data: { enabled: false } },
);
await staff.executeStaffCommand(
{ id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" },
{
command: "educatorUpdate",
data: {
educatorId: educator.id,
firstName: "Team",
lastName: "Geändert",
active: true,
email: person.email,
},
},
);
assert.equal(
(await prisma.staffProfile.findUnique({ where: { id: profile.id } }))
.userId,
person.id,
);
assert.equal(
(await prisma.user.findUnique({ where: { id: person.id } }))
.staffDecisionEmails,
false,
);
await changeTo({ ...person, role: "ADMIN" }, "ERZIEHER");
});
test("Offene Fachkraft-Einladung lässt sich auch nach Admin-Ernennung erneut senden", async () => {
const person = await user("ERZIEHER", {
emailVerifiedAt: null,
passwordHash: "",
});
const educator = await prisma.educator.create({
data: { kitaId: f.kita.id, firstName: "Team", lastName: "Test" },
});
await prisma.staffProfile.create({
data: {
kitaId: f.kita.id,
educatorId: educator.id,
userId: person.id,
employmentStart: new Date("2030-01-01"),
trackingStart: new Date("2030-01-01"),
},
});
await changeTo(person, "ADMIN");
const queued = await staff.executeStaffCommand(
{ id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" },
{ command: "educatorInvite", data: { educatorId: educator.id } },
);
assert.equal(
(
await staffMail.deliverStaffEmails(queued.emailIds, async () => ({
success: true,
}))
).sent,
1,
);
});
for (const role of ["ADMIN", "KOORDINATOR"])
test(`Haushaltsalarme erreichen und akzeptieren Eltern mit Rolle ${role}`, async () => {
await changeTo(f.parent, role);
const plan = await prisma.notdienstPlan.create({
data: {
kitaId: f.kita.id,
year: 2030,
month: 11,
status: "PUBLISHED",
assignments: {
create: {
kitaId: f.kita.id,
childId: f.child.id,
date: new Date("2030-11-04"),
},
},
},
include: { assignments: true },
});
const job = await alarms.executeAlarmCommand(
{ id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" },
"create",
{ assignmentId: plan.assignments[0].id, wholeDay: true },
);
const request = await prisma.notdienstAlarmEmail.findFirstOrThrow({
where: { alarmId: job.alarmId, userId: f.parent.id, kind: "REQUEST" },
});
const token = request.path.split("/").pop();
assert.ok(await alarms.getPublicAlarm(token));
assert.equal(
(
await alarmMail.deliverAlarmEmails([request.id], async () => ({
success: true,
}))
).sent,
1,
);
await alarms.respondToAlarm(token, true);
assert.equal(
(await prisma.notdienstAlarm.findUnique({ where: { id: job.alarmId } }))
.status,
"CONFIRMED",
);
});
test("Bereits ausgestellter Haushalts-Alarmlink bleibt nach Ernennung gültig", async () => {
const plan = await prisma.notdienstPlan.create({
data: {
kitaId: f.kita.id,
year: 2030,
month: 11,
status: "PUBLISHED",
assignments: {
create: {
kitaId: f.kita.id,
childId: f.child.id,
date: new Date("2030-11-04"),
},
},
},
include: { assignments: true },
});
const job = await alarms.executeAlarmCommand(
{ id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" },
"create",
{ assignmentId: plan.assignments[0].id, wholeDay: true },
);
const request = await prisma.notdienstAlarmEmail.findFirstOrThrow({
where: { alarmId: job.alarmId, userId: f.parent.id, kind: "REQUEST" },
});
await changeTo(f.parent, "ADMIN");
assert.ok(await alarms.getPublicAlarm(request.path.split("/").pop()));
});
test("Letzter Admin im Ein-Personen-Haushalt bleibt auch vor kaskadierender Familienlöschung geschützt", async () => {
await prisma.user.update({
where: { id: f.parent.id },
data: { role: "ADMIN" },
});
await prisma.user.update({
where: { id: f.admin.id },
data: { role: "KOORDINATOR" },
});
current = { ...f.parent, role: "ADMIN" };
assert.match((await remove()).error, /Mindestens ein aktiver/);
assert.ok(await prisma.family.findUnique({ where: { id: f.family.id } }));
assert.ok(await prisma.child.findUnique({ where: { id: f.child.id } }));
});
test("Veröffentlichte Umteilung informiert auch den Admin im betroffenen Elternhaushalt", async () => {
await changeTo(f.parent, "ADMIN");
const family = await prisma.family.create({
data: { kitaId: f.kita.id, name: "Ersatz" },
});
const parent = await user("ELTERN", { familyId: family.id });
const child = await prisma.child.create({
data: {
kitaId: f.kita.id,
familyId: family.id,
firstName: "Ersatz",
lastName: "Test",
},
});
const plan = await prisma.notdienstPlan.create({
data: {
kitaId: f.kita.id,
year: 2030,
month: 11,
status: "PUBLISHED",
assignments: {
create: {
kitaId: f.kita.id,
childId: f.child.id,
date: new Date("2030-11-04"),
},
},
},
});
const result = await assign(plan.id, "2030-11-04", child.id);
assert.equal(result.success, true);
assert.ok(emails.some((email) => [email.to].flat().includes(f.parent.email)));
assert.ok(emails.some((email) => [email.to].flat().includes(parent.email)));
});
+57
View File
@@ -0,0 +1,57 @@
/* eslint-disable @typescript-eslint/no-require-imports -- HTTP tests run directly in Node. */
const { test } = require('node:test');
const assert = require('node:assert/strict');
const origin = process.env.TEST_SEO_ORIGIN;
if (!origin || !['127.0.0.1', 'localhost'].includes(new URL(origin).hostname)) throw new Error('TEST_SEO_ORIGIN muss ausdrücklich auf den lokalen Produktionsbuild zeigen.');
const site = new URL(process.env.TEST_SEO_SITE_URL || 'https://mein-kitaplaner.de').origin;
const paths = ['/', '/features/notdienst-planer', '/features/kita-kalender', '/features/eltern-adressbuch', '/features/krankmeldung', '/features/schwarzes-brett', '/features/personal-arbeitszeit', '/impressum', '/datenschutz'];
const request = (path, ua = 'Googlebot') => fetch(new URL(path, origin), { headers: { 'user-agent': ua }, redirect: 'manual', signal: AbortSignal.timeout(15000) });
const attributes = text => Object.fromEntries([...text.matchAll(/([\w:-]+)="([^"]*)"/g)].map(match => [match[1], match[2]]));
const elements = (html, tag) => [...html.matchAll(new RegExp(`<${tag}\\b[^>]*>`, 'g'))].map(match => attributes(match[0]));
function meta(html, key) { const values = elements(html, 'meta').filter(row => row.name === key || row.property === key); assert.equal(values.length, 1, key); return values[0].content; }
const structured = html => [...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/g)].filter(match => attributes(match[1]).type === 'application/ld+json').map(match => ({ attrs: attributes(match[1]), data: JSON.parse(match[2]) }));
for (const path of paths) test(`SEO im ausgelieferten HTML: ${path}`, async () => {
const response = await request(path); assert.equal(response.status, 200); const html = await response.text();
assert.ok(html.includes('<html lang="de"')); assert.equal((html.match(/<h1\b/g) || []).length, 1);
const canonical = elements(html, 'link').filter(row => row.rel === 'canonical'); assert.equal(canonical.length, 1); assert.equal(new URL(canonical[0].href).href, new URL(path, site).href);
assert.equal((html.match(/<title>/g) || []).length, 1); const title = html.match(/<title>(.*?)<\/title>/)[1]; assert.equal((title.match(/Kita-Planer/g) || []).length, 1);
assert.ok(meta(html, 'description').length > 40); assert.ok(meta(html, 'description').length < 180);
assert.equal(meta(html, 'og:description'), meta(html, 'description')); assert.equal(meta(html, 'twitter:description'), meta(html, 'description'));
assert.equal(new URL(meta(html, 'og:url')).href, new URL(path, site).href);
assert.match(meta(html, 'robots'), /\bindex, follow\b/); assert.doesNotMatch(meta(html, 'robots'), /noindex/);
assert.equal(response.headers.get('x-robots-tag'), null); assert.equal(meta(html, 'og:image'), `${site}/og-image.png`);
if (path === '/' || path.startsWith('/features/')) {
const data = structured(html); assert.equal(data.length, 1); assert.equal(data[0].data['@context'], 'https://schema.org');
assert.ok(data[0].attrs.nonce); assert.ok(response.headers.get('content-security-policy').includes(`'nonce-${data[0].attrs.nonce}'`));
const graph = data[0].data['@graph']; assert.ok(graph.some(row => row['@type'] === (path === '/' ? 'SoftwareApplication' : 'BreadcrumbList')));
}
});
test('Social-Crawler erhält Canonical und Vorschaudaten direkt im HTML-Head', async () => {
const response = await request('/features/personal-arbeitszeit', 'facebookexternalhit/1.1'); assert.equal(response.status, 200);
const html = await response.text(); const head = html.match(/<head>([\s\S]*?)<\/head>/)[1];
assert.equal(meta(head, 'og:url'), `${site}/features/personal-arbeitszeit`); assert.equal(meta(head, 'twitter:card'), 'summary_large_image');
assert.ok(elements(head, 'link').some(row => row.rel === 'canonical'));
});
test('Ausgelieferte Sitemap enthält ausschließlich erreichbare kanonische öffentliche Seiten', async () => {
const response = await request('/sitemap.xml'); assert.equal(response.status, 200); const xml = await response.text();
const urls = [...xml.matchAll(/<loc>(.*?)<\/loc>/g)].map(match => new URL(match[1]).href).sort(); assert.deepEqual(urls, paths.map(path => new URL(path, site).href).sort());
assert.ok(xml.includes('<lastmod>2026-10-14</lastmod>')); assert.ok(!xml.includes('T00:'));
});
test('Robots verwenden dieselbe Domain und halten Loginformulare zum Lesen von noindex offen', async () => {
const response = await request('/robots.txt'); assert.equal(response.status, 200); const text = await response.text();
assert.ok(text.includes(`Sitemap: ${site}/sitemap.xml`)); assert.ok(text.includes('Disallow: /admin')); assert.ok(text.includes('Disallow: /neues-passwort/')); assert.ok(!text.includes('Disallow: /login'));
});
test('Social-Image ist tatsächlich ein PNG mit 1200 × 630 Pixeln', async () => {
const response = await request('/og-image.png'); assert.equal(response.status, 200); assert.match(response.headers.get('content-type'), /image\/png/);
const data = Buffer.from(await response.arrayBuffer()); assert.ok(data.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))); assert.equal(data.readUInt32BE(16), 1200); assert.equal(data.readUInt32BE(20), 630);
});
for (const path of ['/login', '/register', '/passwort-vergessen', '/neues-passwort/invalid', '/dashboard', '/admin', '/onboarding', '/alert/invalid', '/invite/invalid', '/forbidden']) test(`Private Route erhält noindex und no-store: ${path}`, async () => {
const response = await request(path); assert.ok(response.status < 500, `${path}: ${response.status}`); assert.match(response.headers.get('x-robots-tag'), /noindex, nofollow/); assert.match(response.headers.get('cache-control'), /private, no-store/);
if (response.status === 200) assert.match(meta(await response.text(), 'robots'), /noindex/);
});
test('Unbekannte Feature-Seiten antworten mit 404 und noindex', async () => {
const response = await request('/features/nicht-vorhanden'); assert.equal(response.status, 404); assert.ok(elements(await response.text(), 'meta').some(row => row.name === 'robots' && row.content.includes('noindex')));
});
+120
View File
@@ -0,0 +1,120 @@
/* eslint-disable @typescript-eslint/no-require-imports -- Load real TS modules with isolated framework boundaries. */
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm');
const ts = require('typescript');
const { renderToStaticMarkup } = require('react-dom/server');
function loader(overrides = {}) {
const cache = new Map();
function load(file) {
file = path.resolve(file);
if (cache.has(file)) return cache.get(file).exports;
const mod = { exports: {} }; cache.set(file, mod);
const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true },
fileName: file,
}).outputText;
const req = spec => {
if (Object.hasOwn(overrides, spec)) return overrides[spec];
if (spec === 'server-only') return {};
if (spec === '@/lib/prisma') throw new Error('Public SEO modules must not query the database.');
if (spec === 'next/headers') return { headers: async () => new Headers({ 'x-nonce': 'synthetic-seo-test-nonce' }) };
if (spec === 'next/server') return { after() {} };
if (spec === 'next/cache') return { revalidatePath() {} };
if (spec === 'next/navigation') return { redirect(url) { throw new Error(`REDIRECT:${url}`); }, notFound() { throw new Error('NOT_FOUND'); } };
if (spec.startsWith('@/') || spec.startsWith('.')) {
const target = spec.startsWith('@/') ? path.resolve('src', spec.slice(2)) : path.resolve(path.dirname(file), spec);
for (const suffix of ['.ts', '.tsx', '/index.ts']) if (fs.existsSync(target + suffix)) return load(target + suffix);
}
return require(spec);
};
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, { filename: file })(req, mod, mod.exports);
return mod.exports;
}
return load;
}
const savedOrigin = process.env.NEXT_PUBLIC_SITE_URL;
afterEach(() => { if (savedOrigin === undefined) delete process.env.NEXT_PUBLIC_SITE_URL; else process.env.NEXT_PUBLIC_SITE_URL = savedOrigin; });
const load = loader();
const seo = load('src/lib/marketing/seo.ts');
const { marketingFeatures } = load('src/lib/marketing/features.ts');
const sitemap = load('src/app/sitemap.ts').default;
const robots = load('src/app/robots.ts').default;
const validSite = () => { process.env.NEXT_PUBLIC_SITE_URL = 'https://example.invalid/'; };
test('Öffentliche URLs verwenden dieselbe normalisierte Adresse ohne doppelte Slashes', () => {
process.env.NEXT_PUBLIC_SITE_URL = ' https://example.invalid/setup?preview=true#fragment ';
assert.equal(seo.getPublicSiteOrigin(), 'https://example.invalid');
assert.equal(seo.publicUrl('/features/schwarzes-brett'), 'https://example.invalid/features/schwarzes-brett');
assert.equal(robots().sitemap, 'https://example.invalid/sitemap.xml');
});
test('Ohne konfigurierte Adresse wird die etablierte öffentliche Domain verwendet', () => {
delete process.env.NEXT_PUBLIC_SITE_URL; assert.equal(seo.publicUrl(), 'https://mein-kitaplaner.de/');
});
test('Ungültige Adressen, Zugangsdaten und fremde Seitenpfade werden zurückgewiesen', () => {
for (const value of ['javascript:alert(1)','ftp://example.invalid','https://user:secret@example.invalid','not-a-url']) {
process.env.NEXT_PUBLIC_SITE_URL = value; assert.throws(() => seo.getPublicSiteOrigin());
}
validSite(); for (const value of ['https://evil.invalid','//evil.invalid','/\\evil.invalid']) assert.throws(() => seo.publicUrl(value));
});
test('Öffentliche Metadaten haben eigene Canonicals, passende soziale URLs und Indexierung', async () => {
validSite(); const page = load('src/app/features/[slug]/page.tsx');
const titles = new Set(); const descriptions = new Set();
for (const feature of marketingFeatures) {
const metadata = await page.generateMetadata({ params: Promise.resolve({ slug: feature.slug }) });
assert.equal(metadata.alternates.canonical, seo.publicUrl(`/features/${feature.slug}`));
assert.equal(metadata.openGraph.url, metadata.alternates.canonical);
assert.equal(metadata.openGraph.description, metadata.description); assert.equal(metadata.twitter.description, metadata.description);
assert.equal(metadata.robots.index, true); assert.equal(metadata.robots.googleBot.index, true);
assert.equal(metadata.openGraph.images[0].width, 1200); assert.equal(metadata.openGraph.images[0].height, 630);
assert.ok(!metadata.openGraph.images[0].alt.includes('Kind spielt'));
assert.ok(!titles.has(metadata.title.absolute)); titles.add(metadata.title.absolute);
assert.ok(!descriptions.has(metadata.description)); descriptions.add(metadata.description);
}
});
test('Startseite und rechtliche Seiten wiederholen den Markennamen im Titel nicht', () => {
validSite(); const metadata = seo.publicPageMetadata('/', seo.marketingTitle, seo.marketingDescription);
assert.equal((metadata.title.absolute.match(/Kita-Planer/g) || []).length, 1);
for (const slug of ['impressum','datenschutz']) {
const value = load(`src/app/${slug}/page.tsx`).metadata;
assert.equal((value.title.absolute.match(/Kita-Planer/g) || []).length, 1);
assert.equal(value.alternates.canonical, seo.publicUrl(`/${slug}`)); assert.equal(value.robots.index, true);
}
});
test('Sitemap enthält genau die öffentlichen Seiten und den neuen Personalbereich', () => {
validSite(); const result = sitemap(); assert.equal(result.length, 9);
assert.equal(new Set(result.map(item => item.url)).size, result.length);
assert.deepEqual(result.map(item => item.url).sort(), ['/', '/impressum', '/datenschutz', ...marketingFeatures.map(feature => `/features/${feature.slug}`)].map(seo.publicUrl).sort());
assert.ok(result.some(item => item.url.endsWith('/features/personal-arbeitszeit')));
assert.ok(result.every(item => !/dashboard|login|register|passwort/.test(item.url)));
});
test('Änderungsdatum bleibt bei erneuter Generierung gleich; Rechtsseiten haben kein erfundenes Datum', () => {
validSite(); assert.deepEqual(sitemap(), sitemap());
for (const row of sitemap()) if (/impressum|datenschutz/.test(row.url)) assert.equal(row.lastModified, undefined); else assert.equal(row.lastModified, seo.marketingLastModified);
});
test('Robots blockieren interne und Tokenrouten; öffentliche Loginformulare erlauben noindex-Prüfung', () => {
validSite(); const result = robots(); assert.ok(result.rules.allow.includes('/features/'));
for (const value of ['/api/','/admin','/dashboard','/invite/','/alert/','/onboarding','/neues-passwort/']) assert.ok(result.rules.disallow.includes(value));
for (const value of ['/login','/register','/passwort-vergessen']) assert.ok(!result.rules.disallow.includes(value));
});
test('JSON-LD beschreibt vorhandene Funktionen ohne erfundene Bewertungen oder Preise', () => {
validSite(); const graph = seo.landingStructuredData(marketingFeatures); const software = graph['@graph'].find(row => row['@type'] === 'SoftwareApplication');
assert.equal(software.featureList.length, 6); assert.ok(software.featureList.some(text => text.startsWith('Personal & Arbeitszeit')));
assert.equal(software.offers, undefined); assert.equal(software.aggregateRating, undefined);
for (const feature of marketingFeatures) {
const data = seo.featureStructuredData(feature); const breadcrumb = data['@graph'].find(row => row['@type'] === 'BreadcrumbList');
assert.equal(breadcrumb.itemListElement[1].item, seo.publicUrl(`/features/${feature.slug}`));
}
});
test('Strukturierte Daten erhalten CSP-Nonce und lassen kein Script-Ende einschleusen', async () => {
const value = { title: '</script><script>alert(1)</script>' };
assert.ok(!seo.serializeStructuredData(value).includes('<')); assert.deepEqual(JSON.parse(seo.serializeStructuredData(value)), value);
const component = load('src/components/marketing/structured-data.tsx').StructuredData;
const html = renderToStaticMarkup(await component({ data: value }));
assert.ok(html.includes('nonce="synthetic-seo-test-nonce"')); assert.equal((html.match(/<script/g) || []).length, 1);
});
test('Landingtitel und veröffentlichte Funktionsbeschreibungen vermeiden veraltete Zusagen', () => {
assert.equal(marketingFeatures.length, 6); assert.equal(new Set(marketingFeatures.map(feature => feature.slug)).size, 6);
const text = JSON.stringify(marketingFeatures); assert.ok(!/1-Klick-Alarmierung|Raumbuchungen|Lesebestätigung/.test(text));
});
+139
View File
@@ -0,0 +1,139 @@
/* eslint-disable @typescript-eslint/no-require-imports -- Render the actual client component through the TypeScript loader. */
const { test } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const ts = require("typescript");
const React = require("react");
const { renderToStaticMarkup } = require("react-dom/server");
function loader(overrides = {}) {
const cache = new Map();
function load(file) {
file = path.resolve(file);
if (cache.has(file)) return cache.get(file).exports;
const mod = { exports: {} };
cache.set(file, mod);
const code = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.CommonJS,
jsx: ts.JsxEmit.ReactJSX,
esModuleInterop: true,
},
fileName: file,
}).outputText;
const req = (spec) => {
if (Object.hasOwn(overrides, spec)) return overrides[spec];
if (spec === "server-only") return {};
if (spec === "@/lib/prisma")
throw new Error("No database in component render tests");
if (spec === "next/headers")
return { headers: async () => new Headers() };
if (spec === "next/cache") return { revalidatePath() {} };
if (spec === "next/navigation")
return {
redirect(url) {
throw new Error(`REDIRECT:${url}`);
},
notFound() {
throw new Error("NOT_FOUND");
},
};
if (spec.startsWith("@/") || spec.startsWith(".")) {
const target = spec.startsWith("@/")
? path.resolve("src", spec.slice(2))
: path.resolve(path.dirname(file), spec);
for (const suffix of [".ts", ".tsx", "/index.ts"])
if (fs.existsSync(target + suffix)) return load(target + suffix);
}
return require(spec);
};
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, {
filename: file,
})(req, mod, mod.exports);
return mod.exports;
}
return load;
}
const load = loader({ "./actions": {} });
const { ManualAssignmentTable } = load(
"src/app/dashboard/notdienst/plan/plan-view.tsx",
);
const children = [
{ id: "a", name: "Alex", parentName: "Haushalt A", familyId: "family-a" },
{ id: "b", name: "Ben", parentName: "Haushalt B", familyId: "family-b" },
{ id: "a2", name: "Anna", parentName: "Haushalt A", familyId: "family-a" },
];
const day = {
date: "2030-11-04T00:00:00.000Z",
assignmentId: null,
childId: null,
};
const familyStatuses = [
{ id: "family-a", selectedDays: [{ iso: day.date }] },
{ id: "family-b", selectedDays: [{ iso: "2030-11-05T00:00:00.000Z" }] },
];
const render = (overrides = {}) =>
renderToStaticMarkup(
React.createElement(ManualAssignmentTable, {
days: [day],
allChildren: children,
familyStatuses,
isDraft: true,
isPending: false,
onAssignmentChange() {},
...overrides,
}),
);
test("Verfügbare Haushalte stehen zuerst; Geschwister zählen als eine verfügbare Familie", () => {
const html = render();
assert.ok(
html.indexOf("Verfügbar an diesem Tag") <
html.indexOf("Keine Verfügbarkeit für diesen Tag"),
);
assert.match(html, /1 Familie ist verfügbar/);
assert.match(html, /value="a2"/);
assert.match(html, /value="b"/);
assert.match(html, /aria-label="Familie für 04.11.2030"/);
});
test("Bestehende Einteilung ohne Rückmeldung bleibt auswählbar und wird kenntlich gemacht", () => {
const html = render({ days: [{ ...day, childId: "b" }] });
assert.match(html, /value="b" selected/);
assert.match(
html,
/Eingeteilte Familie hat für diesen Tag keine Verfügbarkeit angegeben/,
);
});
test("Eingeteilte verfügbare Familie und Haushaltseinteilungszahl werden angezeigt", () => {
const html = render({
days: [
{ ...day, childId: "a" },
{ ...day, date: "2030-11-05T00:00:00.000Z", childId: "a2" },
],
});
assert.match(html, /Eingeteilte Familie ist verfügbar/);
assert.match(html, /Haushalt A \(Alex\) — 2x/);
});
test("Fehlende Verfügbarkeiten erzeugen eine klare Angabe ohne Familien auszublenden", () => {
const html = render({ familyStatuses: [] });
assert.match(html, /0 Familien sind verfügbar/);
assert.match(html, /value="a"/);
assert.match(html, /value="b"/);
});
test("Aktiver Alarm sperrt die Auswahl weiterhin", () => {
const html = render({
days: [{ ...day, childId: "a", activeAlarmId: "alarm" }],
});
assert.match(html, /<select[^>]*disabled/);
});
test("Ungültige alte Einteilung bleibt entfernbar; neue Kinder sind dafür gesperrt", () => {
const html = render({
days: [{ ...day, childId: "inactive", isInvalid: true }],
});
assert.match(html, /value="empty"/);
assert.match(html, /value="inactive" disabled/);
assert.match(html, /value="a" disabled/);
assert.match(html, /Einteilung nicht mehr gültig/);
});
@@ -199,11 +199,11 @@ test('Noch nicht fällige Kitas verdrängen fällige Erinnerungen nicht aus dem
});
test('Geschützter Job startet ohne gültiges Secret keine Verarbeitung',async()=>{
const authorization=loader()('src/lib/alarms/job.ts').authorizeAlarmJob;let called=0;
const route=loader({'@/lib/alarms/job':{authorizeAlarmJob:authorization,runAlarmJob:async()=>{called++;return {};}},'@/lib/notdienst-notifications':{runNotdienstNotificationJob:async()=>{called++;return {};}}})('src/app/api/jobs/notdienstalarme/route.ts').POST;
const route=loader({'@/lib/alarms/job':{authorizeAlarmJob:authorization,runAlarmJob:async()=>{called++;return {};}},'@/lib/notdienst-notifications':{runNotdienstNotificationJob:async()=>{called++;return {};}},'@/lib/announcement-delivery':{deliverDueAnnouncementEmails:async()=>{called++;return {};}}})('src/app/api/jobs/notdienstalarme/route.ts').POST;
const old=process.env.NOTDIENST_JOB_SECRET;
try{delete process.env.NOTDIENST_JOB_SECRET;assert.equal((await route(new Request('http://127.0.0.1/api/jobs/notdienstalarme',{method:'POST'}))).status,503);
process.env.NOTDIENST_JOB_SECRET='test-only-secret-32-characters-long';assert.equal((await route(new Request('http://127.0.0.1/api/jobs/notdienstalarme',{method:'POST',headers:{authorization:'Bearer wrong'}}))).status,401);assert.equal(called,0);
const result=await route(new Request('http://127.0.0.1/api/jobs/notdienstalarme',{method:'POST',headers:{authorization:`Bearer ${process.env.NOTDIENST_JOB_SECRET}`}}));assert.equal(result.status,200);assert.equal(called,2);
const result=await route(new Request('http://127.0.0.1/api/jobs/notdienstalarme',{method:'POST',headers:{authorization:`Bearer ${process.env.NOTDIENST_JOB_SECRET}`}}));assert.equal(result.status,200);assert.equal(called,3);
}finally{if(old===undefined)delete process.env.NOTDIENST_JOB_SECRET;else process.env.NOTDIENST_JOB_SECRET=old;}
});
+13 -3
View File
@@ -69,9 +69,19 @@ test(
const { updateTerminAdmin } = loadAfterMocks(
"../src/app/dashboard/kalender/actions",
) as typeof import("../src/app/dashboard/kalender/actions");
const { createAnnouncement, markAnnouncementRead } = loadAfterMocks(
"../src/actions/announcements",
) as typeof import("../src/actions/announcements");
// Next resolves this marker internally. The standalone Node harness has no
// Next bundler, so replace only the marker while loading the real service.
const nodeModule = loadAfterMocks("node:module");
const originalLoad = nodeModule._load;
let announcementActions: typeof import("../src/actions/announcements");
try {
nodeModule._load = function (this: unknown, specifier: string, ...args: unknown[]) {
if (specifier === "server-only") return {};
return originalLoad.call(this, specifier, ...args);
};
announcementActions = loadAfterMocks("../src/actions/announcements");
} finally { nodeModule._load = originalLoad; }
const { createAnnouncement, markAnnouncementRead } = announcementActions;
const { GET: getAttachment } = loadAfterMocks(
"../src/app/api/announcements/attachments/[id]/route",
) as typeof import("../src/app/api/announcements/attachments/[id]/route");
@@ -0,0 +1,584 @@
/* eslint-disable @typescript-eslint/no-require-imports -- Test the actual services/actions with request and transport boundaries replaced. */
const { test, before, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const ts = require("typescript");
const { randomUUID } = require("node:crypto");
const { PrismaClient } = require("@prisma/client");
const { hash, compare } = require("bcryptjs");
const { render } = require("@react-email/render");
let prisma, current, f, service, actions, tokenHash, initialHash, jwt;
const tenants = [],
emails = [],
background = [];
let transportFailure = false;
const oldPassword = "Test-password-old-2026";
const newPassword = "Test-password-new-2026";
function loader(overrides = {}) {
const cache = new Map();
function load(file) {
file = path.resolve(file);
if (cache.has(file)) return cache.get(file).exports;
const mod = { exports: {} };
cache.set(file, mod);
const code = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.CommonJS,
jsx: ts.JsxEmit.ReactJSX,
esModuleInterop: true,
},
fileName: file,
}).outputText;
const req = (spec) => {
if (Object.hasOwn(overrides, spec)) return overrides[spec];
if (spec === "server-only") return {};
if (spec === "@/lib/prisma") return { prisma };
if (spec === "next/headers")
return { headers: async () => new Headers() };
if (spec === "next/cache") return { revalidatePath() {} };
if (spec === "next/navigation")
return {
redirect(url) {
throw new Error(`REDIRECT:${url}`);
},
notFound() {
throw new Error("NOT_FOUND");
},
};
if (spec.startsWith("@/") || spec.startsWith(".")) {
const target = spec.startsWith("@/")
? path.resolve("src", spec.slice(2))
: path.resolve(path.dirname(file), spec);
for (const suffix of [".ts", ".tsx", "/index.ts"])
if (fs.existsSync(target + suffix)) return load(target + suffix);
}
return require(spec);
};
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, {
filename: file,
})(req, mod, mod.exports);
return mod.exports;
}
return load;
}
const form = (data) => {
const f = new FormData();
for (const [key, value] of Object.entries(data)) f.set(key, value);
return f;
};
const token = () =>
new URL(emails.at(-1).react.props.resetLink).pathname.split("/").pop();
const resetInput = (link = token(), password = newPassword) => ({
token: link,
password,
confirmation: password,
});
const drain = async () => {
while (background.length) await background.shift()();
};
before(async () => {
const raw = process.env.TEST_DATABASE_URL;
assert.ok(
raw,
"TEST_DATABASE_URL muss ausdrücklich auf die lokale kita_account_test zeigen.",
);
const url = new URL(raw);
assert.ok(["127.0.0.1", "localhost"].includes(url.hostname));
assert.equal(url.pathname, "/kita_account_test");
prisma = new PrismaClient({ datasourceUrl: raw });
initialHash = await hash(oldPassword, 12);
process.env.AUTH_URL = "http://127.0.0.1:3014";
const load = loader({
"@/lib/auth-utils": { requireSession: async () => ({ user: current }) },
"@/lib/mail": {
sendAppEmail: async (input) => {
emails.push(input);
return transportFailure
? { success: false, error: "test-only" }
: { success: true };
},
},
"next/server": { after: (fn) => background.push(fn) },
"next-auth": (config) => {
jwt = config.callbacks.jwt;
return {};
},
"next-auth/providers/credentials": (options) => options,
});
service = load("src/lib/password-management.ts");
actions = load("src/app/passwort-vergessen/actions.ts");
tokenHash = load("src/lib/tokens.ts").hashToken;
load("src/auth.ts");
});
beforeEach(async () => {
const kita = await prisma.kita.create({
data: { name: "Kontotest", slug: "account-" + randomUUID() },
});
tenants.push(kita.id);
const user = await prisma.user.create({
data: {
kitaId: kita.id,
email: randomUUID() + "@example.invalid",
firstName: "Test",
lastName: "Konto",
passwordHash: initialHash,
emailVerifiedAt: new Date(),
privacyPolicyAcceptedAt: new Date(),
},
});
f = { kita, user };
current = user;
emails.length = 0;
background.length = 0;
transportFailure = false;
});
after(async () => {
if (!prisma) return;
const users = await prisma.user.findMany({
where: { kitaId: { in: tenants } },
select: { id: true },
});
for (const u of users)
await prisma.verificationToken.deleteMany({
where: {
OR: [
{ identifier: u.id },
{
identifier: {
startsWith:
"password-reset:" +
Buffer.from(u.id).toString("base64url") +
":",
},
},
],
},
});
await prisma.kita.deleteMany({ where: { id: { in: tenants } } });
await prisma.$disconnect();
});
test("Reset speichert nur Hash, gültige App-Adresse und 30-Minuten-Frist; GET verbraucht den Link nicht", async () => {
const before = Date.now();
await service.requestPasswordReset(f.user.email);
const link = token(),
rows = await prisma.verificationToken.findMany({
where: { token: tokenHash(link) },
});
assert.equal(rows.length, 1);
assert.notEqual(rows[0].token, link);
assert.ok(rows[0].identifier.startsWith("password-reset:"));
assert.ok(rows[0].expires.getTime() >= before + 29 * 60_000);
assert.ok(rows[0].expires.getTime() <= Date.now() + 30 * 60_000);
assert.equal(await service.isPasswordResetValid(link), true);
assert.equal(await service.isPasswordResetValid(link), true);
assert.match(
emails[0].react.props.resetLink,
/^http:\/\/127\.0\.0\.1:3014\/neues-passwort\//,
);
const html = await render(emails[0].react);
assert.match(html, /30 Minuten/);
assert.match(html, /ignoriere|ignorieren/);
});
test("Anfrage antwortet gleich für bekannte, unbekannte und deaktivierte Adresse; Verarbeitung folgt nach der Antwort", async () => {
const known = await actions.requestResetAction(
{},
form({ email: f.user.email }),
);
assert.equal(emails.length, 0);
assert.equal(background.length, 1);
await drain();
assert.equal(emails.length, 1);
const missing = await actions.requestResetAction(
{},
form({ email: randomUUID() + "@example.invalid" }),
);
await drain();
await prisma.user.update({
where: { id: f.user.id },
data: { disabledAt: new Date() },
});
const disabled = await actions.requestResetAction(
{},
form({ email: f.user.email }),
);
await drain();
assert.deepEqual(known, missing);
assert.deepEqual(known, disabled);
assert.equal(emails.length, 1);
});
test("Anfrage normalisiert E-Mail und lässt offene Einladungen unverändert", async () => {
await service.requestPasswordReset(" " + f.user.email.toUpperCase() + " ");
assert.equal(emails.length, 1);
await prisma.user.update({
where: { id: f.user.id },
data: { emailVerifiedAt: null, passwordHash: "" },
});
emails.length = 0;
await prisma.verificationToken.create({
data: {
identifier: f.user.id,
token: "invitation-" + randomUUID(),
expires: new Date(Date.now() + 60_000),
},
});
await service.requestPasswordReset(f.user.email);
assert.equal(emails.length, 0);
assert.equal(
await prisma.verificationToken.count({ where: { identifier: f.user.id } }),
1,
);
});
test("Neue Anfrage ersetzt nur frühere Reset-Links und nicht Aktivierungstokens", async () => {
await prisma.verificationToken.create({
data: {
identifier: f.user.id,
token: "invite-" + randomUUID(),
expires: new Date(Date.now() + 60_000),
},
});
await service.requestPasswordReset(f.user.email);
const first = token();
await service.requestPasswordReset(f.user.email);
assert.equal(await service.isPasswordResetValid(first), false);
assert.equal(await service.isPasswordResetValid(token()), true);
assert.equal(
await prisma.verificationToken.count({ where: { identifier: f.user.id } }),
1,
);
});
test("Passwort wird gehasht, Lockout aufgehoben und alle alten Anmeldungen werden ungültig", async () => {
await service.requestPasswordReset(f.user.email);
await prisma.user.update({
where: { id: f.user.id },
data: {
lockedUntil: new Date(Date.now() + 60_000),
failedLoginAttempts: 10,
},
});
await service.resetPassword(resetInput());
const fresh = await prisma.user.findUniqueOrThrow({
where: { id: f.user.id },
});
assert.equal(await compare(newPassword, fresh.passwordHash), true);
assert.equal(await compare(oldPassword, fresh.passwordHash), false);
assert.equal(fresh.sessionVersion, 1);
assert.equal(fresh.failedLoginAttempts, 0);
assert.equal(fresh.lockedUntil, null);
assert.equal(
await jwt({
token: { id: f.user.id, sessionVersion: 0, authenticatedAt: Date.now() },
}),
null,
);
});
test("Reset-Link ist nur einmal nutzbar", async () => {
await service.requestPasswordReset(f.user.email);
const payload = resetInput();
await service.resetPassword(payload);
assert.equal(await service.isPasswordResetValid(payload.token), false);
await assert.rejects(service.resetPassword(payload), /Link ist ungültig/);
});
test("Parallele Verwendung desselben Links ändert das Passwort genau einmal", async () => {
await service.requestPasswordReset(f.user.email);
const payload = resetInput();
const results = await Promise.allSettled([
service.resetPassword(payload),
service.resetPassword({
...payload,
password: "Another-test-password",
confirmation: "Another-test-password",
}),
]);
assert.equal(results.filter((r) => r.status === "fulfilled").length, 1);
assert.equal(
(await prisma.user.findUniqueOrThrow({ where: { id: f.user.id } }))
.sessionVersion,
1,
);
});
test("Abgelaufene, erfundene und zweckfremde Aktivierungslinks funktionieren nicht", async () => {
await service.requestPasswordReset(f.user.email);
const link = token();
await prisma.verificationToken.update({
where: { token: tokenHash(link) },
data: { expires: new Date(0) },
});
await assert.rejects(
service.resetPassword(resetInput(link)),
/Link ist ungültig/,
);
const fake = "a".repeat(64);
await assert.rejects(
service.resetPassword(resetInput(fake)),
/Link ist ungültig/,
);
await prisma.verificationToken.create({
data: {
identifier: f.user.id,
token: tokenHash(fake),
expires: new Date(Date.now() + 60_000),
},
});
await assert.rejects(
service.resetPassword(resetInput(fake)),
/Link ist ungültig/,
);
});
test("Deaktivierung und Kontolöschung verhindern die Verwendung eines vorhandenen Links", async () => {
await service.requestPasswordReset(f.user.email);
const link = token();
await prisma.user.update({
where: { id: f.user.id },
data: { disabledAt: new Date(), sessionVersion: { increment: 1 } },
});
await assert.rejects(
service.resetPassword(resetInput(link)),
/Link ist ungültig/,
);
await prisma.user.delete({ where: { id: f.user.id } });
assert.equal(await service.isPasswordResetValid(link), false);
});
test("Ein Reset-Link für die alte E-Mail-Adresse bleibt auch nach neuer Aktivierung ungültig", async () => {
await service.requestPasswordReset(f.user.email);
const link = token();
await prisma.user.update({
where: { id: f.user.id },
data: {
email: randomUUID() + "@example.invalid",
sessionVersion: { increment: 1 },
emailVerifiedAt: null,
passwordHash: "",
},
});
await prisma.user.update({
where: { id: f.user.id },
data: { emailVerifiedAt: new Date(), passwordHash: initialHash },
});
assert.equal(await service.isPasswordResetValid(link), false);
await assert.rejects(
service.resetPassword(resetInput(link)),
/Link ist ungültig/,
);
});
test("Mailfehler hinterlässt keinen nutzbaren Link und verrät keine Kontodaten in der öffentlichen Antwort", async () => {
transportFailure = true;
const response = await actions.requestResetAction(
{},
form({ email: f.user.email }),
);
await drain();
assert.equal(response.message, service.passwordResetMessage);
assert.equal(await service.isPasswordResetValid(token()), false);
assert.equal(
(await prisma.user.findUniqueOrThrow({ where: { id: f.user.id } }))
.passwordHash,
initialHash,
);
});
test("Anfragelimit greift ohne abweichende öffentliche Antwort", async () => {
const responses = [];
for (let i = 0; i < 4; i++) {
responses.push(
await actions.requestResetAction({}, form({ email: f.user.email })),
);
await drain();
}
assert.equal(emails.length, 3);
for (const response of responses) assert.deepEqual(response, responses[0]);
});
test("Passwortbestätigung und UTF-8-Grenze werden geprüft, bevor ein gültiger Link verbraucht wird", async () => {
await service.requestPasswordReset(f.user.email);
const link = token();
await assert.rejects(
service.resetPassword({ ...resetInput(link), confirmation: "different" }),
/stimmen nicht überein/,
);
await assert.rejects(
service.resetPassword(resetInput(link, "😀".repeat(19))),
/72 UTF-8/,
);
assert.equal(await service.isPasswordResetValid(link), true);
});
test("Eigene Passwortänderung verlangt aktuelles Passwort und kann kein fremdes Konto verändern", async () => {
const other = await prisma.user.create({
data: {
kitaId: f.kita.id,
email: randomUUID() + "@example.invalid",
firstName: "Andere",
lastName: "Person",
passwordHash: initialHash,
emailVerifiedAt: new Date(),
},
});
await assert.rejects(
service.changeOwnPassword(current, {
currentPassword: "wrong",
password: newPassword,
confirmation: newPassword,
}),
/aktuelle Passwort/,
);
assert.equal(
(await prisma.user.findUnique({ where: { id: f.user.id } })).sessionVersion,
0,
);
await service.changeOwnPassword(current, {
userId: other.id,
currentPassword: oldPassword,
password: newPassword,
confirmation: newPassword,
});
assert.equal(
(await prisma.user.findUnique({ where: { id: other.id } })).passwordHash,
initialHash,
);
assert.equal(
await compare(
newPassword,
(await prisma.user.findUnique({ where: { id: f.user.id } })).passwordHash,
),
true,
);
});
test("Aktuelles Passwort wiederverwenden und veraltete Sitzung werden zurückgewiesen", async () => {
await assert.rejects(
service.changeOwnPassword(current, {
currentPassword: oldPassword,
password: oldPassword,
confirmation: oldPassword,
}),
/anderes neues Passwort/,
);
await assert.rejects(
service.changeOwnPassword(
{ ...current, sessionVersion: 4 },
{
currentPassword: oldPassword,
password: newPassword,
confirmation: newPassword,
},
),
/aktuelle Passwort/,
);
});
test("Profil-Aktion ändert Passwort und widerruft ausstehende Reset-Links", async () => {
await service.requestPasswordReset(f.user.email);
const link = token();
const result = await actions.changePasswordAction(
{},
form({
currentPassword: oldPassword,
password: newPassword,
confirmation: newPassword,
}),
);
assert.equal(result.success, true);
assert.equal(await service.isPasswordResetValid(link), false);
assert.equal(
await jwt({
token: { id: f.user.id, sessionVersion: 0, authenticatedAt: Date.now() },
}),
null,
);
});
test("Anonyme Reset-Aktion benötigt keinen Login, validiert das Token und gibt keine internen Daten zurück", async () => {
await service.requestPasswordReset(f.user.email);
assert.equal(
(await actions.finishResetAction({}, form(resetInput()))).success,
true,
);
assert.deepEqual(
Object.keys(
await actions.finishResetAction(
{},
form({ ...resetInput(), token: "invalid" }),
),
),
["error"],
);
});
test("Ein fehlgeschlagener älterer Versand entfernt keinen inzwischen neu angeforderten Link", async () => {
let oldToken;
await service.requestPasswordReset(f.user.email, async (input) => {
oldToken = new URL(input.react.props.resetLink).pathname.split("/").pop();
await service.requestPasswordReset(f.user.email);
return { success: false, error: "test-only" };
});
assert.equal(await service.isPasswordResetValid(oldToken), false);
assert.equal(await service.isPasswordResetValid(token()), true);
});
test("Wiederholte falsche aktuelle Passwörter werden im Profil begrenzt", async () => {
for (let i = 0; i < 5; i++) {
assert.match(
(
await actions.changePasswordAction(
{},
form({
currentPassword: "wrong",
password: newPassword,
confirmation: newPassword,
}),
)
).error,
/aktuelle Passwort/,
);
}
assert.match(
(
await actions.changePasswordAction(
{},
form({
currentPassword: oldPassword,
password: newPassword,
confirmation: newPassword,
}),
)
).error,
/Zu viele Versuche/,
);
assert.equal(
(await prisma.user.findUniqueOrThrow({ where: { id: f.user.id } }))
.sessionVersion,
0,
);
});
test("Fehlende oder unzulässige App-Adresse verschickt keinen nutzbaren Reset-Link", async () => {
const keys = ["AUTH_URL", "NEXTAUTH_URL", "NEXT_PUBLIC_SITE_URL", "NODE_ENV"];
const old = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
try {
process.env.NODE_ENV = "production";
delete process.env.AUTH_URL;
delete process.env.NEXTAUTH_URL;
delete process.env.NEXT_PUBLIC_SITE_URL;
await service.requestPasswordReset(f.user.email);
process.env.NEXT_PUBLIC_SITE_URL = "javascript:invalid";
await service.requestPasswordReset(f.user.email);
assert.equal(emails.length, 0);
assert.equal(
await prisma.verificationToken.count({
where: {
identifier: {
startsWith:
"password-reset:" +
Buffer.from(f.user.id).toString("base64url") +
":",
},
},
}),
0,
);
} finally {
for (const key of keys) {
if (old[key] === undefined) delete process.env[key];
else process.env[key] = old[key];
}
}
});
+1 -1
View File
@@ -384,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,45);
assert.equal(row.count,47);
});
test('role guard authenticates once and keeps role validation on that session', async () => {
let calls = 0;