Files
Calbook/app/api/admin/termine/route.ts

298 lines
8.0 KiB
TypeScript

export const dynamic = "force-dynamic";
import { parseISO } from "date-fns";
import { z } from "zod";
import { requireAdmin } from "@/lib/auth/session";
import { handleAuthError, fail, ok } from "@/lib/api";
import { prisma } from "@/lib/prisma";
import { appointmentsFilterSchema } from "@/lib/validators/admin";
import { sendCancellationEmails } from "@/lib/email/mailer";
import { getSetting } from "@/lib/settings";
import { SETTING_KEYS } from "@/lib/constants";
import { deleteEventInCaldav } from "@/lib/services/caldav";
import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request";
const patchSchema = z
.object({
id: z.string().min(1).max(128),
status: z.enum(["CONFIRMED", "CANCELLED"]).optional(),
noShow: z.boolean().optional()
})
.refine((data) => Boolean(data.status) || typeof data.noShow === "boolean", {
message: "status oder noShow ist erforderlich",
path: ["status"]
});
function resolveNotificationEmail(staff: {
email: string;
calendars: Array<{ notificationEmail: string | null }>;
}) {
const direct = staff.calendars
.map((entry) => entry.notificationEmail?.trim() ?? "")
.find((value) => value.length > 0);
return direct ?? staff.email;
}
async function deleteCalendarEventsForAppointments(
appointments: Array<{
id: string;
staffId: string;
calendarEventUid: string | null;
startAt: Date;
endAt: Date;
}>
) {
const deletedAppointmentIds = (
await Promise.all(
appointments.map(async (appointment) => {
if (!appointment.calendarEventUid) return null;
const deleted = await deleteEventInCaldav(appointment.staffId, {
eventUid: appointment.calendarEventUid,
startAt: appointment.startAt,
endAt: appointment.endAt
});
return deleted ? appointment.id : null;
})
)
).filter((id): id is string => Boolean(id));
if (deletedAppointmentIds.length > 0) {
await prisma.appointment.updateMany({
where: {
id: {
in: deletedAppointmentIds
}
},
data: {
calendarEventUid: null
}
});
}
}
export async function GET(req: Request) {
try {
await requireAdmin();
const url = new URL(req.url);
const parsed = appointmentsFilterSchema.safeParse({
mitarbeiterId: url.searchParams.get("mitarbeiterId") ?? undefined,
status: url.searchParams.get("status") ?? undefined,
noShow: url.searchParams.get("noShow") ?? undefined,
q: url.searchParams.get("q") ?? undefined,
von: url.searchParams.get("von") ?? undefined,
bis: url.searchParams.get("bis") ?? undefined
});
if (!parsed.success) {
return fail("Ungültige Filter", 400, parsed.error.flatten());
}
const { mitarbeiterId, status, noShow, q, von, bis } = parsed.data;
const termine = await prisma.appointment.findMany({
where: {
...(mitarbeiterId ? { staffId: mitarbeiterId } : {}),
...(status ? { status } : {}),
...(noShow === "true"
? { noShowAt: { not: null } }
: noShow === "false"
? { noShowAt: null }
: {}),
...(q
? {
OR: [
{ customerFirstName: { contains: q, mode: "insensitive" } },
{ customerLastName: { contains: q, mode: "insensitive" } },
{ customerEmail: { contains: q, mode: "insensitive" } }
]
}
: {}),
...(von || bis
? {
startAt: {
...(von ? { gte: parseISO(von) } : {}),
...(bis ? { lte: parseISO(bis) } : {})
}
}
: {})
},
include: {
staff: {
select: {
id: true,
name: true,
email: true,
slug: true
}
}
},
orderBy: { startAt: "asc" }
});
return ok({ termine });
} catch (error) {
return handleAuthError(error);
}
}
export async function PATCH(req: Request) {
try {
const originError = validateMutationRequestOrigin(req);
if (originError) return originError;
await requireAdmin();
const bodyResult = await readJsonBody(req, { maxBytes: 32 * 1024 });
if (!bodyResult.ok) return bodyResult.response;
const parsedBody = patchSchema.safeParse(bodyResult.data);
if (!parsedBody.success) {
return fail("Ungültige Eingaben", 400, parsedBody.error.flatten());
}
const { id, status, noShow } = parsedBody.data;
const appointment = await prisma.appointment.findUnique({
where: { id },
include: { staff: true }
});
if (!appointment) return fail("Termin nicht gefunden", 404);
if (typeof noShow === "boolean") {
const targetAppointments = await prisma.appointment.findMany({
where: appointment.bookingGroupId
? {
bookingGroupId: appointment.bookingGroupId,
status: "CONFIRMED"
}
: {
id: appointment.id,
status: "CONFIRMED"
},
select: {
id: true
}
});
if (targetAppointments.length === 0) {
return fail("Kein bestätigter Termin für No-Show-Markierung gefunden", 409);
}
await prisma.appointment.updateMany({
where: {
id: {
in: targetAppointments.map((item) => item.id)
}
},
data: {
noShowAt: noShow ? new Date() : null
}
});
const refreshed = await prisma.appointment.findUnique({
where: { id: appointment.id },
include: {
staff: {
select: {
id: true,
name: true,
email: true,
slug: true
}
}
}
});
return ok({
termin: refreshed,
betroffen: targetAppointments.length
});
}
const targetAppointments = await prisma.appointment.findMany({
where: appointment.bookingGroupId
? {
bookingGroupId: appointment.bookingGroupId,
status: "CONFIRMED"
}
: {
id: appointment.id,
status: "CONFIRMED"
},
include: {
staff: {
select: {
name: true,
email: true,
calendars: {
select: {
notificationEmail: true
},
orderBy: {
createdAt: "asc"
}
}
}
}
}
});
if (targetAppointments.length === 0) {
return fail("Termin ist bereits storniert", 409);
}
await prisma.appointment.updateMany({
where: {
id: {
in: targetAppointments.map((item) => item.id)
}
},
data: {
status,
cancelledAt: status === "CANCELLED" ? new Date() : null,
...(status === "CANCELLED" ? { noShowAt: null } : {})
}
});
const updated = targetAppointments[0]!;
if (status === "CANCELLED") {
await deleteCalendarEventsForAppointments(
targetAppointments.map((item) => ({
id: item.id,
staffId: item.staffId,
calendarEventUid: item.calendarEventUid,
startAt: item.startAt,
endAt: item.endAt
}))
);
const companyName = await getSetting(SETTING_KEYS.COMPANY_NAME);
try {
await sendCancellationEmails({
customerEmail: updated.customerEmail,
customerName: `${updated.customerFirstName} ${updated.customerLastName}`,
staffList: targetAppointments.map((item) => ({
name: item.staff.name,
email: resolveNotificationEmail(item.staff)
})),
date: updated.startAt,
companyName
});
} catch (error) {
// eslint-disable-next-line no-console
console.error("[calbook] sendCancellationEmails(Admin) fehlgeschlagen", error);
}
}
return ok({
termin: updated,
betroffen: targetAppointments.length
});
} catch (error) {
return handleAuthError(error);
}
}