// AK-P16b · public booking submit (race-safe) + post-booking wiring (M06 G/I/J). // // Race protection is two-layered (DoD 2): (1) a Serializable transaction re-checks // slot freshness and inserts the appointment; (2) the PostgreSQL EXCLUDE USING gist // constraint `appointment_no_overlap` is the hard backstop — a lost race surfaces as // an exclusion violation that we map to the same Klartext "slot just taken". // // Post-booking (M06 J): email-match → existing Kontakt + Activity, otherwise a // typed InboxLead (R-P7) is created and linked via Appointment.leadId. The v1 mini // form remains in intakeAnswers and, when the booking page references an intake // form, is mirrored to FormSubmission + Appointment.formSubmissionId; a local // CalendarEvent blocks availability; confirmation mail carries an .ics attachment. import { formatInTimeZone } from "date-fns-tz"; import { logActivity } from "@hivegrid/activity"; import { publishOutboxEvent, type OutboxPublisherTransaction } from "@hivegrid/db/outbox"; import { db } from "~/utils/db.server"; import { createLead } from "~/utils/integrations/leads/leadService.server"; import { sendEmailMailjet } from "~/modules/emails/services/MailjetEmailService"; import { renderSystemMessageEmail } from "@hivegrid/mail"; import { getMailTranslator } from "~/locale/mail-translator.server"; import type { PublicBookingPage } from "../db/booking.db.server"; import { findKontaktIdByEmail } from "../db/booking.db.server"; import { loadSlotsForDate } from "./SlotQueryService.server"; import { pickFirstAvailableProvider } from "./slotEngine"; import { buildIcs, icsToBase64 } from "./ics"; import { buildBookingBookedWorkflowEvents } from "./bookingAutomationEvents.server"; const MINUTE_MS = 60_000; export class BookingValidationError extends Error { readonly code: string; readonly field?: string; constructor(code: string, field?: string) { super(code); this.name = "BookingValidationError"; this.code = code; this.field = field; } } export class BookingSlotTakenError extends Error { readonly code = "slot_taken"; constructor() { super("slot_taken"); this.name = "BookingSlotTakenError"; } } export interface BookingIntake { readonly vorname: string; readonly nachname: string; readonly email: string; readonly telefon?: string; readonly gdprAccepted: boolean; } export interface BookingSubmitInput { readonly page: PublicBookingPage; /** Chosen slot start as a UTC ISO string. */ readonly startAtIso: string; /** Provider-local date `YYYY-MM-DD` the slot belongs to. */ readonly date: string; /** Booker's IANA timezone (for display + the appointment record). */ readonly viewerTimeZone: string; readonly locale?: string; readonly intake: BookingIntake; readonly now: Date; } export interface BookingSubmitResult { readonly appointmentId: string; readonly providerUserId: string; readonly startAt: Date; readonly endAt: Date; readonly icsBase64: string; readonly redirectUrl: string | null; } function validateIntake(intake: BookingIntake): void { if (!intake.vorname.trim()) { throw new BookingValidationError("bookings.error.firstNameRequired", "vorname"); } if (!intake.nachname.trim()) { throw new BookingValidationError("bookings.error.lastNameRequired", "nachname"); } if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/u.test(intake.email.trim())) { throw new BookingValidationError("bookings.error.emailInvalid", "email"); } if (!intake.gdprAccepted) { throw new BookingValidationError("bookings.error.gdprRequired", "gdpr"); } } /** Heuristic: a lost-race insert trips the gist exclusion constraint. */ function isOverlapViolation(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return message.includes("appointment_no_overlap") || message.includes("23P01") || message.toLowerCase().includes("exclusion"); } function renderTemplate(template: string, vars: Record): string { return template.replace(/\{\{\s*([\w.]+)\s*\}\}/gu, (_match, key: string) => vars[key] ?? ""); } /** Submit a public booking. Throws BookingValidationError / BookingSlotTakenError. */ export async function submitBooking(input: BookingSubmitInput): Promise { validateIntake(input.intake); const { page } = input; // 1) Re-check the slot against live availability (M06 G.3). const slots = await loadSlotsForDate({ tenantId: page.tenantId, providerUserIds: page.providerUserIds, date: input.date, config: page.config, now: input.now, }); const slot = slots.find((s) => s.startAt.toISOString() === new Date(input.startAtIso).toISOString()); const providerUserId = slot ? pickFirstAvailableProvider(slot) : null; if (!slot || !providerUserId) { throw new BookingSlotTakenError(); } const startAt = slot.startAt; const endAt = new Date(startAt.getTime() + page.durationMinutes * MINUTE_MS); // 2) Match an existing contact; otherwise create a typed InboxLead after the // appointment exists so the lead can reference the booked appointment id. const kontaktId = await findKontaktIdByEmail(page.tenantId, input.intake.email); let leadId: string | null = null; const intakeAnswers = { vorname: input.intake.vorname.trim(), nachname: input.intake.nachname.trim(), email: input.intake.email.trim(), telefon: input.intake.telefon?.trim() ?? null, gdprAccepted: true, locale: input.locale ?? "de", }; const eventTitle = renderTemplate(page.internalEventTitleTemplate ?? "{{booking_page.title}}", { "booking_page.title": page.title, "contact.name": `${input.intake.vorname} ${input.intake.nachname}`.trim(), }); // 3) Race-safe insert: Serializable re-check + gist backstop (DoD 2). let appointmentId: string; try { appointmentId = await db.$transaction( async (tx) => { const clash = await tx.appointment.findFirst({ where: { tenantId: page.tenantId, providerUserId, status: { in: ["scheduled", "held"] }, AND: [{ startAt: { lt: endAt } }, { endAt: { gt: startAt } }], }, select: { id: true }, }); if (clash) { throw new BookingSlotTakenError(); } const appointment = await tx.appointment.create({ data: { tenantId: page.tenantId, bookingPageId: page.pageId, providerUserId, startAt, endAt, timezone: input.viewerTimeZone, status: "scheduled", kontaktId, intakeAnswers, }, select: { id: true }, }); const formSubmission = page.intakeFormId ? await tx.formSubmission.create({ data: { formId: page.intakeFormId, tenantId: page.tenantId, payload: { values: intakeAnswers, booking: { appointmentId: appointment.id, bookingPageId: page.pageId, bookingPageSlug: page.slug, startAt: startAt.toISOString(), endAt: endAt.toISOString(), }, }, sourceOrigin: `/b/${page.tenantSlug}/${page.slug}`, status: "received", result: { target: "booking_intake" }, }, select: { id: true }, }) : null; // Local calendar mirror so the slot blocks future availability (M06 J.2). const calendarEvent = await tx.calendarEvent.create({ data: { tenantId: page.tenantId, userId: providerUserId, source: "hivegrid_internal", title: eventTitle, startsAt: startAt, endsAt: endAt, isBusyForAvailability: true, internalEntityLink: { type: "booking_appointment", id: appointment.id }, }, select: { id: true }, }); await tx.appointment.update({ where: { id: appointment.id }, data: { internalCalendarEventId: calendarEvent.id, formSubmissionId: formSubmission?.id ?? null }, }); for (const event of buildBookingBookedWorkflowEvents({ tenantId: page.tenantId, appointmentId: appointment.id, bookingPageId: page.pageId, bookingPageSlug: page.slug, providerUserId, calendarEventId: calendarEvent.id, startAt, endAt, kontaktId, })) { await publishOutboxEvent(tx as unknown as OutboxPublisherTransaction, event); } return appointment.id; }, { isolationLevel: "Serializable" } ); } catch (error) { if (error instanceof BookingSlotTakenError || isOverlapViolation(error)) { throw new BookingSlotTakenError(); } throw error; } if (!kontaktId) { const lead = await createLead({ tenantId: page.tenantId, userId: null, quelle: "form", quelleDetail: { kind: "booking", bookingPageId: page.pageId, bookingPageSlug: page.slug }, sourceOrigin: `/b/${page.tenantSlug}/${page.slug}`, vorname: input.intake.vorname, nachname: input.intake.nachname, email: input.intake.email, telefon: input.intake.telefon, message: page.title, rohdaten: { bookingPageId: page.pageId, appointmentId, startAt: startAt.toISOString(), endAt: endAt.toISOString(), intakeAnswers }, zustaendigerUserId: providerUserId, weiterverarbeitungFreigabe: "unklar", }); leadId = lead.id; await db.appointment.update({ where: { id: appointmentId }, data: { leadId } }); } // 4) Activity on the contact/lead (M06 J.4). Best-effort — never break the booking. const activitySubject = kontaktId ? { type: "contact" as const, id: kontaktId } : leadId ? { type: "lead" as const, id: leadId } : null; if (activitySubject) { await logActivity({ tenantId: page.tenantId, actorUserId: null, actorKind: "system", activityType: "appointment_scheduled", subjectType: activitySubject.type, subjectId: activitySubject.id, summaryText: "activity.runtime.booking.scheduled", metadata: { appointmentId, bookingPageId: page.pageId, bookingPageTitle: page.title, startAt: startAt.toISOString(), timezone: input.viewerTimeZone, leadId, kontaktId, }, }).catch(() => undefined); } // 5) Confirmation mail + .ics (best-effort, M06 I.3). const icsBase64 = await sendConfirmation({ input, providerUserId, startAt, endAt, appointmentId }).catch(async () => { await db.appointment .update({ where: { id: appointmentId }, data: { notesInternal: `Bestätigungsmail an ${input.intake.email} konnte nicht versendet werden — Endkunde bitte direkt informieren.` }, }) .catch(() => undefined); return buildBookingIcs({ input, providerUserId, startAt, endAt, appointmentId }); }); return { appointmentId, providerUserId, startAt, endAt, icsBase64, redirectUrl: page.redirectUrlAfterBooking }; } function buildBookingIcs(args: { input: BookingSubmitInput; providerUserId: string; startAt: Date; endAt: Date; appointmentId: string }): string { const { input, startAt, endAt, appointmentId } = args; const ics = buildIcs({ uid: `${appointmentId}@hivegrid`, start: startAt, end: endAt, dtstamp: input.now, summary: input.page.title, description: input.page.description ?? undefined, organizerEmail: undefined, organizerName: input.page.tenantName, attendeeEmail: input.intake.email.trim(), }); return icsToBase64(ics); } async function sendConfirmation(args: { input: BookingSubmitInput; providerUserId: string; startAt: Date; endAt: Date; appointmentId: string; }): Promise { const { input, startAt } = args; const { page } = input; const icsBase64 = buildBookingIcs(args); const vars: Record = { "contact.first_name": input.intake.vorname.trim(), "contact.last_name": input.intake.nachname.trim(), "contact.email": input.intake.email.trim(), "booking_page.title": page.title, "booking_page.description": page.description ?? "", "booking_page.duration_minutes": String(page.durationMinutes), "appointment.date": formatInTimeZone(startAt, input.viewerTimeZone, "dd.MM.yyyy"), "appointment.time": formatInTimeZone(startAt, input.viewerTimeZone, "HH:mm"), "appointment.duration": String(page.durationMinutes), "tenant.company_name": page.tenantName, }; const subject = renderTemplate(page.confirmationEmailSubject, vars); const body = renderTemplate(page.confirmationEmailBody, vars); const { t, locale } = await getMailTranslator(input.locale); const rendered = renderSystemMessageEmail({ locale, brand: { tenantName: page.tenantName }, subject, previewText: t("booking.confirmation.previewText", { company: page.tenantName }), eyebrow: t("booking.confirmation.eyebrow"), title: t("booking.confirmation.title"), intro: body, details: [ { label: t("booking.confirmation.fields.appointment"), value: page.title }, { label: t("booking.confirmation.fields.date"), value: vars["appointment.date"] ?? "" }, { label: t("booking.confirmation.fields.time"), value: t("booking.confirmation.timeValue", { time: vars["appointment.time"] ?? "" }) }, { label: t("booking.confirmation.fields.duration"), value: t("booking.confirmation.durationValue", { minutes: page.durationMinutes }) }, { label: t("booking.confirmation.fields.company"), value: page.tenantName }, ], notice: t("booking.confirmation.notice"), }); await sendEmailMailjet({ data: { to: input.intake.email.trim(), subject, body: rendered.html, text: rendered.text, tenantId: page.tenantId, attachments: [{ filename: "termin.ics", content: icsBase64, disposition: "attachment" }], }, }); return icsBase64; }