Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
Audited the implementation against DS_Delivery_Date_Time_App_Study.docx and closed the actionable gaps (see IMPLEMENTATION_REVIEW_2026-09-04.md). Core (code + unit tests, 156 green): - Wire excludeLocationsWithoutStock into resolveAvailabilityRequest; widget now sends variantIds so inventory-based location exclusion actually runs. - Live slot re-validation at checkout: new checkout-snapshot.server.ts writes a shop-metafield capacity snapshot; validation-slot's evaluateCheckout rejects a complete selection that has since filled / blacked out / closed / hit the daily cap / left the schedule. Refreshed on order webhooks and slot/blackout/location/enforcement edits. - Scopable checkout enforcement: Shop.enforcementMode (all|tagged|off) + enforcementTag, new app.settings.tsx admin page, honoured via the snapshot. - Per-day order cap: Location.dailyOrderCap threaded through getAvailability (dailyCap + consumedPerDate); admin field on the location screen. - Product-rule slot blocking: ProductRule.blockedStartMins, unioned in resolveProductRuleConstraints, enforced in the engine and resolveHoldRequest; admin field on the product rules screen. - Product-page placement: product-availability.liquid block + widget data-mode="preview" (read-only earliest-date line). - Second locale: datetime-widget fr.json / fr.schema.json. - Migration 20260904120000_review_gaps (apply with prisma migrate deploy). New Functions (source + unit tests; need `shopify app deploy` to ship): - extensions/payment-customization: cart.payment-methods.transform.run — hides cash-on-delivery / pay-in-store gateways on SHIPPING orders. - extensions/checkout-datetime/src: restored from a gitignored dist-only state — Plus native picker + Thank you / Order status confirmation blocks, all calling the existing checkout.scheduling.* routes (one capacity pool). tsconfig ships checkJs:false pending reconciliation with live checkout types. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
242 lines
10 KiB
TypeScript
242 lines
10 KiB
TypeScript
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<string, number>;
|
|
/** `${locationId}|${METHOD}|${YYYY-MM-DD}` -> true when that day has no bookable slot (blackout / closed / daily cap / no template). */
|
|
closedDates: Record<string, true>;
|
|
}
|
|
|
|
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<Omit<CheckoutSnapshot, "enforcement" | "schedulableProductIds">> {
|
|
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<string, number> = {};
|
|
const closedDates: Record<string, true> = {};
|
|
|
|
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<string, number>();
|
|
const consumedBySlot = new Map<string, Map<string, number>>(); // 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<string, number>();
|
|
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<string[]> {
|
|
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<void> {
|
|
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<string> {
|
|
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 ?? "";
|
|
}
|