feat: complete notdienst planning, quick wins and notifications
This commit is contained in:
+36
-19
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
>;
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user