From 14727797817c923a6e959f6256202e390147c74b Mon Sep 17 00:00:00 2001 From: "t.indorf" Date: Tue, 13 Oct 2026 08:53:22 +0200 Subject: [PATCH] Add announcement editing and reliable per-recipient mail delivery --- docs/ABLAUFPRUEFUNG.md | 2 +- docs/FUNKTIONSUEBERSICHT.md | 18 + docs/NOTDIENSTALARM.md | 2 +- package.json | 1 + .../migration.sql | 23 + prisma/schema.prisma | 33 ++ scripts/check-tenant-integrity.sql | 4 + src/actions/announcements.ts | 411 +++++++++++++----- src/app/api/jobs/notdienstalarme/route.ts | 22 +- src/app/dashboard/admin/news/[id]/page.tsx | 319 ++++++++++++++ .../dashboard/admin/news/edit-news-form.tsx | 174 ++++++++ src/app/dashboard/admin/news/news-form.tsx | 42 +- src/app/dashboard/admin/news/page.tsx | 189 ++++++-- src/app/dashboard/news-ticker.tsx | 12 +- src/emails/NewsEmail.tsx | 7 +- src/lib/announcement-access.ts | 20 +- src/lib/announcement-delivery.ts | 286 ++++++++++++ src/lib/announcements.ts | 6 +- tests/announcements.integration.test.cjs | 252 +++++++++++ ...tdienst-notifications.integration.test.cjs | 4 +- tests/parent-flows.integration.test.ts | 16 +- tests/security.test.cjs | 2 +- 22 files changed, 1655 insertions(+), 190 deletions(-) create mode 100644 prisma/migrations/20261012010000_announcement_delivery/migration.sql create mode 100644 src/app/dashboard/admin/news/[id]/page.tsx create mode 100644 src/app/dashboard/admin/news/edit-news-form.tsx create mode 100644 src/lib/announcement-delivery.ts create mode 100644 tests/announcements.integration.test.cjs diff --git a/docs/ABLAUFPRUEFUNG.md b/docs/ABLAUFPRUEFUNG.md index 562a7a7..2644521 100644 --- a/docs/ABLAUFPRUEFUNG.md +++ b/docs/ABLAUFPRUEFUNG.md @@ -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). 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. 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 diff --git a/docs/FUNKTIONSUEBERSICHT.md b/docs/FUNKTIONSUEBERSICHT.md index 6961bd8..0947860 100644 --- a/docs/FUNKTIONSUEBERSICHT.md +++ b/docs/FUNKTIONSUEBERSICHT.md @@ -1,5 +1,23 @@ # Kita-Planer – Funktionsübersicht und aktueller Stand +## 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. diff --git a/docs/NOTDIENSTALARM.md b/docs/NOTDIENSTALARM.md index c43db11..23f7177 100644 --- a/docs/NOTDIENSTALARM.md +++ b/docs/NOTDIENSTALARM.md @@ -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 ` 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. diff --git a/package.json b/package.json index 6d74c56..7251422 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "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: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", diff --git a/prisma/migrations/20261012010000_announcement_delivery/migration.sql b/prisma/migrations/20261012010000_announcement_delivery/migration.sql new file mode 100644 index 0000000..58169e2 --- /dev/null +++ b/prisma/migrations/20261012010000_announcement_delivery/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fc82ea8..7be0ce1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/scripts/check-tenant-integrity.sql b/scripts/check-tenant-integrity.sql index 32d8667..db0b4de 100644 --- a/scripts/check-tenant-integrity.sql +++ b/scripts/check-tenant-integrity.sql @@ -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; diff --git a/src/actions/announcements.ts b/src/actions/announcements.ts index e7dfd2c..59b196a 100644 --- a/src/actions/announcements.ts +++ b/src/actions/announcements.ts @@ -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) { - 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 }; +} - if (!result.success) { - emailError = result.error; - } - } - } - } +const editSchema = z.object({ + id: z.string().min(1).max(64), + revision: z.coerce.number().int().positive(), + title: announcementSchema.shape.title, + content: announcementSchema.shape.content, + sendEmail: z.boolean(), +}); - revalidatePath("/dashboard"); - revalidatePath("/dashboard/admin/news"); - 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({ - where: { - id: announcementId, - kitaId: session.user.kitaId, - audience: { in: visibleAnnouncementAudiences(session.user) }, - }, - select: { id: true }, - }); - - if (!announcement) { - return { error: "Ankündigung wurde nicht gefunden." }; + if (!Number.isSafeInteger(revision) || revision < 1) + return { error: "Ungültige Beitragsversion." }; + try { + await serializableTransaction(async (tx) => { + await lockAnnouncement(tx, session.user.kitaId, announcementId); + const announcement = await tx.announcement.findFirst({ + where: { + id: announcementId, + revision, + kitaId: session.user.kitaId, + audience: { in: visibleAnnouncementAudiences(session.user) }, + }, + select: { id: true }, + }); + if (!announcement) + throw new UserFacingError( + "Der Beitrag wurde geändert oder ist nicht verfügbar. Bitte lade die Seite neu.", + ); + await tx.announcementRead.upsert({ + where: { + userId_announcementId: { userId: session.user.id, announcementId }, + }, + create: { + kitaId: session.user.kitaId, + userId: session.user.id, + announcementId, + }, + update: { readAt: new Date() }, + }); + }); + } catch (error) { + return { + error: publicActionError( + error, + "Lesestatus konnte nicht gespeichert werden.", + ), + }; } - - await prisma.announcementRead.upsert({ - where: { - userId_announcementId: { - userId: session.user.id, - announcementId: announcement.id, - }, - }, - create: { - kitaId: session.user.kitaId, - userId: session.user.id, - announcementId: announcement.id, - }, - update: { - readAt: new Date(), - }, - }); - - revalidatePath("/dashboard"); - revalidatePath("/dashboard/mitteilungen"); + revalidateAnnouncements(); return { success: true }; } diff --git a/src/app/api/jobs/notdienstalarme/route.ts b/src/app/api/jobs/notdienstalarme/route.ts index 68ea06f..36632e3 100644 --- a/src/app/api/jobs/notdienstalarme/route.ts +++ b/src/app/api/jobs/notdienstalarme/route.ts @@ -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( diff --git a/src/app/dashboard/admin/news/[id]/page.tsx b/src/app/dashboard/admin/news/[id]/page.tsx new file mode 100644 index 0000000..f139f12 --- /dev/null +++ b/src/app/dashboard/admin/news/[id]/page.tsx @@ -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 ( +
+ +
+

