Add announcement editing and reliable per-recipient mail delivery

This commit is contained in:
t.indorf
2026-10-13 08:53:22 +02:00
parent 661776e609
commit 1472779781
22 changed files with 1655 additions and 190 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
Stand: 9. Oktober 2026 · geprüfter Code: `4702bf5abb0037ab9e0913dbef01ffbe21bbb96d` (`origin/main` zum Prüfzeitpunkt). 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 ## Umfang und Methode
+18
View File
@@ -1,5 +1,23 @@
# Kita-Planer – Funktionsübersicht und aktueller Stand # 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 ## 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. - 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.
+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. 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. 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.
+1
View File
@@ -29,6 +29,7 @@
"test:notdienst:integration": "node --test tests/notdienst-planning.integration.test.cjs", "test:notdienst:integration": "node --test tests/notdienst-planning.integration.test.cjs",
"test:calendar:integration": "node --test tests/calendar-actions.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: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:mail": "node --test tests/mail-transport.test.cjs",
"test:account": "node --test tests/password-management.integration.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:roles": "node --test tests/kita-roles.integration.test.cjs tests/notdienst-assignment-view.test.cjs",
@@ -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[] dutyAssignments DutyAssignment[]
absences Absence[] absences Absence[]
announcements Announcement[] announcements Announcement[]
announcementEmails AnnouncementEmail[]
invitations Invitation[] invitations Invitation[]
termine Termin[] termine Termin[]
mitbringselItems MitbringselItem[] mitbringselItems MitbringselItem[]
@@ -280,6 +281,7 @@ model User {
alarmNotifications NotdienstAlarmNotification[] alarmNotifications NotdienstAlarmNotification[]
alarmEmails NotdienstAlarmEmail[] alarmEmails NotdienstAlarmEmail[]
notdienstEmails NotdienstEmail[] notdienstEmails NotdienstEmail[]
announcementEmails AnnouncementEmail[]
@@index([kitaId]) @@index([kitaId])
@@index([familyId]) @@index([familyId])
@@ -697,6 +699,10 @@ model Announcement {
title String title String
content String content String
audience AnnouncementAudience @default(ALLE) audience AnnouncementAudience @default(ALLE)
revision Int @default(1)
editedAt DateTime?
updatedAt DateTime @default(now())
emailReady Boolean @default(true)
authorId String authorId String
author User @relation("AnnouncementAuthor", fields: [authorId], references: [id], onDelete: Cascade) author User @relation("AnnouncementAuthor", fields: [authorId], references: [id], onDelete: Cascade)
@@ -705,13 +711,40 @@ model Announcement {
attachments Attachment[] attachments Attachment[]
reads AnnouncementRead[] reads AnnouncementRead[]
emails AnnouncementEmail[]
@@index([kitaId, createdAt]) @@index([kitaId, createdAt])
@@index([kitaId, updatedAt])
@@index([authorId]) @@index([authorId])
@@unique([id, kitaId]) @@unique([id, kitaId])
@@map("announcements") @@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 { model Attachment {
id String @id @default(cuid()) id String @id @default(cuid())
announcementId String announcementId String
+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" 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 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" 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; ) violations WHERE invalid_rows > 0;
+305 -106
View File
@@ -2,22 +2,50 @@
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { mkdir, rm, writeFile } from "fs/promises"; 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 { serializableTransaction } from "@/lib/transaction";
import { rateLimit } from "@/lib/rate-limit"; import { rateLimit } from "@/lib/rate-limit";
import { createElement } from "react"; import { after } from "next/server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { AnnouncementAudience, UserRole, type Prisma } from "@prisma/client"; import { UserRole } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import { NewsEmail } from "@/emails/NewsEmail";
import { requireKitaSession, requireRole } from "@/lib/auth-utils"; import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { visibleAnnouncementAudiences } from "@/lib/announcement-access";
import { import {
announcementRecipientWhere, assertAnnouncementAdmin,
visibleAnnouncementAudiences, assertAnnouncementEditable,
} from "@/lib/announcement-access"; 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"]); const audienceSchema = z.enum(["ELTERN", "ERZIEHER", "ALLE"]);
@@ -32,17 +60,6 @@ function getSafeFileName(name: string) {
return name.replace(/[^\w.\- äöüÄÖÜß]/g, "_").slice(0, 160); 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) { export async function createAnnouncement(formData: FormData) {
const session = await requireRole([UserRole.ADMIN]); const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId; const kitaId = session.user.kitaId;
@@ -66,47 +83,89 @@ export async function createAnnouncement(formData: FormData) {
.getAll("attachments") .getAll("attachments")
.filter((value): value is File => value instanceof File && value.size > 0); .filter((value): value is File => value instanceof File && value.size > 0);
if (attachmentFiles.length > 5 || attachmentFiles.some(file => file.size > MAX_ATTACHMENT_SIZE) || if (
attachmentFiles.reduce((total, file) => total + file.size, 0) > 25 * 1024 * 1024) { attachmentFiles.length > 5 ||
return { error: "Maximal 5 Anhänge, je 10 MB und insgesamt 25 MB sind erlaubt." }; 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) { if (
return { error: "Zu viele Ankündigungen. Bitte versuche es später erneut." }; !(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) { for (const file of attachmentFiles) {
const bytes = Buffer.from(await file.arrayBuffer()); const bytes = Buffer.from(await file.arrayBuffer());
const fileType = detectAttachmentType(bytes); const fileType = detectAttachmentType(bytes);
if (!fileType || !attachmentExtensionMatches(file.name, fileType) || (file.type && file.type !== fileType)) { if (
return { error: "Nur PDF-, JPG- und PNG-Dateien mit passenden Dateiinhalten sind erlaubt." }; !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(); const id = randomUUID();
attachmentInputs.push({ id, bytes, fileName: getSafeFileName(file.name), fileType, attachmentInputs.push({
fileUrl: `/api/announcements/attachments/${id}` }); 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) { if (attachmentInputs.length) {
const pending = await tx.attachment.count({ where: { sizeBytes: null } }); const pending = await tx.attachment.count({ where: { sizeBytes: null } });
if (pending) return null; if (pending) return null;
const [tenantUsage, globalUsage] = await Promise.all([ 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 } }), 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 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) || if (
(globalUsage._sum.sizeBytes ?? 0) + bytes > quota(process.env.UPLOAD_GLOBAL_QUOTA_BYTES, 10 * 1024 ** 3)) return null; (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: { data: {
kitaId, kitaId,
title: parsed.data.title, title: parsed.data.title,
content: parsed.data.content, content: parsed.data.content,
audience: parsed.data.audience, audience: parsed.data.audience,
authorId: session.user.id, authorId: session.user.id,
emailReady: attachmentInputs.length === 0,
attachments: { attachments: {
create: attachmentInputs.map((attachment) => ({ create: attachmentInputs.map((attachment) => ({
id: attachment.id, id: attachment.id,
@@ -121,10 +180,19 @@ export async function createAnnouncement(formData: FormData) {
id: true, id: true,
title: true, title: true,
content: 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) { if (attachmentInputs.length > 0) {
const uploadDir = attachmentDirectory(); const uploadDir = attachmentDirectory();
@@ -132,11 +200,10 @@ export async function createAnnouncement(formData: FormData) {
await mkdir(uploadDir, { recursive: true }); await mkdir(uploadDir, { recursive: true });
await Promise.all( await Promise.all(
attachmentInputs.map(async (attachment) => { attachmentInputs.map(async (attachment) => {
await writeFile( await writeFile(attachmentPath(attachment.id), attachment.bytes, {
attachmentPath(attachment.id), flag: "wx",
attachment.bytes, mode: 0o600,
{ flag: "wx", mode: 0o600 }, });
);
}), }),
); );
} catch (error) { } catch (error) {
@@ -154,79 +221,211 @@ export async function createAnnouncement(formData: FormData) {
} }
} }
let emailError: string | null = null; if (attachmentInputs.length)
if (parsed.data.sendEmail) { await prisma.announcement.update({
const configError = getAppEmailConfigError(); where: { id: announcement.id, kitaId },
if (configError) { data: { emailReady: true },
emailError = configError; });
} else { if (parsed.data.sendEmail) startAnnouncementDelivery(kitaId, announcement.id);
const recipients = await getRecipients(kitaId, parsed.data.audience); revalidateAnnouncements();
if (recipients.length > 0) { return { success: true, warning: undefined };
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 (!result.success) { const editSchema = z.object({
emailError = result.error; 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"); export async function updateAnnouncement(formData: FormData) {
revalidatePath("/dashboard/admin/news"); const session = await requireRole([UserRole.ADMIN]);
revalidatePath("/dashboard/mitteilungen"); const input = editSchema.safeParse({
id: formData.get("id"),
if (emailError) { revision: formData.get("revision"),
title: formData.get("title"),
content: formData.get("content"),
sendEmail: formData.get("sendEmail") === "on",
});
if (!input.success)
return { return {
success: true, error:
warning: `Ankündigung gespeichert, E-Mail-Versand fehlgeschlagen: ${emailError}`, "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 session = await requireKitaSession();
const announcement = await prisma.announcement.findFirst({ if (!Number.isSafeInteger(revision) || revision < 1)
where: { return { error: "Ungültige Beitragsversion." };
id: announcementId, try {
kitaId: session.user.kitaId, await serializableTransaction(async (tx) => {
audience: { in: visibleAnnouncementAudiences(session.user) }, await lockAnnouncement(tx, session.user.kitaId, announcementId);
}, const announcement = await tx.announcement.findFirst({
select: { id: true }, where: {
}); id: announcementId,
revision,
if (!announcement) { kitaId: session.user.kitaId,
return { error: "Ankündigung wurde nicht gefunden." }; 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.",
),
};
} }
revalidateAnnouncements();
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");
return { success: true }; return { success: true };
} }
+19 -3
View File
@@ -3,6 +3,8 @@ import { authorizeAlarmJob, runAlarmJob } from "@/lib/alarms/job";
import { runNotdienstNotificationJob } from "@/lib/notdienst-notifications"; import { runNotdienstNotificationJob } from "@/lib/notdienst-notifications";
import { deliverDueAnnouncementEmails } from "@/lib/announcement-delivery";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function POST(request: Request) { export async function POST(request: Request) {
@@ -18,12 +20,26 @@ export async function POST(request: Request) {
{ status }, { status },
); );
try { try {
const [alarm, planning] = await Promise.allSettled([runAlarmJob(), runNotdienstNotificationJob()]); const [alarm, planning, announcements] = await Promise.allSettled([
if (alarm.status === "rejected" || planning.status === "rejected") throw new Error("Ein Hintergrundlauf ist fehlgeschlagen."); runAlarmJob(),
const result = { ...alarm.value, planning: planning.value }; 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");
revalidatePath("/dashboard/notdienst/alarm"); revalidatePath("/dashboard/notdienst/alarm");
revalidatePath("/dashboard/notdienst/plan"); revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard/admin/news");
return Response.json(result); return Response.json(result);
} catch { } catch {
return Response.json( 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>
);
}
+31 -11
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { useRef, useState, useTransition } from "react";
import { Loader2, Send, UploadCloud } from "lucide-react"; import { Loader2, Send, UploadCloud } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -20,6 +20,7 @@ import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/markdown-content"; import { MarkdownContent } from "@/components/markdown-content";
export function NewsForm() { export function NewsForm() {
const formRef = useRef<HTMLFormElement>(null);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [sendEmail, setSendEmail] = useState(false); const [sendEmail, setSendEmail] = useState(false);
const [content, setContent] = useState(""); const [content, setContent] = useState("");
@@ -30,27 +31,43 @@ export function NewsForm() {
formData.set("sendEmail", "on"); formData.set("sendEmail", "on");
} }
const result = await createAnnouncement(formData); try {
if (result.error) { const result = await createAnnouncement(formData);
toast.error(result.error); if (result.error) {
return; toast.error(result.error);
} return;
}
if (result.warning) { if (result.warning) {
toast.warning(result.warning); toast.warning(result.warning);
} else { } 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 ( return (
<form action={handleSubmit} className="grid gap-5"> <form ref={formRef} action={handleSubmit} className="grid gap-5">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="news-title">Titel</Label> <Label htmlFor="news-title">Titel</Label>
<Input <Input
id="news-title" id="news-title"
name="title" name="title"
minLength={3}
maxLength={160}
disabled={isPending}
placeholder="Sommerfest: Aufbau und Mitbringliste" placeholder="Sommerfest: Aufbau und Mitbringliste"
required required
/> />
@@ -62,6 +79,9 @@ export function NewsForm() {
id="news-content" id="news-content"
name="content" name="content"
rows={10} rows={10}
minLength={3}
maxLength={20_000}
disabled={isPending}
value={content} value={content}
onChange={(event) => setContent(event.target.value)} onChange={(event) => setContent(event.target.value)}
placeholder={ placeholder={
+141 -48
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 { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { FileText, Megaphone, Send } from "lucide-react"; 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 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 session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId; const kitaId = session.user.kitaId;
@@ -26,29 +34,46 @@ export default async function AdminNewsPage() {
throw new Error("Kein Mandant zugeordnet."); throw new Error("Kein Mandant zugeordnet.");
} }
const announcements = await prisma.announcement.findMany({ const params = await searchParams;
where: { kitaId }, const q = typeof params.q === "string" ? params.q.trim().slice(0, 160) : "";
select: { const parsedPage = Number(params.page ?? 1);
id: true, const page =
title: true, Number.isSafeInteger(parsedPage) && parsedPage > 0
audience: true, ? Math.min(parsedPage, 100000)
createdAt: true, : 1;
attachments: { const pageSize = 12;
select: { const where = {
id: true, kitaId,
fileName: true, ...(q
}, ? {
OR: [
{ title: { contains: q, mode: "insensitive" as const } },
{ content: { contains: q, mode: "insensitive" as const } },
],
}
: {}),
};
const [announcements, total] = await Promise.all([
prisma.announcement.findMany({
where,
select: {
id: true,
title: true,
audience: true,
createdAt: true,
editedAt: true,
revision: true,
_count: { select: { attachments: true } },
author: { select: { firstName: true, lastName: true } },
}, },
author: { orderBy: [{ createdAt: "desc" }, { id: "desc" }],
select: { skip: (page - 1) * pageSize,
firstName: true, take: pageSize,
lastName: true, }),
}, prisma.announcement.count({ where }),
}, ]);
}, const href = (p: number) =>
orderBy: { createdAt: "desc" }, `/dashboard/admin/news?${new URLSearchParams({ ...(q ? { q } : {}), page: String(p) })}#beitraege`;
take: 8,
});
return ( return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5"> <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)]"> <h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Meldungen{" "} Meldungen{" "}
<em className="font-normal italic text-[var(--accent-deep)]"> <em className="font-normal italic text-[var(--accent-deep)]">
veröffentlichen verwalten
</em> </em>
</h1> </h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]"> <p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
@@ -71,42 +96,77 @@ export default async function AdminNewsPage() {
</div> </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"> <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]" /> <Megaphone className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{announcements.length} zuletzt {total} {total === 1 ? "Beitrag" : "Beiträge"}
</span> </span>
</header> </header>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]"> <div className="grid gap-6">
<Card> <details
open={!q && page === 1}
className="rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-5"
>
<summary className="cursor-pointer text-lg font-semibold">
Neue Ankündigung veröffentlichen
</summary>
<Card className="mt-4 border-0 shadow-none">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Send className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
Neue Ankündigung
</CardTitle>
<CardDescription>
Markdown wird sicher gerendert; PDF, JPG und PNG können als
geschützte Anhänge hinzugefügt werden.
</CardDescription>
</CardHeader>
<CardContent>
<NewsForm />
</CardContent>
</Card>
</details>
<Card id="beitraege" className="h-fit">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="text-base">
<Send className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" /> Veröffentlichte Beiträge
Neue Ankündigung
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Markdown wird sicher gerendert; PDF, JPG und PNG können als Korrigiere auch ältere Beiträge und prüfe den E-Mail-Versand.
geschützte Anhänge hinzugefügt werden.
</CardDescription>
</CardHeader>
<CardContent>
<NewsForm />
</CardContent>
</Card>
<Card className="h-fit">
<CardHeader>
<CardTitle className="text-base">Zuletzt veröffentlicht</CardTitle>
<CardDescription>
Die neuesten Meldungen auf dem Schwarzen Brett.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="grid gap-3"> <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 ? ( {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="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)]"> <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]" /> <Megaphone className="h-6 w-6 [stroke-width:1.7]" />
</div> </div>
<p className="text-sm font-medium text-[var(--ink)]"> <p className="text-sm font-medium text-[var(--ink)]">
Noch keine Ankündigungen Keine Beiträge gefunden
</p> </p>
<p className="mt-1 text-sm text-[var(--ink-soft)]"> <p className="mt-1 text-sm text-[var(--ink-soft)]">
Veröffentlichte Meldungen erscheinen hier. 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" className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
> >
<div className="mb-2 flex items-start justify-between gap-2"> <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} {announcement.title}
</p> </p>
{announcement.attachments.length > 0 && ( {announcement._count.attachments > 0 && (
<Badge variant="secondary" className="shrink-0"> <Badge variant="secondary" className="shrink-0">
<FileText className="h-3 w-3 [stroke-width:1.7]" /> <FileText className="h-3 w-3 [stroke-width:1.7]" />
{announcement.attachments.length} {announcement._count.attachments}
</Badge> </Badge>
)} )}
</div> </div>
@@ -139,9 +199,42 @@ export default async function AdminNewsPage() {
· {announcement.author.firstName}{" "} · {announcement.author.firstName}{" "}
{announcement.author.lastName} {announcement.author.lastName}
</p> </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> </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> </CardContent>
</Card> </Card>
</div> </div>
+8 -4
View File
@@ -26,6 +26,8 @@ import { MarkdownContent } from "@/components/markdown-content";
export type NewsTickerItemDto = { export type NewsTickerItemDto = {
id: string; id: string;
revision: number;
editedAt: string | null;
title: string; title: string;
content: string; content: string;
excerpt: string; excerpt: string;
@@ -53,16 +55,16 @@ export function NewsTicker({
function openAnnouncement(item: NewsTickerItemDto) { function openAnnouncement(item: NewsTickerItemDto) {
setSelected(item); setSelected(item);
if (!item.isUnread || readIds.has(item.id)) return; if (!item.isUnread || readIds.has(`${item.id}:${item.revision}`)) return;
startTransition(async () => { startTransition(async () => {
try { try {
const result = await markAnnouncementRead(item.id); const result = await markAnnouncementRead(item.id, item.revision);
if (result.error) { if (result.error) {
toast.error(result.error); toast.error(result.error);
return; return;
} }
setReadIds((current) => new Set(current).add(item.id)); setReadIds((current) => new Set(current).add(`${item.id}:${item.revision}`));
} catch { } catch {
toast.error("Lesestatus konnte nicht gespeichert werden."); toast.error("Lesestatus konnte nicht gespeichert werden.");
} }
@@ -101,7 +103,7 @@ export function NewsTicker({
</p> </p>
) : null} ) : null}
{items.map((item) => { {items.map((item) => {
const isUnread = item.isUnread && !readIds.has(item.id); const isUnread = item.isUnread && !readIds.has(`${item.id}:${item.revision}`);
return ( return (
<button <button
key={item.id} key={item.id}
@@ -116,6 +118,7 @@ export function NewsTicker({
</div> </div>
{isUnread && <Badge variant="success">NEU</Badge>} {isUnread && <Badge variant="success">NEU</Badge>}
</div> </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)]"> <h3 className="line-clamp-2 font-semibold leading-snug text-[var(--ink)]">
{item.title} {item.title}
</h3> </h3>
@@ -146,6 +149,7 @@ export function NewsTicker({
<DialogTitle className="text-2xl">{selected.title}</DialogTitle> <DialogTitle className="text-2xl">{selected.title}</DialogTitle>
<DialogDescription> <DialogDescription>
{selected.createdAt} · {selected.authorName} {selected.createdAt} · {selected.authorName}
{selected.editedAt && <span className="block">Aktualisiert am {selected.editedAt}</span>}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
+4 -3
View File
@@ -17,20 +17,21 @@ type NewsEmailProps = {
title: string; title: string;
content: string; content: string;
dashboardUrl: string; dashboardUrl: string;
updated?: boolean;
}; };
export function NewsEmail({ title, content, dashboardUrl }: NewsEmailProps) { export function NewsEmail({ title, content, dashboardUrl, updated = false }: NewsEmailProps) {
const excerpt = const excerpt =
content.length > 420 ? `${content.slice(0, 420).trim()}...` : content; content.length > 420 ? `${content.slice(0, 420).trim()}...` : content;
return ( return (
<Html lang="de"> <Html lang="de">
<Head /> <Head />
<Preview>Neue Kita-Ankündigung: {title}</Preview> <Preview>{updated ? "Aktualisierte" : "Neue"} Kita-Ankündigung: {title}</Preview>
<Body style={styles.body}> <Body style={styles.body}>
<Container style={styles.container}> <Container style={styles.container}>
<Section style={styles.header}> <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> <Heading style={styles.heading}>{title}</Heading>
</Section> </Section>
+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 // Admins need access to all publications for management. Other roles only
// receive their audience; a parent with a coordination role is still a parent. // receive their audience; a parent with a coordination role is still a parent.
@@ -19,15 +19,23 @@ export function visibleAnnouncementAudiences(user: {
return audiences; return audiences;
} }
export function announcementRecipientWhere(audience: AnnouncementAudience) { export function announcementRecipientWhere(
audience: AnnouncementAudience,
): Prisma.UserWhereInput {
const parents = { const parents = {
role: { not: UserRole.ERZIEHER }, role: { in: [UserRole.ELTERN, UserRole.KOORDINATOR, UserRole.ADMIN] },
OR: [{ familyId: { not: null } }, { role: UserRole.ELTERN }], 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.ELTERN) return parents;
if (audience === AnnouncementAudience.ERZIEHER) if (audience === AnnouncementAudience.ERZIEHER) return staff;
return { role: UserRole.ERZIEHER }; return { OR: [parents, staff] };
return { OR: [parents, { role: UserRole.ERZIEHER }] };
} }
export const announcementAudienceLabels: Record<AnnouncementAudience, string> = 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, title: true,
content: true, content: true,
createdAt: true, createdAt: true,
revision: true,
editedAt: true,
author: { select: { firstName: true, lastName: true } }, author: { select: { firstName: true, lastName: true } },
reads: { where: { userId: session.user.id }, select: { id: true } }, reads: { where: { userId: session.user.id }, select: { id: true } },
attachments: { attachments: {
select: { id: true, fileName: true, fileUrl: true, fileType: true }, select: { id: true, fileName: true, fileUrl: true, fileType: true },
}, },
}, },
orderBy: [{ createdAt: "desc" }, { id: "desc" }], orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: options.take, take: options.take,
skip: options.skip, skip: options.skip,
}); });
return rows.map((row) => ({ return rows.map((row) => ({
id: row.id, id: row.id,
revision: row.revision,
editedAt: row.editedAt ? format(row.editedAt, "dd.MM.yyyy HH:mm", { locale: de }) : null,
title: row.title, title: row.title,
content: row.content, content: row.content,
excerpt: row.content excerpt: row.content
+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);
});
@@ -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()=>{ 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 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; 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); 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); 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;} }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( const { updateTerminAdmin } = loadAfterMocks(
"../src/app/dashboard/kalender/actions", "../src/app/dashboard/kalender/actions",
) as typeof import("../src/app/dashboard/kalender/actions"); ) as typeof import("../src/app/dashboard/kalender/actions");
const { createAnnouncement, markAnnouncementRead } = loadAfterMocks( // Next resolves this marker internally. The standalone Node harness has no
"../src/actions/announcements", // Next bundler, so replace only the marker while loading the real service.
) as typeof import("../src/actions/announcements"); 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( const { GET: getAttachment } = loadAfterMocks(
"../src/app/api/announcements/attachments/[id]/route", "../src/app/api/announcements/attachments/[id]/route",
) as typeof import("../src/app/api/announcements/attachments/[id]/route"); ) as typeof import("../src/app/api/announcements/attachments/[id]/route");
+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 () => { 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'`; 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 () => { test('role guard authenticates once and keeps role validation on that session', async () => {
let calls = 0; let calls = 0;