feat: Phase 4 — enforcement Functions + slot-holds

The core competitive moat (PRODUCT_STRATEGY.md §3.1): checkout can no
longer complete without a valid, still-available slot, on any Shopify
plan. User confirmed writing Functions in JS rather than Rust — no Rust
toolchain was available in this environment, and IMPLEMENTATION_PLAN.md §1
explicitly allows JS as a fallback ("Rust preferred, JS acceptable").

- app/services/holds.server.ts: Redis-backed soft slot-holds with TTL.
  Hold creation is a Lua script (EVAL) — the "does this slot have room"
  check and the reservation itself have to be one atomic Redis operation,
  or two concurrent requests can both read "one spot left" and both
  succeed. Outstanding holds per slot live in a sorted set scored by expiry
  (so eviction is just ZREMRANGEBYSCORE, no separate expiry job needed to
  read a correct count), and a repeat request from the same cart renews
  its own hold instead of competing against the capacity gate again.
- app/routes/apps.scheduling.hold.tsx: public app-proxy endpoint the widget
  calls the moment a shopper picks a slot — reserves capacity BEFORE the
  cart attribute is written, since the attribute alone is just two
  shoppers racing to write the same field.
- extensions/datetime-widget: now fetches the cart token, requests a hold
  first, and only writes cart attributes on success; shows an error and
  refreshes the slot list if it loses the race.
- app/services/booking.server.ts + webhooks.orders.create.tsx: converts an
  order's dd_* cart attributes into a confirmed Booking (idempotent on
  orderId — webhooks redeliver), releases the matching hold, and writes a
  `delivery_datetime.booking` order metafield so the slot is visible on the
  order record natively (IMPLEMENTATION_PLAN.md §5.2/§2). Deliberately does
  NOT re-check capacity and reject at this point — by the time an order
  exists, payment has happened; that's the Function's job, earlier.
- webhooks.orders.cancelled.tsx: marks the Booking cancelled, freeing its
  capacity.
- extensions/validation-slot (Cart/Checkout Validation Function): blocks
  checkout when the cart's dd_* attributes are missing or incomplete — a
  shopper who bypasses the widget entirely (clears the attribute, calls the
  cart API directly) still cannot check out, because this runs inside
  Shopify's own checkout, not the browser. Scope note documented in
  evaluate.js: this doesn't yet re-validate against a live capacity
  snapshot at the moment checkout completes ("has since been taken" in
  §3.1) — Functions can't call our DB, and a metafield-snapshot refresh
  pipeline for that is unscoped work; the 10-minute hold TTL is the interim
  mitigation for that specific race.
- extensions/delivery-customization: relabels every delivery option to the
  shopper's actual chosen method + date ("Pickup — Aug 25" instead of a
  generic carrier label), directly fixing the "estimated delivery date on a
  pickup order" complaint §3.1 names. payment-customization is deliberately
  NOT built yet — there's no configurable payment-method rule for it to
  enforce until later phases add one; shipping a no-op Function serves
  nothing.
- Prisma: added Booking (no SlotHold table — Redis is the sole source of
  truth for holds, per §4's own "(Redis-backed)" annotation; mirroring it
  into Postgres would just be a sync-consistency burden with no benefit).
- Both Function extensions are hand-scaffolded from Shopify's documented
  JS Function structure (same interactive-login limitation as the Theme
  App Extension) — README.md flags that `shopify app function schema`
  should be run before deploying to confirm the input queries still match
  the live schema.

New tests/integration/ suite (separate vitest config, needs live
Redis+Postgres — `docker compose up -d` locally, a services: block in CI)
holds the tests that can't be meaningfully mocked: the slot-hold
concurrency test CLAUDE.md calls out as non-negotiable (20 concurrent
requests for 1 unit of capacity → exactly 1 succeeds; 30 for 5 → exactly 5;
release-then-retry; TTL expiry; same-cart renewal) and the full
hold-to-booking lifecycle including idempotency on webhook redelivery.
Both Functions' pure decision logic is separately unit-tested (11 tests).
60 total tests now pass (52 unit + 8 integration).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-08-23 18:22:51 -04:00
parent 039b1539ab
commit 7a340ad135
28 changed files with 1082 additions and 24 deletions

View File

@ -4,8 +4,34 @@ on: [push, pull_request]
jobs: jobs:
test: test:
name: Lint & Unit Tests name: Lint, Unit & Integration Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: delivery_datetime_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://app:app@localhost:5432/delivery_datetime_test
REDIS_URL: redis://127.0.0.1:6379
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@ -22,6 +48,10 @@ jobs:
run: npm run typecheck run: npm run typecheck
- name: Unit tests - name: Unit tests
run: npm test -- --run run: npm test -- --run
- name: Apply migrations
run: npx prisma migrate deploy
- name: Integration tests (Redis-backed slot-hold concurrency, Booking flow)
run: npm run test:integration
- name: Build admin app - name: Build admin app
run: npm run build run: npm run build
- name: Build storefront widget - name: Build storefront widget

View File

