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[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 }, }, ], };