From 7a340ad135b8e26976e2418b5910eb7ee233f112 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Sun, 23 Aug 2026 18:22:51 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=204=20=E2=80=94=20enforcement=20F?= =?UTF-8?q?unctions=20+=20slot-holds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 32 ++++- README.md | 21 +++- app/routes/apps.scheduling.hold.tsx | 98 +++++++++++++++ app/routes/webhooks.orders.cancelled.tsx | 13 +- app/routes/webhooks.orders.create.tsx | 56 ++++++++- app/services/booking.server.ts | 87 +++++++++++++ app/services/holds.server.ts | 117 ++++++++++++++++++ .../datetime-widget/assets/datetime-widget.js | 2 +- .../datetime-widget/src/datetime-widget.ts | 82 ++++++++++-- .../delivery-customization/package.json | 12 ++ .../shopify.extension.toml | 22 ++++ .../delivery-customization/src/evaluate.js | 31 +++++ .../delivery-customization/src/run.graphql | 15 +++ extensions/delivery-customization/src/run.js | 42 +++++++ extensions/validation-slot/package.json | 12 ++ .../validation-slot/shopify.extension.toml | 27 ++++ extensions/validation-slot/src/evaluate.js | 39 ++++++ extensions/validation-slot/src/run.graphql | 19 +++ extensions/validation-slot/src/run.js | 43 +++++++ package-lock.json | 1 + package.json | 2 + .../20260823221020_add_booking/migration.sql | 29 +++++ prisma/schema.prisma | 26 ++++ tests/integration/booking.test.ts | 90 ++++++++++++++ tests/integration/holds.concurrency.test.ts | 98 +++++++++++++++ tests/unit/delivery-customization.test.ts | 34 +++++ tests/unit/validation-slot.test.ts | 40 ++++++ vitest.integration.config.ts | 16 +++ 28 files changed, 1082 insertions(+), 24 deletions(-) create mode 100644 app/routes/apps.scheduling.hold.tsx create mode 100644 app/services/booking.server.ts create mode 100644 app/services/holds.server.ts create mode 100644 extensions/delivery-customization/package.json create mode 100644 extensions/delivery-customization/shopify.extension.toml create mode 100644 extensions/delivery-customization/src/evaluate.js create mode 100644 extensions/delivery-customization/src/run.graphql create mode 100644 extensions/delivery-customization/src/run.js create mode 100644 extensions/validation-slot/package.json create mode 100644 extensions/validation-slot/shopify.extension.toml create mode 100644 extensions/validation-slot/src/evaluate.js create mode 100644 extensions/validation-slot/src/run.graphql create mode 100644 extensions/validation-slot/src/run.js create mode 100644 prisma/migrations/20260823221020_add_booking/migration.sql create mode 100644 tests/integration/booking.test.ts create mode 100644 tests/integration/holds.concurrency.test.ts create mode 100644 tests/unit/delivery-customization.test.ts create mode 100644 tests/unit/validation-slot.test.ts create mode 100644 vitest.integration.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da95435..d8aac0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,8 +4,34 @@ on: [push, pull_request] jobs: test: - name: Lint & Unit Tests + name: Lint, Unit & Integration Tests 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: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -22,6 +48,10 @@ jobs: run: npm run typecheck - name: Unit tests 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 run: npm run build - name: Build storefront widget diff --git a/README.md b/README.md index 22b7c63..62fc19b 100644 --- a/README.md +++ b/README.md @@ -34,15 +34,26 @@ runs the BullMQ worker (jobs/worker.ts) once Phase 4 makes it do anything. | `npx prisma migrate dev` | DB migrations | | `npm run deploy` | `shopify app deploy` — deploy extensions/functions | | `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 -Phase 0 (scaffold & CI), Phase 1 (core data model & admin CRUD), Phase 2 -(scheduling engine), and Phase 3 (storefront widget + cart attributes — -`extensions/datetime-widget/`) are complete. See §6 of -`IMPLEMENTATION_PLAN.md` for the phased build order and acceptance criteria -— next up is Phase 4 (enforcement Functions + slot-holds). +Phase 0 (scaffold & CI) through Phase 4 (enforcement Functions + +slot-holds) are complete. See §6 of `IMPLEMENTATION_PLAN.md` for the phased +build order and acceptance criteria — next up is Phase 5 (multi-location, +zones, rates, auto-assignment). Editing the storefront widget's `extensions/datetime-widget/src/`? Run `npm run build:widget` to rebuild `assets/datetime-widget.js` — it also runs 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. diff --git a/app/routes/apps.scheduling.hold.tsx b/app/routes/apps.scheduling.hold.tsx new file mode 100644 index 0000000..e5e08aa --- /dev/null +++ b/app/routes/apps.scheduling.hold.tsx @@ -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(["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), + }); +}; diff --git a/app/routes/webhooks.orders.cancelled.tsx b/app/routes/webhooks.orders.cancelled.tsx index f4e2c44..c00b527 100644 --- a/app/routes/webhooks.orders.cancelled.tsx +++ b/app/routes/webhooks.orders.cancelled.tsx @@ -1,12 +1,19 @@ import type { ActionFunctionArgs } from "@remix-run/node"; import { authenticate } from "../shopify.server"; +import db from "../db.server"; -// TODO (Phase 4): mark the linked Booking cancelled and free its -// capacity/resources. See IMPLEMENTATION_PLAN.md §5.2. +// Marks the linked Booking cancelled, which frees its capacity for +// getAvailability()/the hold endpoint's confirmedCount check on the next +// request — see IMPLEMENTATION_PLAN.md §5.2. export const action = async ({ request }: ActionFunctionArgs) => { 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(); }; diff --git a/app/routes/webhooks.orders.create.tsx b/app/routes/webhooks.orders.create.tsx index f8fab69..107cc02 100644 --- a/app/routes/webhooks.orders.create.tsx +++ b/app/routes/webhooks.orders.create.tsx @@ -1,13 +1,59 @@ import type { ActionFunctionArgs } from "@remix-run/node"; 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, -// consume capacity/resources, write the slot back onto the order via -// metafield, and release the hold. See IMPLEMENTATION_PLAN.md §5.2. +// Converts the cart's dd_* attributes into a confirmed Booking, releases +// the matching Redis hold, and writes the slot back onto the order via a +// 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) => { - 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(); }; + +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 }; +} diff --git a/app/services/booking.server.ts b/app/services/booking.server.ts new file mode 100644 index 0000000..c5775f7 --- /dev/null +++ b/app/services/booking.server.ts @@ -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(["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, + ); + } +} diff --git a/app/services/holds.server.ts b/app/services/holds.server.ts new file mode 100644 index 0000000..200f6df --- /dev/null +++ b/app/services/holds.server.ts @@ -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 { + tryCreateHoldScript( + key: string, + now: number, + expiresAt: number, + member: string, + capacity: number, + ): Promise; + } +} + +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 { + 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 { + 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 { + const key = holdSetKey(slot); + await redis.zremrangebyscore(key, "-inf", Date.now()); + return redis.zcard(key); +} diff --git a/extensions/datetime-widget/assets/datetime-widget.js b/extensions/datetime-widget/assets/datetime-widget.js index 07700fe..025bafe 100644 --- a/extensions/datetime-widget/assets/datetime-widget.js +++ b/extensions/datetime-widget/assets/datetime-widget.js @@ -1 +1 @@ -"use strict";(()=>{var g=Object.defineProperty;var u=(n,t,e)=>t in n?g(n,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):n[t]=e;var l=(n,t,e)=>u(n,typeof t!="symbol"?t+"":t,e);var f="/apps/scheduling";function r(n){let t=Math.floor(n/60),e=n%60,i=t<12?"AM":"PM";return`${t%12===0?12:t%12}:${e.toString().padStart(2,"0")} ${i}`}function c(n){let[t,e,i]=n.split("-").map(Number);return new Date(Date.UTC(t,e-1,i)).toLocaleDateString(void 0,{weekday:"short",month:"short",day:"numeric",timeZone:"UTC"})}function b(n){let t=n.dataset,e=[];return t.showShipping==="true"&&e.push({value:"SHIPPING",label:t.labelShipping||"Shipping",attrLabel:t.attrLabelShipping||"Shipping date"}),t.showLocalDelivery==="true"&&e.push({value:"LOCAL_DELIVERY",label:t.labelLocalDelivery||"Local delivery",attrLabel:t.attrLabelLocalDelivery||"Delivery date"}),t.showPickup==="true"&&e.push({value:"PICKUP",label:t.labelPickup||"Pickup",attrLabel:t.attrLabelPickup||"Pickup date"}),{root:n,heading:t.heading||"",locationId:t.locationId||null,methods:e,labels:{chooseDate:t.labelChooseDate||"Choose a date",chooseTime:t.labelChooseTime||"Choose a time",noDates:t.labelNoDates||"No dates are available right now.",confirmed:t.labelConfirmed||"Confirmed for",change:t.labelChange||"Change",loading:t.labelLoading||"Loading available dates\u2026",error:t.labelError||"Couldn't load available dates. Please try again."}}}async function p(n,t){let e=new URLSearchParams({method:n,days:"14"});t&&e.set("locationId",t);let i=await fetch(`${f}/availability?${e.toString()}`,{headers:{Accept:"application/json"}}),s=await i.json();if(!i.ok)throw new Error(s.error||`Request failed (${i.status})`);return s}async function w(n,t){await fetch("/cart/update.js",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({attributes:{[n]:t.display,...t}})})}var h=class{constructor(t){l(this,"config");l(this,"el",{heading:document.createElement("h3"),methodRow:document.createElement("div"),dateRow:document.createElement("div"),timeRow:document.createElement("div"),status:document.createElement("p"),confirmation:document.createElement("div")});l(this,"selectedMethod",null);l(this,"selectedDate",null);l(this,"availability",null);this.config=t}mount(){let{root:t,heading:e,methods:i}=this.config;t.classList.add("dd-widget--ready"),t.innerHTML="",i.length!==0&&(e&&(this.el.heading.className="dd-widget__heading",this.el.heading.textContent=e,t.appendChild(this.el.heading)),this.el.methodRow.className="dd-widget__row dd-widget__methods",this.el.dateRow.className="dd-widget__row dd-widget__dates",this.el.timeRow.className="dd-widget__row dd-widget__times",this.el.status.className="dd-widget__status",this.el.confirmation.className="dd-widget__confirmation",this.el.confirmation.hidden=!0,t.append(this.el.confirmation,this.el.methodRow,this.el.dateRow,this.el.timeRow,this.el.status),i.length===1?this.selectMethod(i[0]):this.renderMethods())}renderMethods(){var t;this.el.methodRow.innerHTML="";for(let e of this.config.methods){let i=document.createElement("button");i.type="button",i.className="dd-widget__pill",i.textContent=e.label,i.setAttribute("aria-pressed",String(((t=this.selectedMethod)==null?void 0:t.value)===e.value)),i.addEventListener("click",()=>this.selectMethod(e)),this.el.methodRow.appendChild(i)}}async selectMethod(t){this.selectedMethod=t,this.selectedDate=null,this.el.timeRow.innerHTML="",this.el.confirmation.hidden=!0,this.config.methods.length>1&&this.renderMethods(),this.el.status.textContent=this.config.labels.loading,this.el.dateRow.innerHTML="";try{this.availability=await p(t.value,this.config.locationId),this.renderDates()}catch{this.el.status.textContent=this.config.labels.error}}renderDates(){var e,i;let t=Object.keys((i=(e=this.availability)==null?void 0:e.dates)!=null?i:{}).sort();if(this.el.dateRow.innerHTML="",t.length===0){this.el.status.textContent=this.config.labels.noDates;return}this.el.status.textContent=this.config.labels.chooseDate;for(let s of t){let a=document.createElement("button");a.type="button",a.className="dd-widget__pill",a.textContent=c(s),a.setAttribute("aria-pressed",String(this.selectedDate===s)),a.addEventListener("click",()=>this.selectDate(s)),this.el.dateRow.appendChild(a)}}selectDate(t){var i,s;this.selectedDate=t;for(let a of Array.from(this.el.dateRow.children))a.setAttribute("aria-pressed",String(a.textContent===c(t)));let e=(s=(i=this.availability)==null?void 0:i.dates[t])!=null?s:[];this.el.timeRow.innerHTML="",this.el.status.textContent=this.config.labels.chooseTime;for(let a of e){let o=document.createElement("button");o.type="button",o.className="dd-widget__pill",o.textContent=`${r(a.startMin)}\u2013${r(a.endMin)}`,o.addEventListener("click",()=>this.selectSlot(t,a)),this.el.timeRow.appendChild(o)}}async selectSlot(t,e){let i=this.selectedMethod,s=this.availability,a=`${c(t)}, ${r(e.startMin)}\u2013${r(e.endMin)}`;this.el.status.textContent=this.config.labels.loading;try{await w(i.attrLabel,{display:a,dd_method:i.value,dd_date:t,dd_start_min:String(e.startMin),dd_end_min:String(e.endMin),dd_location_id:s.locationId}),this.el.status.textContent="",this.el.methodRow.hidden=!0,this.el.dateRow.hidden=!0,this.el.timeRow.hidden=!0,this.el.confirmation.hidden=!1,this.el.confirmation.innerHTML="";let o=document.createElement("p");o.textContent=`${this.config.labels.confirmed} ${a}`;let d=document.createElement("button");d.type="button",d.className="dd-widget__link",d.textContent=this.config.labels.change,d.addEventListener("click",()=>{this.el.methodRow.hidden=!1,this.el.dateRow.hidden=!1,this.el.timeRow.hidden=!1,this.el.confirmation.hidden=!0}),this.el.confirmation.append(o,d)}catch{this.el.status.textContent=this.config.labels.error}}};function m(){document.querySelectorAll("[data-dd-widget]").forEach(t=>{new h(b(t)).mount()})}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m();})(); +"use strict";(()=>{var p=Object.defineProperty;var b=(n,t,e)=>t in n?p(n,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):n[t]=e;var l=(n,t,e)=>b(n,typeof t!="symbol"?t+"":t,e);var f="/apps/scheduling";function d(n){let t=Math.floor(n/60),e=n%60,i=t<12?"AM":"PM";return`${t%12===0?12:t%12}:${e.toString().padStart(2,"0")} ${i}`}function c(n){let[t,e,i]=n.split("-").map(Number);return new Date(Date.UTC(t,e-1,i)).toLocaleDateString(void 0,{weekday:"short",month:"short",day:"numeric",timeZone:"UTC"})}function w(n){let t=n.dataset,e=[];return t.showShipping==="true"&&e.push({value:"SHIPPING",label:t.labelShipping||"Shipping",attrLabel:t.attrLabelShipping||"Shipping date"}),t.showLocalDelivery==="true"&&e.push({value:"LOCAL_DELIVERY",label:t.labelLocalDelivery||"Local delivery",attrLabel:t.attrLabelLocalDelivery||"Delivery date"}),t.showPickup==="true"&&e.push({value:"PICKUP",label:t.labelPickup||"Pickup",attrLabel:t.attrLabelPickup||"Pickup date"}),{root:n,heading:t.heading||"",locationId:t.locationId||null,methods:e,labels:{chooseDate:t.labelChooseDate||"Choose a date",chooseTime:t.labelChooseTime||"Choose a time",noDates:t.labelNoDates||"No dates are available right now.",confirmed:t.labelConfirmed||"Confirmed for",change:t.labelChange||"Change",loading:t.labelLoading||"Loading available dates\u2026",error:t.labelError||"Couldn't load available dates. Please try again."}}}async function y(n,t){let e=new URLSearchParams({method:n,days:"14"});t&&e.set("locationId",t);let i=await fetch(`${f}/availability?${e.toString()}`,{headers:{Accept:"application/json"}}),o=await i.json();if(!i.ok)throw new Error(o.error||`Request failed (${i.status})`);return o}async function v(){return(await(await fetch("/cart.js",{headers:{Accept:"application/json"}})).json()).token}async function g(n){let t=await fetch(`${f}/hold`,{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify(n)}),e=await t.json();return!t.ok&&n.intent==="create"?{success:!1,error:e.error||"Slot unavailable"}:e}async function C(n,t,e){await fetch("/cart/update.js",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({attributes:{[n]:e,...t}})})}var h=class{constructor(t){l(this,"config");l(this,"el",{heading:document.createElement("h3"),methodRow:document.createElement("div"),dateRow:document.createElement("div"),timeRow:document.createElement("div"),status:document.createElement("p"),confirmation:document.createElement("div")});l(this,"selectedMethod",null);l(this,"selectedDate",null);l(this,"availability",null);l(this,"heldSlot",null);this.config=t}mount(){let{root:t,heading:e,methods:i}=this.config;t.classList.add("dd-widget--ready"),t.innerHTML="",i.length!==0&&(e&&(this.el.heading.className="dd-widget__heading",this.el.heading.textContent=e,t.appendChild(this.el.heading)),this.el.methodRow.className="dd-widget__row dd-widget__methods",this.el.dateRow.className="dd-widget__row dd-widget__dates",this.el.timeRow.className="dd-widget__row dd-widget__times",this.el.status.className="dd-widget__status",this.el.confirmation.className="dd-widget__confirmation",this.el.confirmation.hidden=!0,t.append(this.el.confirmation,this.el.methodRow,this.el.dateRow,this.el.timeRow,this.el.status),i.length===1?this.selectMethod(i[0]):this.renderMethods())}renderMethods(){var t;this.el.methodRow.innerHTML="";for(let e of this.config.methods){let i=document.createElement("button");i.type="button",i.className="dd-widget__pill",i.textContent=e.label,i.setAttribute("aria-pressed",String(((t=this.selectedMethod)==null?void 0:t.value)===e.value)),i.addEventListener("click",()=>this.selectMethod(e)),this.el.methodRow.appendChild(i)}}async selectMethod(t){this.selectedMethod=t,this.selectedDate=null,this.el.timeRow.innerHTML="",this.el.confirmation.hidden=!0,this.config.methods.length>1&&this.renderMethods(),this.el.status.textContent=this.config.labels.loading,this.el.dateRow.innerHTML="";try{this.availability=await y(t.value,this.config.locationId),this.renderDates()}catch{this.el.status.textContent=this.config.labels.error}}renderDates(){var e,i;let t=Object.keys((i=(e=this.availability)==null?void 0:e.dates)!=null?i:{}).sort();if(this.el.dateRow.innerHTML="",t.length===0){this.el.status.textContent=this.config.labels.noDates;return}this.el.status.textContent=this.config.labels.chooseDate;for(let o of t){let a=document.createElement("button");a.type="button",a.className="dd-widget__pill",a.textContent=c(o),a.setAttribute("aria-pressed",String(this.selectedDate===o)),a.addEventListener("click",()=>this.selectDate(o)),this.el.dateRow.appendChild(a)}}selectDate(t){var i,o;this.selectedDate=t;for(let a of Array.from(this.el.dateRow.children))a.setAttribute("aria-pressed",String(a.textContent===c(t)));let e=(o=(i=this.availability)==null?void 0:i.dates[t])!=null?o:[];this.el.timeRow.innerHTML="",this.el.status.textContent=this.config.labels.chooseTime;for(let a of e){let s=document.createElement("button");s.type="button",s.className="dd-widget__pill",s.textContent=`${d(a.startMin)}\u2013${d(a.endMin)}`,s.addEventListener("click",()=>this.selectSlot(t,a)),this.el.timeRow.appendChild(s)}}async selectSlot(t,e){let i=this.selectedMethod,o=this.availability,a=`${c(t)}, ${d(e.startMin)}\u2013${d(e.endMin)}`;this.el.status.textContent=this.config.labels.loading;try{let s=await v();if(!(await g({intent:"create",locationId:o.locationId,method:i.value,date:t,startMin:e.startMin,cartToken:s})).success){this.el.status.textContent=this.config.labels.error,await this.selectMethod(i);return}this.heldSlot={locationId:o.locationId,method:i.value,date:t,startMin:e.startMin,cartToken:s},await C(i.attrLabel,{dd_method:i.value,dd_date:t,dd_start_min:String(e.startMin),dd_end_min:String(e.endMin),dd_location_id:o.locationId},a),this.el.status.textContent="",this.el.methodRow.hidden=!0,this.el.dateRow.hidden=!0,this.el.timeRow.hidden=!0,this.el.confirmation.hidden=!1,this.el.confirmation.innerHTML="";let u=document.createElement("p");u.textContent=`${this.config.labels.confirmed} ${a}`;let r=document.createElement("button");r.type="button",r.className="dd-widget__link",r.textContent=this.config.labels.change,r.addEventListener("click",()=>{this.heldSlot&&(g({intent:"release",...this.heldSlot}),this.heldSlot=null),this.el.methodRow.hidden=!1,this.el.dateRow.hidden=!1,this.el.timeRow.hidden=!1,this.el.confirmation.hidden=!0}),this.el.confirmation.append(u,r)}catch{this.el.status.textContent=this.config.labels.error}}};function m(){document.querySelectorAll("[data-dd-widget]").forEach(t=>{new h(w(t)).mount()})}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m();})(); diff --git a/extensions/datetime-widget/src/datetime-widget.ts b/extensions/datetime-widget/src/datetime-widget.ts index b0b85d3..0af96c4 100644 --- a/extensions/datetime-widget/src/datetime-widget.ts +++ b/extensions/datetime-widget/src/datetime-widget.ts @@ -110,11 +110,41 @@ async function fetchAvailability(method: Method, locationId: string | null): Pro return body; } -async function writeCartAttribute(key: string, value: Record): Promise { +interface HoldResponse { + success: boolean; + expiresAt?: number; + error?: string; +} + +async function getCartToken(): Promise { + 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 { + 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, display: string): Promise { await fetch("/cart/update.js", { method: "POST", 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 selectedDate: string | null = null; private availability: AvailabilityResponse | null = null; + private heldSlot: { locationId: string; method: Method; date: string; startMin: number; cartToken: string } | null = + null; constructor(config: WidgetConfig) { this.config = config; @@ -245,15 +277,43 @@ class DateTimeWidget { this.el.status.textContent = this.config.labels.loading; try { - await writeCartAttribute(method.attrLabel, { - display, - dd_method: method.value, - dd_date: date, - dd_start_min: String(slot.startMin), - dd_end_min: String(slot.endMin), - dd_location_id: availability.locationId, + const cartToken = await getCartToken(); + + // Reserve capacity FIRST. Writing the cart attribute alone would just + // be two shoppers racing to write the same free-text field — nothing + // would stop both checkouts from completing for the last slot. The + // hold is what the Validation Function (Phase 4) actually enforces + // 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.methodRow.hidden = true; this.el.dateRow.hidden = true; @@ -268,6 +328,10 @@ class DateTimeWidget { changeButton.className = "dd-widget__link"; changeButton.textContent = this.config.labels.change; changeButton.addEventListener("click", () => { + if (this.heldSlot) { + void requestHold({ intent: "release", ...this.heldSlot }); + this.heldSlot = null; + } this.el.methodRow.hidden = false; this.el.dateRow.hidden = false; this.el.timeRow.hidden = false; diff --git a/extensions/delivery-customization/package.json b/extensions/delivery-customization/package.json new file mode 100644 index 0000000..d4ef75a --- /dev/null +++ b/extensions/delivery-customization/package.json @@ -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" + } +} diff --git a/extensions/delivery-customization/shopify.extension.toml b/extensions/delivery-customization/shopify.extension.toml new file mode 100644 index 0000000..3147777 --- /dev/null +++ b/extensions/delivery-customization/shopify.extension.toml @@ -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"] diff --git a/extensions/delivery-customization/src/evaluate.js b/extensions/delivery-customization/src/evaluate.js new file mode 100644 index 0000000..312ef0c --- /dev/null +++ b/extensions/delivery-customization/src/evaluate.js @@ -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} */ +const METHOD_LABEL = { + PICKUP: "Pickup", + LOCAL_DELIVERY: "Local delivery", + SHIPPING: "Shipping", +}; + +/** + * @param {Record} 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}` }; +} diff --git a/extensions/delivery-customization/src/run.graphql b/extensions/delivery-customization/src/run.graphql new file mode 100644 index 0000000..bf6bc84 --- /dev/null +++ b/extensions/delivery-customization/src/run.graphql @@ -0,0 +1,15 @@ +query RunInput { + cart { + ddMethod: attribute(key: "dd_method") { + value + } + ddDate: attribute(key: "dd_date") { + value + } + deliveryGroups { + deliveryOptions { + handle + } + } + } +} diff --git a/extensions/delivery-customization/src/run.js b/extensions/delivery-customization/src/run.js new file mode 100644 index 0000000..c16c50f --- /dev/null +++ b/extensions/delivery-customization/src/run.js @@ -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 }; +} diff --git a/extensions/validation-slot/package.json b/extensions/validation-slot/package.json new file mode 100644 index 0000000..5100a3d --- /dev/null +++ b/extensions/validation-slot/package.json @@ -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" + } +} diff --git a/extensions/validation-slot/shopify.extension.toml b/extensions/validation-slot/shopify.extension.toml new file mode 100644 index 0000000..5dec148 --- /dev/null +++ b/extensions/validation-slot/shopify.extension.toml @@ -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"] diff --git a/extensions/validation-slot/src/evaluate.js b/extensions/validation-slot/src/evaluate.js new file mode 100644 index 0000000..9c1134b --- /dev/null +++ b/extensions/validation-slot/src/evaluate.js @@ -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} 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 }; +} diff --git a/extensions/validation-slot/src/run.graphql b/extensions/validation-slot/src/run.graphql new file mode 100644 index 0000000..f3e349f --- /dev/null +++ b/extensions/validation-slot/src/run.graphql @@ -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 + } + } +} diff --git a/extensions/validation-slot/src/run.js b/extensions/validation-slot/src/run.js new file mode 100644 index 0000000..f0237f9 --- /dev/null +++ b/extensions/validation-slot/src/run.js @@ -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", + }, + ], + }; +} diff --git a/package-lock.json b/package-lock.json index 0063270..97ceeb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@types/node": "^22.2.0", "@types/react": "^18.2.31", "@types/react-dom": "^18.2.14", + "dotenv": "^16.4.7", "esbuild": "^0.24.2", "eslint": "^8.42.0", "eslint-config-prettier": "^10.0.1", diff --git a/package.json b/package.json index 02320ab..2336246 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", "typecheck": "tsc --noEmit", "test": "vitest", + "test:integration": "vitest run --config vitest.integration.config.ts", "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", "worker": "tsx jobs/worker.ts", @@ -59,6 +60,7 @@ "@types/node": "^22.2.0", "@types/react": "^18.2.31", "@types/react-dom": "^18.2.14", + "dotenv": "^16.4.7", "esbuild": "^0.24.2", "eslint": "^8.42.0", "eslint-config-prettier": "^10.0.1", diff --git a/prisma/migrations/20260823221020_add_booking/migration.sql b/prisma/migrations/20260823221020_add_booking/migration.sql new file mode 100644 index 0000000..bd873f1 --- /dev/null +++ b/prisma/migrations/20260823221020_add_booking/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 99f41ea..7d1faed 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -66,6 +66,7 @@ model Location { slotTemplates SlotTemplate[] overrides SlotOverride[] blackouts BlackoutDate[] + bookings Booking[] createdAt DateTime @default(now()) @@index([shopDomain]) @@ -117,3 +118,28 @@ model BlackoutDate { @@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. diff --git a/tests/integration/booking.test.ts b/tests/integration/booking.test.ts new file mode 100644 index 0000000..aeef995 --- /dev/null +++ b/tests/integration/booking.test.ts @@ -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); + }); +}); diff --git a/tests/integration/holds.concurrency.test.ts b/tests/integration/holds.concurrency.test.ts new file mode 100644 index 0000000..5c1ad61 --- /dev/null +++ b/tests/integration/holds.concurrency.test.ts @@ -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); + }); +}); diff --git a/tests/unit/delivery-customization.test.ts b/tests/unit/delivery-customization.test.ts new file mode 100644 index 0000000..5632659 --- /dev/null +++ b/tests/unit/delivery-customization.test.ts @@ -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 }); + }); +}); diff --git a/tests/unit/validation-slot.test.ts b/tests/unit/validation-slot.test.ts new file mode 100644 index 0000000..d08a60f --- /dev/null +++ b/tests/unit/validation-slot.test.ts @@ -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"]); + }); +}); diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts new file mode 100644 index 0000000..6db8e6e --- /dev/null +++ b/vitest.integration.config.ts @@ -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"], + }, +});