import type { Method } from "@prisma/client"; import db from "../db.server"; import { slotDateTime } from "../lib/time"; import { releaseHold } from "./holds.server"; const VALID_METHODS = new Set(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]); export interface OrderNoteAttribute { name: string; value: string; } export interface OrderWebhookPayload { admin_graphql_api_id: string; name?: string; cart_token?: string | null; email?: string | null; phone?: string | null; note_attributes?: OrderNoteAttribute[]; } function readAttr(attrs: OrderNoteAttribute[], key: string): string | undefined { return attrs.find((a) => a.name === key)?.value; } /** * Converts a completed order's dd_* cart attributes (written by the * storefront widget, see extensions/datetime-widget) into a confirmed * Booking, and releases the Redis hold that reserved its capacity. * * Idempotent on orderId — webhooks can and do redeliver, so this must be * safe to run twice for the same order without double-booking capacity. * * Deliberately does NOT re-check capacity here and reject the order if * over budget: by the time an order exists, payment has been taken and the * Validation Function already had its chance to block checkout with a * fresh capacity snapshot. This function's job is to record what happened, * not to re-litigate it. */ export async function createBookingFromOrder(shopDomain: string, order: OrderWebhookPayload): Promise { const attrs = order.note_attributes ?? []; const method = readAttr(attrs, "dd_method"); const date = readAttr(attrs, "dd_date"); const startMinRaw = readAttr(attrs, "dd_start_min"); const endMinRaw = readAttr(attrs, "dd_end_min"); const locationId = readAttr(attrs, "dd_location_id"); if (!method || !VALID_METHODS.has(method as Method) || !date || !startMinRaw || !endMinRaw || !locationId) { return; // no scheduling selection on this order — nothing to book } const location = await db.location.findFirst({ where: { id: locationId, shopDomain } }); if (!location) return; const startMin = Number(startMinRaw); const endMin = Number(endMinRaw); const slotStart = slotDateTime(date, startMin, location.timezone); const slotEnd = slotDateTime(date, endMin, location.timezone); await db.booking.upsert({ where: { orderId: order.admin_graphql_api_id }, create: { shopDomain, orderId: order.admin_graphql_api_id, orderName: order.name, locationId: location.id, method: method as Method, slotStart: slotStart.toJSDate(), slotEnd: slotEnd.toJSDate(), customerEmail: order.email ?? undefined, customerPhone: order.phone ?? undefined, }, update: {}, // redelivered webhook — the booking already exists, nothing to change }); if (order.cart_token) { await releaseHold( { shopDomain, locationId: location.id, method: method as Method, slotStartIso: slotStart.toUTC().toISO()!, }, order.cart_token, ); } }