Files
kita-planer/src/actions/announcements.ts
T

247 lines
6.6 KiB
TypeScript

"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<typeof audienceSchema>,
) {
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 };
}