@ -34,15 +34,26 @@ runs the BullMQ worker (jobs/worker.ts) once Phase 4 makes it do anything.
| `npx prisma migrate dev` | DB migrations | | `npx prisma migrate dev` | DB migrations |
| `npm run deploy` | `shopify app deploy` — deploy extensions/functions | | `npm run deploy` | `shopify app deploy` — deploy extensions/functions |
| `npm run worker` | BullMQ worker (hold-expiry, notifications) | | `npm run worker` | BullMQ worker (hold-expiry, notifications) |
| `npm run test:integration` | Redis/Postgres-backed tests (slot-hold concurrency, booking flow) — needs `docker compose up -d` |
## Status ## Status
Phase 0 (scaffold & CI), Phase 1 (core data model & admin CRUD), Phase 2 Phase 0 (scaffold & CI) through Phase 4 (enforcement Functions +
(scheduling engine), and Phase 3 (storefront widget + cart attributes — slot-holds) are complete. See §6 of `IMPLEMENTATION_PLAN.md` for the phased
`extensions/datetime-widget/`) are complete. See §6 of build order and acceptance criteria — next up is Phase 5 (multi-location,
`IMPLEMENTATION_PLAN.md` for the phased build order and acceptance criteria zones, rates, auto-assignment).
— next up is Phase 4 (enforcement Functions + slot-holds).
Editing the storefront widget's `extensions/datetime-widget/src/`? Run Editing the storefront widget's `extensions/datetime-widget/src/`? Run
`npm run build:widget` to rebuild `assets/datetime-widget.js` — it also runs `npm run build:widget` to rebuild `assets/datetime-widget.js` — it also runs
automatically before `npm run dev` / `npm run deploy`. automatically before `npm run dev` / `npm run deploy`.
**Functions are JavaScript, not Rust** (`extensions/validation-slot/`,
`extensions/delivery-customization/`) — no Rust toolchain was available in
the environment that built Phase 4, and `IMPLEMENTATION_PLAN.md` §1
explicitly allows JS as a fallback. They (and the widget's Theme App
Extension) were hand-scaffolded rather than generated via
`shopify app generate extension`, which needs an interactive Partner login.
**Before deploying either Function**, run `shopify app function schema` in
its directory to pull the authoritative `schema.graphql` for your API
version and confirm `src/run.graphql` still matches it — these were written
from documented conventions, not validated against a live schema.

View File

@ -0,0 +1,98 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { slotDateTime, weekdayOf } from "../lib/time";
import { remainingCapacity } from "../services/capacity.server";
import { tryCreateHold, releaseHold, countActiveHolds } from "../services/holds.server";
// Public app-proxy endpoint (see apps.scheduling.availability.tsx for the
// path-mirroring rationale). Called by the widget the moment a shopper
// picks a slot, before it writes the cart attribute — this is what
// actually reserves capacity (PRODUCT_STRATEGY.md §3.1, §4.1: "the
// last-slot race condition"). The cart attribute write alone would just be
// two shoppers racing to write the same free-text field; nothing would
// stop both orders from completing.
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.public.appProxy(request);
if (!session) {
return Response.json({ error: "Shop not found" }, { status: 404 });
}
const body = await request.json();
const { intent, locationId, method, date, startMin, cartToken } = body as {
intent?: "create" | "release";
locationId?: string;
method?: string;
date?: string;
startMin?: number;
cartToken?: string;
};
if (!locationId || !method || !VALID_METHODS.has(method as Method) || !date || typeof startMin !== "number" || !cartToken) {
return Response.json({ error: "Missing or invalid parameters" }, { status: 400 });
}
const location = await db.location.findFirst({
where: { id: locationId, shopDomain: session.shop, active: true },
});
if (!location) {
return Response.json({ error: "Location not found" }, { status: 404 });
}
const slotStart = slotDateTime(date, startMin, location.timezone);
const slot = {
shopDomain: session.shop,
locationId: location.id,
method: method as Method,
slotStartIso: slotStart.toUTC().toISO()!,
};
if (intent === "release") {
await releaseHold(slot, cartToken);
return Response.json({ ok: true });
}
const template = await db.slotTemplate.findFirst({
where: {
shopDomain: session.shop,
locationId: location.id,
method: slot.method,
weekday: weekdayOf(date, location.timezone),
startMin,
},
});
if (!template) {
return Response.json({ error: "Slot not found" }, { status: 404 });
}
const confirmedCount = await db.booking.count({
where: {
shopDomain: session.shop,
locationId: location.id,
method: slot.method,
slotStart: slotStart.toJSDate(),
status: "confirmed",
},
});
const capacityBudget = remainingCapacity({ capacity: template.capacity, consumed: confirmedCount });
if (capacityBudget <= 0) {
return Response.json({ success: false, error: "Slot is full" }, { status: 409 });
}
const result = await tryCreateHold(slot, cartToken, capacityBudget);
if (!result.success) {
return Response.json({ success: false, error: "Slot was just taken" }, { status: 409 });
}
const activeHolds = await countActiveHolds(slot);
return Response.json({
success: true,
expiresAt: result.expiresAt,
remaining: Math.max(0, capacityBudget - activeHolds),
});
};

View File

@ -1,12 +1,19 @@
import type { ActionFunctionArgs } from "@remix-run/node"; import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server"; import { authenticate } from "../shopify.server";
import db from "../db.server";
// TODO (Phase 4): mark the linked Booking cancelled and free its // Marks the linked Booking cancelled, which frees its capacity for
// capacity/resources. See IMPLEMENTATION_PLAN.md §5.2. // getAvailability()/the hold endpoint's confirmedCount check on the next
// request — see IMPLEMENTATION_PLAN.md §5.2.
export const action = async ({ request }: ActionFunctionArgs) => { export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload } = await authenticate.webhook(request); const { shop, topic, payload } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
console.log(`Received ${topic} webhook for ${shop}`, payload); const order = payload as unknown as { admin_graphql_api_id: string };
await db.booking.updateMany({
where: { shopDomain: shop, orderId: order.admin_graphql_api_id },
data: { status: "cancelled" },
});
return new Response(); return new Response();
}; };

