Files
anouma/collections/BookingRequests.ts
T

419 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 },
},
],
};