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"], + }, +});