Merge current features with security hardening and automatic safe initialization

This commit is contained in:
t.indorf
2026-10-09 23:29:19 +02:00
85 changed files with 13522 additions and 1326 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;
}
+99
View File
@@ -0,0 +1,99 @@
import { hashToken } from "@/lib/tokens";
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 && !user.disabledAt && !!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt && (!user.lockedUntil || user.lockedUntil <= now);
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, sessionVersion: user.sessionVersion, OR: [{ token: hashToken(token ?? "") }, ...(token?.length === 36 ? [{ 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 };
}
+698
View File
@@ -0,0 +1,698 @@
import { randomUUID } from "node:crypto";
import { hashToken, newToken } from "@/lib/tokens";
import { UserFacingError } from "@/lib/action-error";
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 UserFacingError("Kein Zugriff auf die Notdienstalarmierung.");
const user = await tx.user.findFirst({
where: {
id: actor.id,
kitaId: actor.kitaId,
role: actor.role,
privacyPolicyAcceptedAt: { not: null },
emailVerifiedAt: { not: null },
disabledAt: null,
},
});
if (!user || (user.lockedUntil && user.lockedUntil > new Date()))
throw new UserFacingError(
"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 UserFacingError(
"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,
requestTokens?: Map<string, string>,
) {
const users =
audience === "managers"
? await tx.user.findMany({
where: { kitaId: alarm.kitaId, role: { in: managerRoles }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
})
: await tx.user.findMany({
where: {
kitaId: alarm.kitaId,
familyId: alarm.familyId,
role: "ELTERN",
emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null },
alarmRecipients: { some: { alarmId: alarm.id } },
},
});
const jobs: string[] = [];
for (const user of users) {
const recipient =
kind === "REQUEST" ? requestTokens?.get(user.id) : null;
if (kind === "REQUEST" && !recipient) throw new Error("Request token missing");
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}`
: `/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 UserFacingError(
"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, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } } },
},
},
},
},
},
});
if (
!assignment ||
assignment.child.kitaId !== actor.kitaId ||
assignment.child.family.kitaId !== actor.kitaId ||
assignment.plan.kitaId !== actor.kitaId
)
throw new UserFacingError("Einteilung nicht gefunden.");
if (!assignment.kita.notdienstModuleEnabled)
throw new UserFacingError("Notdienst ist für diese Kita deaktiviert.");
if (assignment.plan.status !== "PUBLISHED")
throw new UserFacingError("Bitte den Notdienstplan zuerst veröffentlichen.");
const date = dateKey(assignment.date);
if (date < todayKey(now))
throw new UserFacingError("Vergangene Notdienste können nicht alarmiert werden.");
const expiresAt = endOfAlarm(date, input.wholeDay, input.endMinute);
if (expiresAt <= now)
throw new UserFacingError("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 UserFacingError(
"Für diese Einteilung besteht bereits ein aktiver Alarm.",
);
const parents = assignment.child.family.users;
const requestTokens = new Map(parents.map(parent => [parent.id, newToken()]));
if (!parents.length)
throw new UserFacingError(
"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 UserFacingError("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: hashToken(requestTokens.get(parent.id)!),
sessionVersion: parent.sessionVersion,
})),
},
},
});
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.`,
requestTokens,
);
return { alarmId: alarm.id, emailIds };
}
const alarm = await tx.notdienstAlarm.findFirst({
where: { id: id.parse(data.alarmId), kitaId: actor.kitaId },
});
if (!alarm) throw new UserFacingError("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 UserFacingError("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 UserFacingError(
"Dieser Alarm ist storniert oder der Einsatz bereits beendet.",
);
const reason = note.parse(data.reason);
if (command === "approveLate" && alarm.status !== "LATE_CONFIRMATION")
throw new UserFacingError("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 UserFacingError("Unbekannte Alarmaktion.");
});
}
async function recipientForToken(tx: Tx, token: string) {
const recipient = await tx.notdienstAlarmRecipient.findFirst({
where: { OR: [{ token: hashToken(token) }, ...(token.length === 36 ? [{ token }] : [])] },
include: {
user: true,
alarm: { include: { kita: { select: { name: true } } } },
},
});
if (
!recipient ||
recipient.user.role !== "ELTERN" ||
recipient.sessionVersion !== recipient.user.sessionVersion ||
!!recipient.user.disabledAt || !recipient.user.emailVerifiedAt || !recipient.user.privacyPolicyAcceptedAt ||
recipient.alarm.expiresAt <= new Date() ||
(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.findFirst({
where: { OR: [{ confirmationToken: hashToken(token) }, ...(token.length === 36 ? [{ 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 UserFacingError("Ungültiger Rückmeldelink.");
const alarm = recipient.alarm;
if (alarm.status !== "PENDING")
throw new UserFacingError(
"Für diesen Haushalt liegt bereits eine Rückmeldung vor oder der Alarm wurde storniert.",
);
if (alarm.expiresAt <= now)
throw new UserFacingError(
"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,
sessionVersion: a.parentUser.sessionVersion,
})),
},
},
});
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,
},
});
}
+38
View File
@@ -0,0 +1,38 @@
import { AnnouncementAudience, UserRole } from "@prisma/client";
// Admins need access to all publications for management. Other roles only
// receive their audience; a parent with a coordination role is still a parent.
export function visibleAnnouncementAudiences(user: {
role: UserRole;
familyId?: string | null;
}): AnnouncementAudience[] {
if (user.role === UserRole.ADMIN) return Object.values(AnnouncementAudience);
const audiences: AnnouncementAudience[] = [AnnouncementAudience.ALLE];
if (user.role === UserRole.ERZIEHER)
audiences.push(AnnouncementAudience.ERZIEHER);
if (
user.role !== UserRole.ERZIEHER &&
(user.familyId || user.role === UserRole.ELTERN)
) {
audiences.push(AnnouncementAudience.ELTERN);
}
return audiences;
}
export function announcementRecipientWhere(audience: AnnouncementAudience) {
const parents = {
role: { not: UserRole.ERZIEHER },
OR: [{ familyId: { not: null } }, { role: UserRole.ELTERN }],
};
if (audience === AnnouncementAudience.ELTERN) return parents;
if (audience === AnnouncementAudience.ERZIEHER)
return { role: UserRole.ERZIEHER };
return { OR: [parents, { role: UserRole.ERZIEHER }] };
}
export const announcementAudienceLabels: Record<AnnouncementAudience, string> =
{
ELTERN: "Eltern",
ERZIEHER: "ErzieherInnen",
ALLE: "Eltern und ErzieherInnen",
};
+48
View File
@@ -0,0 +1,48 @@
import "server-only";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import type { KitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { visibleAnnouncementAudiences } from "@/lib/announcement-access";
export async function getAnnouncements(
session: KitaSession,
options: { take: number; skip?: number },
) {
const rows = await prisma.announcement.findMany({
where: {
kitaId: session.user.kitaId,
audience: { in: visibleAnnouncementAudiences(session.user) },
},
select: {
id: true,
title: true,
content: true,
createdAt: true,
author: { select: { firstName: true, lastName: true } },
reads: { where: { userId: session.user.id }, select: { id: true } },
attachments: {
select: { id: true, fileName: true, fileUrl: true, fileType: true },
},
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: options.take,
skip: options.skip,
});
return rows.map((row) => ({
id: row.id,
title: row.title,
content: row.content,
excerpt: row.content
.replace(/^#{1,6}\s+/gm, "")
.replace(/!?(\[([^\]]+)\])\([^)]+\)/g, "$2")
.replace(/(\*\*|__|`)/g, "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 300),
createdAt: format(row.createdAt, "dd.MM.yyyy HH:mm", { locale: de }),
authorName: `${row.author.firstName} ${row.author.lastName}`,
isUnread: row.reads.length === 0,
attachments: row.attachments,
}));
}
+10
View File
@@ -0,0 +1,10 @@
import type { Prisma } from "@prisma/client";
// Serialize RSVP writes and changes of the response mode on the same event.
export async function lockEvent(
tx: Prisma.TransactionClient,
eventId: string,
kitaId: string,
) {
await tx.$queryRaw`SELECT id FROM termine WHERE id = ${eventId} AND "kitaId" = ${kitaId} FOR UPDATE`;
}
+12 -7
View File
@@ -9,6 +9,7 @@ type SendAppEmailInput = {
react: ReactNode;
from?: string;
replyTo?: string | string[];
idempotencyKey?: string;
};
type SendAppEmailResult =
@@ -36,6 +37,7 @@ export async function sendAppEmail({
react,
from = process.env.EMAIL_FROM,
replyTo,
idempotencyKey,
}: SendAppEmailInput): Promise<SendAppEmailResult> {
if (Array.isArray(to)) {
let id: string | undefined;
@@ -72,13 +74,16 @@ export async function sendAppEmail({
try {
resendClient ??= new Resend(process.env.RESEND_API_KEY);
const { data, error } = await resendClient.emails.send({
from: from!,
to,
subject,
react,
replyTo,
});
const { data, error } = await resendClient.emails.send(
{
from: from!,
to,
subject,
react,
replyTo,
},
{ idempotencyKey },
);
if (error) {
console.error("Resend konnte die E-Mail nicht versenden:", error);
+77
View File
@@ -0,0 +1,77 @@
import "server-only";
import { RsvpMode, TerminStatus, UserRole } from "@prisma/client";
import type { KitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { hasMissingRsvp, type RsvpEventDto } from "@/lib/rsvp";
export async function getRsvpEvents(
session: KitaSession,
options: { onlyOpen?: boolean } = {},
): Promise<RsvpEventDto[]> {
if (!session.user.familyId || session.user.role === UserRole.ERZIEHER)
return [];
const now = new Date();
const familyId = session.user.familyId;
const [events, children] = await Promise.all([
prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
requiresRsvp: true,
status: TerminStatus.CONFIRMED,
kita: { terminModuleEnabled: true },
...(options.onlyOpen
? {
startDate: { gt: now },
OR: [{ rsvpDeadline: null }, { rsvpDeadline: { gt: now } }],
}
: {}),
},
select: {
id: true,
title: true,
startDate: true,
rsvpDeadline: true,
rsvpMode: true,
participations: {
where: { child: { familyId, active: true } },
select: { childId: true, status: true },
},
familyParticipations: {
where: { familyId },
select: { status: true, adultCount: true },
},
},
orderBy: { startDate: "asc" },
}),
prisma.child.findMany({
where: { kitaId: session.user.kitaId, familyId, active: true },
select: { id: true, firstName: true, lastName: true },
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
}),
]);
const rows: RsvpEventDto[] = events
.filter(
(event) => event.rsvpMode === RsvpMode.FAMILY || children.length > 0,
)
.map((event) => ({
id: event.id,
title: event.title,
startDate: event.startDate.toISOString(),
rsvpDeadline: event.rsvpDeadline?.toISOString() ?? null,
mode: event.rsvpMode,
canReply:
(event.rsvpDeadline ?? event.startDate) > now && event.startDate > now,
familyResponse: event.familyParticipations[0] ?? null,
children:
event.rsvpMode === RsvpMode.CHILD
? children.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
status:
event.participations.find((row) => row.childId === child.id)
?.status ?? null,
}))
: [],
}));
return options.onlyOpen ? rows.filter(hasMissingRsvp) : rows;
}
+70
View File
@@ -0,0 +1,70 @@
import { EventParticipationStatus, RsvpMode, TerminType } from "@prisma/client";
import { z } from "zod";
export const rsvpSubmissionSchema = z.discriminatedUnion("mode", [
z.object({
eventId: z.string().min(1),
mode: z.literal(RsvpMode.CHILD),
responses: z
.array(
z.object({
childId: z.string().min(1),
status: z.nativeEnum(EventParticipationStatus),
}),
)
.min(1)
.refine(
(rows) => new Set(rows.map((row) => row.childId)).size === rows.length,
"Doppelte Kinderantwort",
),
}),
z
.object({
eventId: z.string().min(1),
mode: z.literal(RsvpMode.FAMILY),
status: z.nativeEnum(EventParticipationStatus),
adultCount: z.number().int().min(0).max(2147483647),
})
.refine(
(row) =>
row.status === EventParticipationStatus.ATTENDING
? row.adultCount >= 1
: row.adultCount === 0,
"Bei Zusage bitte die Anzahl Erwachsener angeben; bei Absage muss sie null sein.",
),
]);
export function suggestedRsvpMode(type: TerminType): RsvpMode {
return new Set<TerminType>([
TerminType.ELTERNABEND,
TerminType.MITGLIEDERVERSAMMLUNG,
TerminType.ELTERNCAFE,
TerminType.KITA_FEST,
]).has(type)
? RsvpMode.FAMILY
: RsvpMode.CHILD;
}
export type RsvpEventDto = {
id: string;
title: string;
startDate: string;
rsvpDeadline: string | null;
mode: RsvpMode;
canReply: boolean;
familyResponse: {
status: EventParticipationStatus;
adultCount: number;
} | null;
children: {
id: string;
name: string;
status: EventParticipationStatus | null;
}[];
};
export function hasMissingRsvp(event: RsvpEventDto) {
return event.mode === RsvpMode.FAMILY
? !event.familyResponse
: event.children.some((child) => !child.status);
}
+31
View File
@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import { UserRole } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import type { StaffActor } from "./service";
export async function requireStaffActor(): Promise<StaffActor> {
const { user } = await requireKitaSession();
const current = await prisma.user.findFirst({
where: { id: user.id, kitaId: user.kitaId },
select: {
id: true,
kitaId: true,
role: true,
lockedUntil: true,
disabledAt: true,
emailVerifiedAt: true,
privacyPolicyAcceptedAt: true,
},
});
if (
!current?.kitaId ||
!current.privacyPolicyAcceptedAt ||
!!current.disabledAt || !current.emailVerifiedAt ||
current.role !== user.role ||
(current.role !== UserRole.ADMIN && current.role !== UserRole.ERZIEHER) ||
(current.lockedUntil && current.lockedUntil > new Date())
)
redirect("/forbidden");
return { id: current.id, kitaId: current.kitaId, role: current.role };
}
+315
View File
@@ -0,0 +1,315 @@
import { z } from "zod";
export const DAY_NAMES = [
"Montag",
"Dienstag",
"Mittwoch",
"Donnerstag",
"Freitag",
"Samstag",
"Sonntag",
];
export const STATUS_LABELS: Record<string, string> = {
PENDING: "Beantragt",
APPROVED: "Genehmigt",
REJECTED: "Abgelehnt",
CANCEL_REQUESTED: "Stornierung beantragt",
CANCELLED: "Storniert",
DRAFT: "Offen",
SUBMITTED: "Zur Prüfung",
};
export const KIND_LABELS: Record<string, string> = {
VACATION: "Urlaub",
TIME_OFF: "Freizeitausgleich",
SICKNESS: "Krankheit",
};
export const dateSchema = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.refine((key) => {
const d = new Date(`${key}T00:00:00Z`);
return (
!Number.isNaN(d.getTime()) &&
d.toISOString().slice(0, 10) === key &&
key >= "2000-01-01" &&
key <= "2100-12-31"
);
}, "Bitte ein gültiges Datum zwischen 2000 und 2100 angeben.");
export function dateKey(date: Date) {
return date.toISOString().slice(0, 10);
}
export function dateOnly(key: string) {
return new Date(`${dateSchema.parse(key)}T00:00:00Z`);
}
export function todayKey(now = new Date()) {
return new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(now);
}
export function addDays(key: string, amount: number) {
const d = dateOnly(key);
d.setUTCDate(d.getUTCDate() + amount);
return dateKey(d);
}
export function weekStart(key: string) {
const day = dateOnly(key).getUTCDay();
return addDays(key, -(day === 0 ? 6 : day - 1));
}
export function datesBetween(start: string, end: string) {
if (end < start)
throw new Error("Das Enddatum darf nicht vor dem Beginn liegen.");
if ((dateOnly(end).getTime() - dateOnly(start).getTime()) / 86400000 > 370)
throw new Error("Bitte höchstens ein Jahr auf einmal auswählen.");
const days: string[] = [];
for (let d = start; d <= end; d = addDays(d, 1)) days.push(d);
return days;
}
export function formatMinutes(minutes: number) {
const abs = Math.abs(Math.round(minutes));
return `${minutes < 0 ? "−" : ""}${Math.floor(abs / 60)}:${String(abs % 60).padStart(2, "0")} h`;
}
export function shortDate(key: string) {
return new Intl.DateTimeFormat("de-DE", {
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(dateOnly(key));
}
export function clockMinute(value: string) {
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value))
throw new Error("Bitte eine gültige Uhrzeit angeben.");
const [h, m] = value.split(":").map(Number);
return h * 60 + m;
}
export function minuteClock(value: number) {
return `${String(Math.floor(value / 60)).padStart(2, "0")}:${String(value % 60).padStart(2, "0")}`;
}
export type Schedule = {
employmentStart: Date;
employmentEnd: Date | null;
trackingStart: Date;
workPatterns: { effectiveFrom: Date; dailyMinutes: number[] }[];
dayOverrides: { date: Date; minutes: number }[];
};
export function scheduledMinutes(
profile: Schedule,
key: string,
holidays: Set<string>,
) {
if (
key < dateKey(profile.employmentStart) ||
(profile.employmentEnd && key > dateKey(profile.employmentEnd)) ||
holidays.has(key)
)
return 0;
const override = profile.dayOverrides.find((o) => dateKey(o.date) === key);
if (override) return override.minutes;
const pattern = profile.workPatterns
.filter((p) => dateKey(p.effectiveFrom) <= key)
.sort((a, b) => b.effectiveFrom.getTime() - a.effectiveFrom.getTime())[0];
const weekday = dateOnly(key).getUTCDay();
return pattern?.dailyMinutes[weekday === 0 ? 6 : weekday - 1] ?? 0;
}
export type BookingDay = {
date: string;
vacationUnits: number;
creditMinutes: number;
};
export function calculateLeaveDays(
profile: Schedule,
holidays: Set<string>,
input: {
kind: string;
startDate: string;
endDate: string;
halfDay: boolean;
minutes?: number;
},
) {
if (
input.startDate < dateKey(profile.employmentStart) ||
(profile.employmentEnd && input.endDate > dateKey(profile.employmentEnd))
)
throw new Error("Der Zeitraum liegt außerhalb der Beschäftigung.");
if (input.halfDay && input.startDate !== input.endDate)
throw new Error("Halbe Tage bitte einzeln beantragen.");
if (input.kind === "TIME_OFF" && input.startDate !== input.endDate)
throw new Error(
"Freizeitausgleich bitte für einen einzelnen Tag beantragen.",
);
const days: BookingDay[] = [];
for (const date of datesBetween(input.startDate, input.endDate)) {
const expected = scheduledMinutes(profile, date, holidays);
if (!expected) continue;
const creditMinutes =
input.kind === "TIME_OFF"
? (input.minutes ?? 0)
: input.halfDay
? Math.round(expected / 2)
: expected;
if (creditMinutes <= 0 || creditMinutes > expected)
throw new Error(
"Freizeitausgleich muss zwischen einer Minute und der Sollzeit dieses Tages liegen.",
);
days.push({
date,
creditMinutes,
vacationUnits: input.kind === "VACATION" ? (input.halfDay ? 1 : 2) : 0,
});
}
if (!days.length)
throw new Error("Im ausgewählten Zeitraum liegen keine Arbeitstage.");
return days;
}
export type DailyTotal = {
date: string;
expected: number;
worked: number;
credit: number;
timeOff: number;
balance: number;
};
export function calculateWeek(
profile: Schedule,
holidays: Set<string>,
start: string,
entries: {
date: Date;
startMinute: number;
endMinute: number;
breakMinutes: number;
}[],
leaves: {
kind: string;
status: string;
days: { date: Date; creditMinutes: number }[];
}[],
): DailyTotal[] {
return datesBetween(start, addDays(start, 6)).map((date) => {
const expected =
date < dateKey(profile.trackingStart)
? 0
: scheduledMinutes(profile, date, holidays);
const worked = entries
.filter((e) => dateKey(e.date) === date)
.reduce(
(sum, e) => sum + e.endMinute - e.startMinute - e.breakMinutes,
0,
);
let credit = 0,
timeOff = 0;
for (const leave of leaves) {
if (!["APPROVED", "CANCEL_REQUESTED"].includes(leave.status)) continue;
const minutes = leave.days
.filter((d) => dateKey(d.date) === date)
.reduce((sum, d) => sum + d.creditMinutes, 0);
if (leave.kind === "TIME_OFF") timeOff += minutes;
else credit += minutes;
}
return {
date,
expected,
worked,
credit,
timeOff,
balance: worked + credit - expected,
};
});
}
export function readSnapshot(value: unknown): DailyTotal[] {
const parsed = z
.array(
z.object({
date: dateSchema,
expected: z.number().int(),
worked: z.number().int(),
credit: z.number().int(),
timeOff: z.number().int(),
balance: z.number().int(),
}),
)
.safeParse(value);
return parsed.success ? parsed.data : [];
}
export function getTimeBalance(
profile: { openingMinutes: number; trackingStart: Date },
weeks: { status: string; dailySnapshot: unknown }[],
adjustments: { date: Date; minutes: number }[],
leaves: {
kind: string;
status: string;
days: { date: Date; creditMinutes: number }[];
}[],
asOf = todayKey(),
) {
const approvedDays = weeks
.filter((w) => w.status === "APPROVED")
.flatMap((w) => readSnapshot(w.dailySnapshot));
const postedDates = new Set(
approvedDays.filter((d) => d.date <= asOf).map((d) => d.date),
);
const confirmed =
profile.openingMinutes +
approvedDays
.filter((d) => d.date <= asOf)
.reduce((sum, d) => sum + d.balance, 0) +
adjustments
.filter((a) => dateKey(a.date) <= asOf)
.reduce((sum, a) => sum + a.minutes, 0);
const reserved = leaves
.filter(
(l) =>
l.kind === "TIME_OFF" &&
["APPROVED", "CANCEL_REQUESTED"].includes(l.status),
)
.flatMap((l) => l.days)
.filter(
(d) =>
dateKey(d.date) >= dateKey(profile.trackingStart) &&
!postedDates.has(dateKey(d.date)),
)
.reduce((sum, d) => sum + d.creditMinutes, 0);
return { confirmed, reserved, available: confirmed - reserved };
}
export function vacationBalance(
account: { year: number; entitlementUnits: number; carryUnits: number },
leaves: {
kind: string;
status: string;
days: { date: Date; vacationUnits: number }[];
}[],
asOf = todayKey(),
) {
let taken = 0,
planned = 0,
pending = 0;
for (const leave of leaves.filter((l) => l.kind === "VACATION")) {
for (const day of leave.days.filter(
(d) => d.date.getUTCFullYear() === account.year,
)) {
if (leave.status === "PENDING") pending += day.vacationUnits;
if (["APPROVED", "CANCEL_REQUESTED"].includes(leave.status)) {
if (dateKey(day.date) < asOf) taken += day.vacationUnits;
else planned += day.vacationUnits;
}
}
}
return {
entitlement: account.entitlementUnits + account.carryUnits,
taken,
planned,
pending,
available: account.entitlementUnits + account.carryUnits - taken - planned,
};
}
+116
View File
@@ -0,0 +1,116 @@
import { createElement } from "react";
import { hashToken } from "@/lib/tokens";
import { prisma } from "@/lib/prisma";
import { sendAppEmail } from "@/lib/mail";
import { InviteEmail } from "@/emails/InviteEmail";
import { StaffNotificationEmail } from "@/emails/StaffNotificationEmail";
// Called after the staff transaction commits. Tests inject a transport and never send mail.
export async function deliverStaffEmails(ids: string[], send = sendAppEmail) {
let failed = 0;
let sent = 0;
const deliver = async (id: string) => {
const now = new Date();
const leaseUntil = new Date(now.getTime() + 5 * 60_000);
const claim = await prisma.staffEmail.updateMany({
where: {
id,
sentAt: null,
skippedAt: null,
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
},
data: { leaseUntil, attempts: { increment: 1 } },
});
if (!claim.count) return;
const finish = (data: {
sentAt?: Date;
skippedAt?: Date;
failed: boolean;
}) =>
prisma.staffEmail.updateMany({
where: { id, leaseUntil },
data: { ...data, leaseUntil: null },
});
try {
const job = await prisma.staffEmail.findUniqueOrThrow({
where: { id },
include: {
user: true,
profile: { include: { kita: true, educator: true } },
},
});
const { user, profile } = job;
let eligible = user.kitaId === profile.kitaId && !user.disabledAt && (!user.lockedUntil || user.lockedUntil <= now);
if (job.inviteToken) {
const token = await prisma.verificationToken.findUnique({
where: { token: hashToken(job.inviteToken) },
});
eligible &&=
profile.userId === user.id &&
user.role === "ERZIEHER" &&
!user.emailVerifiedAt &&
!!token &&
token.identifier === user.id &&
token.expires > now;
} else {
eligible &&=
!!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt &&
(!user.lockedUntil || user.lockedUntil <= now) &&
(job.toAdmins
? user.role === "ADMIN"
: user.role === "ERZIEHER" &&
profile.userId === user.id &&
user.staffDecisionEmails);
}
if (!eligible) {
await finish({ skippedAt: now, 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 für E-Mails fehlt.");
const site = new URL(base);
if (
!["https:", "http:"].includes(site.protocol) ||
site.username ||
site.password
)
throw new Error("App-Adresse für E-Mails ist ungültig.");
const link = new URL(job.path, site.origin).toString();
const result = await send({
to: user.email,
subject: job.subject,
idempotencyKey: `staff-email/${job.id}`,
react: job.inviteToken
? createElement(InviteEmail, {
parentName: `${profile.educator.firstName} ${profile.educator.lastName}`,
kitaName: profile.kita.name,
inviteLink: link,
staffAccount: true,
})
: createElement(StaffNotificationEmail, {
name: user.firstName,
kitaName: profile.kita.name,
message: job.message,
link,
}),
});
if (!result.success) {
await finish({ failed: true });
failed++;
} else {
await finish({ sentAt: new Date(), failed: false });
sent++;
}
} catch {
await finish({ failed: true });
failed++;
}
};
const uniqueIds = [...new Set(ids)];
for (let offset = 0; offset < uniqueIds.length; offset += 4)
await Promise.all(uniqueIds.slice(offset, offset + 4).map(deliver));
return { sent, failed };
}
File diff suppressed because it is too large Load Diff