Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
Also fixes the [events] gate that was blocking ALL extension generation
(discovered while starting this phase).
- shopify.app.toml: this org appears enrolled in Shopify's "Next
Generation Events" developer preview, which the CLI now treats as a
REQUIRED top-level [events] section even though nothing in this app
actually uses it (real webhook handling is entirely classic [webhooks],
unaffected). Iteratively discovered the required shape from the CLI's
own field-by-field validation errors, then found the real docs (Events
is optional/developer-preview, api_version pinned to "unstable") to
confirm rather than keep guessing. Added a functionally-inert
[[events.subscription]] placeholder + a stub handler
(webhooks.events.placeholder.tsx) solely to satisfy the gate.
- app/services/availability-request.server.ts +
app/services/hold-request.server.ts: extracted the resolution logic that
used to live directly in apps.scheduling.availability.tsx/hold.tsx into
shared functions. This is what actually makes "same capacity pool feeds
every surface" (CLAUDE.md) true by construction rather than by
convention — the storefront, POS, and checkout routes now call the exact
same code, not three copies that could quietly drift apart.
- extensions/pos-datetime (generated via `shopify app generate extension
--template=pos_smart_grid` — pos_action's flavor requirement contradicted
the CLI's own global --flavor validator, so smart_grid was used instead):
a home-screen tile opening a modal where staff pick method -> date -> time
against the same availability/hold endpoints (pos.scheduling.*.tsx,
session-token authenticated), writing the same dd_* cart properties via
CartApi.addCartProperties — booking.server.ts needed zero changes to
handle POS-originated orders. Several API-shape guesses (toast isError
option, ChoiceList's `value`/`label` props, a nonexistent
action.dismissModal(), shopify.cart.cart.current) were wrong and caught
by typechecking directly against @shopify/ui-extensions' own bundled
.d.ts files (`npm run typecheck:pos`, now also in CI) — none of this was
verified against a live POS session, which isn't possible in this
environment.
- extensions/checkout-datetime (generated via `--template=checkout_ui`):
the Plus-only native picker in checkout itself
(purchase.checkout.block.render) plus a Thank You confirmation block
(purchase.thank-you.block.render). Went looking for an order-status
target too ("all plans show confirmed slot on thank-you/order-status" is
the Phase 7 accept criterion) and confirmed via the installed package's
own type definitions that purchase.order-status.block.render does not
exist in this API version — checkout UI extensions' thank-you/order-status
surfaces are Plus-only regardless. The actual "all plans" mechanism is
extensions/datetime-widget/blocks/order-confirmation.liquid — a new Theme
App Extension block reading order.note_attributes, which works on every
plan since it's plain Liquid, not checkout extensibility.
Both new UI extensions share one real unverified assumption, called out in
code comments: process.env.APP_URL is expected to be substituted at build
time by the Shopify CLI to the app's backend origin, since these run in a
different origin than the app and need an absolute URL, unlike the
storefront widget's relative /apps/scheduling/* path. Needs confirming
against a live dev session.
Verified: lint, typecheck (root + both new extensions'
`npm run typecheck:pos`/`typecheck:checkout`, all now in CI), 102 unit +
18 integration tests (unchanged — this phase didn't touch pure business
logic, only added thin auth wrappers around already-tested services), both
admin/widget builds.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
69 lines
2.5 KiB
TypeScript
69 lines
2.5 KiB
TypeScript
import type { Method } from "@prisma/client";
|
|
import db from "../db.server";
|
|
import { slotDateTime, weekdayOf } from "../lib/time";
|
|
import { remainingCapacity } from "./capacity.server";
|
|
import { tryCreateHold, releaseHold, countActiveHolds } from "./holds.server";
|
|
|
|
// Shared by every surface that reserves capacity — storefront widget (via
|
|
// apps.scheduling.hold.tsx) and POS (via pos.scheduling.hold.tsx). Same
|
|
// pool, same code, different auth wrapper. See holds.server.ts for why
|
|
// creating a hold has to be a single atomic Redis operation.
|
|
|
|
export interface HoldRequestParams {
|
|
intent: "create" | "release";
|
|
locationId: string;
|
|
method: Method;
|
|
date: string;
|
|
startMin: number;
|
|
cartToken: string;
|
|
}
|
|
|
|
export interface HoldRequestResult {
|
|
status: number;
|
|
body: { ok?: true; success?: boolean; expiresAt?: number; remaining?: number; error?: string };
|
|
}
|
|
|
|
export async function resolveHoldRequest(shopDomain: string, params: HoldRequestParams): Promise<HoldRequestResult> {
|
|
const { intent, locationId, method, date, startMin, cartToken } = params;
|
|
|
|
const location = await db.location.findFirst({ where: { id: locationId, shopDomain, active: true } });
|
|
if (!location) {
|
|
return { status: 404, body: { error: "Location not found" } };
|
|
}
|
|
|
|
const slotStart = slotDateTime(date, startMin, location.timezone);
|
|
const slot = { shopDomain, locationId: location.id, method, slotStartIso: slotStart.toUTC().toISO()! };
|
|
|
|
if (intent === "release") {
|
|
await releaseHold(slot, cartToken);
|
|
return { status: 200, body: { ok: true } };
|
|
}
|
|
|
|
const template = await db.slotTemplate.findFirst({
|
|
where: { shopDomain, locationId: location.id, method, weekday: weekdayOf(date, location.timezone), startMin },
|
|
});
|
|
if (!template) {
|
|
return { status: 404, body: { error: "Slot not found" } };
|
|
}
|
|
|
|
const confirmedCount = await db.booking.count({
|
|
where: { shopDomain, locationId: location.id, method, slotStart: slotStart.toJSDate(), status: "confirmed" },
|
|
});
|
|
|
|
const capacityBudget = remainingCapacity({ capacity: template.capacity, consumed: confirmedCount });
|
|
if (capacityBudget <= 0) {
|
|
return { status: 409, body: { success: false, error: "Slot is full" } };
|
|
}
|
|
|
|
const result = await tryCreateHold(slot, cartToken, capacityBudget);
|
|
if (!result.success) {
|
|
return { status: 409, body: { success: false, error: "Slot was just taken" } };
|
|
}
|
|
|
|
const activeHolds = await countActiveHolds(slot);
|
|
return {
|
|
status: 200,
|
|
body: { success: true, expiresAt: result.expiresAt, remaining: Math.max(0, capacityBudget - activeHolds) },
|
|
};
|
|
}
|