feat: add household emergency duty alarms and deadline worker

This commit is contained in:
t.indorf
2026-10-09 11:02:24 +02:00
parent a96ba0fd84
commit 8b32e4a832
34 changed files with 3947 additions and 646 deletions
+20
View File
@@ -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;
}
+98
View File
@@ -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 };
}
+89
View File
@@ -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)
);
}
+40
View File
@@ -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 };
}
+688
View File
@@ -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,
},
});
}