368 lines
14 KiB
TypeScript
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;
|
|
}
|