+ SCHWARZES BRETT · BEITRAG +

+

+ Mitteilung{" "} + bearbeiten +

+

+ Veröffentlicht am {format(announcement.createdAt, "dd.MM.yyyy HH:mm")} + {announcement.editedAt && + ` · Aktualisiert am ${format(announcement.editedAt, "dd.MM.yyyy HH:mm")}`} +

+
+
+ + + Inhalt korrigieren + + Empfängergruppe und bestehende Anhänge bleiben bei einer Korrektur + erhalten. + + + {announcementAudienceLabels[announcement.audience]} + + + + + {announcement.attachments.length > 0 && ( +
+

Anhänge

+
+ {announcement.attachments.map((a) => ( + + + {a.fileName} + + ))} +
+
+ )} +
+
+ + + + + E-Mail-Versand + + + Status der aktuellen Fassung. „Gesendet“ bedeutet, dass der + Maildienst die Nachricht angenommen hat. + + + +
+ {sent} gesendet + {pending} ausstehend + {failed} fehlgeschlagen + {skipped > 0 && ( + {skipped} übersprungen + )} +
+ {!backgroundConfigured && ( +

+ Automatische Wiederholungen sind noch nicht eingerichtet. Bitte + wende dich an die Hosting-Administration; fehlgeschlagene + E-Mails kannst du hier selbst erneut versuchen. +

+ )} + {!announcement.emailReady && ( +

+ Die Anhänge werden noch vorbereitet. Der Versand beginnt erst, + wenn alle Dateien gespeichert sind. +

+ )} +

+ Bei eingerichteter Automatik werden fehlgeschlagene Sendungen + erneut versucht. Deaktivierte Konten und Personen, die nicht mehr + zur Empfängergruppe gehören, werden übersprungen. +

+ {failed > 0 && ( +
+ +
+ )} + + {statuses.length === 0 && ( +

+ 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. +

+ )} +
+ {emails.slice(0, 50).map((job) => ( +
+
+

+ {job.user.firstName} {job.user.lastName} +

+ + {job.sentAt + ? "Gesendet" + : job.skippedAt + ? "Übersprungen" + : job.failed + ? "Fehlgeschlagen" + : job.leaseUntil && job.leaseUntil > new Date() + ? "Wird versendet" + : "Ausstehend"} + +
+

+ {job.user.email} +

+ {job.sentAt && ( +

+ {format(job.sentAt, "dd.MM.yyyy HH:mm")} +

+ )} + {job.lastError && ( +

+ {job.lastError} +

+ )} +
+ ))} +
+ {history.size > 0 && ( +
+ + Versand früherer Fassungen + +
+ {[...history] + .sort(([a], [b]) => b - a) + .map(([revision, summary]) => ( +

+ Fassung {revision}: {summary.sent} gesendet ·{" "} + {summary.skipped} übersprungen · {summary.failed}{" "} + fehlgeschlagen +

+ ))} +
+

+ Ungesendete E-Mails älterer Fassungen wurden durch die + Korrektur ersetzt. +

+
+ )} + {(page > 1 || emails.length > 50) && ( + + )} +
+
+
+
+ ); +} diff --git a/src/app/dashboard/admin/news/edit-news-form.tsx b/src/app/dashboard/admin/news/edit-news-form.tsx new file mode 100644 index 0000000..bafc805 --- /dev/null +++ b/src/app/dashboard/admin/news/edit-news-form.tsx @@ -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 ( +
+
+ + +
+
+ +