Add announcement editing and reliable per-recipient mail delivery
This commit is contained in:
+305
-106
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user