298 lines
8.0 KiB
TypeScript
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);
|
|
}
|
|
}
|