feat: complete notdienst planning, quick wins and notifications
This commit is contained in:
@@ -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>
|
||||
>;
|
||||
Reference in New Issue
Block a user