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

432 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 };
}