"use server"; import { randomUUID } from "crypto"; import { mkdir, rm, writeFile } from "fs/promises"; import path from "path"; import { createElement } from "react"; import { revalidatePath } from "next/cache"; import { AnnouncementAudience, UserRole } from "@prisma/client"; import { z } from "zod"; import { NewsEmail } from "@/emails/NewsEmail"; import { requireKitaSession, requireRole } from "@/lib/auth-utils"; import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail"; import { prisma } from "@/lib/prisma"; import { announcementRecipientWhere, visibleAnnouncementAudiences, } from "@/lib/announcement-access"; const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; const ALLOWED_TYPES = new Map([ ["application/pdf", ".pdf"], ["image/jpeg", ".jpg"], ["image/png", ".png"], ]); const ALLOWED_EXTENSIONS = new Set([".pdf", ".jpg", ".jpeg", ".png"]); 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 getUploadDir() { return path.resolve(process.env.UPLOAD_DIR ?? "uploads", "announcements"); } function getSafeFileName(name: string) { return name.replace(/[^\w.\- äöüÄÖÜß]/g, "_").slice(0, 160); } function validateAttachment(file: File) { const extension = path.extname(file.name).toLowerCase(); const typeAllowed = ALLOWED_TYPES.has(file.type); const extensionAllowed = ALLOWED_EXTENSIONS.has(extension); if (!typeAllowed && !extensionAllowed) { return "Nur PDF, JPG und PNG sind erlaubt."; } if (file.size > MAX_ATTACHMENT_SIZE) { return "Anhänge dürfen maximal 10 MB groß sein."; } return null; } async function getRecipients( kitaId: string, audience: z.infer, ) { const where = { kitaId, emailVerifiedAt: { not: null }, ...announcementRecipientWhere(audience as AnnouncementAudience), }; const users = await prisma.user.findMany({ where, 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); for (const file of attachmentFiles) { const error = validateAttachment(file); if (error) { return { error }; } } const attachmentInputs = attachmentFiles.map((file) => { const id = randomUUID(); return { id, file, fileName: getSafeFileName(file.name), fileType: file.type || "application/octet-stream", fileUrl: `/api/announcements/attachments/${id}`, }; }); const announcement = await prisma.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, fileUrl: attachment.fileUrl, })), }, }, select: { id: true, title: true, content: true, }, }); if (attachmentInputs.length > 0) { const uploadDir = getUploadDir(); try { await mkdir(uploadDir, { recursive: true }); await Promise.all( attachmentInputs.map(async (attachment) => { const arrayBuffer = await attachment.file.arrayBuffer(); await writeFile( path.join(uploadDir, attachment.id), Buffer.from(arrayBuffer), ); }), ); } catch (error) { await prisma.announcement.delete({ where: { id: announcement.id } }); await Promise.all( attachmentInputs.map((attachment) => rm(path.join(uploadDir, 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: { userId: session.user.id, announcementId: announcement.id, }, update: { readAt: new Date(), }, }); revalidatePath("/dashboard"); revalidatePath("/dashboard/mitteilungen"); return { success: true }; }