Add announcement editing and reliable per-recipient mail delivery

This commit is contained in:
t.indorf
2026-10-13 08:53:22 +02:00
parent 661776e609
commit 1472779781
22 changed files with 1655 additions and 190 deletions
+305 -106
View File
@@ -2,22 +2,50 @@
import { randomUUID } from "crypto";
import { mkdir, rm, writeFile } from "fs/promises";
import { attachmentDirectory, attachmentPath, MAX_ATTACHMENT_SIZE, detectAttachmentType, attachmentExtensionMatches } from "@/lib/attachments";
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 { after } from "next/server";
import { revalidatePath } from "next/cache";
import { AnnouncementAudience, UserRole, type Prisma } from "@prisma/client";
import { 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 { visibleAnnouncementAudiences } from "@/lib/announcement-access";
import {
announcementRecipientWhere,
visibleAnnouncementAudiences,
} from "@/lib/announcement-access";
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"]);
@@ -32,17 +60,6 @@ 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;
@@ -66,47 +83,89 @@ export async function createAnnouncement(formData: 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 (
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." };
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 }[] = [];
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." };
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}` });
attachmentInputs.push({
id,
bytes,
fileName: getSafeFileName(file.name),
fileType,
fileUrl: `/api/announcements/attachments/${id}`,
});
}
const announcement = await serializableTransaction(async tx => {
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({
where: { announcement: { kitaId } },
_sum: { sizeBytes: true },
}),
tx.attachment.aggregate({ _sum: { sizeBytes: true } }),
]);
const bytes = attachmentInputs.reduce((total, item) => total + item.bytes.length, 0);
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;
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;
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({
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,
@@ -121,10 +180,19 @@ export async function createAnnouncement(formData: FormData) {
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 (!announcement)
return {
error:
"Anhänge können momentan nicht gespeichert werden. Bitte wende dich an die Administration.",
};
if (attachmentInputs.length > 0) {
const uploadDir = attachmentDirectory();
@@ -132,11 +200,10 @@ export async function createAnnouncement(formData: FormData) {
await mkdir(uploadDir, { recursive: true });
await Promise.all(
attachmentInputs.map(async (attachment) => {
await writeFile(
attachmentPath(attachment.id),
attachment.bytes,
{ flag: "wx", mode: 0o600 },
);
await writeFile(attachmentPath(attachment.id), attachment.bytes, {
flag: "wx",
mode: 0o600,
});
}),
);
} catch (error) {
@@ -154,79 +221,211 @@ export async function createAnnouncement(formData: FormData) {
}
}
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 (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 };
}
if (!result.success) {
emailError = result.error;
}
}
}
}
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(),
});
revalidatePath("/dashboard");
revalidatePath("/dashboard/admin/news");
revalidatePath("/dashboard/mitteilungen");
if (emailError) {
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 {
success: true,
warning: `Ankündigung gespeichert, E-Mail-Versand fehlgeschlagen: ${emailError}`,
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.",
),
};
}
return { success: true };
}
export async function markAnnouncementRead(announcementId: string) {
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();
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." };
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.",
),
};
}
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");
revalidateAnnouncements();
return { success: true };
}