import { APIError } from "payload"; import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload"; import { isAdmin, isAdminFieldLevel } from "@/access"; 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"; 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 new Date(iso).toISOString().slice(0, 10); } function fmtDate(iso: string): string { return new Date(iso).toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" }); } function fmtTime(iso: string): string { return new Date(iso).toLocaleTimeString("de-DE", { 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))); } // Runs whenever a booking is about to become "confirmed": locks the day // (see lib/booking/lock.ts), re-checks for overlapping confirmed bookings // inside that same lock/transaction, and — for online appointments — creates // the linked video-call Event (reusing the existing meeting/password system) // before the booking itself is written. const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req, operation }) => { if (operation !== "update" || data.status !== "confirmed" || originalDoc?.status === "confirmed") { return data; } const date = data.date ?? originalDoc.date; const startTime = data.startTime ?? originalDoc.startTime; const endTime = data.endTime ?? originalDoc.endTime; const appointmentType = data.appointmentType ?? originalDoc.appointmentType; await acquireBookingDayLock(req, dateKey(date)); const { docs: sameDayConfirmed } = await req.payload.find({ collection: "booking-requests", where: { and: [ { status: { equals: "confirmed" } }, { id: { not_equals: originalDoc.id } }, { date: { greater_than_equal: new Date(new Date(date).setHours(0, 0, 0, 0)).toISOString() } }, { date: { less_than_equal: new Date(new Date(date).setHours(23, 59, 59, 999)).toISOString() } }, ], }, req, limit: 200, }); const newStart = new Date(startTime).getTime(); const newEnd = new Date(endTime).getTime(); const conflict = sameDayConfirmed.some((doc) => { const s = new Date(doc.startTime).getTime(); const e = new Date(doc.endTime).getTime(); return newStart < e && s < newEnd; }); if (conflict) { throw new APIError( "Dieser Zeitraum ist bereits mit einem anderen bestätigten Termin belegt. Bitte wähle eine andere Zeit oder lehne den überschneidenden Termin zuerst ab.", 409, undefined, true, ); } if (appointmentType === "online" && !data.linkedEvent && !originalDoc.linkedEvent) { const offer = (await req.payload.findByID({ collection: "offers", id: data.offer ?? originalDoc.offer, req })) as Offer; const event = (await req.payload.create({ collection: "events", req, data: { title: offer.title, date, startTime, endTime, category: "sonstiges", isOnline: true, // Events' own beforeChange hook auto-generates the password. isPrivateBooking: true, bookingRequest: originalDoc.id, }, // Created as a draft so it never appears in public /termine listings // (see lib/payload/content.ts) — it's still reachable via its direct // join link, which is exactly what a private 1:1 booking needs. draft: true, })) as Event; 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`; if (operation === "create") { 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 = previousDoc?.status !== doc.status; if (statusChanged && 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 && doc.proposedAlternative.date !== previousDoc?.proposedAlternative?.date; 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 Nutzer:innen — 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 true; if (req.user?.collection === "customers") return { user: { equals: req.user.id } }; return false; }, update: isAdmin, delete: isAdmin, }, hooks: { beforeChange: [handleConfirmation], afterChange: [notifyByEmail], }, fields: [ { name: "user", type: "relationship", relationTo: "customers", label: "Nutzer:in", 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 }, }, ], };