419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { APIError } from "payload";
|
|
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig, Where } from "payload";
|
|
import { isAdmin, isAdminFieldLevel } from "@/access";
|
|
import { combineDateAndTime } from "@/lib/meeting/status";
|
|
import { acquireBookingDayLock } from "@/lib/booking/lock";
|
|
import {
|
|
alternativeProposedEmail,
|
|
bookingCancelledAdminEmail,
|
|
bookingConfirmedEmail,
|
|
bookingRejectedEmail,
|
|
bookingRequestReceivedEmail,
|
|
newBookingRequestAdminEmail,
|
|
} from "@/lib/email/bookingTemplates";
|
|
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
|
import type { Customer, Event, Offer } from "@/payload-types";
|
|
import { berlinDateKey, formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
|
|
|
|
export const APPOINTMENT_TYPES = [
|
|
{ label: "Vor Ort", value: "onsite" },
|
|
{ label: "Online", value: "online" },
|
|
] as const;
|
|
|
|
export const BOOKING_STATUSES = [
|
|
{ label: "Ausstehend", value: "pending" },
|
|
{ label: "Bestätigt", value: "confirmed" },
|
|
{ label: "Abgelehnt", value: "rejected" },
|
|
{ label: "Storniert", value: "cancelled" },
|
|
] as const;
|
|
|
|
function dateKey(iso: string): string {
|
|
return berlinDateKey(new Date(iso));
|
|
}
|
|
|
|
function fmtDate(iso: string): string {
|
|
return formatBerlinDate(new Date(iso), { day: "2-digit", month: "long", year: "numeric" });
|
|
}
|
|
|
|
function fmtTime(iso: string): string {
|
|
return formatBerlinTime(new Date(iso), { hour: "2-digit", minute: "2-digit" });
|
|
}
|
|
|
|
const appointmentTypeLabel = (value: string) =>
|
|
APPOINTMENT_TYPES.find((t) => t.value === value)?.label ?? value;
|
|
|
|
async function notifyAdmins(
|
|
req: Parameters<CollectionAfterChangeHook>[0]["req"],
|
|
template: { subject: string; html: string },
|
|
) {
|
|
const { docs: admins } = await req.payload.find({ collection: "users", limit: 50, req });
|
|
await Promise.all(admins.filter((admin) => admin.email).map((admin) => sendEmail(admin.email, template)));
|
|
}
|
|
|
|
// Normalize Payload time-only inputs before conflict checks and synchronize
|
|
// the private meeting whenever a booking is moved, cancelled or reactivated.
|
|
export const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req }) => {
|
|
const status = data.status ?? originalDoc?.status ?? "pending";
|
|
const date = data.date ?? originalDoc?.date;
|
|
const startTime = data.startTime ?? originalDoc?.startTime;
|
|
const endTime = data.endTime ?? originalDoc?.endTime;
|
|
const appointmentType = data.appointmentType ?? originalDoc?.appointmentType;
|
|
if (!date || !startTime || !endTime) return data;
|
|
const start = combineDateAndTime(date, startTime);
|
|
const end = combineDateAndTime(date, endTime);
|
|
if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()) || end <= start) {
|
|
throw new APIError("Das Ende muss nach dem Beginn am selben Tag liegen.", 400, undefined, true);
|
|
}
|
|
data.date = start.toISOString();
|
|
data.startTime = start.toISOString();
|
|
data.endTime = end.toISOString();
|
|
|
|
const alternative = data.proposedAlternative;
|
|
if (alternative && (alternative.date || alternative.startTime || alternative.endTime)) {
|
|
if (!alternative.date || !alternative.startTime || !alternative.endTime) {
|
|
throw new APIError("Bitte den alternativen Termin vollständig angeben.", 400, undefined, true);
|
|
}
|
|
const altStart = combineDateAndTime(alternative.date, alternative.startTime);
|
|
const altEnd = combineDateAndTime(alternative.date, alternative.endTime);
|
|
if (!Number.isFinite(altStart.getTime()) || !Number.isFinite(altEnd.getTime()) || altEnd <= altStart) {
|
|
throw new APIError("Der alternative Zeitraum ist ungültig.", 400, undefined, true);
|
|
}
|
|
data.proposedAlternative = { date: altStart.toISOString(), startTime: altStart.toISOString(), endTime: altEnd.toISOString() };
|
|
}
|
|
|
|
if (status === "confirmed") {
|
|
await acquireBookingDayLock(req, dateKey(data.date));
|
|
const conflictClauses: Where[] = [
|
|
{ status: { equals: "confirmed" } },
|
|
{ startTime: { less_than: data.endTime } },
|
|
{ endTime: { greater_than: data.startTime } },
|
|
];
|
|
if (originalDoc?.id) conflictClauses.push({ id: { not_equals: originalDoc.id } });
|
|
const { totalDocs } = await req.payload.count({ collection: "booking-requests", where: { and: conflictClauses }, req });
|
|
if (totalDocs) throw new APIError("Dieser Zeitraum ist bereits mit einem bestätigten Termin belegt.", 409, undefined, true);
|
|
}
|
|
|
|
const linked = data.linkedEvent ?? originalDoc?.linkedEvent;
|
|
const linkedId = typeof linked === "object" ? linked?.id : linked;
|
|
if (linkedId) {
|
|
const existing = await req.payload.findByID({ collection: "events", id: linkedId, depth: 0, req });
|
|
if (existing.isPrivateBooking) {
|
|
const isOnline = status === "confirmed" && appointmentType === "online";
|
|
if (existing.date !== data.date || existing.startTime !== data.startTime || existing.endTime !== data.endTime || existing.isOnline !== isOnline) {
|
|
await req.payload.update({ collection: "events", id: existing.id, req, data: {
|
|
date: data.date, startTime: data.startTime, endTime: data.endTime, isOnline,
|
|
} });
|
|
}
|
|
}
|
|
} else if (status === "confirmed" && appointmentType === "online") {
|
|
const relatedOffer = data.offer ?? originalDoc?.offer;
|
|
const offer = await req.payload.findByID({ collection: "offers", id: typeof relatedOffer === "object" ? relatedOffer.id : relatedOffer, req });
|
|
const event = await req.payload.create({ collection: "events", req, draft: true, data: {
|
|
title: offer.title,
|
|
slug: `buchung-${randomUUID()}`,
|
|
date: data.date, startTime: data.startTime, endTime: data.endTime,
|
|
category: "sonstiges", isOnline: true, isPrivateBooking: true,
|
|
...(originalDoc?.id ? { bookingRequest: originalDoc.id } : {}),
|
|
} });
|
|
data.linkedEvent = event.id;
|
|
}
|
|
return data;
|
|
};
|
|
|
|
const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, operation, req }) => {
|
|
try {
|
|
const user = (doc.user && typeof doc.user === "object" ? doc.user : await req.payload.findByID({
|
|
collection: "customers",
|
|
id: doc.user,
|
|
req,
|
|
})) as Customer;
|
|
const offer = (doc.offer && typeof doc.offer === "object" ? doc.offer : await req.payload.findByID({
|
|
collection: "offers",
|
|
id: doc.offer,
|
|
req,
|
|
})) as Offer;
|
|
|
|
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
|
|
const adminUrl = `${serverUrl}/admin/collections/booking-requests/${doc.id}`;
|
|
const accountUrl = `${serverUrl}/konto/termine`;
|
|
|
|
// A brand-new request (the normal customer-initiated path) always gets
|
|
// the "received" + admin-notification pair. A booking Anna creates
|
|
// manually as already-confirmed/rejected (operation 26: manual bookings)
|
|
// skips this and falls through to the same status-based emails below.
|
|
if (operation === "create" && doc.status === "pending") {
|
|
await sendEmail(
|
|
user.email,
|
|
bookingRequestReceivedEmail({
|
|
name: user.name,
|
|
offerTitle: offer.title,
|
|
dateLabel: fmtDate(doc.date),
|
|
timeLabel: fmtTime(doc.startTime),
|
|
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
|
|
}),
|
|
);
|
|
await notifyAdmins(
|
|
req,
|
|
newBookingRequestAdminEmail({
|
|
customerName: user.name,
|
|
customerEmail: user.email,
|
|
offerTitle: offer.title,
|
|
dateLabel: fmtDate(doc.date),
|
|
timeLabel: fmtTime(doc.startTime),
|
|
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
|
|
adminUrl,
|
|
}),
|
|
);
|
|
return doc;
|
|
}
|
|
|
|
const statusChanged = operation === "create" || previousDoc?.status !== doc.status;
|
|
|
|
const scheduleChanged = previousDoc && ["date", "startTime", "endTime", "appointmentType"].some((field) => doc[field] !== previousDoc[field]);
|
|
if ((statusChanged || scheduleChanged) && doc.status === "confirmed") {
|
|
let online: { joinUrl: string; password: string } | undefined;
|
|
let onsite: { address: string; mapsUrl: string } | undefined;
|
|
|
|
if (doc.appointmentType === "online" && doc.linkedEvent) {
|
|
const event = (typeof doc.linkedEvent === "object" ? doc.linkedEvent : await req.payload.findByID({
|
|
collection: "events",
|
|
id: doc.linkedEvent,
|
|
req,
|
|
})) as Event;
|
|
online = {
|
|
joinUrl: `${serverUrl}/termine/${event.slug}/beitreten`,
|
|
password: event.meetingPassword || "-",
|
|
};
|
|
} else if (doc.appointmentType === "onsite") {
|
|
const settings = await req.payload.findGlobal({ slug: "booking-settings", req });
|
|
const addressParts = [settings.locationName, settings.street, [settings.postalCode, settings.city].filter(Boolean).join(" ")].filter(Boolean);
|
|
const address = addressParts.join(", ");
|
|
onsite = { address, mapsUrl: `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}` };
|
|
}
|
|
|
|
await sendEmail(
|
|
user.email,
|
|
bookingConfirmedEmail({
|
|
name: user.name,
|
|
offerTitle: offer.title,
|
|
dateLabel: fmtDate(doc.date),
|
|
timeLabel: `${fmtTime(doc.startTime)} - ${fmtTime(doc.endTime)}`,
|
|
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
|
|
online,
|
|
onsite,
|
|
}),
|
|
);
|
|
} else if (statusChanged && doc.status === "rejected") {
|
|
await sendEmail(
|
|
user.email,
|
|
bookingRejectedEmail({
|
|
name: user.name,
|
|
offerTitle: offer.title,
|
|
dateLabel: fmtDate(doc.date),
|
|
timeLabel: fmtTime(doc.startTime),
|
|
}),
|
|
);
|
|
} else if (statusChanged && doc.status === "cancelled") {
|
|
await notifyAdmins(
|
|
req,
|
|
bookingCancelledAdminEmail({
|
|
customerName: user.name,
|
|
offerTitle: offer.title,
|
|
dateLabel: fmtDate(doc.date),
|
|
timeLabel: fmtTime(doc.startTime),
|
|
adminUrl,
|
|
}),
|
|
);
|
|
}
|
|
|
|
const proposedNew = doc.proposedAlternative?.date && JSON.stringify(doc.proposedAlternative) !== JSON.stringify(previousDoc?.proposedAlternative);
|
|
if (proposedNew && doc.status === "pending") {
|
|
await sendEmail(
|
|
user.email,
|
|
alternativeProposedEmail({
|
|
name: user.name,
|
|
offerTitle: offer.title,
|
|
originalDateLabel: fmtDate(doc.date),
|
|
originalTimeLabel: fmtTime(doc.startTime),
|
|
altDateLabel: fmtDate(doc.proposedAlternative.date),
|
|
altTimeLabel: fmtTime(doc.proposedAlternative.startTime),
|
|
accountUrl,
|
|
}),
|
|
);
|
|
}
|
|
} catch (err) {
|
|
// Email delivery must never break the booking write itself.
|
|
req.payload.logger.error({ err, msg: "booking-requests notifyByEmail failed" });
|
|
}
|
|
|
|
return doc;
|
|
};
|
|
|
|
export const BookingRequests: CollectionConfig = {
|
|
slug: "booking-requests",
|
|
labels: {
|
|
singular: "Buchungsanfrage",
|
|
plural: "Buchungsanfragen",
|
|
},
|
|
admin: {
|
|
useAsTitle: "id",
|
|
defaultColumns: ["offer", "user", "date", "startTime", "status"],
|
|
group: "Buchungen",
|
|
description: "Terminanfragen von Nutzern - bestätigen, ablehnen oder einen alternativen Termin vorschlagen, indem du die Felder unten änderst und speicherst.",
|
|
},
|
|
access: {
|
|
// Customer-initiated writes always go through the vetted /api/booking/*
|
|
// routes (Local API, elevated access, after the route verifies
|
|
// ownership) - never directly through REST/GraphQL. This keeps a
|
|
// customer from ever reading/writing another customer's booking, or
|
|
// setting their own status to "confirmed" directly.
|
|
create: isAdmin,
|
|
read: ({ req }) => {
|
|
if (req.user?.collection === "users") return req.user.role === "admin";
|
|
if (req.user?.collection === "customers") return { user: { equals: req.user.id } };
|
|
return false;
|
|
},
|
|
update: isAdmin,
|
|
delete: isAdmin,
|
|
},
|
|
hooks: {
|
|
beforeOperation: [async ({ args, operation, req }) => {
|
|
if (operation === "create" || operation === "update" || operation === "delete") {
|
|
await acquireBookingDayLock(req, "booking-calendar");
|
|
}
|
|
return args;
|
|
}],
|
|
beforeChange: [handleConfirmation],
|
|
afterChange: [notifyByEmail],
|
|
afterDelete: [async ({ doc, req }) => {
|
|
const id = typeof doc.linkedEvent === "object" ? doc.linkedEvent?.id : doc.linkedEvent;
|
|
if (id) await req.payload.update({ collection: "events", id, req, data: { isOnline: false } });
|
|
return doc;
|
|
}],
|
|
},
|
|
fields: [
|
|
{
|
|
name: "user",
|
|
type: "relationship",
|
|
relationTo: "customers",
|
|
label: "Nutzer",
|
|
required: true,
|
|
index: true,
|
|
},
|
|
{
|
|
name: "offer",
|
|
type: "relationship",
|
|
relationTo: "offers",
|
|
label: "Angebot",
|
|
required: true,
|
|
},
|
|
{
|
|
type: "row",
|
|
fields: [
|
|
{
|
|
name: "date",
|
|
type: "date",
|
|
label: "Datum",
|
|
required: true,
|
|
admin: { date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" }, width: "34%" },
|
|
},
|
|
{
|
|
name: "startTime",
|
|
type: "date",
|
|
label: "Startzeit",
|
|
required: true,
|
|
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
|
|
},
|
|
{
|
|
name: "endTime",
|
|
type: "date",
|
|
label: "Endzeit",
|
|
required: true,
|
|
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "appointmentType",
|
|
type: "select",
|
|
label: "Terminart",
|
|
required: true,
|
|
defaultValue: "onsite",
|
|
options: [...APPOINTMENT_TYPES],
|
|
},
|
|
{
|
|
name: "status",
|
|
type: "select",
|
|
label: "Status",
|
|
required: true,
|
|
defaultValue: "pending",
|
|
options: [...BOOKING_STATUSES],
|
|
admin: {
|
|
description: "Auf „Bestätigt“ setzen und speichern, um den Termin verbindlich zu machen (Konfliktprüfung läuft automatisch).",
|
|
},
|
|
},
|
|
{
|
|
name: "userMessage",
|
|
type: "textarea",
|
|
label: "Nachricht der Nutzerin/des Nutzers",
|
|
admin: { readOnly: true },
|
|
},
|
|
{
|
|
name: "internalNote",
|
|
type: "textarea",
|
|
label: "Interne Notiz",
|
|
access: { read: isAdminFieldLevel, update: isAdminFieldLevel },
|
|
admin: { description: "Nur für Anna sichtbar." },
|
|
},
|
|
{
|
|
type: "collapsible",
|
|
label: "Alternativer Termin",
|
|
fields: [
|
|
{
|
|
type: "row",
|
|
fields: [
|
|
{
|
|
name: "proposedAlternative",
|
|
type: "group",
|
|
label: "",
|
|
fields: [
|
|
{
|
|
type: "row",
|
|
fields: [
|
|
{
|
|
name: "date",
|
|
type: "date",
|
|
label: "Alternatives Datum",
|
|
admin: { date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" }, width: "34%" },
|
|
},
|
|
{
|
|
name: "startTime",
|
|
type: "date",
|
|
label: "Alternative Startzeit",
|
|
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
|
|
},
|
|
{
|
|
name: "endTime",
|
|
type: "date",
|
|
label: "Alternative Endzeit",
|
|
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "linkedEvent",
|
|
type: "relationship",
|
|
relationTo: "events",
|
|
label: "Verknüpfter Video-Termin",
|
|
admin: { position: "sidebar", readOnly: true },
|
|
},
|
|
],
|
|
};
|