Merge current features with security hardening and automatic safe initialization
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,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 };
|
||||
}
|
||||
@@ -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,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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user