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
+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>
>;