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 };
}
+19 -3
View File
@@ -3,6 +3,8 @@ import { authorizeAlarmJob, runAlarmJob } from "@/lib/alarms/job";
import { runNotdienstNotificationJob } from "@/lib/notdienst-notifications";
import { deliverDueAnnouncementEmails } from "@/lib/announcement-delivery";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
@@ -18,12 +20,26 @@ export async function POST(request: Request) {
{ status },
);
try {
const [alarm, planning] = await Promise.allSettled([runAlarmJob(), runNotdienstNotificationJob()]);
if (alarm.status === "rejected" || planning.status === "rejected") throw new Error("Ein Hintergrundlauf ist fehlgeschlagen.");
const result = { ...alarm.value, planning: planning.value };
const [alarm, planning, announcements] = await Promise.allSettled([
runAlarmJob(),
runNotdienstNotificationJob(),
deliverDueAnnouncementEmails(),
]);
if (
alarm.status === "rejected" ||
planning.status === "rejected" ||
announcements.status === "rejected"
)
throw new Error("Ein Hintergrundlauf ist fehlgeschlagen.");
const result = {
...alarm.value,
planning: planning.value,
announcements: announcements.value,
};
revalidatePath("/dashboard");
revalidatePath("/dashboard/notdienst/alarm");
revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard/admin/news");
return Response.json(result);
} catch {
return Response.json(
+319
View File
@@ -0,0 +1,319 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { format } from "date-fns";
import { ArrowLeft, FileText, Mail } from "lucide-react";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { announcementAudienceLabels } from "@/lib/announcement-access";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { EditNewsForm, RetryNewsEmails } from "../edit-news-form";
export const metadata = { title: "Mitteilung bearbeiten · Kita-Planer" };
export default async function EditNewsPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ page?: string }>;
}) {
const session = await requireRole(["ADMIN"]);
const { id } = await params;
const announcement = await prisma.announcement.findFirst({
where: { id, kitaId: session.user.kitaId! },
include: {
attachments: { select: { id: true, fileName: true, fileUrl: true } },
},
});
if (!announcement) notFound();
const rawPage = Number((await searchParams).page ?? 1);
const page =
Number.isSafeInteger(rawPage) && rawPage > 0
? Math.min(rawPage, 100000)
: 1;
const where = {
kitaId: session.user.kitaId!,
announcementId: id,
revision: announcement.revision,
};
const [statuses, emails, older] = await Promise.all([
prisma.announcementEmail.findMany({
where,
select: { sentAt: true, skippedAt: true, failed: true },
}),
prisma.announcementEmail.findMany({
where,
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
take: 51,
skip: (page - 1) * 50,
select: {
id: true,
sentAt: true,
skippedAt: true,
failed: true,
attempts: true,
leaseUntil: true,
lastError: true,
user: { select: { firstName: true, lastName: true, email: true } },
},
}),
prisma.announcementEmail.findMany({
where: {
kitaId: session.user.kitaId!,
announcementId: id,
revision: { lt: announcement.revision },
},
select: { revision: true, sentAt: true, skippedAt: true, failed: true },
}),
]);
const history = new Map<
number,
{ sent: number; skipped: number; failed: number }
>();
for (const job of older) {
const summary = history.get(job.revision) ?? {
sent: 0,
skipped: 0,
failed: 0,
};
if (job.sentAt) summary.sent++;
else if (job.skippedAt) summary.skipped++;
else if (job.failed) summary.failed++;
history.set(job.revision, summary);
}
const backgroundConfigured =
(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32;
const sent = statuses.filter((job) => job.sentAt).length;
const skipped = statuses.filter((job) => job.skippedAt).length;
const failed = statuses.filter(
(job) => !job.sentAt && !job.skippedAt && job.failed,
).length;
const pending = statuses.length - sent - skipped - failed;
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<Button asChild variant="ghost" size="sm" className="mb-5">
<Link href="/dashboard/admin/news#beitraege">
<ArrowLeft className="h-4 w-4" />
Alle Beiträge
</Link>
</Button>
<header className="mb-7">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-deep)]">
SCHWARZES BRETT · BEITRAG
</p>
<h1 className="mt-2 text-[36px] tracking-[-0.025em]">
Mitteilung{" "}
<em className="font-normal text-[var(--accent-deep)]">bearbeiten</em>
</h1>
<p className="mt-2 text-sm text-[var(--ink-soft)]">
Veröffentlicht am {format(announcement.createdAt, "dd.MM.yyyy HH:mm")}
{announcement.editedAt &&
` · Aktualisiert am ${format(announcement.editedAt, "dd.MM.yyyy HH:mm")}`}
</p>
</header>
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)]">
<Card>
<CardHeader>
<CardTitle>Inhalt korrigieren</CardTitle>
<CardDescription>
Empfängergruppe und bestehende Anhänge bleiben bei einer Korrektur
erhalten.
</CardDescription>
<Badge variant="outline" className="mt-2 w-fit">
{announcementAudienceLabels[announcement.audience]}
</Badge>
</CardHeader>
<CardContent>
<EditNewsForm
key={announcement.revision}
announcement={{
id,
revision: announcement.revision,
title: announcement.title,
content: announcement.content,
}}
/>
{announcement.attachments.length > 0 && (
<div className="mt-6 border-t border-[var(--hairline)] pt-4">
<h2 className="mb-2 text-sm font-medium">Anhänge</h2>
<div className="grid gap-2">
{announcement.attachments.map((a) => (
<a
key={a.id}
href={a.fileUrl}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 break-all text-sm text-[var(--accent-deep)] underline"
>
<FileText className="h-4 w-4 shrink-0" />
{a.fileName}
</a>
))}
</div>
</div>
)}
</CardContent>
</Card>
<Card id="versand">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-4 w-4" />
E-Mail-Versand
</CardTitle>
<CardDescription>
Status der aktuellen Fassung. „Gesendet“ bedeutet, dass der
Maildienst die Nachricht angenommen hat.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex flex-wrap gap-2">
<Badge variant="success">{sent} gesendet</Badge>
<Badge variant="secondary">{pending} ausstehend</Badge>
<Badge variant="outline">{failed} fehlgeschlagen</Badge>
{skipped > 0 && (
<Badge variant="outline">{skipped} übersprungen</Badge>
)}
</div>
{!backgroundConfigured && (
<p className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3 text-sm leading-6">
Automatische Wiederholungen sind noch nicht eingerichtet. Bitte
wende dich an die Hosting-Administration; fehlgeschlagene
E-Mails kannst du hier selbst erneut versuchen.
</p>
)}
{!announcement.emailReady && (
<p className="text-sm text-[var(--ink-soft)]">
Die Anhänge werden noch vorbereitet. Der Versand beginnt erst,
wenn alle Dateien gespeichert sind.
</p>
)}
<p className="text-xs leading-5 text-[var(--ink-soft)]">
Bei eingerichteter Automatik werden fehlgeschlagene Sendungen
erneut versucht. Deaktivierte Konten und Personen, die nicht mehr
zur Empfängergruppe gehören, werden übersprungen.
</p>
{failed > 0 && (
<div className="[&_button]:h-auto [&_button]:whitespace-normal">
<RetryNewsEmails
id={id}
revision={announcement.revision}
failed={failed}
/>
</div>
)}
<Button
asChild
variant="ghost"
size="sm"
className="justify-self-start"
>
<Link href={`/dashboard/admin/news/${id}?page=${page}#versand`}>
Status aktualisieren
</Link>
</Button>
{statuses.length === 0 && (
<p className="rounded-lg bg-[var(--surface-2)] p-4 text-sm leading-6 text-[var(--ink-soft)]">
Für diese Fassung sind keine E-Mails vorgemerkt. Bei Beiträgen
aus der Zeit vor Einführung dieser Übersicht liegt kein
damaliger Versandstatus vor. Über das Formular kannst du die
aktuelle Fassung einmalig versenden.
</p>
)}
<div className="grid gap-2">
{emails.slice(0, 50).map((job) => (
<div
key={job.id}
className="min-w-0 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="text-sm font-medium">
{job.user.firstName} {job.user.lastName}
</p>
<Badge variant={job.sentAt ? "success" : "outline"}>
{job.sentAt
? "Gesendet"
: job.skippedAt
? "Übersprungen"
: job.failed
? "Fehlgeschlagen"
: job.leaseUntil && job.leaseUntil > new Date()
? "Wird versendet"
: "Ausstehend"}
</Badge>
</div>
<p className="mt-1 break-all text-xs text-[var(--ink-muted)]">
{job.user.email}
</p>
{job.sentAt && (
<p className="mt-1 text-xs text-[var(--ink-muted)]">
{format(job.sentAt, "dd.MM.yyyy HH:mm")}
</p>
)}
{job.lastError && (
<p className="mt-2 text-xs leading-5 text-[var(--ink-soft)]">
{job.lastError}
</p>
)}
</div>
))}
</div>
{history.size > 0 && (
<details className="rounded-lg border border-[var(--hairline)] p-3">
<summary className="cursor-pointer text-sm font-medium">
Versand früherer Fassungen
</summary>
<div className="mt-3 grid gap-2">
{[...history]
.sort(([a], [b]) => b - a)
.map(([revision, summary]) => (
<p
key={revision}
className="text-xs leading-5 text-[var(--ink-soft)]"
>
Fassung {revision}: {summary.sent} gesendet ·{" "}
{summary.skipped} übersprungen · {summary.failed}{" "}
fehlgeschlagen
</p>
))}
</div>
<p className="mt-3 text-xs leading-5 text-[var(--ink-muted)]">
Ungesendete E-Mails älterer Fassungen wurden durch die
Korrektur ersetzt.
</p>
</details>
)}
{(page > 1 || emails.length > 50) && (
<nav
aria-label="Empfänger durchblättern"
className="flex flex-wrap justify-between gap-3"
>
<div>
{page > 1 && (
<Button asChild variant="outline" size="sm">
<Link href={`?page=${page - 1}#versand`}>Zurück</Link>
</Button>
)}
</div>
<span className="text-sm">Seite {page}</span>
<div>
{emails.length > 50 && (
<Button asChild variant="outline" size="sm">
<Link href={`?page=${page + 1}#versand`}>Weitere</Link>
</Button>
)}
</div>
</nav>
)}
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,174 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Loader2, RotateCw, Save } from "lucide-react";
import {
updateAnnouncement,
retryAnnouncementEmails,
} from "@/actions/announcements";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { MarkdownContent } from "@/components/markdown-content";
export function EditNewsForm({
announcement,
}: {
announcement: {
id: string;
revision: number;
title: string;
content: string;
};
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [content, setContent] = useState(announcement.content);
const [sendEmail, setSendEmail] = useState(false);
function submit(formData: FormData) {
formData.set("id", announcement.id);
formData.set("revision", String(announcement.revision));
formData.set("sendEmail", sendEmail ? "on" : "off");
startTransition(async () => {
try {
const result = await updateAnnouncement(formData);
if (!("success" in result)) {
toast.error(result.error);
return;
}
toast.success(
result.queued
? `Mitteilung gespeichert. ${result.queued} E-Mails sind zum Versand vorgemerkt.`
: result.changed
? "Mitteilung aktualisiert."
: "Keine Änderungen vorhanden.",
);
setSendEmail(false);
router.refresh();
} catch {
toast.error(
"Mitteilung konnte nicht gespeichert werden. Bitte versuche es erneut.",
);
}
});
}
return (
<form action={submit} className="grid gap-5">
<div className="grid gap-2">
<Label htmlFor="edit-title">Titel</Label>
<Input
id="edit-title"
name="title"
defaultValue={announcement.title}
minLength={3}
maxLength={160}
required
disabled={pending}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-content">Text</Label>
<Textarea
id="edit-content"
name="content"
rows={10}
value={content}
onChange={(event) => setContent(event.target.value)}
minLength={3}
maxLength={20_000}
required
disabled={pending}
/>
<p className="text-xs text-[var(--ink-muted)]">
Markdown ist aktiv. HTML wird nicht interpretiert.
</p>
</div>
<details className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
<summary className="cursor-pointer text-sm font-medium">
Vorschau ansehen
</summary>
<div className="mt-4 text-sm">
<MarkdownContent content={content} />
</div>
</details>
<div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
<div className="flex items-start gap-2">
<Checkbox
id="correction-email"
checked={sendEmail}
disabled={pending}
onCheckedChange={(checked) => setSendEmail(checked === true)}
/>
<Label
htmlFor="correction-email"
className="cursor-pointer leading-5"
>
Aktuelle Fassung auch per E-Mail senden
</Label>
</div>
<p className="mt-2 text-xs leading-5 text-[var(--ink-soft)]">
Bei einer Textänderung erscheint der Beitrag für alle wieder als
ungelesen. Jede Fassung wird je Person höchstens einmal zum Versand
vorgemerkt.
</p>
</div>
<Button type="submit" disabled={pending} className="justify-self-end">
{pending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Änderungen speichern
</Button>
</form>
);
}
export function RetryNewsEmails({
id,
revision,
failed,
}: {
id: string;
revision: number;
failed: number;
}) {
const [pending, startTransition] = useTransition();
const router = useRouter();
return (
<Button
variant="outline"
disabled={pending || failed === 0}
onClick={() =>
startTransition(async () => {
try {
const result = await retryAnnouncementEmails(id, revision);
if (!("success" in result)) {
toast.error(result.error);
return;
}
toast.success(
result.queued
? `${result.queued} fehlgeschlagene E-Mails werden erneut versucht.`
: "Keine fehlgeschlagenen Sendungen vorhanden.",
);
router.refresh();
} catch {
toast.error("Versand konnte nicht gestartet werden.");
}
})
}
>
{pending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCw className="h-4 w-4" />
)}
Fehlgeschlagene erneut versuchen
</Button>
);
}
+31 -11
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useTransition } from "react";
import { useRef, useState, useTransition } from "react";
import { Loader2, Send, UploadCloud } from "lucide-react";
import { toast } from "sonner";
@@ -20,6 +20,7 @@ import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/markdown-content";
export function NewsForm() {
const formRef = useRef<HTMLFormElement>(null);
const [isPending, startTransition] = useTransition();
const [sendEmail, setSendEmail] = useState(false);
const [content, setContent] = useState("");
@@ -30,27 +31,43 @@ export function NewsForm() {
formData.set("sendEmail", "on");
}
const result = await createAnnouncement(formData);
if (result.error) {
toast.error(result.error);
return;
}
try {
const result = await createAnnouncement(formData);
if (result.error) {
toast.error(result.error);
return;
}
if (result.warning) {
toast.warning(result.warning);
} else {
toast.success("Ankündigung veröffentlicht.");
if (result.warning) {
toast.warning(result.warning);
} else {
toast.success(
sendEmail
? "Ankündigung veröffentlicht. E-Mails sind zum Versand vorgemerkt."
: "Ankündigung veröffentlicht.",
);
}
formRef.current?.reset();
setContent("");
setSendEmail(false);
} catch {
toast.error(
"Ankündigung konnte nicht veröffentlicht werden. Bitte versuche es erneut.",
);
}
});
}
return (
<form action={handleSubmit} className="grid gap-5">
<form ref={formRef} action={handleSubmit} className="grid gap-5">
<div className="grid gap-2">
<Label htmlFor="news-title">Titel</Label>
<Input
id="news-title"
name="title"
minLength={3}
maxLength={160}
disabled={isPending}
placeholder="Sommerfest: Aufbau und Mitbringliste"
required
/>
@@ -62,6 +79,9 @@ export function NewsForm() {
id="news-content"
name="content"
rows={10}
minLength={3}
maxLength={20_000}
disabled={isPending}
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder={
+141 -48
View File
@@ -1,3 +1,7 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { FileText, Megaphone, Send } from "lucide-react";
@@ -18,7 +22,11 @@ import { NewsForm } from "./news-form";
export const metadata = { title: "Schwarzes Brett · Kita-Planer" };
export default async function AdminNewsPage() {
export default async function AdminNewsPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; page?: string }>;
}) {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
@@ -26,29 +34,46 @@ export default async function AdminNewsPage() {
throw new Error("Kein Mandant zugeordnet.");
}
const announcements = await prisma.announcement.findMany({
where: { kitaId },
select: {
id: true,
title: true,
audience: true,
createdAt: true,
attachments: {
select: {
id: true,
fileName: true,
},
const params = await searchParams;
const q = typeof params.q === "string" ? params.q.trim().slice(0, 160) : "";
const parsedPage = Number(params.page ?? 1);
const page =
Number.isSafeInteger(parsedPage) && parsedPage > 0
? Math.min(parsedPage, 100000)
: 1;
const pageSize = 12;
const where = {
kitaId,
...(q
? {
OR: [
{ title: { contains: q, mode: "insensitive" as const } },
{ content: { contains: q, mode: "insensitive" as const } },
],
}
: {}),
};
const [announcements, total] = await Promise.all([
prisma.announcement.findMany({
where,
select: {
id: true,
title: true,
audience: true,
createdAt: true,
editedAt: true,
revision: true,
_count: { select: { attachments: true } },
author: { select: { firstName: true, lastName: true } },
},
author: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: { createdAt: "desc" },
take: 8,
});
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.announcement.count({ where }),
]);
const href = (p: number) =>
`/dashboard/admin/news?${new URLSearchParams({ ...(q ? { q } : {}), page: String(p) })}#beitraege`;
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
@@ -60,7 +85,7 @@ export default async function AdminNewsPage() {
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Meldungen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
veröffentlichen
verwalten
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
@@ -71,42 +96,77 @@ export default async function AdminNewsPage() {
</div>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
<Megaphone className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{announcements.length} zuletzt
{total} {total === 1 ? "Beitrag" : "Beiträge"}
</span>
</header>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
<Card>
<div className="grid gap-6">
<details
open={!q && page === 1}
className="rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-5"
>
<summary className="cursor-pointer text-lg font-semibold">
Neue Ankündigung veröffentlichen
</summary>
<Card className="mt-4 border-0 shadow-none">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Send className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
Neue Ankündigung
</CardTitle>
<CardDescription>
Markdown wird sicher gerendert; PDF, JPG und PNG können als
geschützte Anhänge hinzugefügt werden.
</CardDescription>
</CardHeader>
<CardContent>
<NewsForm />
</CardContent>
</Card>
</details>
<Card id="beitraege" className="h-fit">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Send className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
Neue Ankündigung
<CardTitle className="text-base">
Veröffentlichte Beiträge
</CardTitle>
<CardDescription>
Markdown wird sicher gerendert; PDF, JPG und PNG können als
geschützte Anhänge hinzugefügt werden.
</CardDescription>
</CardHeader>
<CardContent>
<NewsForm />
</CardContent>
</Card>
<Card className="h-fit">
<CardHeader>
<CardTitle className="text-base">Zuletzt veröffentlicht</CardTitle>
<CardDescription>
Die neuesten Meldungen auf dem Schwarzen Brett.
Korrigiere auch ältere Beiträge und prüfe den E-Mail-Versand.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
<form
className="mb-4 flex flex-wrap items-end gap-3"
action="/dashboard/admin/news#beitraege"
>
<div className="grid flex-1 gap-2">
<Label htmlFor="news-search">Beiträge durchsuchen</Label>
<Input
id="news-search"
name="q"
defaultValue={q}
maxLength={160}
placeholder="Titel oder Text"
/>
</div>
<Button type="submit" variant="outline">
Suchen
</Button>
{q && (
<Button asChild variant="ghost">
<Link href="/dashboard/admin/news#beitraege">
Alle Beiträge
</Link>
</Button>
)}
</form>
{announcements.length === 0 ? (
<div className="flex max-h-[280px] min-h-[220px] flex-col items-center justify-center rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-6 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Megaphone className="h-6 w-6 [stroke-width:1.7]" />
</div>
<p className="text-sm font-medium text-[var(--ink)]">
Noch keine Ankündigungen
Keine Beiträge gefunden
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Veröffentlichte Meldungen erscheinen hier.
@@ -119,13 +179,13 @@ export default async function AdminNewsPage() {
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
>
<div className="mb-2 flex items-start justify-between gap-2">
<p className="font-medium leading-snug text-[var(--ink)]">
<p className="min-w-0 break-words font-medium leading-snug text-[var(--ink)]">
{announcement.title}
</p>
{announcement.attachments.length > 0 && (
{announcement._count.attachments > 0 && (
<Badge variant="secondary" className="shrink-0">
<FileText className="h-3 w-3 [stroke-width:1.7]" />
{announcement.attachments.length}
{announcement._count.attachments}
</Badge>
)}
</div>
@@ -139,9 +199,42 @@ export default async function AdminNewsPage() {
· {announcement.author.firstName}{" "}
{announcement.author.lastName}
</p>
{announcement.editedAt && (
<p className="mt-1 text-xs text-[var(--accent-deep)]">
Aktualisiert am{" "}
{format(announcement.editedAt, "dd.MM.yyyy HH:mm")}
</p>
)}
<Button asChild variant="outline" size="sm" className="mt-3">
<Link href={`/dashboard/admin/news/${announcement.id}`}>
Bearbeiten &amp; Versand
</Link>
</Button>
</div>
))
)}
<nav
aria-label="Beiträge durchblättern"
className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-[var(--hairline)] pt-4"
>
<div>
{page > 1 && (
<Button asChild variant="outline" size="sm">
<Link href={href(page - 1)}>Neuere Beiträge</Link>
</Button>
)}
</div>
<span className="text-sm text-[var(--ink-soft)]">
Seite {page} · {total} {total === 1 ? "Beitrag" : "Beiträge"}
</span>
<div>
{page * pageSize < total && (
<Button asChild variant="outline" size="sm">
<Link href={href(page + 1)}>Ältere Beiträge</Link>
</Button>
)}
</div>
</nav>
</CardContent>
</Card>
</div>
+8 -4
View File
@@ -26,6 +26,8 @@ import { MarkdownContent } from "@/components/markdown-content";
export type NewsTickerItemDto = {
id: string;
revision: number;
editedAt: string | null;
title: string;
content: string;
excerpt: string;
@@ -53,16 +55,16 @@ export function NewsTicker({
function openAnnouncement(item: NewsTickerItemDto) {
setSelected(item);
if (!item.isUnread || readIds.has(item.id)) return;
if (!item.isUnread || readIds.has(`${item.id}:${item.revision}`)) return;
startTransition(async () => {
try {
const result = await markAnnouncementRead(item.id);
const result = await markAnnouncementRead(item.id, item.revision);
if (result.error) {
toast.error(result.error);
return;
}
setReadIds((current) => new Set(current).add(item.id));
setReadIds((current) => new Set(current).add(`${item.id}:${item.revision}`));
} catch {
toast.error("Lesestatus konnte nicht gespeichert werden.");
}
@@ -101,7 +103,7 @@ export function NewsTicker({
</p>
) : null}
{items.map((item) => {
const isUnread = item.isUnread && !readIds.has(item.id);
const isUnread = item.isUnread && !readIds.has(`${item.id}:${item.revision}`);
return (
<button
key={item.id}
@@ -116,6 +118,7 @@ export function NewsTicker({
</div>
{isUnread && <Badge variant="success">NEU</Badge>}
</div>
{item.editedAt && <p className="mb-2 text-xs font-medium text-[var(--accent-deep)]">Aktualisiert am {item.editedAt}</p>}
<h3 className="line-clamp-2 font-semibold leading-snug text-[var(--ink)]">
{item.title}
</h3>
@@ -146,6 +149,7 @@ export function NewsTicker({
<DialogTitle className="text-2xl">{selected.title}</DialogTitle>
<DialogDescription>
{selected.createdAt} · {selected.authorName}
{selected.editedAt && <span className="block">Aktualisiert am {selected.editedAt}</span>}
</DialogDescription>
</DialogHeader>
+4 -3
View File
@@ -17,20 +17,21 @@ type NewsEmailProps = {
title: string;
content: string;
dashboardUrl: string;
updated?: boolean;
};
export function NewsEmail({ title, content, dashboardUrl }: NewsEmailProps) {
export function NewsEmail({ title, content, dashboardUrl, updated = false }: NewsEmailProps) {
const excerpt =
content.length > 420 ? `${content.slice(0, 420).trim()}...` : content;
return (
<Html lang="de">
<Head />
<Preview>Neue Kita-Ankündigung: {title}</Preview>
<Preview>{updated ? "Aktualisierte" : "Neue"} Kita-Ankündigung: {title}</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Section style={styles.header}>
<Text style={styles.kicker}>Schwarzes Brett</Text>
<Text style={styles.kicker}>{updated ? "Schwarzes Brett · Aktualisiert" : "Schwarzes Brett"}</Text>
<Heading style={styles.heading}>{title}</Heading>
</Section>
+14 -6
View File
@@ -1,4 +1,4 @@
import { AnnouncementAudience, UserRole } from "@prisma/client";
import { AnnouncementAudience, UserRole, type Prisma } from "@prisma/client";
// Admins need access to all publications for management. Other roles only
// receive their audience; a parent with a coordination role is still a parent.
@@ -19,15 +19,23 @@ export function visibleAnnouncementAudiences(user: {
return audiences;
}
export function announcementRecipientWhere(audience: AnnouncementAudience) {
export function announcementRecipientWhere(
audience: AnnouncementAudience,
): Prisma.UserWhereInput {
const parents = {
role: { not: UserRole.ERZIEHER },
role: { in: [UserRole.ELTERN, UserRole.KOORDINATOR, UserRole.ADMIN] },
OR: [{ familyId: { not: null } }, { role: UserRole.ELTERN }],
};
// A promoted staff account keeps its team membership through the profile.
const staff = {
OR: [
{ role: UserRole.ERZIEHER },
{ role: UserRole.ADMIN, staffProfile: { isNot: null } },
],
};
if (audience === AnnouncementAudience.ELTERN) return parents;
if (audience === AnnouncementAudience.ERZIEHER)
return { role: UserRole.ERZIEHER };
return { OR: [parents, { role: UserRole.ERZIEHER }] };
if (audience === AnnouncementAudience.ERZIEHER) return staff;
return { OR: [parents, staff] };
}
export const announcementAudienceLabels: Record<AnnouncementAudience, string> =
+286
View File
@@ -0,0 +1,286 @@
import "server-only";
import { stat } from "node:fs/promises";
import { attachmentPath } from "@/lib/attachments";
import { randomUUID } from "node:crypto";
import { createElement } from "react";
import type { Announcement, Prisma } from "@prisma/client";
import { NewsEmail } from "@/emails/NewsEmail";
import { announcementRecipientWhere } from "@/lib/announcement-access";
import { prisma } from "@/lib/prisma";
import { sendAppEmail } from "@/lib/mail";
import { UserFacingError } from "@/lib/action-error";
export async function lockAnnouncement(
tx: Prisma.TransactionClient,
kitaId: string,
id: string,
) {
await tx.$queryRaw`SELECT id FROM announcements WHERE id = ${id} AND "kitaId" = ${kitaId} FOR UPDATE`;
}
export async function queueAnnouncementEmails(
tx: Prisma.TransactionClient,
announcement: Pick<Announcement, "id" | "kitaId" | "audience" | "revision">,
now = new Date(),
) {
const users = await tx.user.findMany({
where: {
kitaId: announcement.kitaId,
disabledAt: null,
emailVerifiedAt: { not: null },
privacyPolicyAcceptedAt: { not: null },
AND: [
announcementRecipientWhere(announcement.audience),
{ OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }] },
],
},
select: { id: true },
});
if (!users.length) return 0;
return (
await tx.announcementEmail.createMany({
data: users.map((user) => ({
id: randomUUID(),
kitaId: announcement.kitaId,
announcementId: announcement.id,
revision: announcement.revision,
userId: user.id,
nextAttemptAt: now,
})),
skipDuplicates: true,
})
).count;
}
type Scope = { kitaId?: string; announcementId?: string; revision?: number };
export async function deliverDueAnnouncementEmails(
scope: Scope = {},
send = sendAppEmail,
now = new Date(),
) {
// Recover a crash between completed file writes and the final readiness update.
// Partial or missing files never release a message for sending.
let preparationCursor: string | undefined;
for (;;) {
const unfinished = await prisma.announcement.findMany({
where: {
kitaId: scope.kitaId,
id: scope.announcementId,
revision: scope.revision,
emailReady: false,
createdAt: { lte: new Date(now.getTime() - 5 * 60_000) },
...(preparationCursor
? { AND: { id: { gt: preparationCursor } } }
: {}),
},
select: {
id: true,
attachments: { select: { id: true, sizeBytes: true } },
},
orderBy: { id: "asc" },
take: 12,
});
for (const row of unfinished) {
const complete = await Promise.all(
row.attachments.map(async (file) => {
try {
return (
file.sizeBytes !== null &&
(await stat(attachmentPath(file.id))).size === file.sizeBytes
);
} catch {
return false;
}
}),
);
if (complete.every(Boolean))
await prisma.announcement.updateMany({
where: { id: row.id, kitaId: scope.kitaId, emailReady: false },
data: { emailReady: true },
});
}
if (unfinished.length < 12) break;
preparationCursor = unfinished[unfinished.length - 1].id;
}
const jobs = await prisma.announcementEmail.findMany({
where: {
...scope,
sentAt: null,
skippedAt: null,
nextAttemptAt: { lte: now },
announcement: { emailReady: true },
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
},
select: { id: true, kitaId: true, announcementId: true },
orderBy: [{ nextAttemptAt: "asc" }, { createdAt: "asc" }, { id: "asc" }],
take: 12,
});
const result = { sent: 0, failed: 0, skipped: 0 };
const deliver = async (candidate: (typeof jobs)[number]) => {
const claimToken = randomUUID();
// Edits and claims share the publication lock. An active send blocks editing
// until it finishes, so a retried version always has the same message body.
// ReadCommitted plus the row lock and atomic claim is sufficient here;
// recipient updates must not cause serialization conflicts for other jobs.
const claimed = await prisma.$transaction(
async (tx) => {
await lockAnnouncement(tx, candidate.kitaId, candidate.announcementId);
return tx.announcementEmail.updateMany({
where: {
id: candidate.id,
sentAt: null,
skippedAt: null,
nextAttemptAt: { lte: now },
announcement: { emailReady: true },
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
},
data: {
claimToken,
leaseUntil: new Date(now.getTime() + 5 * 60_000),
attempts: { increment: 1 },
},
});
},
{ isolationLevel: "ReadCommitted" },
);
if (!claimed.count) return;
const finish = (data: Prisma.AnnouncementEmailUpdateManyMutationInput) =>
prisma.announcementEmail.updateMany({
where: { id: candidate.id, claimToken },
data: { ...data, claimToken: null, leaseUntil: null },
});
try {
const job = await prisma.announcementEmail.findUniqueOrThrow({
where: { id: candidate.id },
include: { announcement: true, user: true },
});
const eligible = await prisma.user.count({
where: {
id: job.userId,
kitaId: job.kitaId,
disabledAt: null,
emailVerifiedAt: { not: null },
privacyPolicyAcceptedAt: { not: null },
AND: [
announcementRecipientWhere(job.announcement.audience),
{ OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }] },
],
},
});
if (
!eligible ||
job.announcement.revision !== job.revision ||
job.announcement.kitaId !== job.kitaId
) {
await finish({
skippedAt: now,
failed: false,
lastError: "Empfänger oder Beitragsversion nicht mehr gültig.",
});
result.skipped++;
return;
}
const base =
process.env.NEXT_PUBLIC_SITE_URL ||
process.env.AUTH_URL ||
process.env.NEXTAUTH_URL;
if (!base) throw new Error("App-Adresse fehlt.");
const site = new URL(base);
if (
!["http:", "https:"].includes(site.protocol) ||
site.username ||
site.password
)
throw new Error("App-Adresse ungültig.");
const response = await send({
to: job.user.email,
idempotencyKey: `announcement-email/${job.id}`,
subject: `${job.revision > 1 ? "Aktualisierte" : "Neue"} Kita-Ankündigung: ${job.announcement.title}`,
react: createElement(NewsEmail, {
title: job.announcement.title,
content: job.announcement.content,
updated: job.revision > 1,
dashboardUrl: new URL(
"/dashboard/mitteilungen",
site.origin,
).toString(),
}),
});
if (!response.success) throw new Error(response.error);
await finish({ sentAt: now, failed: false, lastError: null });
result.sent++;
} catch {
const job = await prisma.announcementEmail.findUnique({
where: { id: candidate.id },
select: { attempts: true },
});
const delay = Math.min(
6 * 60 * 60_000,
60_000 * 2 ** Math.min(job?.attempts ?? 1, 9),
);
await finish({
failed: true,
lastError:
"E-Mail-Versand fehlgeschlagen. Ein neuer Versuch folgt automatisch.",
nextAttemptAt: new Date(now.getTime() + delay),
});
result.failed++;
}
};
for (let i = 0; i < jobs.length; i += 4)
await Promise.all(jobs.slice(i, i + 4).map(deliver));
return result;
}
export type AnnouncementActor = {
id: string;
kitaId: string;
sessionVersion: number;
};
export async function assertAnnouncementAdmin(
tx: Prisma.TransactionClient,
actor: AnnouncementActor,
) {
const active = await tx.user.findFirst({
where: {
id: actor.id,
kitaId: actor.kitaId,
sessionVersion: actor.sessionVersion,
role: "ADMIN",
disabledAt: null,
emailVerifiedAt: { not: null },
privacyPolicyAcceptedAt: { not: null },
OR: [{ lockedUntil: null }, { lockedUntil: { lte: new Date() } }],
},
select: { id: true },
});
if (!active)
throw new UserFacingError(
"Du darfst Mitteilungen nicht mehr verwalten. Bitte melde dich erneut an.",
);
}
export async function assertAnnouncementEditable(
tx: Prisma.TransactionClient,
kitaId: string,
id: string,
revision: number,
) {
await lockAnnouncement(tx, kitaId, id);
const announcement = await tx.announcement.findFirst({
where: { id, kitaId, revision, emailReady: true },
});
if (!announcement)
throw new UserFacingError(
"Der Beitrag wurde inzwischen geändert oder ist nicht verfügbar. Bitte lade die Seite neu.",
);
if (
await tx.announcementEmail.count({
where: { kitaId, announcementId: id, leaseUntil: { gt: new Date() } },
})
)
throw new UserFacingError(
"Für diesen Beitrag läuft gerade der E-Mail-Versand. Bitte versuche es gleich erneut.",
);
return announcement;
}
+5 -1
View File
@@ -19,18 +19,22 @@ export async function getAnnouncements(
title: true,
content: true,
createdAt: true,
revision: true,
editedAt: true,
author: { select: { firstName: true, lastName: true } },
reads: { where: { userId: session.user.id }, select: { id: true } },
attachments: {
select: { id: true, fileName: true, fileUrl: true, fileType: true },
},
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: options.take,
skip: options.skip,
});
return rows.map((row) => ({
id: row.id,
revision: row.revision,
editedAt: row.editedAt ? format(row.editedAt, "dd.MM.yyyy HH:mm", { locale: de }) : null,
title: row.title,
content: row.content,
excerpt: row.content