"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 { createElement } from "react"; import { revalidatePath } from "next/cache"; import { AnnouncementAudience, UserRole, type Prisma } from "@prisma/client"; import { z } from "zod"; import { NewsEmail } from "@/emails/NewsEmail"; import { requireKitaSession, requireRole } from "@/lib/auth-utils"; import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail"; import { prisma } from "@/lib/prisma"; import { announcementRecipientWhere, visibleAnnouncementAudiences, } from "@/lib/announcement-access"; 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); } async function getRecipients(kitaId: string, audience: z.infer) { const where: Prisma.UserWhereInput = { kitaId, emailVerifiedAt: { not: null }, ...announcementRecipientWhere(audience as AnnouncementAudience) }; const users = await prisma.user.findMany({ where: { ...where, disabledAt: null, privacyPolicyAcceptedAt: { not: null } }, select: { email: true }, }); return Array.from(new Set(users.map((user) => user.email))); } export async function createAnnouncement(formData: FormData) { const session = await requireRole([UserRole.ADMIN]); const kitaId = session.user.kitaId; 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; } return tx.announcement.create({ data: { kitaId, title: parsed.data.title, content: parsed.data.content, audience: parsed.data.audience, authorId: session.user.id, 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, }, }); }); 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." }; } } let emailError: string | null = null; if (parsed.data.sendEmail) { const configError = getAppEmailConfigError(); if (configError) { emailError = configError; } else { const recipients = await getRecipients(kitaId, parsed.data.audience); if (recipients.length > 0) { const result = await sendAppEmail({ to: recipients, subject: `Neue Kita-Ankündigung: ${announcement.title}`, react: createElement(NewsEmail, { title: announcement.title, content: announcement.content, dashboardUrl: `${process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000"}/dashboard`, }), }); if (!result.success) { emailError = result.error; } } } } revalidatePath("/dashboard"); revalidatePath("/dashboard/admin/news"); revalidatePath("/dashboard/mitteilungen"); if (emailError) { return { success: true, warning: `Ankündigung gespeichert, E-Mail-Versand fehlgeschlagen: ${emailError}`, }; } return { success: true }; } export async function markAnnouncementRead(announcementId: string) { const session = await requireKitaSession(); const announcement = await prisma.announcement.findFirst({ where: { id: announcementId, kitaId: session.user.kitaId, audience: { in: visibleAnnouncementAudiences(session.user) }, }, select: { id: true }, }); if (!announcement) { return { error: "Ankündigung wurde nicht gefunden." }; } 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 }; }