import { DateTime } from "luxon"; import type { Method } from "@prisma/client"; import db from "../db.server"; import { getAvailability } from "./scheduling.server"; import type { AdminGraphQLClient } from "./zones.server"; // Denormalized capacity + enforcement snapshot that the Cart/Checkout // Validation Function reads from a shop metafield. Shopify Functions can't // query this app's database, so anything the Function needs to enforce // "the slot has since become invalid" (study §5.2) or "only enforce for // tagged products" (study §3.5) has to be pushed into a metafield it can // read directly. This module builds and writes that metafield; it's // refreshed whenever bookings or scheduling config change (order webhooks, // slot/blackout admin edits) and on a periodic worker sweep so the rolling // horizon keeps advancing. export const SNAPSHOT_NAMESPACE = "delivery_datetime"; export const SNAPSHOT_KEY = "checkout_snapshot"; const HORIZON_DAYS = 30; const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]; // A fixed instant far in the past, so getAvailability's cutoff/lead-time // filters never hide a slot here — the snapshot captures *capacity and // calendar* state (blackout, closed override, daily cap, bookings), not the // time-of-day cutoff, which is inherently second-sensitive and already // covered by the hold TTL and the widget. const EPOCH = DateTime.fromISO("1970-01-01T00:00:00Z"); export interface CheckoutSnapshot { version: 1; generatedAt: string; /** Slots whose date is after this (UTC calendar date) aren't covered — the Function stays permissive past the horizon. */ horizonDate: string; enforcement: { mode: "all" | "tagged" | "off" }; /** Product GIDs that trigger enforcement when mode = "tagged". Ignored otherwise. */ schedulableProductIds: string[]; /** `${locationId}|${METHOD}|${YYYY-MM-DD}|${startMin}` -> remaining capacity. */ slots: Record; /** `${locationId}|${METHOD}|${YYYY-MM-DD}` -> true when that day has no bookable slot (blackout / closed / daily cap / no template). */ closedDates: Record; } export function slotSnapshotKey(locationId: string, method: Method, date: string, startMin: number): string { return `${locationId}|${method}|${date}|${startMin}`; } export function closedDateKey(locationId: string, method: Method, date: string): string { return `${locationId}|${method}|${date}`; } function toIsoDate(date: Date): string { return date.toISOString().slice(0, 10); } /** * Builds the capacity/calendar half of the snapshot from the database. * Enforcement config and tagged-product resolution are layered on by * writeCheckoutSnapshot (they need the Admin API / are cheap to add there). */ export async function buildCheckoutSnapshot(shopDomain: string): Promise> { const nowUtc = DateTime.utc(); const startDate = nowUtc.toISODate()!; const endDate = nowUtc.plus({ days: HORIZON_DAYS }).toISODate()!; const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).minus({ days: 1 }).toJSDate(); const rangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).plus({ days: 1 }).toJSDate(); const locations = await db.location.findMany({ where: { shopDomain, active: true } }); const slots: Record = {}; const closedDates: Record = {}; for (const location of locations) { const [templates, overrides, blackouts, bookings] = await Promise.all([ db.slotTemplate.findMany({ where: { shopDomain, locationId: location.id } }), db.slotOverride.findMany({ where: { shopDomain, locationId: location.id, date: { gte: rangeStart, lte: rangeEnd } }, }), db.blackoutDate.findMany({ where: { shopDomain, date: { gte: rangeStart, lte: rangeEnd }, OR: [{ locationId: location.id }, { locationId: null }] }, }), db.booking.findMany({ where: { shopDomain, locationId: location.id, status: { in: ["confirmed", "fulfilled"] }, slotStart: { gte: rangeStart, lte: rangeEnd }, }, select: { slotStart: true, method: true }, }), ]); // Per-date booking tally (all methods) drives the daily cap; per-slot // tally is method-specific. const consumedPerDate = new Map(); const consumedBySlot = new Map>(); // method -> (date|startMin -> n) for (const booking of bookings) { const local = DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(location.timezone); const isoDate = local.toISODate()!; consumedPerDate.set(isoDate, (consumedPerDate.get(isoDate) ?? 0) + 1); const perMethod = consumedBySlot.get(booking.method) ?? new Map(); const key = `${isoDate}|${local.hour * 60 + local.minute}`; perMethod.set(key, (perMethod.get(key) ?? 0) + 1); consumedBySlot.set(booking.method, perMethod); } for (const method of METHODS) { const availability = getAvailability({ timezone: location.timezone, dateRange: { startDate, endDate }, slotTemplates: templates .filter((t) => t.method === method) .map((t) => ({ weekday: t.weekday, startMin: t.startMin, endMin: t.endMin, capacity: t.capacity, cutoffMin: null, // deliberately ignored here — see EPOCH note leadTimeMin: 0, transitMinDays: t.transitMinDays, transitMaxDays: t.transitMaxDays, })), overrides: overrides .filter((o) => o.method === method) .map((o) => ({ date: toIsoDate(o.date), closed: o.closed, startMin: o.startMin, endMin: o.endMin, capacity: o.capacity })), blackoutDates: blackouts .filter((b) => b.method === null || b.method === method) .map((b) => ({ date: toIsoDate(b.date) })), now: EPOCH, consumed: consumedBySlot.get(method) ?? new Map(), dailyCap: location.dailyOrderCap, consumedPerDate, }); // Walk every calendar day in the horizon: present -> record each slot's // remaining capacity; absent -> the day is closed for this method. let cursor = DateTime.fromISO(startDate, { zone: "utc" }); const last = DateTime.fromISO(endDate, { zone: "utc" }); for (; cursor <= last; cursor = cursor.plus({ days: 1 })) { const date = cursor.toISODate()!; const daySlots = availability[date]; if (!daySlots || daySlots.length === 0) { closedDates[closedDateKey(location.id, method, date)] = true; continue; } for (const slot of daySlots) { slots[slotSnapshotKey(location.id, method, date, slot.startMin)] = slot.remainingCapacity; } } } } return { version: 1, generatedAt: nowUtc.toISO()!, horizonDate: endDate, slots, closedDates }; } /** Resolves the Product GIDs carrying `tag` via the Admin API (paged). */ async function resolveTaggedProductIds(admin: AdminGraphQLClient, tag: string): Promise { const ids: string[] = []; let cursor: string | null = null; // Cap the sweep so a huge catalog can't stall a webhook — 2000 tagged // products is already an extreme case for this feature. for (let page = 0; page < 8; page += 1) { const response: Response = await admin.graphql( `#graphql query TaggedProducts($query: String!, $after: String) { products(first: 250, query: $query, after: $after) { nodes { id } pageInfo { hasNextPage endCursor } } }`, { variables: { query: `tag:'${tag.replace(/'/g, "")}'`, after: cursor } }, ); const body = (await response.json()) as { data?: { products: { nodes: Array<{ id: string }>; pageInfo: { hasNextPage: boolean; endCursor: string | null } } }; }; const products = body.data?.products; if (!products) break; for (const node of products.nodes) ids.push(node.id); if (!products.pageInfo.hasNextPage) break; cursor = products.pageInfo.endCursor; } return ids; } /** * Builds the snapshot and writes it to the shop metafield the Validation * Function reads. Safe to call from a webhook handler — failures are logged, * not thrown, so a metafield hiccup never fails order processing. */ export async function writeCheckoutSnapshot(admin: AdminGraphQLClient, shopDomain: string): Promise { try { const shop = await db.shop.findUnique({ where: { shopDomain }, select: { enforcementMode: true, enforcementTag: true }, }); const mode = (shop?.enforcementMode as CheckoutSnapshot["enforcement"]["mode"]) ?? "all"; const base = await buildCheckoutSnapshot(shopDomain); const schedulableProductIds = mode === "tagged" && shop?.enforcementTag ? await resolveTaggedProductIds(admin, shop.enforcementTag) : []; const snapshot: CheckoutSnapshot = { ...base, enforcement: { mode }, schedulableProductIds }; const response = await admin.graphql( `#graphql mutation WriteCheckoutSnapshot($metafields: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $metafields) { userErrors { field message } } }`, { variables: { metafields: [ { ownerId: await currentShopGid(admin), namespace: SNAPSHOT_NAMESPACE, key: SNAPSHOT_KEY, type: "json", value: JSON.stringify(snapshot), }, ], }, }, ); const body = (await response.json()) as { data?: { metafieldsSet?: { userErrors: Array<{ message: string }> } } }; const errors = body.data?.metafieldsSet?.userErrors ?? []; if (errors.length > 0) { console.error("writeCheckoutSnapshot userErrors", errors); } } catch (error) { console.error("writeCheckoutSnapshot failed", error); } } async function currentShopGid(admin: AdminGraphQLClient): Promise { const response = await admin.graphql(`#graphql query CurrentShopId { shop { id } }`); const body = (await response.json()) as { data?: { shop?: { id: string } } }; return body.data?.shop?.id ?? ""; }