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:
parent
039b1539ab
commit
7a340ad135
32
.github/workflows/ci.yml
vendored
32
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
21
README.md
21
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.
|
||||
|
||||
98
app/routes/apps.scheduling.hold.tsx
Normal file
98
app/routes/apps.scheduling.hold.tsx
Normal 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),
|
||||
});
|
||||
};
|
||||
@ -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();
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
87
app/services/booking.server.ts
Normal file
87
app/services/booking.server.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
117
app/services/holds.server.ts
Normal file
117
app/services/holds.server.ts
Normal 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
@ -110,11 +110,41 @@ async function fetchAvailability(method: Method, locationId: string | null): Pro
|
||||
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", {
|
||||
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;
|
||||
|
||||
12
extensions/delivery-customization/package.json
Normal file
12
extensions/delivery-customization/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
22
extensions/delivery-customization/shopify.extension.toml
Normal file
22
extensions/delivery-customization/shopify.extension.toml
Normal 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"]
|
||||
31
extensions/delivery-customization/src/evaluate.js
Normal file
31
extensions/delivery-customization/src/evaluate.js
Normal 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}` };
|
||||
}
|
||||
15
extensions/delivery-customization/src/run.graphql
Normal file
15
extensions/delivery-customization/src/run.graphql
Normal file
@ -0,0 +1,15 @@
|
||||
query RunInput {
|
||||
cart {
|
||||
ddMethod: attribute(key: "dd_method") {
|
||||
value
|
||||
}
|
||||
ddDate: attribute(key: "dd_date") {
|
||||
value
|
||||
}
|
||||
deliveryGroups {
|
||||
deliveryOptions {
|
||||
handle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
extensions/delivery-customization/src/run.js
Normal file
42
extensions/delivery-customization/src/run.js
Normal 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 };
|
||||
}
|
||||
12
extensions/validation-slot/package.json
Normal file
12
extensions/validation-slot/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
27
extensions/validation-slot/shopify.extension.toml
Normal file
27
extensions/validation-slot/shopify.extension.toml
Normal 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"]
|
||||
39
extensions/validation-slot/src/evaluate.js
Normal file
39
extensions/validation-slot/src/evaluate.js
Normal 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 };
|
||||
}
|
||||
19
extensions/validation-slot/src/run.graphql
Normal file
19
extensions/validation-slot/src/run.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
43
extensions/validation-slot/src/run.js
Normal file
43
extensions/validation-slot/src/run.js
Normal 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
1
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
29
prisma/migrations/20260823221020_add_booking/migration.sql
Normal file
29
prisma/migrations/20260823221020_add_booking/migration.sql
Normal 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;
|
||||
@ -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.
|
||||
|
||||
90
tests/integration/booking.test.ts
Normal file
90
tests/integration/booking.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
98
tests/integration/holds.concurrency.test.ts
Normal file
98
tests/integration/holds.concurrency.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
34
tests/unit/delivery-customization.test.ts
Normal file
34
tests/unit/delivery-customization.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
40
tests/unit/validation-slot.test.ts
Normal file
40
tests/unit/validation-slot.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
16
vitest.integration.config.ts
Normal file
16
vitest.integration.config.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user