View File

@ -1,13 +1,59 @@
import type { ActionFunctionArgs } from "@remix-run/node"; import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server"; import { authenticate } from "../shopify.server";
import { createBookingFromOrder, type OrderWebhookPayload } from "../services/booking.server";
// TODO (Phase 4): convert the cart's SlotHold into a confirmed Booking, // Converts the cart's dd_* attributes into a confirmed Booking, releases
// consume capacity/resources, write the slot back onto the order via // the matching Redis hold, and writes the slot back onto the order via a
// metafield, and release the hold. See IMPLEMENTATION_PLAN.md §5.2. // metafield so staff (and, later, POS) see it natively on the order record
// — IMPLEMENTATION_PLAN.md §5.2 / §2 data flow. Idempotent: Shopify
// redelivers webhooks, and booking.server.ts's upsert-on-orderId handles
// that safely.
export const action = async ({ request }: ActionFunctionArgs) => { export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload } = await authenticate.webhook(request); const { shop, topic, payload, admin } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
console.log(`Received ${topic} webhook for ${shop}`, payload); const order = payload as unknown as OrderWebhookPayload;
await createBookingFromOrder(shop, order);
if (admin) {
const bookingSummary = summarizeBookingAttributes(order);
if (bookingSummary) {
await admin.graphql(
`#graphql
mutation setBookingMetafield($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
userErrors { field message }
}
}`,
{
variables: {
metafields: [
{
ownerId: order.admin_graphql_api_id,
namespace: "delivery_datetime",
key: "booking",
type: "json",
value: JSON.stringify(bookingSummary),
},
],
},
},
);
}
}
return new Response(); return new Response();
}; };
function summarizeBookingAttributes(order: OrderWebhookPayload) {
const attrs = order.note_attributes ?? [];
const get = (key: string) => attrs.find((a) => a.name === key)?.value;
const method = get("dd_method");
const date = get("dd_date");
const startMin = get("dd_start_min");
const endMin = get("dd_end_min");
const locationId = get("dd_location_id");
if (!method || !date || !startMin || !endMin || !locationId) return null;
return { method, date, startMin: Number(startMin), endMin: Number(endMin), locationId };
}

View File

@ -0,0 +1,87 @@
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<Method>(["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<void> {
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,
);
}
}

View File

