"use server"; import { randomUUID } from "crypto"; import { mkdir, rm, writeFile } from "fs/promises"; import { attachmentDirectory, attachmentPath, MAX_ATTACHMENT_SIZE, detectAttachmentType, attachmentExtensionMatches, } from "@/lib/attachments"; import { serializableTransaction } from "@/lib/transaction"; import { rateLimit } from "@/lib/rate-limit"; import { after } from "next/server"; import { revalidatePath } from "next/cache"; import { UserRole } from "@prisma/client"; import { z } from "zod"; import { requireKitaSession, requireRole } from "@/lib/auth-utils"; import { prisma } from "@/lib/prisma"; import { visibleAnnouncementAudiences } from "@/lib/announcement-access"; import { assertAnnouncementAdmin, assertAnnouncementEditable, deliverDueAnnouncementEmails, lockAnnouncement, queueAnnouncementEmails, } from "@/lib/announcement-delivery"; import { publicActionError, UserFacingError } from "@/lib/action-error"; function revalidateAnnouncements() { revalidatePath("/dashboard"); revalidatePath("/dashboard/admin/news"); revalidatePath("/dashboard/mitteilungen"); } function startAnnouncementDelivery(kitaId: string, announcementId: string) { after(async () => { try { await deliverDueAnnouncementEmails({ kitaId, announcementId }); } catch { console.error( "Mitteilungsversand wird im nächsten Hintergrundlauf erneut versucht.", ); } revalidateAnnouncements(); }); } const audienceSchema = z.enum(["ELTERN", "ERZIEHER", "ALLE"]); const announcementSchema = z.object({ title: z.string().trim().min(3).max(160), content: z.string().trim().min(3).max(20_000), sendEmail: z.boolean(), audience: audienceSchema, }); function getSafeFileName(name: string) { return name.replace(/[^\w.\- äöüÄÖÜß]/g, "_").slice(0, 160); } export async function createAnnouncement(formData: FormData) { const session = await requireRole([UserRole.ADMIN]); const kitaId = session.user.kitaId; if (!kitaId) { return { error: "Kein Mandant zugeordnet." }; } const parsed = announcementSchema.safeParse({ title: formData.get("title"), content: formData.get("content"), sendEmail: formData.get("sendEmail") === "on", audience: formData.get("audience") || "ELTERN", }); if (!parsed.success) { return { error: "Bitte Titel und Inhalt ausfüllen." }; } const attachmentFiles = formData .getAll("attachments") .filter((value): value is File => value instanceof File && value.size > 0); if ( attachmentFiles.length > 5 || attachmentFiles.some((file) => file.size > MAX_ATTACHMENT_SIZE) || attachmentFiles.reduce((total, file) => total + file.size, 0) > 25 * 1024 * 1024 ) { return { error: "Maximal 5 Anhänge, je 10 MB und insgesamt 25 MB sind erlaubt.", }; } if ( !(await rateLimit(`announcements:${kitaId}:${session.user.id}`, 20, 3600)) .allowed ) { return { error: "Zu viele Ankündigungen. Bitte versuche es später erneut.", }; } const attachmentInputs: { id: string; bytes: Buffer; fileName: string; fileType: string; fileUrl: string; }[] = []; for (const file of attachmentFiles) { const bytes = Buffer.from(await file.arrayBuffer()); const fileType = detectAttachmentType(bytes); if ( !fileType || !attachmentExtensionMatches(file.name, fileType) || (file.type && file.type !== fileType) ) { return { error: "Nur PDF-, JPG- und PNG-Dateien mit passenden Dateiinhalten sind erlaubt.", }; } const id = randomUUID(); attachmentInputs.push({ id, bytes, fileName: getSafeFileName(file.name), fileType, fileUrl: `/api/announcements/attachments/${id}`, }); } const announcement = await serializableTransaction(async (tx) => { if (attachmentInputs.length) { const pending = await tx.attachment.count({ where: { sizeBytes: null } }); if (pending) return null; const [tenantUsage, globalUsage] = await Promise.all([ tx.attachment.aggregate({ where: { announcement: { kitaId } }, _sum: { sizeBytes: true }, }), tx.attachment.aggregate({ _sum: { sizeBytes: true } }), ]); const bytes = attachmentInputs.reduce( (total, item) => total + item.bytes.length, 0, ); const quota = (value: string | undefined, fallback: number) => { const parsed = Number(value); return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; }; if ( (tenantUsage._sum.sizeBytes ?? 0) + bytes > quota(process.env.UPLOAD_TENANT_QUOTA_BYTES, 256 * 1024 ** 2) || (globalUsage._sum.sizeBytes ?? 0) + bytes > quota(process.env.UPLOAD_GLOBAL_QUOTA_BYTES, 10 * 1024 ** 3) ) return null; } const row = await tx.announcement.create({ data: { kitaId, title: parsed.data.title, content: parsed.data.content, audience: parsed.data.audience, authorId: session.user.id, emailReady: attachmentInputs.length === 0, attachments: { create: attachmentInputs.map((attachment) => ({ id: attachment.id, fileName: attachment.fileName, fileType: attachment.fileType, sizeBytes: attachment.bytes.length, fileUrl: attachment.fileUrl, })), }, }, select: { id: true, title: true, content: true, kitaId: true, audience: true, revision: true, }, }); if (parsed.data.sendEmail) await queueAnnouncementEmails(tx, row); return row; }); if (!announcement) return { error: "Anhänge können momentan nicht gespeichert werden. Bitte wende dich an die Administration.", }; if (attachmentInputs.length > 0) { const uploadDir = attachmentDirectory(); try { await mkdir(uploadDir, { recursive: true }); await Promise.all( attachmentInputs.map(async (attachment) => { await writeFile(attachmentPath(attachment.id), attachment.bytes, { flag: "wx", mode: 0o600, }); }), ); } catch (error) { await prisma.announcement.delete({ where: { id: announcement.id } }); await Promise.all( attachmentInputs.map((attachment) => rm(attachmentPath(attachment.id), { force: true }), ), ); console.error( "Ankündigungs-Anhang konnte nicht gespeichert werden:", error, ); return { error: "Anhang konnte nicht gespeichert werden." }; } } if (attachmentInputs.length) await prisma.announcement.update({ where: { id: announcement.id, kitaId }, data: { emailReady: true }, }); if (parsed.data.sendEmail) startAnnouncementDelivery(kitaId, announcement.id); revalidateAnnouncements(); return { success: true, warning: undefined }; } const editSchema = z.object({ id: z.string().min(1).max(64), revision: z.coerce.number().int().positive(), title: announcementSchema.shape.title, content: announcementSchema.shape.content, sendEmail: z.boolean(), }); export async function updateAnnouncement(formData: FormData) { const session = await requireRole([UserRole.ADMIN]); const input = editSchema.safeParse({ id: formData.get("id"), revision: formData.get("revision"), title: formData.get("title"), content: formData.get("content"), sendEmail: formData.get("sendEmail") === "on", }); if (!input.success) return { error: "Bitte Titel (3–160 Zeichen) und Inhalt (3–20.000 Zeichen) prüfen.", }; const actor = { id: session.user.id, kitaId: session.user.kitaId!, sessionVersion: session.user.sessionVersion, }; if ( !( await rateLimit(`announcement-edit:${actor.kitaId}:${actor.id}`, 30, 3600) ).allowed ) return { error: "Zu viele Änderungen. Bitte versuche es später erneut." }; try { const result = await serializableTransaction(async (tx) => { await assertAnnouncementAdmin(tx, actor); const previous = await assertAnnouncementEditable( tx, actor.kitaId, input.data.id, input.data.revision, ); const changed = previous.title !== input.data.title || previous.content !== input.data.content; const row = changed ? await tx.announcement.update({ where: { id: previous.id, kitaId: actor.kitaId }, data: { title: input.data.title, content: input.data.content, editedAt: new Date(), updatedAt: new Date(), revision: { increment: 1 }, }, }) : previous; if (changed) { await tx.announcementRead.deleteMany({ where: { kitaId: actor.kitaId, announcementId: row.id }, }); await tx.announcementEmail.updateMany({ where: { kitaId: actor.kitaId, announcementId: row.id, sentAt: null, skippedAt: null, }, data: { skippedAt: new Date(), failed: false, lastError: "Durch eine neuere Beitragsversion ersetzt.", claimToken: null, leaseUntil: null, }, }); } const queued = input.data.sendEmail ? await queueAnnouncementEmails(tx, row) : 0; return { changed, queued }; }); if (result.queued) startAnnouncementDelivery(actor.kitaId, input.data.id); revalidateAnnouncements(); return { success: true, ...result }; } catch (error) { return { error: publicActionError( error, "Mitteilung konnte nicht gespeichert werden. Bitte versuche es erneut.", ), }; } } 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(); if (!Number.isSafeInteger(revision) || revision < 1) return { error: "Ungültige Beitragsversion." }; try { await serializableTransaction(async (tx) => { await lockAnnouncement(tx, session.user.kitaId, announcementId); const announcement = await tx.announcement.findFirst({ where: { id: announcementId, revision, kitaId: session.user.kitaId, audience: { in: visibleAnnouncementAudiences(session.user) }, }, select: { id: true }, }); if (!announcement) throw new UserFacingError( "Der Beitrag wurde geändert oder ist nicht verfügbar. Bitte lade die Seite neu.", ); await tx.announcementRead.upsert({ where: { userId_announcementId: { userId: session.user.id, announcementId }, }, create: { kitaId: session.user.kitaId, userId: session.user.id, announcementId, }, update: { readAt: new Date() }, }); }); } catch (error) { return { error: publicActionError( error, "Lesestatus konnte nicht gespeichert werden.", ), }; } revalidateAnnouncements(); return { success: true }; }