feat: complete notdienst planning, quick wins and notifications

This commit is contained in:
t.indorf
2026-10-12 15:34:09 +02:00
parent b915857951
commit 00f602a7aa
50 changed files with 2443 additions and 513 deletions
+36 -19
View File
@@ -1,35 +1,52 @@
import {
addMonths,
getDate,
getYear,
getMonth,
format,
} from "date-fns";
import { de } from "date-fns/locale";
import { format } from "date-fns";
/**
* Der Notdienst-Plan wird immer für den nächsten Monat erstellt.
* Nach dem 26. des aktuellen Monats ist der Plan gesperrt.
*/
const LOCK_DAY = 26;
export const NOTDIENST_LOCK_DAY = 26;
export function berlinDateKey(now = new Date()) {
return new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(now);
}
export function getTargetMonthData(now = new Date()) {
const currentDay = getDate(now);
const nextMonthDate = addMonths(now, 1);
const targetYear = getYear(nextMonthDate);
const targetMonth = getMonth(nextMonthDate) + 1; // 1-12
// Sperre aktiv, sobald der aktuelle Tag (im aktuellen Monat) > LOCK_DAY ist.
const isLocked = currentDay > LOCK_DAY;
const [year, month, currentDay] = berlinDateKey(now).split("-").map(Number);
const targetYear = month === 12 ? year + 1 : year;
const targetMonth = month === 12 ? 1 : month + 1;
return {
targetYear,
targetMonth,
isLocked,
monthName: format(nextMonthDate, "MMMM yyyy", { locale: de }),
isLocked: currentDay > NOTDIENST_LOCK_DAY,
monthName: new Intl.DateTimeFormat("de-DE", {
timeZone: "UTC",
year: "numeric",
month: "long",
}).format(new Date(Date.UTC(targetYear, targetMonth - 1, 1))),
};
}
export function isNotdienstReminderDue(daysBefore: number, now = new Date()) {
const parts = new Intl.DateTimeFormat("en-GB", {
timeZone: "Europe/Berlin",
day: "2-digit",
hour: "2-digit",
hourCycle: "h23",
}).formatToParts(now);
const day = Number(parts.find((part) => part.type === "day")?.value);
const hour = Number(parts.find((part) => part.type === "hour")?.value);
return (
daysBefore > 0 &&
day >= Math.max(1, NOTDIENST_LOCK_DAY - daysBefore) &&
day <= NOTDIENST_LOCK_DAY &&
hour >= 9
);
}
export function getWorkingDaysOfMonth(year: number, month: number) {
const start = new Date(Date.UTC(year, month - 1, 1));
const end = new Date(Date.UTC(year, month, 0)); // last day of month
+16 -4
View File
@@ -1,4 +1,6 @@
import type { ReactNode } from "react";
import { createHash } from "node:crypto";
import type { ReactNode, ReactElement } from "react";
import { render } from "@react-email/render";
import { Resend } from "resend";
let resendClient: Resend | null = null;
@@ -10,6 +12,7 @@ type SendAppEmailInput = {
from?: string;
replyTo?: string | string[];
idempotencyKey?: string;
timeoutMs?: number;
};
type SendAppEmailResult =
@@ -38,11 +41,12 @@ export async function sendAppEmail({
from = process.env.EMAIL_FROM,
replyTo,
idempotencyKey,
timeoutMs = 10000,
}: SendAppEmailInput): Promise<SendAppEmailResult> {
if (Array.isArray(to)) {
let id: string | undefined;
for (const recipient of new Set(to)) {
const result = await sendAppEmail({ to: recipient, subject, react, from, replyTo });
const result = await sendAppEmail({ to: recipient, subject, react, from, replyTo, idempotencyKey: idempotencyKey ? `${idempotencyKey}/${createHash("sha256").update(recipient).digest("hex").slice(0, 16)}` : undefined, timeoutMs });
if (!result.success) return result;
id = result.id;
}
@@ -74,15 +78,23 @@ export async function sendAppEmail({
try {
resendClient ??= new Resend(process.env.RESEND_API_KEY);
const html = await render(react as ReactElement);
// Retries keep the same key for identical payloads, while corrected messages get a new version.
const payloadKey = idempotencyKey ? `${idempotencyKey}/${createHash("sha256").update(JSON.stringify({ to, subject, from, replyTo, html })).digest("hex")}` : undefined;
// The installed SDK forwards request options to fetch; its types omit signal.
const requestOptions: NonNullable<Parameters<typeof resendClient.emails.send>[1]> & { signal: AbortSignal } = {
idempotencyKey: payloadKey,
signal: AbortSignal.timeout(timeoutMs),
};
const { data, error } = await resendClient.emails.send(
{
from: from!,
to,
subject,
react,
html,
replyTo,
},
{ idempotencyKey },
requestOptions,
);
if (error) {
+26
View File
@@ -0,0 +1,26 @@
import { formatUtcDateKey, getWorkingDaysOfMonth } from "./date-utils";
export type NotdienstClosure = { startDate: Date; endDate: Date };
const berlinDate = new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
// Calendar events are instants; assignments are UTC-midnight date values.
// A closure excludes every Berlin calendar day from start through end, inclusive.
export function getNotdienstDays(
year: number,
month: number,
closures: NotdienstClosure[],
) {
const ranges = closures.map((closure) => ({
start: berlinDate.format(closure.startDate),
end: berlinDate.format(closure.endDate),
}));
return getWorkingDaysOfMonth(year, month).filter((day) => {
const key = formatUtcDateKey(day);
return !ranges.some((range) => range.start <= key && key <= range.end);
});
}
+529
View File
@@ -0,0 +1,529 @@
import { randomUUID } from "node:crypto";
import { createElement } from "react";
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { serializableTransaction } from "./transaction";
import { UserFacingError } from "./action-error";
import {
berlinDateKey,
formatUtcDateKey,
getTargetMonthData,
isNotdienstReminderDue,
NOTDIENST_LOCK_DAY,
} from "./date-utils";
import { loadNotdienstDays } from "./notdienst-planning";
import { sendAppEmail } from "@/lib/mail";
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
import { NotdienstPlanPublishedEmail } from "@/emails/NotdienstPlanPublishedEmail";
type Tx = Prisma.TransactionClient;
const parentRoles = ["ELTERN", "ADMIN", "KOORDINATOR"] as const;
const eligibleParents = (now: Date): Prisma.UserWhereInput => ({
role: { in: [...parentRoles] },
emailVerifiedAt: { not: null },
privacyPolicyAcceptedAt: { not: null },
disabledAt: null,
OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }],
});
const monthLabel = (year: number, month: number) =>
new Intl.DateTimeFormat("de-DE", {
timeZone: "UTC",
month: "long",
year: "numeric",
}).format(new Date(Date.UTC(year, month - 1, 1)));
const period = (year: number, month: number) => ({
gte: new Date(Date.UTC(year, month - 1, 1)),
lt: new Date(Date.UTC(year, month, 1)),
});
export async function getFamilyNotdienstProgress(
db: Pick<Tx, "child" | "notdienstAvailability" | "termin">,
kitaId: string,
familyId: string,
year: number,
month: number,
minimum: number,
) {
const [children, entries, days] = await Promise.all([
db.child.count({ where: { kitaId, familyId, active: true } }),
db.notdienstAvailability.findMany({
where: {
kitaId,
child: { kitaId, familyId, active: true },
date: period(year, month),
},
select: { date: true },
}),
loadNotdienstDays(db, kitaId, year, month),
]);
const valid = new Set(days.map(formatUtcDateKey));
const selected = new Set(
entries
.map((a) => formatUtcDateKey(a.date))
.filter((key) => valid.has(key)),
).size;
return { selected, required: children * minimum };
}
export async function queueNotdienstReminders(
kitaId: string,
year: number,
month: number,
automatic = false,
now = new Date(),
) {
return serializableTransaction(async (tx) => {
const target = getTargetMonthData(now);
if (
target.isLocked ||
target.targetYear !== year ||
target.targetMonth !== month
) {
if (automatic) return 0;
throw new UserFacingError(
"Erinnerungen sind nur während der Eintragsfrist für den Folgemonat möglich.",
);
}
const kita = await tx.kita.findUniqueOrThrow({
where: { id: kitaId },
select: {
notdienstModuleEnabled: true,
notdienstMinPerChildPerMonth: true,
notdienstReminderDaysBefore: true,
},
});
if (!kita.notdienstModuleEnabled) {
if (automatic) return 0;
throw new UserFacingError("Das Notdienstmodul ist deaktiviert.");
}
const plan = await tx.notdienstPlan.findUnique({
where: { kitaId_year_month: { kitaId, year, month } },
select: { status: true },
});
if (plan?.status === "PUBLISHED") return 0;
if (automatic) {
if (!isNotdienstReminderDue(kita.notdienstReminderDaysBefore, now))
return 0;
const run = await tx.notdienstReminderRun.createMany({
data: [{ id: randomUUID(), kitaId, year, month, createdAt: now }],
skipDuplicates: true,
});
if (!run.count) return 0;
}
const [families, entries, days] = await Promise.all([
tx.family.findMany({
where: { kitaId, children: { some: { kitaId, active: true } } },
select: {
id: true,
children: { where: { kitaId, active: true }, select: { id: true } },
users: {
where: { kitaId, ...eligibleParents(now) },
select: { id: true },
},
},
}),
tx.notdienstAvailability.findMany({
where: {
kitaId,
date: period(year, month),
child: { kitaId, active: true },
},
select: { date: true, child: { select: { familyId: true } } },
}),
loadNotdienstDays(tx, kitaId, year, month),
]);
const valid = new Set(days.map(formatUtcDateKey));
const selected = new Map<string, Set<string>>();
for (const entry of entries) {
const key = formatUtcDateKey(entry.date);
if (!valid.has(key)) continue;
const dates = selected.get(entry.child.familyId) ?? new Set<string>();
dates.add(key);
selected.set(entry.child.familyId, dates);
}
const jobs = families
.filter(
(family) =>
(selected.get(family.id)?.size ?? 0) <
family.children.length * kita.notdienstMinPerChildPerMonth,
)
.flatMap((family) =>
family.users.map((user) => ({
id: randomUUID(),
kitaId,
familyId: family.id,
userId: user.id,
year,
month,
kind: "REMINDER",
eventKey: `reminder:${berlinDateKey(now)}`,
createdAt: now,
nextAttemptAt: now,
})),
);
if (!jobs.length) return 0;
return (
await tx.notdienstEmail.createMany({ data: jobs, skipDuplicates: true })
).count;
});
}
// Must run in the same transaction as the first publication.
export async function queueNotdienstPublication(
tx: Tx,
kitaId: string,
planId: string,
year: number,
month: number,
now = new Date(),
) {
const assigned = await tx.notdienstAssignment.findMany({
where: { kitaId, planId, child: { kitaId, active: true } },
select: { child: { select: { familyId: true } } },
});
const families = await tx.family.findMany({
where: {
kitaId,
id: { in: [...new Set(assigned.map((a) => a.child.familyId))] },
},
select: {
id: true,
users: {
where: { kitaId, ...eligibleParents(now) },
select: { id: true },
},
},
});
const jobs = families.flatMap((family) =>
family.users.map((user) => ({
id: randomUUID(),
kitaId,
familyId: family.id,
userId: user.id,
planId,
year,
month,
kind: "PUBLICATION",
eventKey: `publication:${planId}`,
createdAt: now,
nextAttemptAt: now,
})),
);
return jobs.length
? (await tx.notdienstEmail.createMany({ data: jobs, skipDuplicates: true }))
.count
: 0;
}
export async function deliverNotdienstEmails(
ids: string[],
send = sendAppEmail,
now = new Date(),
) {
const result = { sent: 0, failed: 0, skipped: 0 };
const deliver = async (id: string) => {
const claimToken = randomUUID();
const leaseUntil = new Date(now.getTime() + 5 * 60_000);
const claim = await prisma.notdienstEmail.updateMany({
where: {
id,
sentAt: null,
skippedAt: null,
nextAttemptAt: { lte: now },
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
},
data: { claimToken, leaseUntil, attempts: { increment: 1 } },
});
if (!claim.count) return;
const finish = (data: Prisma.NotdienstEmailUpdateManyMutationInput) =>
prisma.notdienstEmail.updateMany({
where: { id, claimToken },
data: { ...data, claimToken: null, leaseUntil: null },
});
try {
const job = await prisma.notdienstEmail.findUniqueOrThrow({
where: { id },
include: { user: true, kita: true, plan: { select: { status: true } } },
});
const { user, kita } = job;
let eligible =
kita.notdienstModuleEnabled &&
user.kitaId === job.kitaId &&
user.familyId === job.familyId &&
parentRoles.some((role) => role === user.role) &&
!!user.emailVerifiedAt &&
!!user.privacyPolicyAcceptedAt &&
!user.disabledAt &&
(!user.lockedUntil || user.lockedUntil <= now);
let progress: { selected: number; required: number } | undefined;
let dates: string[] = [];
if (job.kind === "REMINDER") {
const target = getTargetMonthData(now);
const plan = await prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: {
kitaId: job.kitaId,
year: job.year,
month: job.month,
},
},
select: { status: true },
});
eligible &&=
!target.isLocked &&
target.targetYear === job.year &&
target.targetMonth === job.month &&
plan?.status !== "PUBLISHED";
if (eligible) {
progress = await getFamilyNotdienstProgress(
prisma,
job.kitaId,
job.familyId,
job.year,
job.month,
kita.notdienstMinPerChildPerMonth,
);
eligible &&=
progress.required > 0 && progress.selected < progress.required;
}
} else if (job.kind === "PUBLICATION") {
eligible &&= !!job.planId && job.plan?.status === "PUBLISHED";
if (eligible) {
const [assignments, days] = await Promise.all([
prisma.notdienstAssignment.findMany({
where: {
kitaId: job.kitaId,
planId: job.planId!,
child: {
kitaId: job.kitaId,
familyId: job.familyId,
active: true,
},
date: {
...period(job.year, job.month),
},
},
select: { date: true },
orderBy: { date: "asc" },
}),
loadNotdienstDays(prisma, job.kitaId, job.year, job.month),
]);
const valid = new Set(days.map(formatUtcDateKey));
dates = assignments
.map((a) => formatUtcDateKey(a.date))
.filter((key) => key >= berlinDateKey(now) && valid.has(key));
eligible &&= dates.length > 0;
}
} else eligible = false;
if (!eligible) {
await finish({ skippedAt: now, failed: false, lastError: null });
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 für E-Mails fehlt.");
const site = new URL(base);
if (
!["http:", "https:"].includes(site.protocol) ||
site.username ||
site.password
)
throw new Error("App-Adresse ungültig.");
const label = monthLabel(job.year, job.month);
const link = new URL(
job.kind === "REMINDER"
? "/dashboard/notdienst"
: "/dashboard/kalender",
site.origin,
).toString();
const response = await send({
to: user.email,
idempotencyKey: `notdienst-email/${job.id}`,
subject:
job.kind === "REMINDER"
? `Erinnerung: Notdienst-Verfügbarkeiten für ${label}`
: `Notdienstplan ${label} veröffentlicht`,
react:
job.kind === "REMINDER"
? createElement(NotdienstAvailabilityReminderEmail, {
parentName: user.firstName,
kitaName: kita.name,
monthLabel: label,
link,
selectedDays: progress!.selected,
requiredDays: progress!.required,
deadlineLabel: new Intl.DateTimeFormat("de-DE", {
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(
new Date(
Date.UTC(job.year, job.month - 2, NOTDIENST_LOCK_DAY),
),
),
})
: createElement(NotdienstPlanPublishedEmail, {
parentName: user.firstName,
kitaName: kita.name,
monthLabel: label,
dateLabels: dates.map((key) =>
new Intl.DateTimeFormat("de-DE", {
timeZone: "UTC",
weekday: "long",
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(new Date(`${key}T00:00:00Z`)),
),
link,
}),
});
if (!response.success) throw new Error(response.error);
await finish({ sentAt: now, failed: false, lastError: null });
result.sent++;
} catch {
const current = await prisma.notdienstEmail.findUnique({
where: { id },
select: { attempts: true },
});
const delay = Math.min(
6 * 60 * 60_000,
60_000 * 2 ** Math.min(current?.attempts ?? 1, 8),
);
await finish({
failed: true,
lastError:
"E-Mail-Versand fehlgeschlagen. Der Versand wird erneut versucht.",
nextAttemptAt: new Date(now.getTime() + delay),
});
result.failed++;
}
};
const unique = [...new Set(ids)];
for (let i = 0; i < unique.length; i += 4)
await Promise.all(unique.slice(i, i + 4).map(deliver));
return result;
}
type MailScope = {
kitaId?: string;
year?: number;
month?: number;
planId?: string;
};
export async function deliverDueNotdienstEmails(
scope: MailScope = {},
send = sendAppEmail,
now = new Date(),
) {
const jobs = await prisma.notdienstEmail.findMany({
where: {
...scope,
sentAt: null,
skippedAt: null,
nextAttemptAt: { lte: now },
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
},
select: { id: true },
orderBy: [{ nextAttemptAt: "asc" }, { createdAt: "asc" }],
take: 12,
});
return deliverNotdienstEmails(
jobs.map((job) => job.id),
send,
now,
);
}
export async function runNotdienstNotificationJob(
send = sendAppEmail,
now = new Date(),
kitaId?: string,
) {
const target = getTargetMonthData(now);
let queued = 0;
if (!target.isLocked && isNotdienstReminderDue(31, now)) {
const candidates = await prisma.kita.findMany({
where: {
...(kitaId ? { id: kitaId } : {}),
notdienstModuleEnabled: true,
notdienstPlans: { none: { year: target.targetYear, month: target.targetMonth, status: "PUBLISHED" } },
notdienstReminderDaysBefore: {
gte: Math.max(
1,
NOTDIENST_LOCK_DAY - Number(berlinDateKey(now).slice(-2)),
),
},
notdienstReminderRuns: {
none: { year: target.targetYear, month: target.targetMonth },
},
},
select: { id: true, notdienstReminderDaysBefore: true },
orderBy: { id: "asc" },
take: 100,
});
for (const kita of candidates)
if (isNotdienstReminderDue(kita.notdienstReminderDaysBefore, now))
queued += await queueNotdienstReminders(
kita.id,
target.targetYear,
target.targetMonth,
true,
now,
);
}
return {
queued,
...(await deliverDueNotdienstEmails(kitaId ? { kitaId } : {}, send, now)),
};
}
export async function getNotdienstEmailStatus(
kitaId: string,
year: number,
month: number,
) {
const jobs = await prisma.notdienstEmail.findMany({
where: { kitaId, year, month },
select: {
id: true,
kind: true,
sentAt: true,
skippedAt: true,
failed: true,
lastError: true,
user: { select: { firstName: true, lastName: true } },
family: { select: { name: true } },
},
orderBy: { createdAt: "desc" },
});
const status = (job: (typeof jobs)[number]) =>
job.sentAt
? ("sent" as const)
: job.skippedAt
? ("skipped" as const)
: job.failed
? ("failed" as const)
: ("pending" as const);
return {
sent: jobs.filter((job) => status(job) === "sent").length,
failed: jobs.filter((job) => status(job) === "failed").length,
pending: jobs.filter((job) => status(job) === "pending").length,
skipped: jobs.filter((job) => status(job) === "skipped").length,
entries: jobs.slice(0, 30).map((job) => ({
id: job.id,
kind: job.kind,
status: status(job),
name: `${job.user.firstName} ${job.user.lastName}`,
familyName: job.family.name,
error: job.lastError,
})),
};
}
export type NotdienstEmailStatus = Awaited<
ReturnType<typeof getNotdienstEmailStatus>
>;
+56
View File
@@ -0,0 +1,56 @@
import { Prisma } from "@prisma/client";
import { UserFacingError } from "./action-error";
import { getNotdienstDays } from "./notdienst-days";
import { todayKey } from "./staff/calculations";
export async function loadNotdienstDays(
db: Pick<Prisma.TransactionClient, "termin">,
kitaId: string,
year: number,
month: number,
) {
const closures = await db.termin.findMany({
where: {
kitaId,
status: "CONFIRMED",
type: { in: ["SCHLIESSTAG", "TEAMTAG"] },
// Include closures crossing the month boundary and Berlin midnight.
startDate: { lt: new Date(Date.UTC(year, month, 1)) },
endDate: { gte: new Date(Date.UTC(year, month - 1, 1) - 86400000) },
},
select: { startDate: true, endDate: true },
});
return getNotdienstDays(year, month, closures);
}
export async function assertChildrenRemovable(
tx: Prisma.TransactionClient,
kitaId: string,
familyId: string,
childIds: string[],
) {
if (childIds.length === 0) return;
// Coordinate with the FK locks taken by concurrent assignment writes.
await tx.$queryRaw`SELECT id FROM children WHERE "kitaId" = ${kitaId}
AND "familyId" = ${familyId} AND id IN (${Prisma.join(childIds)}) ORDER BY id FOR UPDATE`;
const obligation = await tx.notdienstAssignment.findFirst({
where: {
kitaId,
childId: { in: childIds },
child: { kitaId, familyId },
OR: [
{
date: { gte: new Date(`${todayKey()}T00:00:00Z`) },
plan: { kitaId, status: "PUBLISHED" },
},
{ alarms: { some: { kitaId, activeAssignmentId: { not: null } } } },
],
},
select: { id: true },
});
if (obligation) {
throw new UserFacingError(
"Das Kind kann nicht entfernt werden, solange veröffentlichte Notdienste oder aktive Alarme bestehen. Bitte zuerst die Einteilung mit der Koordination klären.",
);
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { UserRole } from "@prisma/client";
export function canRequestNotdienstSwap(
role: UserRole,
familyId: string | null | undefined,
) {
return (
!!familyId &&
(role === "ELTERN" || role === "ADMIN" || role === "KOORDINATOR")
);
}