432 lines
12 KiB
TypeScript
432 lines
12 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 { 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 };
|
||
}
|