530 lines
16 KiB
TypeScript
530 lines
16 KiB
TypeScript
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>
|
|
>;
|