Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/booking/services/BookingSubmitService.server.ts
T

368 lines
14 KiB
TypeScript

// 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, string>): 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<BookingSubmitResult> {
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<string> {
const { input, startAt } = args;
const { page } = input;
const icsBase64 = buildBookingIcs(args);
const vars: Record<string, string> = {
"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;
}