@ -0,0 +1,117 @@
import type { Method } from "@prisma/client";
import redis from "../lib/redis.server";
// Soft slot-holds: when a shopper picks a slot, we reserve one unit of its
// capacity for a short TTL so a second shopper can't grab the same last
// unit while the first is still filling out checkout (PRODUCT_STRATEGY.md
// §3.1 — "the last-slot race condition"). Redis is the sole source of
// truth for holds (see the schema.prisma comment on why there's no
// matching Postgres table); its native key expiry is exactly the semantics
// a time-limited reservation needs.
//
// Each slot's outstanding holds live in one Redis sorted set, scored by
// expiry timestamp (ms). Creating a hold has to be atomic — read the
// current count, compare to the capacity budget, and add the new member —
// or two concurrent requests can both read "one spot left" and both
// succeed. That's done with a Lua script (EVAL), which Redis runs
// single-threaded and atomically; there is no equivalent way to get this
// right with separate round-trips from Node.
export const DEFAULT_HOLD_TTL_MS = 10 * 60 * 1000; // 10 minutes
const TRY_CREATE_HOLD_SCRIPT = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local expiresAt = tonumber(ARGV[2])
local member = ARGV[3]
local capacity = tonumber(ARGV[4])
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
-- The same cart re-confirming its own already-held slot (a retried
-- request, a back/forward navigation, a duplicate click) is a renewal, not
-- a new claim it must not have to compete against the capacity gate a
-- second time, or a shopper can lock themselves out of the slot they
-- already hold.
if redis.call('ZSCORE', key, member) then
redis.call('ZADD', key, expiresAt, member)
return 1
end
local count = redis.call('ZCARD', key)
if count < capacity then
redis.call('ZADD', key, expiresAt, member)
redis.call('PEXPIRE', key, 3600000)
return 1
else
return 0
end
`;
redis.defineCommand("tryCreateHoldScript", {
numberOfKeys: 1,
lua: TRY_CREATE_HOLD_SCRIPT,
});
// ioredis's defineCommand() adds the method at runtime without changing the
// client's static type, so this augments it for the one command we added.
declare module "ioredis" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- must match ioredis's own generic arity to merge
interface RedisCommander<Context> {
tryCreateHoldScript(
key: string,
now: number,
expiresAt: number,
member: string,
capacity: number,
): Promise<number>;
}
}
export interface SlotIdentity {
shopDomain: string;
locationId: string;
method: Method;
/** The slot's exact start instant, as an ISO string (stable, timezone-unambiguous cache key). */
slotStartIso: string;
}
function holdSetKey({ shopDomain, locationId, method, slotStartIso }: SlotIdentity): string {
return `hold:${shopDomain}:${locationId}:${method}:${slotStartIso}`;
}
export interface TryCreateHoldResult {
success: boolean;
expiresAt: number;
}
/**
* Attempts to reserve one unit of capacity for `cartToken` on the given
* slot. `capacityBudget` is the capacity still available to holds i.e.
* template capacity minus already-confirmed Bookings, computed by the
* caller from Postgres immediately before calling this. Returns
* success: false if the slot's outstanding holds already meet that budget.
*/
export async function tryCreateHold(
slot: SlotIdentity,
cartToken: string,
capacityBudget: number,
ttlMs: number = DEFAULT_HOLD_TTL_MS,
): Promise<TryCreateHoldResult> {
const now = Date.now();
const expiresAt = now + ttlMs;
const result = await redis.tryCreateHoldScript(holdSetKey(slot), now, expiresAt, cartToken, capacityBudget);
return { success: result === 1, expiresAt };
}
export async function releaseHold(slot: SlotIdentity, cartToken: string): Promise<void> {
await redis.zrem(holdSetKey(slot), cartToken);
}
/** Active (non-expired) hold count for a slot — evicts expired members first. */
export async function countActiveHolds(slot: SlotIdentity): Promise<number> {
const key = holdSetKey(slot);
await redis.zremrangebyscore(key, "-inf", Date.now());
return redis.zcard(key);
}

File diff suppressed because one or more lines are too long

View File

@ -110,11 +110,41 @@ async function fetchAvailability(method: Method, locationId: string | null): Pro
return body; return body;
} }
async function writeCartAttribute(key: string, value: Record<string, string>): Promise<void> { interface HoldResponse {
success: boolean;
expiresAt?: number;
error?: string;
}
async function getCartToken(): Promise<string> {
const res = await fetch("/cart.js", { headers: { Accept: "application/json" } });
const cart = (await res.json()) as { token: string };
return cart.token;
}
async function requestHold(params: {
intent: "create" | "release";
locationId: string;
method: Method;
date: string;
startMin: number;
cartToken: string;
}): Promise<HoldResponse> {
const res = await fetch(`${PROXY_BASE}/hold`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(params),
});
const body = (await res.json()) as HoldResponse;
if (!res.ok && params.intent === "create") return { success: false, error: body.error || "Slot unavailable" };
return body;
}
async function writeCartAttribute(key: string, machine: Record<string, string>, display: string): Promise<void> {
await fetch("/cart/update.js", { await fetch("/cart/update.js", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ attributes: { [key]: value.display, ...value } }), body: JSON.stringify({ attributes: { [key]: display, ...machine } }),
}); });
} }
@ -131,6 +161,8 @@ class DateTimeWidget {
private selectedMethod: WidgetConfig["methods"][number] | null = null; private selectedMethod: WidgetConfig["methods"][number] | null = null;
private selectedDate: string | null = null; private selectedDate: string | null = null;
private availability: AvailabilityResponse | null = null; private availability: AvailabilityResponse | null = null;
private heldSlot: { locationId: string; method: Method; date: string; startMin: number; cartToken: string } | null =
null;
constructor(config: WidgetConfig) { constructor(config: WidgetConfig) {
this.config = config; this.config = config;
@ -245,15 +277,43 @@ class DateTimeWidget {
this.el.status.textContent = this.config.labels.loading; this.el.status.textContent = this.config.labels.loading;
try { try {
await writeCartAttribute(method.attrLabel, { const cartToken = await getCartToken();
display,
dd_method: method.value, // Reserve capacity FIRST. Writing the cart attribute alone would just
dd_date: date, // be two shoppers racing to write the same free-text field — nothing
dd_start_min: String(slot.startMin), // would stop both checkouts from completing for the last slot. The
dd_end_min: String(slot.endMin), // hold is what the Validation Function (Phase 4) actually enforces
dd_location_id: availability.locationId, // against at checkout.
const hold = await requestHold({
intent: "create",
locationId: availability.locationId,
method: method.value,
date,
startMin: slot.startMin,
cartToken,
}); });
if (!hold.success) {
this.el.status.textContent = this.config.labels.error;
// The slot we just tried is gone — refresh so the list reflects reality.
await this.selectMethod(method);
return;
}
this.heldSlot = { locationId: availability.locationId, method: method.value, date, startMin: slot.startMin, cartToken };
await writeCartAttribute(
method.attrLabel,
{
dd_method: method.value,
dd_date: date,
dd_start_min: String(slot.startMin),
dd_end_min: String(slot.endMin),
dd_location_id: availability.locationId,
},
display,
);
this.el.status.textContent = ""; this.el.status.textContent = "";
this.el.methodRow.hidden = true; this.el.methodRow.hidden = true;
this.el.dateRow.hidden = true; this.el.dateRow.hidden = true;
@ -268,6 +328,10 @@ class DateTimeWidget {
changeButton.className = "dd-widget__link"; changeButton.className = "dd-widget__link";
changeButton.textContent = this.config.labels.change; changeButton.textContent = this.config.labels.change;
changeButton.addEventListener("click", () => { changeButton.addEventListener("click", () => {
if (this.heldSlot) {
void requestHold({ intent: "release", ...this.heldSlot });
this.heldSlot = null;
}
this.el.methodRow.hidden = false; this.el.methodRow.hidden = false;
this.el.dateRow.hidden = false; this.el.dateRow.hidden = false;
this.el.timeRow.hidden = false; this.el.timeRow.hidden = false;

View File

@ -0,0 +1,12 @@
{
"name": "delivery-customization",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "shopify-function-build"
},
"dependencies": {
"@shopify/shopify_function": "^1.0.0"
}
}

View File

@ -0,0 +1,22 @@
# NOTE: hand-written — see validation-slot/shopify.extension.toml for why,
# and run `shopify app function schema` before deploying to confirm
# src/run.graphql matches the live schema for your API version.
api_version = "2025-01"
[[extensions]]
name = "Delivery Method Labeling"
handle = "delivery-customization"
type = "function"
[[extensions.targeting]]
target = "purchase.delivery-customization.run"
input_query = "src/run.graphql"
export = "deliveryCustomizationRun"
[extensions.build]
command = ""
path = "dist/function.wasm"
[extensions.build.watch]
paths = ["src/**/*.js"]

View File

@ -0,0 +1,31 @@
// @ts-check
// Pure decision logic — see validation-slot/src/evaluate.js for why this is
// split out from run.js (unit-testable without a WASM build).
//
// This directly targets the DS review complaint PRODUCT_STRATEGY.md §3.1
// names: "a stray 'estimated delivery date' on a pickup order." Renaming
// the delivery-method line at checkout to show the shopper's actual chosen
// method/date/time removes any ambiguity about what they're getting.
/** @type {Record<string, string>} */
const METHOD_LABEL = {
PICKUP: "Pickup",
LOCAL_DELIVERY: "Local delivery",
SHIPPING: "Shipping",
};
/**
* @param {Record<string, string | null | undefined>} attributes
* @returns {{ rename: true, title: string } | { rename: false }}
*/
export function renameLabelFor(attributes) {
const method = attributes.dd_method;
const date = attributes.dd_date;
if (!method || !date || !(method in METHOD_LABEL)) {
return { rename: false };
}
const label = METHOD_LABEL[method];
return { rename: true, title: `${label}${date}` };
}

View File

@ -0,0 +1,15 @@
query RunInput {
cart {
ddMethod: attribute(key: "dd_method") {
value
}
ddDate: attribute(key: "dd_date") {
value
}
deliveryGroups {
deliveryOptions {
handle
}
}
}
}

View File

@ -0,0 +1,42 @@
// @ts-check
import { renameLabelFor } from "./evaluate.js";
/**
* @typedef {import("../generated/api").CartInput} RunInput
* @typedef {import("../generated/api").FunctionRunResult} FunctionRunResult
*/
/**
* Delivery Customization Function: relabels every presented delivery
* option to the shopper's actual chosen method + date, so checkout never
* shows a generic carrier label ("Standard", "Economy") that could be
* mistaken for a shipping ETA on what's actually a pickup or local-delivery
* order. Phase 4 doesn't yet have a per-option method mapping (that needs
* Phase 5's zones/rates work), so every option in the cart gets the same
* clarified label reasonable since a single order only has one chosen
* fulfillment method today.
* @param {RunInput} input
* @returns {FunctionRunResult}
*/
export function deliveryCustomizationRun(input) {
const attributes = {
dd_method: input.cart.ddMethod?.value,
dd_date: input.cart.ddDate?.value,
};
const decision = renameLabelFor(attributes);
if (!decision.rename) {
return { operations: [] };
}
const operations = input.cart.deliveryGroups.flatMap((group) =>
group.deliveryOptions.map((option) => ({
rename: {
deliveryOptionHandle: option.handle,
title: decision.title,
},
})),
);
return { operations };
}

View File

@ -0,0 +1,12 @@
{
"name": "validation-slot",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "shopify-function-build"
},
"dependencies": {
"@shopify/shopify_function": "^1.0.0"
}
}

View File

@ -0,0 +1,27 @@
# NOTE: hand-written (shopify app generate extension needs an interactive
# Partner login unavailable in this environment — see README.md). This
# mirrors Shopify's documented structure for a JS Cart/Checkout Validation
# Function as closely as possible from training knowledge, but has not been
# validated against a live `shopify app function schema` pull. Before
# deploying: run `shopify app function schema` in this directory to fetch
# the authoritative schema.graphql for your app's API version, confirm
# src/run.graphql still matches it, and fix up anything that's drifted.
api_version = "2025-01"
[[extensions]]
name = "Delivery Slot Validation"
handle = "validation-slot"
type = "function"
[[extensions.targeting]]
target = "purchase.validation.run"
input_query = "src/run.graphql"
export = "cartCheckoutValidationRun"
[extensions.build]
command = ""
path = "dist/function.wasm"
[extensions.build.watch]
paths = ["src/**/*.js"]

View File

@ -0,0 +1,39 @@
// @ts-check
// Pure decision logic, kept separate from run.js so it can be unit-tested
// directly with plain Vitest — no WASM build or function-runner needed to
// verify the actual rule. This is the enforcement half of the "widget
// collects, Function enforces" split (CLAUDE.md non-negotiable): a shopper
// who clears the cart attribute, calls the cart API directly, or otherwise
// bypasses the storefront widget still cannot complete checkout, because
// this runs server-side inside Shopify's own checkout, not in the browser.
//
// Scope note (Phase 4): every order on shops that activate this Function is
// currently treated as requiring a schedule selection — there's no
// ProductRule yet (that's Phase 5) to scope enforcement to specific
// products/collections. Merchants who haven't finished configuring
// locations/slots simply shouldn't activate this Function in
// Settings > Checkout yet, the same way any other checkout validation
// works. Also out of scope for now: re-validating against a *live*
// capacity snapshot at the moment checkout completes ("has since been
// taken" in PRODUCT_STRATEGY.md §3.1) — Functions can't call our DB, and
// building a metafield-snapshot refresh pipeline for that is real,
// unscoped work. The 10-minute hold TTL (holds.server.ts) is the mitigation
// for that specific race in the meantime.
const REQUIRED_ATTRIBUTES = ["dd_method", "dd_date", "dd_start_min", "dd_end_min", "dd_location_id"];
/**
* @param {Record<string, string | null | undefined>} attributes
* @returns {{ valid: true } | { valid: false, reason: "no_slot_selected" | "incomplete_slot_selection", missing: string[] }}
*/
export function evaluateCartAttributes(attributes) {
const missing = REQUIRED_ATTRIBUTES.filter((key) => !attributes[key]);
if (missing.length === 0) {
return { valid: true };
}
if (missing.length === REQUIRED_ATTRIBUTES.length) {
return { valid: false, reason: "no_slot_selected", missing };
}
return { valid: false, reason: "incomplete_slot_selection", missing };
}

View File

@ -0,0 +1,19 @@
query RunInput {
cart {
ddMethod: attribute(key: "dd_method") {
value
}
ddDate: attribute(key: "dd_date") {
value
}
ddStartMin: attribute(key: "dd_start_min") {
value
}
ddEndMin: attribute(key: "dd_end_min") {
value
}
ddLocationId: attribute(key: "dd_location_id") {
value
}
}
}

View File

@ -0,0 +1,43 @@
// @ts-check
import { evaluateCartAttributes } from "./evaluate.js";
/**
* @typedef {import("../generated/api").CartInput} RunInput
* @typedef {import("../generated/api").FunctionRunResult} FunctionRunResult
*/
const ERROR_MESSAGE = {
no_slot_selected: "Please choose a delivery date and time before checking out.",
incomplete_slot_selection: "Your delivery date/time selection is incomplete — please choose it again.",
};
/**
* Cart & Checkout Validation Function the server-side enforcement half of
* the widget/Function split (see evaluate.js for the full scope note).
* @param {RunInput} input
* @returns {FunctionRunResult}
*/
export function cartCheckoutValidationRun(input) {
const attributes = {
dd_method: input.cart.ddMethod?.value,
dd_date: input.cart.ddDate?.value,
dd_start_min: input.cart.ddStartMin?.value,
dd_end_min: input.cart.ddEndMin?.value,
dd_location_id: input.cart.ddLocationId?.value,
};
const result = evaluateCartAttributes(attributes);
if (result.valid) {
return { errors: [] };
}
return {
errors: [
{
localizedMessage: ERROR_MESSAGE[result.reason],
target: "cart",
},
],
};
}

1
package-lock.json generated
View File

@ -35,6 +35,7 @@
"@types/node": "^22.2.0", "@types/node": "^22.2.0",
"@types/react": "^18.2.31", "@types/react": "^18.2.31",
"@types/react-dom": "^18.2.14", "@types/react-dom": "^18.2.14",
"dotenv": "^16.4.7",
"esbuild": "^0.24.2", "esbuild": "^0.24.2",
"eslint": "^8.42.0", "eslint": "^8.42.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",

View File

@ -17,6 +17,7 @@
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest", "test": "vitest",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"build:widget": "esbuild extensions/datetime-widget/src/datetime-widget.ts --bundle --minify --target=es2019 --outfile=extensions/datetime-widget/assets/datetime-widget.js", "build:widget": "esbuild extensions/datetime-widget/src/datetime-widget.ts --bundle --minify --target=es2019 --outfile=extensions/datetime-widget/assets/datetime-widget.js",
"worker": "tsx jobs/worker.ts", "worker": "tsx jobs/worker.ts",
@ -59,6 +60,7 @@
"@types/node": "^22.2.0", "@types/node": "^22.2.0",
"@types/react": "^18.2.31", "@types/react": "^18.2.31",
"@types/react-dom": "^18.2.14", "@types/react-dom": "^18.2.14",
"dotenv": "^16.4.7",
"esbuild": "^0.24.2", "esbuild": "^0.24.2",
"eslint": "^8.42.0", "eslint": "^8.42.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",

View File

@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "Booking" (
"id" TEXT NOT NULL,
"shopDomain" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"orderName" TEXT,
"locationId" TEXT NOT NULL,
"method" "Method" NOT NULL,
"slotStart" TIMESTAMP(3) NOT NULL,
"slotEnd" TIMESTAMP(3) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'confirmed',
"customerEmail" TEXT,
"customerPhone" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Booking_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Booking_orderId_key" ON "Booking"("orderId");
-- CreateIndex
CREATE INDEX "Booking_shopDomain_locationId_method_slotStart_idx" ON "Booking"("shopDomain", "locationId", "method", "slotStart");
-- CreateIndex
CREATE INDEX "Booking_shopDomain_status_idx" ON "Booking"("shopDomain", "status");
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -66,6 +66,7 @@ model Location {
slotTemplates SlotTemplate[] slotTemplates SlotTemplate[]
overrides SlotOverride[] overrides SlotOverride[]
blackouts BlackoutDate[] blackouts BlackoutDate[]
bookings Booking[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([shopDomain]) @@index([shopDomain])
@ -117,3 +118,28 @@ model BlackoutDate {
@@index([shopDomain, date]) @@index([shopDomain, date])
} }
model Booking {
id String @id @default(cuid())
shopDomain String
orderId String @unique // Shopify order GID — also our idempotency key for webhook retries
orderName String? // e.g. "#1001", for display only
locationId String
location Location @relation(fields: [locationId], references: [id])
method Method
slotStart DateTime
slotEnd DateTime
status String @default("confirmed") // confirmed|cancelled|fulfilled|no_show
customerEmail String?
customerPhone String?
createdAt DateTime @default(now())
@@index([shopDomain, locationId, method, slotStart])
@@index([shopDomain, status])
}
// SlotHold is intentionally NOT a Prisma model — per IMPLEMENTATION_PLAN.md
// §4 it's "App DB (Redis-backed)": Redis's native TTL/expiry is exactly the
// semantics a soft, time-limited reservation needs, so it's the sole source
// of truth for holds (app/services/holds.server.ts). Mirroring it into
// Postgres too would only add a sync-consistency burden with no benefit.

View File

@ -0,0 +1,90 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import db from "../../app/db.server";
import redis from "../../app/lib/redis.server";
import { createBookingFromOrder } from "../../app/services/booking.server";
import { countActiveHolds, tryCreateHold } from "../../app/services/holds.server";
const shopDomain = "booking-integration-test.myshopify.com";
async function cleanup() {
await db.booking.deleteMany({ where: { shopDomain } });
await db.location.deleteMany({ where: { shopDomain } });
await db.shop.deleteMany({ where: { shopDomain } });
}
describe("createBookingFromOrder", () => {
beforeEach(cleanup);
afterAll(async () => {
await cleanup();
await db.$disconnect();
await redis.quit();
});
it("creates a Booking from an order's dd_* attributes and releases the matching hold", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" },
});
const slot = {
shopDomain,
locationId: location.id,
method: "PICKUP" as const,
slotStartIso: "2026-08-25T13:00:00.000Z",
};
await tryCreateHold(slot, "cart_abc123", 5);
expect(await countActiveHolds(slot)).toBe(1);
await createBookingFromOrder(shopDomain, {
admin_graphql_api_id: "gid://shopify/Order/1",
name: "#1001",
cart_token: "cart_abc123",
email: "shopper@example.com",
note_attributes: [
{ name: "dd_method", value: "PICKUP" },
{ name: "dd_date", value: "2026-08-25" },
{ name: "dd_start_min", value: "540" },
{ name: "dd_end_min", value: "600" },
{ name: "dd_location_id", value: location.id },
],
});
const booking = await db.booking.findUnique({ where: { orderId: "gid://shopify/Order/1" } });
expect(booking).not.toBeNull();
expect(booking?.method).toBe("PICKUP");
expect(booking?.status).toBe("confirmed");
expect(booking?.customerEmail).toBe("shopper@example.com");
expect(booking?.slotStart.toISOString()).toBe("2026-08-25T13:00:00.000Z"); // 9 AM EDT
// The hold this order consumed should now be released.
expect(await countActiveHolds(slot)).toBe(0);
});
it("is idempotent — a redelivered webhook does not create a second Booking", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" },
});
const order = {
admin_graphql_api_id: "gid://shopify/Order/2",
note_attributes: [
{ name: "dd_method", value: "PICKUP" },
{ name: "dd_date", value: "2026-08-25" },
{ name: "dd_start_min", value: "540" },
{ name: "dd_end_min", value: "600" },
{ name: "dd_location_id", value: location.id },
],
};
await createBookingFromOrder(shopDomain, order);
await createBookingFromOrder(shopDomain, order); // redelivery
const count = await db.booking.count({ where: { shopDomain, orderId: "gid://shopify/Order/2" } });
expect(count).toBe(1);
});
it("does nothing for an order with no scheduling attributes", async () => {
await createBookingFromOrder(shopDomain, { admin_graphql_api_id: "gid://shopify/Order/3", note_attributes: [] });
const count = await db.booking.count({ where: { shopDomain, orderId: "gid://shopify/Order/3" } });
expect(count).toBe(0);
});
});

View File

@ -0,0 +1,98 @@
import { afterAll, describe, expect, it } from "vitest";
import redis from "../../app/lib/redis.server";
import { countActiveHolds, releaseHold, tryCreateHold, type SlotIdentity } from "../../app/services/holds.server";
// The concurrency test CLAUDE.md calls out as non-negotiable: "Slot-holds
// (Redis, TTL) prevent last-slot double-booking — this has a dedicated
// concurrency test that must pass." This needs a real Redis — the
// atomicity guarantee comes from a Lua script Redis runs single-threaded,
// which a mock can't meaningfully exercise. Run via `npm run test:integration`
// against the docker-compose Redis (or CI's redis service).
function uniqueSlot(): SlotIdentity {
return {
shopDomain: "concurrency-test.myshopify.com",
locationId: "loc_test",
method: "PICKUP",
slotStartIso: `2026-08-25T13:00:00.000Z#${Math.random().toString(36).slice(2)}`,
};
}
describe("tryCreateHold concurrency", () => {
afterAll(async () => {
await redis.quit();
});
it("lets exactly one of many concurrent requests claim the last unit of capacity", async () => {
const slot = uniqueSlot();
const capacity = 1;
const contenders = 20;
const results = await Promise.all(
Array.from({ length: contenders }, (_, i) => tryCreateHold(slot, `cart-${i}`, capacity)),
);
const successes = results.filter((r) => r.success);
expect(successes).toHaveLength(1);
const activeCount = await countActiveHolds(slot);
expect(activeCount).toBe(1);
});
it("allows exactly `capacity` concurrent holds, no more, no fewer", async () => {
const slot = uniqueSlot();
const capacity = 5;
const contenders = 30;
const results = await Promise.all(
Array.from({ length: contenders }, (_, i) => tryCreateHold(slot, `cart-${i}`, capacity)),
);
expect(results.filter((r) => r.success)).toHaveLength(capacity);
expect(await countActiveHolds(slot)).toBe(capacity);
});
it("releasing a hold frees capacity for a subsequent request", async () => {
const slot = uniqueSlot();
const capacity = 1;
const first = await tryCreateHold(slot, "cart-a", capacity);
expect(first.success).toBe(true);
const blocked = await tryCreateHold(slot, "cart-b", capacity);
expect(blocked.success).toBe(false);
await releaseHold(slot, "cart-a");
const afterRelease = await tryCreateHold(slot, "cart-b", capacity);
expect(afterRelease.success).toBe(true);
});
it("an expired hold no longer counts against capacity", async () => {
const slot = uniqueSlot();
const capacity = 1;
// A negative TTL means it's already expired the instant it's created.
const first = await tryCreateHold(slot, "cart-expired", capacity, -1000);
expect(first.success).toBe(true);
const second = await tryCreateHold(slot, "cart-fresh", capacity);
expect(second.success).toBe(true);
expect(await countActiveHolds(slot)).toBe(1);
});
it("the same cart re-requesting the same slot does not consume a second unit", async () => {
const slot = uniqueSlot();
const capacity = 1;
const first = await tryCreateHold(slot, "cart-repeat", capacity);
expect(first.success).toBe(true);
// ZADD on an existing member updates its score rather than adding a
// second entry, so a shopper re-confirming the same slot (e.g. a retried
// request) doesn't burn extra capacity against themselves.
const second = await tryCreateHold(slot, "cart-repeat", capacity);
expect(second.success).toBe(true);
expect(await countActiveHolds(slot)).toBe(1);
});
});

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { renameLabelFor } from "../../extensions/delivery-customization/src/evaluate.js";
describe("renameLabelFor", () => {
it("relabels a pickup order unambiguously", () => {
const result = renameLabelFor({ dd_method: "PICKUP", dd_date: "2026-08-25" });
expect(result).toEqual({ rename: true, title: "Pickup — 2026-08-25" });
});
it("relabels a local delivery order distinctly from pickup", () => {
const result = renameLabelFor({ dd_method: "LOCAL_DELIVERY", dd_date: "2026-08-25" });
expect(result.rename).toBe(true);
if (!result.rename) throw new Error("unreachable");
expect(result.title).toContain("Local delivery");
expect(result.title).not.toContain("Pickup");
});
it("relabels shipping too, for consistency across all three methods", () => {
const result = renameLabelFor({ dd_method: "SHIPPING", dd_date: "2026-08-25" });
expect(result).toEqual({ rename: true, title: "Shipping — 2026-08-25" });
});
it("does nothing when no method was selected", () => {
expect(renameLabelFor({})).toEqual({ rename: false });
});
it("does nothing for an unrecognized method value", () => {
expect(renameLabelFor({ dd_method: "TELEPORT", dd_date: "2026-08-25" })).toEqual({ rename: false });
});
it("does nothing when the date is missing even if method is present", () => {
expect(renameLabelFor({ dd_method: "PICKUP" })).toEqual({ rename: false });
});
});

