1155 lines
35 KiB
TypeScript
1155 lines
35 KiB
TypeScript
"use server";
|
|
|
|
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
|
import { createElement } from "react";
|
|
import { revalidatePath } from "next/cache";
|
|
import {
|
|
Prisma,
|
|
NotdienstAlertStatus,
|
|
NotdienstPlanStatus,
|
|
NotdienstSwapStatus,
|
|
RsvpMode,
|
|
TerminStatus,
|
|
TerminType,
|
|
UserRole,
|
|
} from "@prisma/client";
|
|
import { format } from "date-fns";
|
|
import { de } from "date-fns/locale";
|
|
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";
|
|
import { lockEvent } from "@/lib/event-lock";
|
|
import { canRequestNotdienstSwap } from "@/lib/notdienst-swaps";
|
|
|
|
// =====================================================================
|
|
// /dashboard/kalender · Server Actions
|
|
// =====================================================================
|
|
|
|
const terminSchema = z.object({
|
|
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(),
|
|
allDay: z.boolean().default(false),
|
|
mitbringselListEnabled: z.boolean().optional(),
|
|
requiresRsvp: z.boolean().optional(),
|
|
rsvpMode: z.nativeEnum(RsvpMode).optional(),
|
|
rsvpDeadline: z.string().datetime().nullable().optional(),
|
|
participantInfo: z.string().trim().max(1000).optional(),
|
|
});
|
|
|
|
const swapRequestSchema = z.object({
|
|
targetAssignmentId: z.string().min(1),
|
|
offeredAssignmentId: z.string().min(1),
|
|
message: z.string().trim().max(500).optional(),
|
|
});
|
|
|
|
const swapIdSchema = z.string().min(1);
|
|
|
|
const activeNotdienstAlertStatuses = [
|
|
NotdienstAlertStatus.PENDING,
|
|
NotdienstAlertStatus.CONFIRMED,
|
|
];
|
|
|
|
function getTodayUtcDateOnly() {
|
|
const today = new Date();
|
|
return new Date(
|
|
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
|
|
);
|
|
}
|
|
|
|
function getBaseUrl() {
|
|
return (
|
|
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000"
|
|
);
|
|
}
|
|
|
|
function formatNotdienstDate(date: Date) {
|
|
return format(date, "EEEE, dd. MMMM yyyy", { locale: de });
|
|
}
|
|
|
|
function revalidateCalendarAndNotdienst() {
|
|
try {
|
|
revalidatePath("/dashboard/kalender");
|
|
revalidatePath("/dashboard/notdienst/plan");
|
|
revalidatePath("/dashboard/notdienst");
|
|
revalidatePath("/dashboard");
|
|
} catch {
|
|
// Ignore if called outside Next.js context
|
|
}
|
|
}
|
|
|
|
type SwapAssignmentWithFamily = {
|
|
id: string;
|
|
childId: string;
|
|
date: Date;
|
|
plan: {
|
|
status: NotdienstPlanStatus;
|
|
};
|
|
child: {
|
|
familyId: string;
|
|
family: {
|
|
name: string;
|
|
users: {
|
|
email: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
}[];
|
|
};
|
|
};
|
|
alerts: {
|
|
id: string;
|
|
}[];
|
|
alarms?: { id: string }[];
|
|
};
|
|
|
|
async function getSwapAssignment(
|
|
assignmentId: string,
|
|
kitaId: string,
|
|
db: Prisma.TransactionClient = prisma,
|
|
): Promise<SwapAssignmentWithFamily | null> {
|
|
return db.notdienstAssignment.findFirst({
|
|
where: { id: assignmentId, kitaId },
|
|
select: {
|
|
id: true,
|
|
childId: true,
|
|
date: true,
|
|
plan: { select: { status: true } },
|
|
child: {
|
|
select: {
|
|
familyId: true,
|
|
family: {
|
|
select: {
|
|
name: true,
|
|
users: {
|
|
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
|
select: {
|
|
email: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
alerts: {
|
|
where: { alarmId: null, status: { in: activeNotdienstAlertStatuses } },
|
|
select: { id: true },
|
|
},
|
|
alarms: {
|
|
where: { activeAssignmentId: { not: null } },
|
|
select: { id: true },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function validateSwapAssignmentPair({
|
|
targetAssignment,
|
|
offeredAssignment,
|
|
requesterFamilyId,
|
|
today,
|
|
}: {
|
|
targetAssignment: SwapAssignmentWithFamily | null;
|
|
offeredAssignment: SwapAssignmentWithFamily | null;
|
|
requesterFamilyId: string;
|
|
today: Date;
|
|
}) {
|
|
if (!targetAssignment || !offeredAssignment) {
|
|
return "Notdienst nicht gefunden.";
|
|
}
|
|
|
|
if (targetAssignment.id === offeredAssignment.id) {
|
|
return "Bitte wähle zwei unterschiedliche Notdienste aus.";
|
|
}
|
|
|
|
if (
|
|
targetAssignment.plan.status !== NotdienstPlanStatus.PUBLISHED ||
|
|
offeredAssignment.plan.status !== NotdienstPlanStatus.PUBLISHED
|
|
) {
|
|
return "Es können nur veröffentlichte Notdienste getauscht werden.";
|
|
}
|
|
|
|
if (targetAssignment.date < today || offeredAssignment.date < today) {
|
|
return "Vergangene Notdienste können nicht getauscht werden.";
|
|
}
|
|
|
|
if (offeredAssignment.child.familyId !== requesterFamilyId) {
|
|
return "Du kannst nur eigene Notdienste als Tausch anbieten.";
|
|
}
|
|
|
|
if (targetAssignment.child.familyId === requesterFamilyId) {
|
|
return "Du kannst keinen Tausch mit deinem eigenen Haushalt anfragen.";
|
|
}
|
|
|
|
if (
|
|
targetAssignment.alerts.length > 0 ||
|
|
offeredAssignment.alerts.length > 0 ||
|
|
targetAssignment.alarms?.length ||
|
|
offeredAssignment.alarms?.length
|
|
) {
|
|
return "Notdienste mit aktiver Alarmierung können nicht getauscht werden.";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function sendSwapMail({
|
|
to,
|
|
parentName,
|
|
subject,
|
|
heading,
|
|
preview,
|
|
kitaName,
|
|
body,
|
|
targetDate,
|
|
offeredDate,
|
|
}: {
|
|
to: string;
|
|
parentName: string;
|
|
subject: string;
|
|
heading: string;
|
|
preview: string;
|
|
kitaName: string;
|
|
body: string;
|
|
targetDate: Date;
|
|
offeredDate: Date;
|
|
}) {
|
|
const result = await sendAppEmail({
|
|
to,
|
|
subject,
|
|
react: createElement(NotdienstSwapEmail, {
|
|
parentName,
|
|
heading,
|
|
preview,
|
|
kitaName,
|
|
body,
|
|
targetDateLabel: formatNotdienstDate(targetDate),
|
|
offeredDateLabel: formatNotdienstDate(offeredDate),
|
|
link: `${getBaseUrl()}/dashboard/kalender`,
|
|
}),
|
|
});
|
|
|
|
if (!result.success) {
|
|
console.error(
|
|
"Notdienst-Tausch-Mail konnte nicht versendet werden:",
|
|
result.error,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function notifyFamilyAboutSwap({
|
|
family,
|
|
subject,
|
|
heading,
|
|
preview,
|
|
kitaName,
|
|
body,
|
|
targetDate,
|
|
offeredDate,
|
|
}: {
|
|
family: SwapAssignmentWithFamily["child"]["family"];
|
|
subject: string;
|
|
heading: string;
|
|
preview: string;
|
|
kitaName: string;
|
|
body: string;
|
|
targetDate: Date;
|
|
offeredDate: Date;
|
|
}) {
|
|
await Promise.all(
|
|
family.users
|
|
.filter((parent) => !!parent.email)
|
|
.map((parent) =>
|
|
sendSwapMail({
|
|
to: parent.email,
|
|
parentName: `${parent.firstName} ${parent.lastName}`,
|
|
subject,
|
|
heading,
|
|
preview,
|
|
kitaName,
|
|
body,
|
|
targetDate,
|
|
offeredDate,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
function buildTerminData(
|
|
data: z.infer<typeof terminSchema>,
|
|
allowPast = false,
|
|
) {
|
|
const startDate = new Date(data.startDate);
|
|
const endDate = new Date(data.endDate);
|
|
const now = new Date();
|
|
now.setSeconds(0, 0);
|
|
|
|
if (!allowPast && startDate < now) {
|
|
return {
|
|
error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen.",
|
|
};
|
|
}
|
|
|
|
if (endDate < startDate) {
|
|
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
|
|
}
|
|
|
|
if (
|
|
data.requiresRsvp &&
|
|
data.rsvpDeadline &&
|
|
new Date(data.rsvpDeadline) > startDate
|
|
) {
|
|
return {
|
|
error: "Die Rückmeldefrist darf nicht nach dem Terminbeginn liegen.",
|
|
};
|
|
}
|
|
|
|
return {
|
|
data: {
|
|
title: data.title,
|
|
description: data.description || null,
|
|
type: data.type,
|
|
startDate,
|
|
endDate,
|
|
allDay: data.allDay,
|
|
mitbringselListEnabled: data.mitbringselListEnabled ?? false,
|
|
requiresRsvp: data.requiresRsvp ?? false,
|
|
rsvpMode: data.rsvpMode ?? RsvpMode.CHILD,
|
|
rsvpDeadline:
|
|
data.requiresRsvp && data.rsvpDeadline
|
|
? new Date(data.rsvpDeadline)
|
|
: null,
|
|
participantInfo:
|
|
data.requiresRsvp && data.participantInfo ? data.participantInfo : null,
|
|
},
|
|
};
|
|
}
|
|
|
|
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 (!canRequestNotdienstSwap(session.user.role, session.user.familyId)) {
|
|
return { error: "Nur Eltern mit einem eigenen Haushalt können Notdienst-Tausche anfragen." };
|
|
}
|
|
|
|
const parsed = swapRequestSchema.safeParse(rawPayload);
|
|
if (!parsed.success) {
|
|
return { error: "Ungültige Eingabedaten." };
|
|
}
|
|
|
|
const { targetAssignmentId, offeredAssignmentId, message } = parsed.data;
|
|
const today = getTodayUtcDateOnly();
|
|
|
|
try {
|
|
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) {
|
|
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.");
|
|
}
|
|
|
|
if (await tx.notdienstAlarm.count({ where: { kitaId: session.user.kitaId, activeAssignmentId: { in: [targetAssignment.id, offeredAssignment.id] } } })) throw new UserFacingError("Notdienste mit aktiver Alarmierung können nicht getauscht werden.");
|
|
|
|
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({
|
|
family: targetAssignment.child.family,
|
|
subject: `Notdienst-Tauschanfrage für ${formatNotdienstDate(targetAssignment.date)}`,
|
|
heading: "Neue Tauschanfrage",
|
|
preview: "Du hast eine neue Notdienst-Tauschanfrage.",
|
|
kitaName: kita.name,
|
|
body: `Ein Elternteil aus einem anderen Haushalt möchte einen Notdienst mit dir tauschen.${message ? ` Nachricht: ${message}` : ""}`,
|
|
targetDate: targetAssignment.date,
|
|
offeredDate: offeredAssignment.date,
|
|
});
|
|
|
|
revalidateCalendarAndNotdienst();
|
|
return { success: true };
|
|
} catch (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: publicActionError(error, "Tauschanfrage konnte nicht erstellt werden.") };
|
|
}
|
|
}
|
|
|
|
export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
|
const session = await requireKitaSession();
|
|
if (!session.user.familyId) {
|
|
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
|
}
|
|
|
|
const parsed = swapIdSchema.safeParse(requestIdRaw);
|
|
if (!parsed.success) {
|
|
return { error: "Ungültige Eingabedaten." };
|
|
}
|
|
|
|
const today = getTodayUtcDateOnly();
|
|
|
|
try {
|
|
const result = await serializableTransaction(async (tx) => {
|
|
const request = await tx.notdienstSwapRequest.findFirst({
|
|
where: {
|
|
id: parsed.data,
|
|
kitaId: session.user.kitaId,
|
|
status: NotdienstSwapStatus.PENDING,
|
|
},
|
|
select: {
|
|
id: true,
|
|
requesterFamilyId: true,
|
|
targetAssignmentId: true,
|
|
offeredAssignmentId: true,
|
|
targetAssignment: {
|
|
select: {
|
|
id: true,
|
|
childId: true,
|
|
date: true,
|
|
plan: { select: { status: true } },
|
|
child: {
|
|
select: {
|
|
familyId: true,
|
|
family: {
|
|
select: {
|
|
name: true,
|
|
users: {
|
|
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
|
select: { email: true, firstName: true, lastName: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
alerts: {
|
|
where: { status: { in: activeNotdienstAlertStatuses } },
|
|
select: { id: true },
|
|
},
|
|
},
|
|
},
|
|
offeredAssignment: {
|
|
select: {
|
|
id: true,
|
|
childId: true,
|
|
date: true,
|
|
plan: { select: { status: true } },
|
|
child: {
|
|
select: {
|
|
familyId: true,
|
|
family: {
|
|
select: {
|
|
name: true,
|
|
users: {
|
|
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
|
select: { email: true, firstName: true, lastName: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
alerts: {
|
|
where: { status: { in: activeNotdienstAlertStatuses } },
|
|
select: { id: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!request) {
|
|
throw new UserFacingError("Tauschanfrage nicht gefunden oder nicht mehr offen.");
|
|
}
|
|
|
|
if (request.targetAssignment.child.familyId !== session.user.familyId) {
|
|
throw new UserFacingError("Nur der angefragte Haushalt kann diese Anfrage annehmen.");
|
|
}
|
|
|
|
const validationError = validateSwapAssignmentPair({
|
|
targetAssignment: request.targetAssignment,
|
|
offeredAssignment: request.offeredAssignment,
|
|
requesterFamilyId: request.requesterFamilyId,
|
|
today,
|
|
});
|
|
if (validationError) {
|
|
throw new UserFacingError(validationError);
|
|
}
|
|
|
|
if (await tx.notdienstAlarm.count({ where: { kitaId: session.user.kitaId, activeAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } } })) throw new UserFacingError("Notdienste mit aktiver Alarmierung können nicht getauscht werden.");
|
|
|
|
await tx.notdienstAssignment.update({
|
|
where: { id: request.targetAssignmentId },
|
|
data: { childId: request.offeredAssignment.childId },
|
|
});
|
|
await tx.notdienstAssignment.update({
|
|
where: { id: request.offeredAssignmentId },
|
|
data: { childId: request.targetAssignment.childId },
|
|
});
|
|
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 },
|
|
kitaId: session.user.kitaId,
|
|
status: NotdienstSwapStatus.PENDING,
|
|
OR: [
|
|
{ targetAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } },
|
|
{ offeredAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } },
|
|
],
|
|
},
|
|
data: {
|
|
status: NotdienstSwapStatus.CANCELLED,
|
|
respondedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
return {
|
|
targetAssignment: request.targetAssignment,
|
|
offeredAssignment: request.offeredAssignment,
|
|
};
|
|
});
|
|
|
|
const kita = await prisma.kita.findUnique({
|
|
where: { id: session.user.kitaId },
|
|
select: { name: true },
|
|
});
|
|
if (kita) {
|
|
await Promise.all([
|
|
notifyFamilyAboutSwap({
|
|
family: result.targetAssignment.child.family,
|
|
subject: "Notdienst-Tausch bestätigt",
|
|
heading: "Tausch bestätigt",
|
|
preview: "Ein Notdienst-Tausch wurde bestätigt.",
|
|
kitaName: kita.name,
|
|
body: "die Tauschanfrage wurde angenommen. Der Notdienst-Plan wurde automatisch aktualisiert.",
|
|
targetDate: result.targetAssignment.date,
|
|
offeredDate: result.offeredAssignment.date,
|
|
}),
|
|
notifyFamilyAboutSwap({
|
|
family: result.offeredAssignment.child.family,
|
|
subject: "Notdienst-Tausch bestätigt",
|
|
heading: "Tausch bestätigt",
|
|
preview: "Ein Notdienst-Tausch wurde bestätigt.",
|
|
kitaName: kita.name,
|
|
body: "deine Tauschanfrage wurde angenommen. Der Notdienst-Plan wurde automatisch aktualisiert.",
|
|
targetDate: result.targetAssignment.date,
|
|
offeredDate: result.offeredAssignment.date,
|
|
}),
|
|
]);
|
|
}
|
|
|
|
revalidateCalendarAndNotdienst();
|
|
return { success: true };
|
|
} catch (error) {
|
|
return {
|
|
error: publicActionError(error, "Tauschanfrage konnte nicht angenommen werden."),
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
|
|
const session = await requireKitaSession();
|
|
if (!session.user.familyId) {
|
|
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
|
}
|
|
|
|
const parsed = swapIdSchema.safeParse(requestIdRaw);
|
|
if (!parsed.success) {
|
|
return { error: "Ungültige Eingabedaten." };
|
|
}
|
|
|
|
try {
|
|
const request = await prisma.notdienstSwapRequest.findFirst({
|
|
where: {
|
|
id: parsed.data,
|
|
kitaId: session.user.kitaId,
|
|
status: NotdienstSwapStatus.PENDING,
|
|
},
|
|
select: {
|
|
id: true,
|
|
targetAssignment: {
|
|
select: {
|
|
date: true,
|
|
child: {
|
|
select: {
|
|
familyId: true,
|
|
family: {
|
|
select: {
|
|
name: true,
|
|
users: {
|
|
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
|
select: { email: true, firstName: true, lastName: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
offeredAssignment: {
|
|
select: {
|
|
date: true,
|
|
child: {
|
|
select: {
|
|
family: {
|
|
select: {
|
|
name: true,
|
|
users: {
|
|
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
|
select: { email: true, firstName: true, lastName: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!request) {
|
|
return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." };
|
|
}
|
|
if (request.targetAssignment.child.familyId !== session.user.familyId) {
|
|
return {
|
|
error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen.",
|
|
};
|
|
}
|
|
|
|
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 },
|
|
select: { name: true },
|
|
});
|
|
if (kita) {
|
|
await Promise.all([
|
|
notifyFamilyAboutSwap({
|
|
family: request.targetAssignment.child.family,
|
|
subject: "Notdienst-Tauschanfrage abgelehnt",
|
|
heading: "Tauschanfrage abgelehnt",
|
|
preview: "Eine Notdienst-Tauschanfrage wurde abgelehnt.",
|
|
kitaName: kita.name,
|
|
body: "du hast die Tauschanfrage abgelehnt. Der Notdienst-Plan bleibt unverändert.",
|
|
targetDate: request.targetAssignment.date,
|
|
offeredDate: request.offeredAssignment.date,
|
|
}),
|
|
notifyFamilyAboutSwap({
|
|
family: request.offeredAssignment.child.family,
|
|
subject: "Notdienst-Tauschanfrage abgelehnt",
|
|
heading: "Tauschanfrage abgelehnt",
|
|
preview: "Eine Notdienst-Tauschanfrage wurde abgelehnt.",
|
|
kitaName: kita.name,
|
|
body: "deine Tauschanfrage wurde abgelehnt. Der Notdienst-Plan bleibt unverändert.",
|
|
targetDate: request.targetAssignment.date,
|
|
offeredDate: request.offeredAssignment.date,
|
|
}),
|
|
]);
|
|
}
|
|
|
|
revalidateCalendarAndNotdienst();
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Fehler beim Ablehnen der Notdienst-Tauschanfrage:", error);
|
|
return { error: "Tauschanfrage konnte nicht abgelehnt werden." };
|
|
}
|
|
}
|
|
|
|
export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) {
|
|
const session = await requireKitaSession();
|
|
if (!session.user.familyId) {
|
|
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
|
}
|
|
|
|
const parsed = swapIdSchema.safeParse(requestIdRaw);
|
|
if (!parsed.success) {
|
|
return { error: "Ungültige Eingabedaten." };
|
|
}
|
|
|
|
try {
|
|
const request = await prisma.notdienstSwapRequest.findFirst({
|
|
where: {
|
|
id: parsed.data,
|
|
kitaId: session.user.kitaId,
|
|
requesterFamilyId: session.user.familyId,
|
|
status: NotdienstSwapStatus.PENDING,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
if (!request) {
|
|
return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." };
|
|
}
|
|
|
|
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 };
|
|
} catch (error) {
|
|
console.error(
|
|
"Fehler beim Zurückziehen der Notdienst-Tauschanfrage:",
|
|
error,
|
|
);
|
|
return { error: "Tauschanfrage konnte nicht zurückgezogen werden." };
|
|
}
|
|
}
|
|
|
|
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) {
|
|
return { error: "Ungültige Eingabedaten." };
|
|
}
|
|
|
|
const data = parsed.data;
|
|
const terminData = buildTerminData(data);
|
|
|
|
if ("error" in terminData) {
|
|
return { error: terminData.error };
|
|
}
|
|
|
|
try {
|
|
await prisma.termin.create({
|
|
data: {
|
|
kitaId: session.user.kitaId,
|
|
createdById: session.user.id,
|
|
title: terminData.data.title,
|
|
description: terminData.data.description,
|
|
type: terminData.data.type,
|
|
startDate: terminData.data.startDate,
|
|
endDate: terminData.data.endDate,
|
|
allDay: terminData.data.allDay,
|
|
status: TerminStatus.PENDING,
|
|
},
|
|
});
|
|
|
|
revalidatePath("/dashboard/kalender");
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Fehler beim Erstellen der Terminanfrage:", error);
|
|
return { error: "Ein Fehler ist aufgetreten." };
|
|
}
|
|
}
|
|
|
|
export async function createTerminAdmin(rawPayload: unknown) {
|
|
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
|
const kitaId = session.user.kitaId;
|
|
if (!kitaId) {
|
|
return { error: "Kein Mandant zugeordnet." };
|
|
}
|
|
|
|
const parsed = terminSchema.safeParse(rawPayload);
|
|
if (!parsed.success) {
|
|
return { error: "Ungültige Eingabedaten." };
|
|
}
|
|
|
|
const data = parsed.data;
|
|
const terminData = buildTerminData(data);
|
|
|
|
if ("error" in terminData) {
|
|
return { error: terminData.error };
|
|
}
|
|
|
|
try {
|
|
await prisma.termin.create({
|
|
data: {
|
|
kitaId,
|
|
createdById: session.user.id,
|
|
...terminData.data,
|
|
status: TerminStatus.CONFIRMED,
|
|
approvedById: session.user.id,
|
|
approvedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
revalidatePath("/dashboard/kalender");
|
|
revalidatePath("/dashboard/admin/kalender");
|
|
revalidatePath("/dashboard");
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Fehler beim Erstellen des Termins:", error);
|
|
return { error: "Ein Fehler ist aufgetreten." };
|
|
}
|
|
}
|
|
|
|
export async function updateTerminAdmin(
|
|
terminId: string,
|
|
rawPayload: unknown,
|
|
): Promise<{ error?: string; success?: boolean }> {
|
|
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
|
const kitaId = session.user.kitaId;
|
|
|
|
if (!kitaId) {
|
|
return { error: "Kein Mandant zugeordnet." };
|
|
}
|
|
|
|
const parsed = terminSchema.safeParse(rawPayload);
|
|
if (!parsed.success) {
|
|
return { error: "Ungültige Eingabedaten." };
|
|
}
|
|
|
|
try {
|
|
const result = await prisma.$transaction(async (tx) => {
|
|
await lockEvent(tx, terminId, kitaId);
|
|
const existing = await tx.termin.findFirst({
|
|
where: { id: terminId, kitaId },
|
|
include: {
|
|
_count: {
|
|
select: { participations: true, familyParticipations: true },
|
|
},
|
|
},
|
|
});
|
|
if (!existing) return { error: "Termin wurde nicht gefunden." };
|
|
const data = {
|
|
...parsed.data,
|
|
mitbringselListEnabled:
|
|
parsed.data.mitbringselListEnabled ?? existing.mitbringselListEnabled,
|
|
requiresRsvp: parsed.data.requiresRsvp ?? existing.requiresRsvp,
|
|
rsvpMode: parsed.data.rsvpMode ?? existing.rsvpMode,
|
|
rsvpDeadline:
|
|
parsed.data.rsvpDeadline === undefined
|
|
? (existing.rsvpDeadline?.toISOString() ?? null)
|
|
: parsed.data.rsvpDeadline,
|
|
participantInfo:
|
|
parsed.data.participantInfo ?? existing.participantInfo ?? "",
|
|
};
|
|
if (
|
|
data.rsvpMode !== existing.rsvpMode &&
|
|
existing._count.participations + existing._count.familyParticipations >
|
|
0
|
|
) {
|
|
return {
|
|
error:
|
|
"Die Antwortart kann nach der ersten Rückmeldung nicht mehr geändert werden.",
|
|
};
|
|
}
|
|
const terminData = buildTerminData(data, true);
|
|
if ("error" in terminData) return { error: terminData.error };
|
|
await tx.termin.update({
|
|
where: { id: terminId, kitaId },
|
|
data: terminData.data,
|
|
});
|
|
return { success: true };
|
|
});
|
|
if (result.error) return result;
|
|
|
|
revalidatePath("/dashboard/kalender");
|
|
revalidatePath("/dashboard/admin/kalender");
|
|
revalidatePath("/dashboard");
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Fehler beim Aktualisieren des Termins:", error);
|
|
return { error: "Termin konnte nicht aktualisiert werden." };
|
|
}
|
|
}
|
|
|
|
export async function approveTermin(terminId: string) {
|
|
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
|
const kitaId = session.user.kitaId;
|
|
if (!kitaId) {
|
|
return { error: "Kein Mandant zugeordnet." };
|
|
}
|
|
|
|
try {
|
|
await prisma.termin.update({
|
|
where: {
|
|
id: terminId,
|
|
kitaId,
|
|
},
|
|
data: {
|
|
status: TerminStatus.CONFIRMED,
|
|
approvedById: session.user.id,
|
|
approvedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
revalidatePath("/dashboard/kalender");
|
|
revalidatePath("/dashboard/admin/kalender");
|
|
revalidatePath("/dashboard");
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Fehler beim Bestätigen des Termins:", error);
|
|
return { error: "Ein Fehler ist aufgetreten." };
|
|
}
|
|
}
|
|
|
|
export async function rejectTermin(terminId: string, reason?: string) {
|
|
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
|
const kitaId = session.user.kitaId;
|
|
if (!kitaId) {
|
|
return { error: "Kein Mandant zugeordnet." };
|
|
}
|
|
|
|
try {
|
|
await prisma.termin.update({
|
|
where: {
|
|
id: terminId,
|
|
kitaId,
|
|
},
|
|
data: {
|
|
status: TerminStatus.REJECTED,
|
|
rejectionReason: reason,
|
|
approvedById: session.user.id,
|
|
approvedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
revalidatePath("/dashboard/kalender");
|
|
revalidatePath("/dashboard/admin/kalender");
|
|
revalidatePath("/dashboard");
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Fehler beim Ablehnen des Termins:", error);
|
|
return { error: "Ein Fehler ist aufgetreten." };
|
|
}
|
|
}
|
|
|
|
export async function toggleMitbringselList(
|
|
terminId: string,
|
|
enabled: boolean,
|
|
) {
|
|
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
|
const kitaId = session.user.kitaId;
|
|
if (!kitaId) {
|
|
return { error: "Kein Mandant zugeordnet." };
|
|
}
|
|
|
|
try {
|
|
await prisma.termin.update({
|
|
where: {
|
|
id: terminId,
|
|
kitaId,
|
|
},
|
|
data: {
|
|
mitbringselListEnabled: enabled,
|
|
},
|
|
});
|
|
|
|
revalidatePath("/dashboard/kalender");
|
|
revalidatePath("/dashboard/admin/kalender");
|
|
revalidatePath("/dashboard");
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Fehler beim Umschalten der Mitbring-Liste:", error);
|
|
return { error: "Ein Fehler ist aufgetreten." };
|
|
}
|
|
}
|
|
|
|
const mitbringselSchema = z.object({
|
|
terminId: z.string(),
|
|
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) {
|
|
return { error: "Ungültige Eingabedaten." };
|
|
}
|
|
|
|
const { terminId, content } = parsed.data;
|
|
|
|
try {
|
|
// Verify termin is active and list is enabled
|
|
const termin = await prisma.termin.findUnique({
|
|
where: { id: terminId, kitaId: session.user.kitaId },
|
|
});
|
|
|
|
if (
|
|
!termin ||
|
|
termin.status !== TerminStatus.CONFIRMED ||
|
|
!termin.mitbringselListEnabled
|
|
) {
|
|
return { error: "Mitbring-Liste ist für diesen Termin nicht aktiv." };
|
|
}
|
|
|
|
await prisma.mitbringselItem.create({
|
|
data: {
|
|
kitaId: session.user.kitaId,
|
|
terminId: terminId,
|
|
userId: session.user.id,
|
|
content: content,
|
|
},
|
|
});
|
|
|
|
revalidatePath("/dashboard/kalender");
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Fehler beim Hinzufügen des Eintrags:", error);
|
|
return { error: "Ein Fehler ist aufgetreten." };
|
|
}
|
|
}
|
|
|
|
const updateMitbringselSchema = z.object({
|
|
itemId: z.string().min(1).max(100),
|
|
content: mitbringselSchema.shape.content,
|
|
});
|
|
|
|
export async function updateMitbringsel(rawPayload: unknown) {
|
|
const session = await requireKitaSession();
|
|
const parsed = updateMitbringselSchema.safeParse(rawPayload);
|
|
if (!parsed.success) return { error: "Bitte einen Beitrag mit 1 bis 500 Zeichen eingeben." };
|
|
if (!(await rateLimit(`updateMitbringsel:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) {
|
|
return { error: "Zu viele Änderungen. Bitte versuche es später erneut." };
|
|
}
|
|
|
|
try {
|
|
await serializableTransaction(async (tx) => {
|
|
const item = await tx.mitbringselItem.findFirst({
|
|
where: { id: parsed.data.itemId, kitaId: session.user.kitaId, userId: session.user.id },
|
|
select: { terminId: true },
|
|
});
|
|
if (!item) throw new UserFacingError("Du kannst nur eigene Mitbringbeiträge bearbeiten.");
|
|
await lockEvent(tx, item.terminId, session.user.kitaId);
|
|
const termin = await tx.termin.findFirst({
|
|
where: { id: item.terminId, kitaId: session.user.kitaId, status: TerminStatus.CONFIRMED, mitbringselListEnabled: true },
|
|
select: { id: true },
|
|
});
|
|
if (!termin) throw new UserFacingError("Die Mitbring-Liste ist für diesen Termin nicht mehr aktiv.");
|
|
await tx.mitbringselItem.update({
|
|
where: { id: parsed.data.itemId, kitaId: session.user.kitaId, userId: session.user.id },
|
|
data: { content: parsed.data.content },
|
|
});
|
|
});
|
|
revalidatePath("/dashboard/kalender");
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { error: publicActionError(error, "Der Beitrag konnte nicht gespeichert werden.") };
|
|
}
|
|
}
|
|
|
|
export async function deleteMitbringsel(itemId: string) {
|
|
const session = await requireKitaSession();
|
|
|
|
try {
|
|
const item = await prisma.mitbringselItem.findUnique({
|
|
where: { id: itemId, kitaId: session.user.kitaId },
|
|
});
|
|
|
|
if (!item) {
|
|
return { error: "Eintrag nicht gefunden." };
|
|
}
|
|
|
|
// Admins/Koords can delete any item. Users can only delete their own.
|
|
if (
|
|
session.user.role !== UserRole.ADMIN &&
|
|
session.user.role !== UserRole.KOORDINATOR &&
|
|
item.userId !== session.user.id
|
|
) {
|
|
return { error: "Keine Berechtigung diesen Eintrag zu löschen." };
|
|
}
|
|
|
|
await prisma.mitbringselItem.delete({
|
|
where: { id: itemId },
|
|
});
|
|
|
|
revalidatePath("/dashboard/kalender");
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Fehler beim Löschen des Eintrags:", error);
|
|
return { error: "Ein Fehler ist aufgetreten." };
|
|
}
|
|
}
|
|
|
|
export async function deleteTerminAdmin(terminId: string) {
|
|
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
|
const kitaId = session.user.kitaId;
|
|
if (!kitaId) {
|
|
return { error: "Kein Mandant zugeordnet." };
|
|
}
|
|
|
|
try {
|
|
await prisma.termin.delete({
|
|
where: {
|
|
id: terminId,
|
|
kitaId,
|
|
},
|
|
});
|
|
|
|
revalidatePath("/dashboard/kalender");
|
|
revalidatePath("/dashboard/admin/kalender");
|
|
revalidatePath("/dashboard");
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Fehler beim Löschen des Termins:", error);
|
|
return { error: "Termin konnte nicht gelöscht werden." };
|
|
}
|
|
}
|