feat: add household emergency duty alarms and deadline worker
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { managerRoles, validateAlarmActor, type AlarmActor } from "./service";
|
||||
|
||||
export async function requireAlarmActor(manager = true): Promise<AlarmActor> {
|
||||
const session = await requireKitaSession();
|
||||
const actor = {
|
||||
id: session.user.id!,
|
||||
kitaId: session.user.kitaId,
|
||||
role: session.user.role,
|
||||
};
|
||||
if (manager && !managerRoles.includes(actor.role)) redirect("/forbidden");
|
||||
try {
|
||||
await validateAlarmActor(prisma, actor, manager);
|
||||
} catch {
|
||||
redirect("/forbidden");
|
||||
}
|
||||
return actor;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createElement } from "react";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendAppEmail } from "@/lib/mail";
|
||||
import { NotdienstAlarmEmail } from "@/emails/NotdienstAlarmEmail";
|
||||
import { managerRoles } from "./service";
|
||||
|
||||
export async function deliverAlarmEmails(
|
||||
ids: string[],
|
||||
send = sendAppEmail,
|
||||
now = new Date(),
|
||||
) {
|
||||
let failed = 0,
|
||||
sent = 0;
|
||||
const deliver = async (id: string) => {
|
||||
const leaseUntil = new Date(Date.now() + 5 * 60_000);
|
||||
const claimed = await prisma.notdienstAlarmEmail.updateMany({
|
||||
where: {
|
||||
id,
|
||||
sentAt: null,
|
||||
skippedAt: null,
|
||||
OR: [{ leaseUntil: null }, { leaseUntil: { lte: new Date() } }],
|
||||
},
|
||||
data: { leaseUntil, attempts: { increment: 1 } },
|
||||
});
|
||||
if (!claimed.count) return;
|
||||
const finish = (data: {
|
||||
sentAt?: Date;
|
||||
skippedAt?: Date;
|
||||
failed: boolean;
|
||||
}) =>
|
||||
prisma.notdienstAlarmEmail.updateMany({
|
||||
where: { id, leaseUntil },
|
||||
data: { ...data, leaseUntil: null },
|
||||
});
|
||||
try {
|
||||
const job = await prisma.notdienstAlarmEmail.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { user: true, alarm: { include: { kita: true } } },
|
||||
});
|
||||
const { user, alarm } = job;
|
||||
let eligible = user.kitaId === alarm.kitaId;
|
||||
if (job.kind === "MANAGER") eligible &&= managerRoles.includes(user.role);
|
||||
else
|
||||
eligible &&= user.role === "ELTERN" && user.familyId === alarm.familyId;
|
||||
if (job.kind === "REQUEST") {
|
||||
const token = job.path.split("/").pop();
|
||||
const recipient = await prisma.notdienstAlarmRecipient.findFirst({
|
||||
where: { alarmId: alarm.id, userId: user.id, token },
|
||||
});
|
||||
eligible &&=
|
||||
!!recipient && alarm.status === "PENDING" && alarm.expiresAt > now;
|
||||
}
|
||||
if (!eligible) {
|
||||
await finish({ skippedAt: new Date(), failed: false });
|
||||
return;
|
||||
}
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||
process.env.AUTH_URL ||
|
||||
process.env.NEXTAUTH_URL;
|
||||
if (!base) throw new Error("App-Adresse fehlt.");
|
||||
const site = new URL(base);
|
||||
if (
|
||||
!["https:", "http:"].includes(site.protocol) ||
|
||||
site.username ||
|
||||
site.password
|
||||
)
|
||||
throw new Error("App-Adresse ungültig.");
|
||||
const result = await send({
|
||||
to: user.email,
|
||||
subject: job.subject,
|
||||
idempotencyKey: `notdienst-alarm/${job.id}`,
|
||||
react: createElement(NotdienstAlarmEmail, {
|
||||
name: user.firstName,
|
||||
kitaName: alarm.kita.name,
|
||||
message: job.message,
|
||||
link: new URL(job.path, site.origin).toString(),
|
||||
request: job.kind === "REQUEST",
|
||||
cancelled: job.kind === "CANCEL",
|
||||
}),
|
||||
});
|
||||
if (result.success) {
|
||||
await finish({ sentAt: new Date(), failed: false });
|
||||
sent++;
|
||||
} else {
|
||||
await finish({ failed: true });
|
||||
failed++;
|
||||
}
|
||||
} catch {
|
||||
await finish({ failed: true });
|
||||
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 { sent, failed };
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { NotdienstAlarm } from "@prisma/client";
|
||||
import {
|
||||
addDays,
|
||||
dateSchema,
|
||||
dateKey,
|
||||
todayKey,
|
||||
minuteClock,
|
||||
} from "@/lib/staff/calculations";
|
||||
|
||||
export { dateKey, todayKey };
|
||||
const berlin = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
export function berlinInstant(date: string, minute: number) {
|
||||
dateSchema.parse(date);
|
||||
const target = new Date(`${date}T00:00:00Z`).getTime() + minute * 60_000;
|
||||
let value = target;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const parts = Object.fromEntries(
|
||||
berlin.formatToParts(new Date(value)).map((p) => [p.type, p.value]),
|
||||
);
|
||||
const represented = Date.parse(
|
||||
`${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:00Z`,
|
||||
);
|
||||
const diff = target - represented;
|
||||
if (!diff) return new Date(value);
|
||||
value += diff;
|
||||
}
|
||||
throw new Error(
|
||||
"Diese Uhrzeit existiert wegen der Zeitumstellung nicht. Bitte eine andere Uhrzeit wählen.",
|
||||
);
|
||||
}
|
||||
export function endOfAlarm(
|
||||
date: string,
|
||||
wholeDay: boolean,
|
||||
endMinute?: number | null,
|
||||
) {
|
||||
return wholeDay
|
||||
? berlinInstant(addDays(date, 1), 0)
|
||||
: berlinInstant(date, endMinute!);
|
||||
}
|
||||
export function durationLabel(
|
||||
alarm: Pick<NotdienstAlarm, "wholeDay" | "startMinute" | "endMinute">,
|
||||
) {
|
||||
return alarm.wholeDay
|
||||
? "Ganzer Kita-Tag"
|
||||
: `${minuteClock(alarm.startMinute!)}–${minuteClock(alarm.endMinute!)} Uhr`;
|
||||
}
|
||||
export function instantLabel(value: Date | string) {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
export function alarmLabel(
|
||||
alarm: Pick<NotdienstAlarm, "status" | "deadline" | "expiresAt">,
|
||||
now = new Date(),
|
||||
) {
|
||||
if (alarm.status === "CANCELLED") return "Storniert";
|
||||
if (alarm.status === "CONFIRMED") return "Übernahme bestätigt";
|
||||
if (alarm.status === "DECLINED") return "Klärung erforderlich – Absage";
|
||||
if (alarm.status === "LATE_CONFIRMATION")
|
||||
return "Verspätete Zusage – Freigabe erforderlich";
|
||||
if (alarm.status === "REVIEW_REQUIRED")
|
||||
return "Klärung erforderlich – bisherige Rückmeldungen prüfen";
|
||||
if (alarm.deadline <= now || alarm.expiresAt <= now)
|
||||
return "Klärung erforderlich – Frist abgelaufen";
|
||||
return "Antwort ausstehend";
|
||||
}
|
||||
export function needsClarification(
|
||||
alarm: Pick<NotdienstAlarm, "status" | "deadline" | "expiresAt">,
|
||||
now = new Date(),
|
||||
) {
|
||||
return (
|
||||
!["CONFIRMED", "CANCELLED"].includes(alarm.status) &&
|
||||
(alarm.status !== "PENDING" ||
|
||||
alarm.deadline <= now ||
|
||||
alarm.expiresAt <= now)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { importLegacyAlarms, processAlarmDeadlines } from "./service";
|
||||
import { deliverAlarmEmails } from "./email";
|
||||
|
||||
export function authorizeAlarmJob(
|
||||
header: string | null,
|
||||
secret = process.env.NOTDIENST_JOB_SECRET,
|
||||
) {
|
||||
if (!secret || secret.length < 32) return 503;
|
||||
const expected = Buffer.from(`Bearer ${secret}`),
|
||||
received = Buffer.from(header ?? "");
|
||||
return expected.length === received.length &&
|
||||
timingSafeEqual(expected, received)
|
||||
? 200
|
||||
: 401;
|
||||
}
|
||||
export async function runAlarmJob(
|
||||
send?: Parameters<typeof deliverAlarmEmails>[1],
|
||||
now = new Date(),
|
||||
) {
|
||||
const imported = await importLegacyAlarms();
|
||||
const deadlineEmails = await processAlarmDeadlines(now);
|
||||
const jobs = await prisma.notdienstAlarmEmail.findMany({
|
||||
where: {
|
||||
sentAt: null,
|
||||
skippedAt: null,
|
||||
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
|
||||
},
|
||||
select: { id: true },
|
||||
orderBy: [{ attempts: "asc" }, { createdAt: "asc" }],
|
||||
take: 100,
|
||||
});
|
||||
const delivery = await deliverAlarmEmails(
|
||||
jobs.map((j) => j.id),
|
||||
send,
|
||||
now,
|
||||
);
|
||||
return { imported, deadlineJobs: deadlineEmails.length, ...delivery };
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Prisma, type NotdienstAlarm, type UserRole } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
dateKey,
|
||||
todayKey,
|
||||
endOfAlarm,
|
||||
berlinInstant,
|
||||
durationLabel,
|
||||
instantLabel,
|
||||
} from "./format";
|
||||
|
||||
export type AlarmActor = { id: string; kitaId: string; role: UserRole };
|
||||
export const managerRoles: UserRole[] = ["ADMIN", "KOORDINATOR"];
|
||||
const id = z.string().min(1).max(100);
|
||||
const tokenSchema = z.string().min(20).max(128);
|
||||
const note = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, "Bitte eine Begründung angeben.")
|
||||
.max(500);
|
||||
type Tx = Prisma.TransactionClient;
|
||||
async function transaction<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await prisma.$transaction(run, {
|
||||
isolationLevel: "Serializable",
|
||||
timeout: 15000,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
["P2034", "P2002"].includes(error.code) &&
|
||||
attempt < 3
|
||||
)
|
||||
continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
export async function validateAlarmActor(
|
||||
tx: Tx,
|
||||
actor: AlarmActor,
|
||||
manager = true,
|
||||
) {
|
||||
if (
|
||||
manager
|
||||
? !managerRoles.includes(actor.role)
|
||||
: ![...managerRoles, "ELTERN"].includes(actor.role)
|
||||
)
|
||||
throw new Error("Kein Zugriff auf die Notdienstalarmierung.");
|
||||
const user = await tx.user.findFirst({
|
||||
where: {
|
||||
id: actor.id,
|
||||
kitaId: actor.kitaId,
|
||||
role: actor.role,
|
||||
privacyPolicyAcceptedAt: { not: null },
|
||||
},
|
||||
});
|
||||
if (!user || (user.lockedUntil && user.lockedUntil > new Date()))
|
||||
throw new Error(
|
||||
"Dein Konto hat keinen Zugriff auf die Notdienstalarmierung.",
|
||||
);
|
||||
return user;
|
||||
}
|
||||
export async function assertAssignmentNotAlarmed(
|
||||
tx: Tx,
|
||||
assignmentId: string,
|
||||
kitaId: string,
|
||||
) {
|
||||
if (
|
||||
(await tx.notdienstAlarm.count({
|
||||
where: { kitaId, activeAssignmentId: assignmentId },
|
||||
})) ||
|
||||
(await tx.notdienstAlert.count({
|
||||
where: {
|
||||
kitaId,
|
||||
assignmentId,
|
||||
alarmId: null,
|
||||
status: { not: "CANCELLED" },
|
||||
},
|
||||
}))
|
||||
)
|
||||
throw new Error(
|
||||
"Für diese Einteilung besteht ein aktiver Alarm. Bitte den Alarm zuerst stornieren.",
|
||||
);
|
||||
}
|
||||
|
||||
async function history(
|
||||
tx: Tx,
|
||||
alarmId: string,
|
||||
actorId: string | null,
|
||||
action: string,
|
||||
details: string,
|
||||
) {
|
||||
await tx.notdienstAlarmHistory.create({
|
||||
data: { alarmId, actorId, action, details },
|
||||
});
|
||||
}
|
||||
async function queue(
|
||||
tx: Tx,
|
||||
alarm: NotdienstAlarm,
|
||||
audience: "parents" | "managers",
|
||||
kind: string,
|
||||
eventKey: string,
|
||||
message: string,
|
||||
) {
|
||||
const users =
|
||||
audience === "managers"
|
||||
? await tx.user.findMany({
|
||||
where: { kitaId: alarm.kitaId, role: { in: managerRoles } },
|
||||
})
|
||||
: await tx.user.findMany({
|
||||
where: {
|
||||
kitaId: alarm.kitaId,
|
||||
familyId: alarm.familyId,
|
||||
role: "ELTERN",
|
||||
alarmRecipients: { some: { alarmId: alarm.id } },
|
||||
},
|
||||
});
|
||||
const jobs: string[] = [];
|
||||
for (const user of users) {
|
||||
const recipient =
|
||||
kind === "REQUEST"
|
||||
? await tx.notdienstAlarmRecipient.findUnique({
|
||||
where: { alarmId_userId: { alarmId: alarm.id, userId: user.id } },
|
||||
})
|
||||
: null;
|
||||
await tx.notdienstAlarmNotification.create({
|
||||
data: { alarmId: alarm.id, userId: user.id, eventKey, message },
|
||||
});
|
||||
const job = await tx.notdienstAlarmEmail.create({
|
||||
data: {
|
||||
alarmId: alarm.id,
|
||||
userId: user.id,
|
||||
eventKey,
|
||||
kind,
|
||||
subject: `Notdienst ${dateKey(alarm.date)} · ${kind === "REQUEST" ? "Bitte Rückmeldung" : kind === "CANCEL" ? "Einsatz storniert" : "Neue Rückmeldung"}`,
|
||||
message,
|
||||
path: recipient
|
||||
? `/alert/${recipient.token}`
|
||||
: `/dashboard/notdienst/alarm?alarm=${alarm.id}`,
|
||||
},
|
||||
});
|
||||
jobs.push(job.id);
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
export function alarmDetails(alarm: NotdienstAlarm) {
|
||||
return `${dateKey(alarm.date)} · ${durationLabel(alarm)} · ${alarm.familyName}`;
|
||||
}
|
||||
|
||||
export async function executeAlarmCommand(
|
||||
actor: AlarmActor,
|
||||
command: string,
|
||||
raw: unknown,
|
||||
now = new Date(),
|
||||
): Promise<{ alarmId?: string; emailIds: string[] }> {
|
||||
const data = z.record(z.string(), z.unknown()).parse(raw);
|
||||
return transaction(async (tx) => {
|
||||
const user = await validateAlarmActor(tx, actor, command !== "readNotice");
|
||||
if (command === "readNotice") {
|
||||
await tx.notdienstAlarmNotification.updateMany({
|
||||
where: {
|
||||
id: id.parse(data.id),
|
||||
userId: user.id,
|
||||
alarm: { kitaId: actor.kitaId },
|
||||
},
|
||||
data: { readAt: now },
|
||||
});
|
||||
return { emailIds: [] };
|
||||
}
|
||||
if (command === "create") {
|
||||
const input = z
|
||||
.object({
|
||||
assignmentId: id,
|
||||
wholeDay: z.boolean(),
|
||||
startMinute: z.number().int().min(0).max(1439).optional(),
|
||||
endMinute: z.number().int().min(1).max(1439).optional(),
|
||||
replyMinutes: z.number().int().min(1).max(1440).default(30),
|
||||
educatorId: id.optional(),
|
||||
notes: z.string().trim().max(500).optional(),
|
||||
})
|
||||
.parse(data);
|
||||
if (
|
||||
!input.wholeDay &&
|
||||
(input.startMinute === undefined ||
|
||||
input.endMinute === undefined ||
|
||||
input.endMinute <= input.startMinute)
|
||||
)
|
||||
throw new Error(
|
||||
"Bitte gültige Einsatzzeiten mit Ende nach Beginn angeben.",
|
||||
);
|
||||
const assignment = await tx.notdienstAssignment.findFirst({
|
||||
where: { id: input.assignmentId, kitaId: actor.kitaId },
|
||||
include: {
|
||||
kita: true,
|
||||
plan: true,
|
||||
child: {
|
||||
include: {
|
||||
family: {
|
||||
include: {
|
||||
users: { where: { role: "ELTERN", kitaId: actor.kitaId } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (
|
||||
!assignment ||
|
||||
assignment.child.kitaId !== actor.kitaId ||
|
||||
assignment.child.family.kitaId !== actor.kitaId ||
|
||||
assignment.plan.kitaId !== actor.kitaId
|
||||
)
|
||||
throw new Error("Einteilung nicht gefunden.");
|
||||
if (!assignment.kita.notdienstModuleEnabled)
|
||||
throw new Error("Notdienst ist für diese Kita deaktiviert.");
|
||||
if (assignment.plan.status !== "PUBLISHED")
|
||||
throw new Error("Bitte den Notdienstplan zuerst veröffentlichen.");
|
||||
const date = dateKey(assignment.date);
|
||||
if (date < todayKey(now))
|
||||
throw new Error("Vergangene Notdienste können nicht alarmiert werden.");
|
||||
const expiresAt = endOfAlarm(date, input.wholeDay, input.endMinute);
|
||||
if (expiresAt <= now)
|
||||
throw new Error("Dieser Einsatz ist bereits beendet.");
|
||||
if (!input.wholeDay) berlinInstant(date, input.startMinute!);
|
||||
if (
|
||||
(await tx.notdienstAlarm.count({
|
||||
where: { activeAssignmentId: assignment.id },
|
||||
})) ||
|
||||
(await tx.notdienstAlert.count({
|
||||
where: {
|
||||
assignmentId: assignment.id,
|
||||
alarmId: null,
|
||||
status: { not: "CANCELLED" },
|
||||
},
|
||||
}))
|
||||
)
|
||||
throw new Error(
|
||||
"Für diese Einteilung besteht bereits ein aktiver Alarm.",
|
||||
);
|
||||
const parents = assignment.child.family.users;
|
||||
if (!parents.length)
|
||||
throw new Error(
|
||||
"Für diesen Haushalt sind keine Elternkonten hinterlegt.",
|
||||
);
|
||||
if (
|
||||
input.educatorId &&
|
||||
!(await tx.educator.count({
|
||||
where: { id: input.educatorId, kitaId: actor.kitaId, active: true },
|
||||
}))
|
||||
)
|
||||
throw new Error("Fachkraft nicht gefunden.");
|
||||
const alarm = await tx.notdienstAlarm.create({
|
||||
data: {
|
||||
kitaId: actor.kitaId,
|
||||
assignmentId: assignment.id,
|
||||
activeAssignmentId: assignment.id,
|
||||
familyId: assignment.child.familyId,
|
||||
familyName: assignment.child.family.name,
|
||||
childName: `${assignment.child.firstName} ${assignment.child.lastName}`,
|
||||
date: assignment.date,
|
||||
wholeDay: input.wholeDay,
|
||||
startMinute: input.wholeDay ? null : input.startMinute,
|
||||
endMinute: input.wholeDay ? null : input.endMinute,
|
||||
expiresAt,
|
||||
deadline: new Date(
|
||||
Math.min(
|
||||
now.getTime() + input.replyMinutes * 60_000,
|
||||
expiresAt.getTime(),
|
||||
),
|
||||
),
|
||||
triggeredAt: now,
|
||||
triggeredById: actor.id,
|
||||
educatorId: input.educatorId,
|
||||
notes: input.notes,
|
||||
recipients: {
|
||||
create: parents.map((parent) => ({
|
||||
userId: parent.id,
|
||||
token: randomUUID(),
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
await history(
|
||||
tx,
|
||||
alarm.id,
|
||||
actor.id,
|
||||
"Alarm ausgelöst",
|
||||
alarmDetails(alarm),
|
||||
);
|
||||
const emailIds = await queue(
|
||||
tx,
|
||||
alarm,
|
||||
"parents",
|
||||
"REQUEST",
|
||||
"request",
|
||||
`${alarmDetails(alarm)}. Bitte gebt bis ${instantLabel(alarm.deadline)} Uhr für euren Haushalt Bescheid, ob ihr den Einsatz übernehmt.`,
|
||||
);
|
||||
return { alarmId: alarm.id, emailIds };
|
||||
}
|
||||
const alarm = await tx.notdienstAlarm.findFirst({
|
||||
where: { id: id.parse(data.alarmId), kitaId: actor.kitaId },
|
||||
});
|
||||
if (!alarm) throw new Error("Alarm nicht gefunden.");
|
||||
if (command === "retry") {
|
||||
const jobs = await tx.notdienstAlarmEmail.findMany({
|
||||
where: { alarmId: alarm.id, sentAt: null, skippedAt: null },
|
||||
select: { id: true },
|
||||
take: 100,
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return { alarmId: alarm.id, emailIds: jobs.map((job) => job.id) };
|
||||
}
|
||||
if (command === "cancel") {
|
||||
const reason = note.parse(data.reason);
|
||||
if (alarm.status === "CANCELLED")
|
||||
throw new Error("Der Alarm ist bereits storniert.");
|
||||
const updated = await tx.notdienstAlarm.update({
|
||||
where: { id: alarm.id },
|
||||
data: {
|
||||
status: "CANCELLED",
|
||||
activeAssignmentId: null,
|
||||
cancelledAt: now,
|
||||
cancelReason: reason,
|
||||
},
|
||||
});
|
||||
await history(tx, alarm.id, actor.id, "Alarm storniert", reason);
|
||||
return {
|
||||
alarmId: alarm.id,
|
||||
emailIds: await queue(
|
||||
tx,
|
||||
updated,
|
||||
"parents",
|
||||
"CANCEL",
|
||||
randomUUID(),
|
||||
`${alarmDetails(updated)}: Der Einsatz wurde storniert. Ihr müsst für diesen Alarm nicht einspringen.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (command === "recordResponse" || command === "approveLate") {
|
||||
if (alarm.status === "CANCELLED" || alarm.expiresAt <= now)
|
||||
throw new Error(
|
||||
"Dieser Alarm ist storniert oder der Einsatz bereits beendet.",
|
||||
);
|
||||
const reason = note.parse(data.reason);
|
||||
if (command === "approveLate" && alarm.status !== "LATE_CONFIRMATION")
|
||||
throw new Error("Es liegt keine verspätete Zusage zur Freigabe vor.");
|
||||
const approve =
|
||||
command === "approveLate" || z.boolean().parse(data.approve);
|
||||
const updated = await tx.notdienstAlarm.update({
|
||||
where: { id: alarm.id },
|
||||
data: {
|
||||
status: approve ? "CONFIRMED" : "DECLINED",
|
||||
respondedAt: now,
|
||||
respondedById: actor.id,
|
||||
responseSource: command === "approveLate" ? "COORDINATION" : "PHONE",
|
||||
},
|
||||
});
|
||||
await history(
|
||||
tx,
|
||||
alarm.id,
|
||||
actor.id,
|
||||
command === "approveLate"
|
||||
? "Verspätete Zusage freigegeben"
|
||||
: "Rückmeldung dokumentiert",
|
||||
reason,
|
||||
);
|
||||
const message = `${alarmDetails(updated)}: ${approve ? "Übernahme bestätigt" : "Absage – Klärung erforderlich"}.`;
|
||||
const emails = await queue(
|
||||
tx,
|
||||
updated,
|
||||
"managers",
|
||||
"MANAGER",
|
||||
randomUUID(),
|
||||
message,
|
||||
);
|
||||
emails.push(
|
||||
...(await queue(
|
||||
tx,
|
||||
updated,
|
||||
"parents",
|
||||
"STATUS",
|
||||
randomUUID(),
|
||||
message,
|
||||
)),
|
||||
);
|
||||
return { alarmId: alarm.id, emailIds: emails };
|
||||
}
|
||||
throw new Error("Unbekannte Alarmaktion.");
|
||||
});
|
||||
}
|
||||
|
||||
async function recipientForToken(tx: Tx, token: string) {
|
||||
const recipient = await tx.notdienstAlarmRecipient.findUnique({
|
||||
where: { token },
|
||||
include: {
|
||||
user: true,
|
||||
alarm: { include: { kita: { select: { name: true } } } },
|
||||
},
|
||||
});
|
||||
if (
|
||||
!recipient ||
|
||||
recipient.user.role !== "ELTERN" ||
|
||||
(recipient.user.lockedUntil && recipient.user.lockedUntil > new Date()) ||
|
||||
recipient.user.kitaId !== recipient.alarm.kitaId ||
|
||||
recipient.user.familyId !== recipient.alarm.familyId
|
||||
)
|
||||
return null;
|
||||
return recipient;
|
||||
}
|
||||
export async function getPublicAlarm(token: string) {
|
||||
if (!tokenSchema.safeParse(token).success) return null;
|
||||
const legacy = await prisma.notdienstAlert.findUnique({
|
||||
where: { confirmationToken: token },
|
||||
select: { alarmId: true, assignmentId: true },
|
||||
});
|
||||
if (legacy && !legacy.alarmId)
|
||||
await importLegacyAlarms(undefined, legacy.assignmentId);
|
||||
const recipient = await recipientForToken(prisma, token);
|
||||
if (!recipient) return null;
|
||||
const a = recipient.alarm;
|
||||
return {
|
||||
id: a.id,
|
||||
kitaName: a.kita.name,
|
||||
firstName: recipient.user.firstName,
|
||||
familyName: a.familyName,
|
||||
date: a.date,
|
||||
wholeDay: a.wholeDay,
|
||||
startMinute: a.startMinute,
|
||||
endMinute: a.endMinute,
|
||||
deadline: a.deadline,
|
||||
expiresAt: a.expiresAt,
|
||||
status: a.status,
|
||||
};
|
||||
}
|
||||
export async function respondToAlarm(
|
||||
token: string,
|
||||
approve: boolean,
|
||||
now = new Date(),
|
||||
) {
|
||||
tokenSchema.parse(token);
|
||||
z.boolean().parse(approve);
|
||||
// A legacy token is imported before the response transaction, without sending any new request.
|
||||
await getPublicAlarm(token);
|
||||
return transaction(async (tx) => {
|
||||
const recipient = await recipientForToken(tx, token);
|
||||
if (!recipient) throw new Error("Ungültiger Rückmeldelink.");
|
||||
const alarm = recipient.alarm;
|
||||
if (alarm.status !== "PENDING")
|
||||
throw new Error(
|
||||
"Für diesen Haushalt liegt bereits eine Rückmeldung vor oder der Alarm wurde storniert.",
|
||||
);
|
||||
if (alarm.expiresAt <= now)
|
||||
throw new Error(
|
||||
"Der Einsatz ist beendet. Dieser Rückmeldelink ist abgelaufen.",
|
||||
);
|
||||
const updated = await tx.notdienstAlarm.update({
|
||||
where: { id: alarm.id },
|
||||
data: {
|
||||
status: approve
|
||||
? alarm.deadline <= now
|
||||
? "LATE_CONFIRMATION"
|
||||
: "CONFIRMED"
|
||||
: "DECLINED",
|
||||
respondedAt: now,
|
||||
respondedById: recipient.user.id,
|
||||
responseSource: "LINK",
|
||||
},
|
||||
});
|
||||
await history(
|
||||
tx,
|
||||
alarm.id,
|
||||
recipient.user.id,
|
||||
approve ? "Zusage des Haushalts" : "Absage des Haushalts",
|
||||
updated.status,
|
||||
);
|
||||
const message = `${alarmDetails(updated)}: ${updated.status === "CONFIRMED" ? "Übernahme bestätigt" : updated.status === "LATE_CONFIRMATION" ? "Verspätete Zusage – bitte freigeben" : "Absage – Klärung erforderlich"}.`;
|
||||
return {
|
||||
alarmId: alarm.id,
|
||||
emailIds: await queue(
|
||||
tx,
|
||||
updated,
|
||||
"managers",
|
||||
"MANAGER",
|
||||
randomUUID(),
|
||||
message,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function processAlarmDeadlines(now = new Date(), kitaId?: string) {
|
||||
const candidates = await prisma.notdienstAlarm.findMany({
|
||||
where: {
|
||||
status: "PENDING",
|
||||
escalatedAt: null,
|
||||
deadline: { lte: now },
|
||||
...(kitaId ? { kitaId } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
orderBy: { deadline: "asc" },
|
||||
take: 100,
|
||||
});
|
||||
const emailIds: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
emailIds.push(
|
||||
...(await transaction(async (tx) => {
|
||||
const alarm = await tx.notdienstAlarm.findUnique({
|
||||
where: { id: candidate.id },
|
||||
});
|
||||
if (
|
||||
!alarm ||
|
||||
alarm.status !== "PENDING" ||
|
||||
alarm.escalatedAt ||
|
||||
alarm.deadline > now
|
||||
)
|
||||
return [];
|
||||
const updated = await tx.notdienstAlarm.update({
|
||||
where: { id: alarm.id },
|
||||
data: { escalatedAt: now },
|
||||
});
|
||||
await history(
|
||||
tx,
|
||||
alarm.id,
|
||||
null,
|
||||
"Antwortfrist abgelaufen",
|
||||
"Klärung erforderlich; Ersatz manuell organisieren.",
|
||||
);
|
||||
return queue(
|
||||
tx,
|
||||
updated,
|
||||
"managers",
|
||||
"MANAGER",
|
||||
"deadline",
|
||||
`${alarmDetails(updated)}: Keine Antwort innerhalb der Frist. Klärung erforderlich; bitte Familie kontaktieren oder Ersatz organisieren.`,
|
||||
);
|
||||
})),
|
||||
);
|
||||
}
|
||||
await prisma.notdienstAlarm.updateMany({
|
||||
where: {
|
||||
expiresAt: { lte: now },
|
||||
activeAssignmentId: { not: null },
|
||||
...(kitaId ? { kitaId } : {}),
|
||||
},
|
||||
data: { activeAssignmentId: null },
|
||||
});
|
||||
return emailIds;
|
||||
}
|
||||
|
||||
export async function importLegacyAlarms(
|
||||
kitaId?: string,
|
||||
assignmentId?: string,
|
||||
) {
|
||||
const groups = await prisma.notdienstAlert.findMany({
|
||||
where: {
|
||||
alarmId: null,
|
||||
...(kitaId ? { kitaId } : {}),
|
||||
...(assignmentId ? { assignmentId } : {}),
|
||||
},
|
||||
distinct: ["assignmentId"],
|
||||
select: { assignmentId: true },
|
||||
take: 100,
|
||||
});
|
||||
for (const group of groups)
|
||||
await transaction(async (tx) => {
|
||||
const assignment = await tx.notdienstAssignment.findUnique({
|
||||
where: { id: group.assignmentId },
|
||||
include: {
|
||||
child: { include: { family: true } },
|
||||
alerts: {
|
||||
where: { alarmId: null },
|
||||
include: { parentUser: true },
|
||||
orderBy: { triggeredAt: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!assignment || !assignment.alerts.length) return;
|
||||
const old = assignment.alerts;
|
||||
const valid = old.filter(
|
||||
(a) =>
|
||||
a.parentUser.role === "ELTERN" &&
|
||||
a.parentUser.familyId === assignment.child.familyId &&
|
||||
a.parentUser.kitaId === assignment.kitaId,
|
||||
);
|
||||
const uniqueRecipients = [
|
||||
...new Map(valid.map((a) => [a.parentUserId, a])).values(),
|
||||
];
|
||||
const cancelled = old.every((a) => a.status === "CANCELLED");
|
||||
const confirmed =
|
||||
uniqueRecipients.length === old.length &&
|
||||
old.every((a) => a.status === "CONFIRMED");
|
||||
const pending =
|
||||
uniqueRecipients.length === old.length &&
|
||||
old.every((a) => a.status === "PENDING");
|
||||
const expiresAt = endOfAlarm(dateKey(assignment.date), true);
|
||||
const alarm = await tx.notdienstAlarm.create({
|
||||
data: {
|
||||
kitaId: assignment.kitaId,
|
||||
assignmentId: assignment.id,
|
||||
activeAssignmentId:
|
||||
!cancelled && expiresAt > new Date() ? assignment.id : null,
|
||||
familyId: assignment.child.familyId,
|
||||
familyName: assignment.child.family.name,
|
||||
childName: `${assignment.child.firstName} ${assignment.child.lastName}`,
|
||||
date: assignment.date,
|
||||
expiresAt,
|
||||
deadline: new Date(
|
||||
Math.min(
|
||||
old[0].triggeredAt.getTime() + 30 * 60_000,
|
||||
expiresAt.getTime(),
|
||||
),
|
||||
),
|
||||
triggeredAt: old[0].triggeredAt,
|
||||
triggeredById: old[0].triggeredById,
|
||||
educatorId: old[0].educatorId,
|
||||
notes:
|
||||
old
|
||||
.map((a) => a.notes)
|
||||
.filter(Boolean)
|
||||
.filter((note, index, all) => all.indexOf(note) === index)
|
||||
.join("\n") || null,
|
||||
status: cancelled
|
||||
? "CANCELLED"
|
||||
: confirmed
|
||||
? "CONFIRMED"
|
||||
: pending
|
||||
? "PENDING"
|
||||
: "REVIEW_REQUIRED",
|
||||
responseSource: "LEGACY",
|
||||
respondedAt: confirmed ? old[0].confirmedAt : null,
|
||||
recipients: {
|
||||
create: uniqueRecipients.map((a) => ({
|
||||
userId: a.parentUserId,
|
||||
token: a.confirmationToken,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
await tx.notdienstAlert.updateMany({
|
||||
where: { id: { in: old.map((a) => a.id) } },
|
||||
data: { alarmId: alarm.id },
|
||||
});
|
||||
await history(
|
||||
tx,
|
||||
alarm.id,
|
||||
null,
|
||||
"Bisherige Alarmierung übernommen",
|
||||
`Einzelstatus erhalten. ${alarm.status === "REVIEW_REQUIRED" ? "Rückmeldungen müssen manuell geprüft werden." : "Gemeinsamer Status eindeutig."}`,
|
||||
);
|
||||
});
|
||||
return groups.length;
|
||||
}
|
||||
|
||||
export async function getAlarmFeed(actor: AlarmActor, now = new Date()) {
|
||||
const user = await validateAlarmActor(prisma, actor, false);
|
||||
const manager = managerRoles.includes(user.role);
|
||||
if (!manager && !user.familyId) return [];
|
||||
return prisma.notdienstAlarm.findMany({
|
||||
where: {
|
||||
kitaId: actor.kitaId,
|
||||
...(manager ? {} : { familyId: user.familyId! }),
|
||||
OR: [
|
||||
{ expiresAt: { gt: now } },
|
||||
{
|
||||
status: "CANCELLED",
|
||||
cancelledAt: { gte: new Date(now.getTime() - 86400000) },
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: [{ date: "asc" }, { triggeredAt: "desc" }],
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
date: true,
|
||||
familyName: true,
|
||||
wholeDay: true,
|
||||
startMinute: true,
|
||||
endMinute: true,
|
||||
deadline: true,
|
||||
expiresAt: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user