Harden authentication, tenant isolation and security boundaries

This commit is contained in:
t.indorf
2026-10-09 22:49:55 +02:00
parent ec3f4f933d
commit 656ada80bd
71 changed files with 3107 additions and 1200 deletions
+25 -22
View File
@@ -1,6 +1,9 @@
"use server";
import crypto from "crypto";
import { serializableTransaction } from "@/lib/transaction";
import { UserFacingError, publicActionError } from "@/lib/action-error";
import { hashToken, newToken } from "@/lib/tokens";
import { rateLimit } from "@/lib/rate-limit";
import { createElement } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
@@ -19,6 +22,7 @@ export async function triggerAlertAction(assignmentId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
if (!(await rateLimit(`alerts:${kitaId}:${session.user.id}`, 50, 3600)).allowed) return { error: "Zu viele Alarme. Bitte versuche es später erneut." };
try {
const [existing, assignment] = await Promise.all([
// Prüfen ob es schon einen aktiven Alert für dieses Assignment gibt
@@ -26,17 +30,18 @@ export async function triggerAlertAction(assignmentId: string) {
where: { assignmentId, kitaId },
}),
prisma.notdienstAssignment.findFirst({
where: { id: assignmentId, kitaId },
where: { id: assignmentId, kitaId, plan: { status: "PUBLISHED" } },
include: {
child: {
select: {
id: true,
familyId: true,
firstName: true,
lastName: true,
family: {
select: {
users: {
where: { role: UserRole.ELTERN },
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: {
id: true,
email: true,
@@ -75,24 +80,22 @@ export async function triggerAlertAction(assignmentId: string) {
locale: de,
});
const childName = `${assignment.child.firstName} ${assignment.child.lastName}`;
const alertJobs: { email: string; token: string }[] = [];
await prisma.$transaction(
parents.map((parent) => {
const token = crypto.randomUUID();
alertJobs.push({ email: parent.email, token });
return prisma.notdienstAlert.create({
data: {
kitaId,
assignmentId,
parentUserId: parent.id,
triggeredById: session.user.id,
status: NotdienstAlertStatus.PENDING,
confirmationToken: token,
},
});
}),
);
const alertJobs = parents.map(parent => ({ email: parent.email, token: newToken(), parentId: parent.id }));
await serializableTransaction(async tx => {
if (await tx.notdienstAlert.findFirst({ where: { assignmentId, kitaId }, select: { id: true } })) {
throw new UserFacingError("Für diese Einteilung wurde bereits ein Alarm ausgelöst.");
}
const current = await tx.notdienstAssignment.findFirst({ where: { id: assignmentId, kitaId, plan: { status: "PUBLISHED" } }, select: { childId: true, date: true } });
const today = new Date(); today.setUTCHours(0, 0, 0, 0);
if (!current || current.childId !== assignment.child.id || current.date < today) throw new UserFacingError("Die Einteilung hat sich geändert oder liegt in der Vergangenheit. Bitte lade die Seite neu.");
for (const job of alertJobs) {
const parent = await tx.user.findFirst({ where: { id: job.parentId, kitaId, familyId: assignment.child.familyId,
email: job.email, disabledAt: null, emailVerifiedAt: { not: null }, privacyPolicyAcceptedAt: { not: null } }, select: { id: true } });
if (!parent) throw new UserFacingError("Die Empfänger haben sich geändert. Bitte lade die Seite neu.");
await tx.notdienstAlert.create({ data: { kitaId, assignmentId, parentUserId: parent.id,
triggeredById: session.user.id, status: NotdienstAlertStatus.PENDING, confirmationToken: hashToken(job.token) } });
}
});
for (const job of alertJobs) {
const emailResult = await sendAppEmail({
@@ -118,6 +121,6 @@ export async function triggerAlertAction(assignmentId: string) {
return { success: true };
} catch (error) {
console.error(error);
return { error: "Fehler beim Auslösen des Alarms." };
return { error: publicActionError(error, "Fehler beim Auslösen des Alarms.") };
}
}
+7 -2
View File
@@ -5,11 +5,13 @@ import { revalidatePath } from "next/cache";
import { DutyAssignmentStatus, UserRole } from "@prisma/client";
import { endOfWeek, format, startOfDay, startOfWeek } from "date-fns";
import { de } from "date-fns/locale";
import { dateOnlySchema } from "@/lib/date-schema";
import { z } from "zod";
import { DutyReminderEmail } from "@/emails/DutyReminderEmail";
import { requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { rateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
const dutyTypeSchema = z.object({
@@ -20,8 +22,8 @@ const dutyTypeSchema = z.object({
const assignmentSchema = z.object({
dutyTypeId: z.string().min(1),
familyId: z.string().nullable(),
startDate: z.string().min(1),
endDate: z.string().min(1),
startDate: dateOnlySchema,
endDate: dateOnlySchema,
});
async function requireDutyAdmin() {
@@ -160,6 +162,7 @@ export async function upsertDutyAssignment(rawPayload: unknown) {
export async function checkAndSendDutyReminders() {
const { kitaId } = await requireDutyAdmin();
if (!(await rateLimit(`duty-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
const configError = getAppEmailConfigError();
if (configError) {
@@ -195,6 +198,8 @@ export async function checkAndSendDutyReminders() {
users: {
where: {
emailVerifiedAt: { not: null },
disabledAt: null,
privacyPolicyAcceptedAt: { not: null },
},
select: {
email: true,
+1 -1
View File
@@ -93,7 +93,7 @@ export function NewsForm() {
/>
<p className="mt-2 flex items-center gap-1 text-xs text-[var(--ink-muted)]">
<UploadCloud className="h-3.5 w-3.5 [stroke-width:1.7]" />
PDF, JPG und PNG bis 10 MB pro Datei.
PDF, JPG und PNG: maximal 5 Dateien, je 10 MB und insgesamt 25 MB.
</p>
</div>
</div>
@@ -1,7 +1,9 @@
"use client";
import { useState } from "react";
import { useState, useTransition } from "react";
import { LayoutGrid, Mail, Search, Table, Users } from "lucide-react";
import { toast } from "sonner";
import { resendParentInvite } from "../actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -245,6 +247,7 @@ export function FamiliesListClient({
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{parent.email}
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
</span>
</div>
</div>
@@ -351,6 +354,7 @@ export function FamiliesListClient({
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{parent.email}
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
</span>
</div>
</div>
@@ -416,3 +420,11 @@ export function FamiliesListClient({
</div>
);
}
function ResendInviteButton({ userId }: { userId: string }) {
const [pending, startTransition] = useTransition();
return <Button type="button" size="sm" variant="ghost" disabled={pending} onClick={() => startTransition(async () => {
const result = await resendParentInvite(userId);
if (result.error) toast.error(result.error); else toast.success("Einladung erneut gesendet.");
})}>{pending ? "Wird gesendet…" : "Einladung erneut senden"}</Button>;
}
+55 -13
View File
@@ -1,6 +1,6 @@
"use server";
import crypto from "crypto";
import { hashToken, newToken } from "@/lib/tokens";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import { Prisma, UserRole } from "@prisma/client";
@@ -9,6 +9,7 @@ import { z } from "zod";
import { InviteEmail } from "@/emails/InviteEmail";
import { requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { rateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
const INVITE_TOKEN_TTL_DAYS = 7;
@@ -115,7 +116,7 @@ function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
}
function createInviteToken() {
const token = crypto.randomUUID();
const token = newToken();
const expires = new Date(
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000,
);
@@ -153,6 +154,8 @@ export async function addFamilyAction(
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
}
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { errors: { _form: ["Zu viele Einladungen. Bitte versuche es später erneut."] } };
const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData));
if (!parsedFamily.success) {
return { errors: parsedFamily.error.flatten().fieldErrors };
@@ -245,7 +248,7 @@ export async function addFamilyAction(
await tx.verificationToken.create({
data: {
identifier: createdParent.id,
token: invite.token,
token: hashToken(invite.token),
expires: invite.expires,
},
});
@@ -320,6 +323,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
const removedChildIdSet = new Set(removedChildIds);
const activeChildren = children.filter((child) => !removedChildIdSet.has(child.id));
if (activeChildren.length + newChildren.length > 20) return { error: "Maximal 20 Kinder pro Familie." };
if (activeChildren.length + newChildren.length === 0) {
return { error: "Eine Familie benötigt mindestens ein Kind." };
}
@@ -347,7 +351,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
where: { id: familyId, kitaId },
select: {
id: true,
users: { select: { id: true } },
users: { select: { id: true, email: true } },
children: { select: { id: true } },
},
});
@@ -356,6 +360,13 @@ export async function updateFamilyAction(rawPayload: unknown) {
return { error: "Familie wurde nicht gefunden." };
}
const needsEmail = newParents.length > 0 || parents.some(parent => existingFamily.users.find(user => user.id === parent.id)?.email !== parent.email);
if (needsEmail) {
const configError = getAppEmailConfigError();
if (configError) return { error: configError };
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { error: "Zu viele Einladungen. Bitte versuche es später erneut." };
}
const existingParentIds = new Set(existingFamily.users.map((user) => user.id));
const existingChildIds = new Set(existingFamily.children.map((child) => child.id));
@@ -382,14 +393,20 @@ export async function updateFamilyAction(rawPayload: unknown) {
});
for (const parent of parents) {
const current = await tx.user.findFirst({ where: { id: parent.id, kitaId, familyId }, select: { email: true } });
if (!current) throw new Error("Parent assignment changed");
const emailChanged = current.email !== parent.email;
await tx.user.update({
where: { id: parent.id },
data: {
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
},
where: { id: parent.id, kitaId, familyId },
data: { firstName: parent.firstName, lastName: parent.lastName, email: parent.email,
...(emailChanged ? { passwordHash: "", emailVerifiedAt: null, sessionVersion: { increment: 1 } } : {}) },
});
if (emailChanged) {
const invite = createInviteToken();
await tx.verificationToken.deleteMany({ where: { identifier: parent.id } });
await tx.verificationToken.create({ data: { identifier: parent.id, token: hashToken(invite.token), expires: invite.expires } });
inviteJobs.push({ to: parent.email, parentName: `${parent.firstName} ${parent.lastName}`, inviteUrl: invite.inviteUrl });
}
}
for (const parent of newParents) {
@@ -413,7 +430,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
await tx.verificationToken.create({
data: {
identifier: createdParent.id,
token: invite.token,
token: hashToken(invite.token),
expires: invite.expires,
},
});
@@ -421,7 +438,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
for (const child of activeChildren) {
await tx.child.update({
where: { id: child.id },
where: { id: child.id, kitaId, familyId },
data: {
firstName: child.firstName,
lastName: child.lastName,
@@ -430,7 +447,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
}
for (const childId of removedChildIds) {
await tx.child.delete({ where: { id: childId } });
await tx.child.delete({ where: { id: childId, kitaId, familyId } });
}
for (const child of newChildren) {
@@ -469,3 +486,28 @@ export async function updateFamilyAction(rawPayload: unknown) {
revalidatePath("/dashboard");
return { success: true };
}
export async function resendParentInvite(userId: string) {
const session = await requireRole([UserRole.ADMIN]);
if (typeof userId !== "string" || !userId || userId.length > 64) return { error: "Ungültige Eingabedaten." };
if (!(await rateLimit(`family-invites:${session.user.kitaId}`, 30, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
if (!(await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
const user = await prisma.user.findFirst({ where: { id: userId, kitaId: session.user.kitaId, emailVerifiedAt: null, passwordHash: "", disabledAt: null },
select: { id: true, email: true, firstName: true, lastName: true, kita: { select: { name: true } } } });
if (!user?.kita) return { error: "Keine offene Einladung gefunden." };
const configError = getAppEmailConfigError(); if (configError) return { error: configError };
const invite = createInviteToken();
const renewed = await prisma.$transaction(async tx => {
// Lock and re-check the account before issuing a link for the selected mailbox.
// A concurrent address change/activation must invalidate this resend.
const eligible = await tx.user.updateMany({ where: { id: user.id, email: user.email, kitaId: session.user.kitaId,
emailVerifiedAt: null, passwordHash: "", disabledAt: null }, data: { sessionVersion: { increment: 1 } } });
if (eligible.count !== 1) return false;
await tx.verificationToken.deleteMany({ where: { identifier: user.id } });
await tx.verificationToken.create({ data: { identifier: user.id, token: hashToken(invite.token), expires: invite.expires } });
return true;
});
if (!renewed) return { error: "Die Einladung hat sich geändert. Bitte lade die Seite neu." };
const error = await sendInviteJobs(user.kita.name, [{ to: user.email, parentName: `${user.firstName} ${user.lastName}`, inviteUrl: invite.inviteUrl }]);
return error ? { error } : { success: true };
}
+1 -1
View File
@@ -9,7 +9,7 @@ import { prisma } from "@/lib/prisma";
const dutySchema = z.object({
name: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein").max(50),
description: z.string().optional(),
description: z.string().trim().max(2000).optional(),
});
export async function createDuty(rawPayload: unknown) {
@@ -154,7 +154,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
return;
}
toast.success("Familie aktualisiert.");
toast.success("Familie aktualisiert. Geänderte E-Mail-Adressen müssen über einen neuen Einladungslink bestätigt werden.");
setRemovedChildIds([]);
});
}
+84 -67
View File
@@ -1,8 +1,10 @@
"use server";
import { UserFacingError, publicActionError } from "@/lib/action-error";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import {
Prisma,
NotdienstAlertStatus,
NotdienstPlanStatus,
NotdienstSwapStatus,
@@ -17,6 +19,8 @@ import { z } from "zod";
import { NotdienstSwapEmail } from "@/emails/NotdienstSwapEmail";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { sendAppEmail } from "@/lib/mail";
import { serializableTransaction } from "@/lib/transaction";
import { rateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
// =====================================================================
@@ -24,8 +28,8 @@ import { prisma } from "@/lib/prisma";
// =====================================================================
const terminSchema = z.object({
title: z.string().trim().min(2, "Titel muss mindestens 2 Zeichen lang sein"),
description: z.string().trim().optional(),
title: z.string().trim().min(2, "Titel muss mindestens 2 Zeichen lang sein").max(160),
description: z.string().trim().max(20000).optional(),
type: z.nativeEnum(TerminType),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
@@ -99,8 +103,9 @@ type SwapAssignmentWithFamily = {
async function getSwapAssignment(
assignmentId: string,
kitaId: string,
db: Prisma.TransactionClient = prisma,
): Promise<SwapAssignmentWithFamily | null> {
return prisma.notdienstAssignment.findFirst({
return db.notdienstAssignment.findFirst({
where: { id: assignmentId, kitaId },
select: {
id: true,
@@ -114,7 +119,7 @@ async function getSwapAssignment(
select: {
name: true,
users: {
where: { role: UserRole.ELTERN },
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: {
email: true,
firstName: true,
@@ -295,6 +300,7 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
export async function createNotdienstSwapRequest(rawPayload: unknown) {
const session = await requireKitaSession();
if (!(await rateLimit(`createNotdienstSwapRequest:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." };
if (session.user.role !== UserRole.ELTERN) {
return { error: "Nur Eltern können Notdienst-Tausche anfragen." };
}
@@ -311,54 +317,58 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
const today = getTodayUtcDateOnly();
try {
const [targetAssignment, offeredAssignment, kita, existingRequest] =
await Promise.all([
getSwapAssignment(targetAssignmentId, session.user.kitaId),
getSwapAssignment(offeredAssignmentId, session.user.kitaId),
prisma.kita.findUnique({
where: { id: session.user.kitaId },
select: { name: true },
}),
prisma.notdienstSwapRequest.findFirst({
where: {
kitaId: session.user.kitaId,
status: NotdienstSwapStatus.PENDING,
OR: [
{ targetAssignmentId },
{ offeredAssignmentId },
{ targetAssignmentId: offeredAssignmentId },
{ offeredAssignmentId: targetAssignmentId },
],
},
select: { id: true },
}),
]);
const { targetAssignment, offeredAssignment, kita } = await serializableTransaction(async tx => {
const [targetAssignment, offeredAssignment, kita, existingRequest] =
await Promise.all([
getSwapAssignment(targetAssignmentId, session.user.kitaId, tx),
getSwapAssignment(offeredAssignmentId, session.user.kitaId, tx),
tx.kita.findUnique({
where: { id: session.user.kitaId },
select: { name: true },
}),
tx.notdienstSwapRequest.findFirst({
where: {
kitaId: session.user.kitaId,
status: NotdienstSwapStatus.PENDING,
OR: [
{ targetAssignmentId },
{ offeredAssignmentId },
{ targetAssignmentId: offeredAssignmentId },
{ offeredAssignmentId: targetAssignmentId },
],
},
select: { id: true },
}),
]);
const validationError = validateSwapAssignmentPair({
targetAssignment,
offeredAssignment,
requesterFamilyId: session.user.familyId,
today,
});
if (validationError) {
return { error: validationError };
}
if (!targetAssignment || !offeredAssignment || !kita) {
return { error: "Notdienst nicht gefunden." };
}
if (existingRequest) {
return { error: "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage." };
}
const validationError = validateSwapAssignmentPair({
targetAssignment,
offeredAssignment,
requesterFamilyId: session.user.familyId!,
today,
});
if (validationError) {
throw new UserFacingError(validationError);
}
if (!targetAssignment || !offeredAssignment || !kita) {
throw new UserFacingError("Notdienst nicht gefunden.");
}
if (existingRequest) {
throw new UserFacingError("Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.");
}
await prisma.notdienstSwapRequest.create({
data: {
kitaId: session.user.kitaId,
requesterUserId: session.user.id,
requesterFamilyId: session.user.familyId,
targetAssignmentId,
offeredAssignmentId,
message: message || null,
},
await tx.notdienstSwapRequest.create({
data: {
kitaId: session.user.kitaId,
requesterUserId: session.user.id,
requesterFamilyId: session.user.familyId!,
targetAssignmentId,
offeredAssignmentId,
message: message || null,
},
});
return { targetAssignment, offeredAssignment, kita };
});
await notifyFamilyAboutSwap({
@@ -375,7 +385,9 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
revalidateCalendarAndNotdienst();
return { success: true };
} catch (error) {
console.error("Fehler beim Erstellen der Notdienst-Tauschanfrage:", error);
if (!(error instanceof Error && error.message === "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.")) {
console.error("Fehler beim Erstellen der Notdienst-Tauschanfrage:", error);
}
return { error: "Tauschanfrage konnte nicht erstellt werden." };
}
}
@@ -394,7 +406,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
const today = getTodayUtcDateOnly();
try {
const result = await prisma.$transaction(async (tx) => {
const result = await serializableTransaction(async (tx) => {
const request = await tx.notdienstSwapRequest.findFirst({
where: {
id: parsed.data,
@@ -419,7 +431,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN },
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true },
},
},
@@ -445,7 +457,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN },
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true },
},
},
@@ -462,11 +474,11 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
});
if (!request) {
throw new Error("Tauschanfrage nicht gefunden oder nicht mehr offen.");
throw new UserFacingError("Tauschanfrage nicht gefunden oder nicht mehr offen.");
}
if (request.targetAssignment.child.familyId !== session.user.familyId) {
throw new Error("Nur der angefragte Haushalt kann diese Anfrage annehmen.");
throw new UserFacingError("Nur der angefragte Haushalt kann diese Anfrage annehmen.");
}
const validationError = validateSwapAssignmentPair({
@@ -476,7 +488,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
today,
});
if (validationError) {
throw new Error(validationError);
throw new UserFacingError(validationError);
}
await tx.notdienstAssignment.update({
@@ -487,14 +499,15 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
where: { id: request.offeredAssignmentId },
data: { childId: request.targetAssignment.childId },
});
await tx.notdienstSwapRequest.update({
where: { id: request.id },
const responded = await tx.notdienstSwapRequest.updateMany({
where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING },
data: {
status: NotdienstSwapStatus.ACCEPTED,
respondedById: session.user.id,
respondedAt: new Date(),
},
});
if (responded.count !== 1) throw new UserFacingError("Diese Anfrage ist nicht mehr offen.");
await tx.notdienstSwapRequest.updateMany({
where: {
id: { not: request.id },
@@ -550,7 +563,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
return { success: true };
} catch (error) {
return {
error: error instanceof Error ? error.message : "Tauschanfrage konnte nicht angenommen werden.",
error: publicActionError(error, "Tauschanfrage konnte nicht angenommen werden."),
};
}
}
@@ -585,7 +598,7 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN },
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true },
},
},
@@ -603,7 +616,7 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN },
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true },
},
},
@@ -622,14 +635,15 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
return { error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen." };
}
await prisma.notdienstSwapRequest.update({
where: { id: request.id },
const responded = await prisma.notdienstSwapRequest.updateMany({
where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING },
data: {
status: NotdienstSwapStatus.DECLINED,
respondedById: session.user.id,
respondedAt: new Date(),
},
});
if (responded.count !== 1) return { error: "Diese Anfrage ist nicht mehr offen." };
const kita = await prisma.kita.findUnique({
where: { id: session.user.kitaId },
@@ -694,14 +708,15 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) {
return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." };
}
await prisma.notdienstSwapRequest.update({
where: { id: request.id },
const responded = await prisma.notdienstSwapRequest.updateMany({
where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING },
data: {
status: NotdienstSwapStatus.CANCELLED,
respondedById: session.user.id,
respondedAt: new Date(),
},
});
if (responded.count !== 1) return { error: "Diese Anfrage ist nicht mehr offen." };
revalidateCalendarAndNotdienst();
return { success: true };
@@ -713,6 +728,7 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) {
export async function createTerminRequest(rawPayload: unknown) {
const session = await requireKitaSession();
if (!(await rateLimit(`createTerminRequest:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." };
const parsed = terminSchema.safeParse(rawPayload);
if (!parsed.success) {
@@ -914,11 +930,12 @@ export async function toggleMitbringselList(terminId: string, enabled: boolean)
const mitbringselSchema = z.object({
terminId: z.string(),
content: z.string().min(1, "Eintrag darf nicht leer sein"),
content: z.string().trim().min(1, "Eintrag darf nicht leer sein").max(500),
});
export async function addMitbringsel(rawPayload: unknown) {
const session = await requireKitaSession();
if (!(await rateLimit(`addMitbringsel:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." };
const parsed = mitbringselSchema.safeParse(rawPayload);
if (!parsed.success) {
+4
View File
@@ -239,6 +239,8 @@ export default async function KalenderPage({
const allUserTermine: CalendarListItemDto[] = [
...confirmedTermineRows.map((termin) => ({
...termin,
participantInfo: isAdmin || termin.participations.some(p => p.status === EventParticipationStatus.ATTENDING)
? termin.participantInfo : null,
kind: "termin" as const,
showParticipantInfo:
isAdmin ||
@@ -249,6 +251,8 @@ export default async function KalenderPage({
})),
...myPendingTermineRows.map((termin) => ({
...termin,
participantInfo: isAdmin || termin.participations.some(p => p.status === EventParticipationStatus.ATTENDING)
? termin.participantInfo : null,
kind: "termin" as const,
showParticipantInfo:
isAdmin ||
+3 -3
View File
@@ -18,13 +18,13 @@ import { getTargetMonthData } from "@/lib/date-utils";
const dateKeyPattern = /^\d{4}-\d{2}-\d{2}$/;
const payloadSchema = z.object({
childrenIds: z.array(z.string()).min(1),
childrenIds: z.array(z.string().min(1).max(64)).min(1).max(20),
dates: z.array(
z.string().regex(dateKeyPattern).refine((value) => {
const date = dateKeyToUtcDate(value);
return value === date.toISOString().slice(0, 10);
return Number.isFinite(date.getTime()) && value === date.toISOString().slice(0, 10);
}),
),
).max(31),
});
function dateKeyToUtcDate(dateKey: string) {
+21 -11
View File
@@ -1,11 +1,14 @@
"use server";
import { UserFacingError, publicActionError } from "@/lib/action-error";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import { NotdienstPlanStatus, UserRole } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { dateOnlySchema, validPlanMonth } from "@/lib/date-schema";
import { rateLimit } from "@/lib/rate-limit";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
@@ -32,6 +35,8 @@ export async function generatePlanAction(year?: number, month?: number) {
targetMonth = data.targetMonth;
}
if (!validPlanMonth(targetYear, targetMonth)) return { error: "Ungültiger Planungsmonat." };
// Prüfen, ob schon ein Plan existiert
const existingPlan = await prisma.notdienstPlan.findUnique({
where: {
@@ -156,6 +161,7 @@ export async function updateAssignmentAction(
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
if (!dateOnlySchema.safeParse(date).success) return { error: "Ungültiges Datum." };
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
@@ -166,21 +172,21 @@ export async function updateAssignmentAction(
});
if (!plan) {
throw new Error("Plan nicht gefunden.");
throw new UserFacingError("Plan nicht gefunden.");
}
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
if (!isDraft && !isPublished) {
throw new Error("Plan kann nicht bearbeitet werden.");
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
}
if (isPublished) {
const today = new Date();
const todayUtc = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
if (parsedDate < todayUtc) {
throw new Error("Vergangene Notdienste können nicht geändert werden.");
throw new UserFacingError("Vergangene Notdienste können nicht geändert werden.");
}
}
@@ -192,7 +198,7 @@ export async function updateAssignmentAction(
family: {
include: {
users: {
where: { role: UserRole.ELTERN },
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true },
},
},
@@ -222,7 +228,7 @@ export async function updateAssignmentAction(
family: {
include: {
users: {
where: { role: UserRole.ELTERN },
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true },
},
},
@@ -231,7 +237,7 @@ export async function updateAssignmentAction(
});
if (!newChild) {
throw new Error("Kind gehört nicht zu dieser Kita.");
throw new UserFacingError("Kind gehört nicht zu dieser Kita.");
}
}
@@ -364,7 +370,7 @@ export async function updateAssignmentAction(
return { success: true };
} catch (error) {
return {
error: error instanceof Error ? error.message : "Update fehlgeschlagen.",
error: publicActionError(error, "Update fehlgeschlagen."),
};
}
}
@@ -379,6 +385,8 @@ export async function updateKitaNotdienstSettingsAction(
return { error: "Kein Mandant zugeordnet." };
}
if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 ||
!Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) return { error: "Ungültige Einstellungen." };
try {
await prisma.kita.update({
where: { id: kitaId },
@@ -398,6 +406,8 @@ export async function updateKitaNotdienstSettingsAction(
export async function sendNotdienstRemindersAction(year: number, month: number) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
if (!(await rateLimit(`notdienst-reminders:${session.user.kitaId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
@@ -426,7 +436,7 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
},
include: {
users: {
where: { role: UserRole.ELTERN },
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
},
},
});
@@ -513,11 +523,11 @@ export async function resetPlanAction(planId: string) {
});
if (!plan) {
throw new Error("Plan nicht gefunden.");
throw new UserFacingError("Plan nicht gefunden.");
}
if (plan.status !== NotdienstPlanStatus.DRAFT) {
throw new Error("Nur Pläne im Entwurfsstatus können zurückgesetzt werden.");
throw new UserFacingError("Nur Pläne im Entwurfsstatus können zurückgesetzt werden.");
}
// Delete all assignments
@@ -535,6 +545,6 @@ export async function resetPlanAction(planId: string) {
return { success: true };
} catch (error) {
console.error("Fehler beim Zurücksetzen des Plans:", error);
return { error: error instanceof Error ? error.message : "Fehler beim Zurücksetzen des Plans." };
return { error: publicActionError(error, "Fehler beim Zurücksetzen des Plans.") };
}
}
+21 -13
View File
@@ -10,6 +10,7 @@ import {
getChildBirthdateRange,
parseDateInput,
} from "@/lib/child-birthdate";
import { serializableTransaction } from "@/lib/transaction";
import { prisma } from "@/lib/prisma";
// Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche,
@@ -118,14 +119,18 @@ export async function createMyChild(rawPayload: unknown) {
}
try {
await prisma.child.create({
data: {
kitaId: session.user.kitaId,
familyId: session.user.familyId,
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
dateOfBirth: parseDateInput(parsed.data.dateOfBirth),
},
await serializableTransaction(async tx => {
const count = await tx.child.count({ where: { kitaId: session.user.kitaId, familyId: session.user.familyId! } });
if (count >= 20) throw new Error("Household child limit reached");
await tx.child.create({
data: {
kitaId: session.user.kitaId,
familyId: session.user.familyId!,
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
dateOfBirth: parseDateInput(parsed.data.dateOfBirth),
},
});
});
revalidatePath("/dashboard/profil");
@@ -158,10 +163,11 @@ export async function createMyFamily(rawPayload: unknown) {
},
});
await tx.user.update({
where: { id: session.user.id },
const attached = await tx.user.updateMany({
where: { id: session.user.id, kitaId: session.user.kitaId, familyId: null, disabledAt: null, sessionVersion: session.user.sessionVersion },
data: { familyId: family.id },
});
if (attached.count !== 1) throw new Error("Account already assigned or revoked");
});
revalidatePath("/dashboard/profil");
@@ -170,7 +176,9 @@ export async function createMyFamily(rawPayload: unknown) {
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
if (!(error instanceof Error && error.message === "Account already assigned or revoked")) {
console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
}
return { error: "Der Haushalt konnte nicht angelegt werden." };
}
}
@@ -239,7 +247,7 @@ export async function updateMyChild(childId: string, rawPayload: unknown) {
}
await prisma.child.update({
where: { id: childId },
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! },
data: {
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
@@ -271,7 +279,7 @@ export async function deleteMyChild(childId: string) {
}
await prisma.child.delete({
where: { id: childId },
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! },
});
revalidatePath("/dashboard/profil");