View File

@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { evaluateCartAttributes } from "../../extensions/validation-slot/src/evaluate.js";
const COMPLETE = {
dd_method: "PICKUP",
dd_date: "2026-08-25",
dd_start_min: "540",
dd_end_min: "600",
dd_location_id: "loc_123",
};
describe("evaluateCartAttributes", () => {
it("is valid when every required attribute is present", () => {
expect(evaluateCartAttributes(COMPLETE)).toEqual({ valid: true });
});
it("rejects a cart with no scheduling attributes at all", () => {
const result = evaluateCartAttributes({});
expect(result).toMatchObject({ valid: false, reason: "no_slot_selected" });
});
it("rejects a cart missing just one required attribute", () => {
const { dd_start_min, ...rest } = COMPLETE;
const result = evaluateCartAttributes(rest);
expect(result).toMatchObject({ valid: false, reason: "incomplete_slot_selection", missing: ["dd_start_min"] });
});
it("treats an empty-string attribute the same as missing", () => {
const result = evaluateCartAttributes({ ...COMPLETE, dd_date: "" });
expect(result.valid).toBe(false);
});
it("treats null/undefined attribute values as missing", () => {
const result = evaluateCartAttributes({ ...COMPLETE, dd_location_id: null, dd_method: undefined });
expect(result.valid).toBe(false);
if (result.valid) throw new Error("unreachable");
expect(result.reason).toBe("incomplete_slot_selection");
expect([...result.missing].sort()).toEqual(["dd_location_id", "dd_method"]);
});
});

View File

@ -0,0 +1,16 @@
import "dotenv/config";
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
// Separate from vitest.config.ts on purpose: these tests hit a real Redis
// (and, later, Postgres) instance rather than pure functions, so they're
// kept out of the fast `npm test` unit loop and run explicitly via
// `npm run test:integration` (locally: docker compose up -d; in CI: a
// redis service — see .github/workflows/ci.yml).
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: "node",
include: ["tests/integration/**/*.test.ts"],
},
});