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 };
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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> =
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user