233 lines
7.9 KiB
TypeScript
233 lines
7.9 KiB
TypeScript
"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<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) {
|
|
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 };
|
|
}
|