diff --git a/use-crystallize/mcp-servers/crystallize/bin/eval-reference-selection.cases.json b/use-crystallize/mcp-servers/crystallize/bin/eval-reference-selection.cases.json index 8419748..16ea4ee 100644 --- a/use-crystallize/mcp-servers/crystallize/bin/eval-reference-selection.cases.json +++ b/use-crystallize/mcp-servers/crystallize/bin/eval-reference-selection.cases.json @@ -64,5 +64,30 @@ "task": "boost high-margin products in category listings", "skills": ["vector-ranking"], "expected": ["vector-ranking/ranking-signals"] + }, + { + "task": "write the shared helper that creates the order once and records a payment for any provider", + "skills": ["payments"], + "expected": ["payments/crystallize-payments-lib"] + }, + { + "task": "build a back-office tool that takes a bookable unit out of service", + "skills": ["bookable-resources"], + "expected": ["bookable-resources/booking-admin"] + }, + { + "task": "merge several near-identical product shapes into one", + "skills": ["content-model"], + "expected": ["content-model/shape-consolidation"] + }, + { + "task": "show me a complete operations file that creates a content model in the right order", + "skills": ["content-model"], + "expected": ["content-model/mass-operation-example"] + }, + { + "task": "create a custom role with the API and assign it to a user", + "skills": ["permissions"], + "expected": ["permissions/role-api"] } ] diff --git a/use-crystallize/skills/agent-tenants/SKILL.md b/use-crystallize/skills/agent-tenants/SKILL.md index 2c7f327..2824a68 100644 --- a/use-crystallize/skills/agent-tenants/SKILL.md +++ b/use-crystallize/skills/agent-tenants/SKILL.md @@ -62,7 +62,7 @@ curl -s -X POST https://api.crystallize.com/agents/tenants \ | ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `name` | yes | String, 1 to 100 characters once trimmed. The tenant's display name. | | `identifier` | no | String, 1 to 100 characters. Lowercased; anything outside `a-z 0-9 -` becomes `-`. Without it (or if nothing usable remains) it comes from `name`, else `agent-<8 hex>`. | -| `source` | no | String, 1 to 100 characters. Who is provisioning (`claude-code`, `cursor`). Shown in Crystallize's Slack and as the agent user's name. | +| `source` | no | String, 1 to 100 characters. Who is provisioning (`claude-code`, `cursor`). Shown as the agent user's name. | Any other key is ignored, `omitDefaultShapes` included. Values that are not plain strings are refused with 400. @@ -168,12 +168,14 @@ EOF - **The claim invite's role must be `tenantAdmin`**, for whoever will own the tenant: the user, or someone they name. Only a human `tenantAdmin` claims the tenant. An invite with any other role lets a human in and keeps the - tenant from deletion, but never claims it: send one on top of the admin invite, never instead of it. + tenant from deletion, but does not claim it: send one on top of the admin invite, never instead of it. - **`expiresAt` is optional in the schema; send it anyway**, no later than the tenant's. An invite that - outlives the tenant is useless. + outlives the tenant is useless. A date in the past is accepted and gives a dead link: if a human has kept the + tenant past its deadline, send a future date. - **Errors come back in the result, not as HTTP errors.** An unknown role gives `RoleNotFoundError` instead of `InviteToken`. -- You can create a new invite at any time, for instance when one has expired. +- **An invite works once.** A second human needs a second invite, and a used link only shows an "already + redeemed" error. You can create a new invite at any time, for instance when one has expired or been used. ## 4 · Hand the invite to the human @@ -198,9 +200,8 @@ What happens next needs no call from you: 1. The App redeems the token (`redeemInviteToken`) and the human becomes `tenantAdmin` on the tenant. 2. At the next cleanup run, within 2 hours, Crystallize sees a human `tenantAdmin` and the tenant is claimed: - its provisioning record is deleted, so it can never be removed automatically. -3. Crystallize's Slack gets a "claimed" message, and the human is subscribed to the newsletter under the usual - signup rules. + from then on it can never be removed automatically. +3. The human is subscribed to the newsletter under the usual signup rules. ## 5 · Check whether the human joined (optional) @@ -236,17 +237,18 @@ they join, a second member appears with `role: "tenantAdmin"` and `kind: "human" | Topic | Rule | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Lifetime | `expiresAt` = provisioning + 72 h. A cleanup runs every 2 hours, so an unclaimed tenant goes between 72 h and about 74 h later, with all its content, the agent user and its token. | -| What keeps a tenant | Any human with a role on it. A human `tenantAdmin` claims it for good. A human with a lesser role keeps it from deletion but never claims it: it waits for a human admin. | +| What keeps a tenant | Any human with a role on it. A human `tenantAdmin` claims it for good. A human with a lesser role keeps it from deletion but does not claim it: it waits for a human admin. | +| Promoting a human | Promoting a lesser-role human to `tenantAdmin` claims the tenant: `setUserRole(userId: "", role: "tenantAdmin")` on the Core API, or the user list in the App. | | Nothing else | No call extends the 72 hours. Only a human joining keeps the tenant. | | After the claim | The agent stays `tenantAdmin` and its token keeps working. The human can remove the agent user in the App; the cleanup then deletes it and its tokens. | | One tenant per call | An agent cannot create tenants any other way (`createTenant` on `/@me`, PIM `tenant.create`), copy a tenant, or redeem invites. Another tenant is another `POST /agents/tenants`. | | The agent user | Named `Agent `, no email, and no one can log in as it. It acts only through its token, which works on its own tenant only. | -| Rate limit | None specific to provisioning. The API firewall's per-IP limit applies to every call (429). | +| Rate limit | Provisioning has its own limit, per IP and across all callers, on top of the per-IP limit on every call (429). On a shared cloud IP you can hit it with few calls of your own. | ## Errors -Provisioning answers plain HTTP errors with a JSON body, `{ "message": "…", "statusCode": 400 }`. GraphQL calls -answer HTTP 200 with the error inside the result union (`__typename`, `errorName`, `message`). +Provisioning answers plain HTTP errors with a JSON body, `{ "message": "…", "statusCode": 400 }`. Authenticated +GraphQL calls answer HTTP 200 with the error inside the result union (`__typename`, `errorName`, `message`). | Call | Status | Message | What to do | | ---------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | @@ -256,8 +258,10 @@ answer HTTP 200 with the error inside the result union (`__typename`, `errorName | Provision | 400 | `source must be a string of 1 to 100 characters.` | Fix or drop `source`. | | Provision | 409 | `That tenant identifier was just taken. Please retry.` | Retry the same call once: a new identifier is picked. Nothing was created. | | Provision | 500 | `Provisioning failed (trace ).` | Retry later; give the trace id to Crystallize support if it repeats. Half-created is cleaned. | +| Provision | 503 | `Crystallize cannot provision more agent tenants right now. …` | At capacity. Retry later, or have the user create a tenant and give you an access token. | | Provision | 404 | none | The endpoint is not deployed on that host. Tell the user. | -| Any | 429 | none | Too many requests from this IP: back off and retry. | +| Any | 429 | none | Too many requests. Provisioning is also limited across all callers: wait minutes, then retry. | +| Any call, once the tenant is deleted | 403 | `Invalid access token pair provided` | The tenant, its agent user and its token are gone. Provision again and replay your build. | | `createInviteToken` | 200 | `RoleNotFoundError`: `Role not found: (tenant id )` | The claim invite takes `tenantAdmin`; any other role must exist on the tenant. | | Agent-forbidden: `/@me` `createTenant`, invite redemption, tenant copy | 200 | `UnauthorizedError`: `This operation is not available to agent users.` | Do not retry. Create tenants with step 1 only, and let humans redeem invites. | @@ -270,7 +274,7 @@ answer HTTP 200 with the error inside the result union (`__typename`, `errorName | Calling `/@` | The calls fail when the identifier was taken and got a suffix | Use the returned `identifier` and `apiUrl` | | Provisioning again to start over while the tenant is alive | A second tenant, agent user and token; the first lingers for 72 h | Fix or recreate the shapes in the tenant you have | | Building only with one-off mutations | An expired tenant takes the whole build with it | Keep the build as a mass operations file and replay it into a new tenant | -| Sending only an invite with another role | The human gets in and keeps the tenant, but it is never claimed | Always create a `tenantAdmin` invite for whoever will own it; others go on top | +| Sending only an invite with another role | The human gets in and keeps the tenant, but it is not claimed | Always create a `tenantAdmin` invite for whoever will own it; others go on top | | An invite with no `expiresAt`, or one past the tenant's | A link that still looks valid after the tenant is gone | Pass the tenant's `expiresAt` | | Giving the link without the deadline | The user opens it on day four and finds nothing | Always state the date and time with the link | | Saying "it's yours, nothing to transfer" after provisioning | The user never opens the invite, and the tenant is deleted | The tenant is the agent's until a human `tenantAdmin` joins | diff --git a/use-crystallize/skills/bookable-resources/SKILL.md b/use-crystallize/skills/bookable-resources/SKILL.md index b4b9926..27b0d26 100644 --- a/use-crystallize/skills/bookable-resources/SKILL.md +++ b/use-crystallize/skills/bookable-resources/SKILL.md @@ -3,13 +3,15 @@ name: bookable-resources description: > Sell Crystallize products that are held in time rather than counted in stock — rentals, services, rooms, courses, equipment, appointments. Covers booking policies and bookable pools on the Core API, - and the booking flow on the Shop API /cart endpoint: availability, holds, checkout and confirmation. + the booking flow on the Shop API /cart endpoint (availability, holds, checkout and confirmation), and + the Shop API /booking/admin endpoint (maintenance blocks, reservation calendars, staff cancellations). Use when the user wants to rent something out, take bookings or reservations, sell time slots or - by the day/weekend/week, show a booking calendar, manage a fleet of machines, rooms or seats, or - handle cancellations. Trigger on "bookable", "booking", "reservation", "rental", "rent", "hire", - "availability", "time slot", "calendar", "hold", "setBookable", "bookSkuItem", - "createBookingPolicy", "confirmCartBooking", "cancelReservation", "rebookReservation", - "nearestAvailability", "checkBooking", "booking policy", "cancellation window", "pool", "units". + by the day/weekend/week, show a booking calendar, manage a fleet of machines, rooms or seats, take a + unit out of service, or handle cancellations. Trigger on "bookable", "booking", "reservation", + "rental", "rent", "hire", "availability", "time slot", "calendar", "hold", "setBookable", + "bookSkuItem", "createBookingPolicy", "confirmCartBooking", "cancelReservation", "rebookReservation", + "nearestAvailability", "checkBooking", "booking/admin", "booking policy", "cancellation window", + "pool", "units". metadata: author: Crystallize version: "1.0" @@ -80,26 +82,31 @@ for the policies to find out whether this session can manage them: A connection means yes. `FORBIDDEN` means the role lacks the `bookingPolicies` permission. That usually happens on a custom role created before bookings existed, and it is fixed on the role, not in -the query. +the query. Putting a pool on a product is a second permission: `setBookable`, `reapplyBookablePolicy` +and `clearBookable` need `productBookable` on top of the right to edit the item. ## Failure modes Three of these are silent — a paid booking whose reservation has no order, a pool or policy change that -has no effect, and a booking that vanishes when two are taken at once. They are the reason this skill -exists; the rest name themselves. +has no effect, and a bookable SKU sold with nothing held. They are the reason this skill exists; the +rest name themselves. | Symptom | Cause | Fix | | ------------------------------------------------------------ | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | Admin shows "No order — not checked out" on a paid booking | `confirmCartBooking` ran before the cart had its `orderId` | Confirm **again** after `createFromCart` — see [booking-flow](references/booking-flow.md) | +| An order for a bookable product with nothing reserved | The SKU went through `addSkuItem` or `hydrate` with no `booking`: a plain line | Book with `bookSkuItem`, or send `booking` on the `hydrate` line | | `NotBookable` on a product you just made bookable | The item is not published, or the query's `language` is wrong | Publish it; pass the language the item exists in | | A pool or policy change has no effect in the Shop | Bookable edits are drafts; products keep the `policySnapshot` they were set with | `reapplyBookablePolicy` for a policy change, then **publish** the product | | `FORBIDDEN` from `createBookingPolicy` | The role has no `bookingPolicies` permission | Grant it on the role | | Holds vanish within seconds, or `InvalidRange` on every date | A duration was sent in minutes, hours or days | Every policy duration is **seconds**; read `humanized` back to check | | `hydrate` throws "A placed cart cannot be hydrated" | The cart is placed; its contents are frozen | Change bookings before `place`, or through `/booking/admin` after | | `ReservationConflict` on a unit that looked free | Someone took it between the availability query and the booking | Walk the other `freeUnitIds` and retry | -| Two bookings taken at once, one of them missing | Concurrent `bookSkuItem` on one cart — both answered `Cart` | Serialize booking writes per cart, then read the cart back | +| `ReservationConflict` whose `message` says the cart changed | Another write landed on the same cart first, and nothing was booked | Read the cart back, then retry; serialize booking writes per cart | +| `ReservationConflict` on a capacity pool with seats free | Capacity counts every booking overlapping the window, not the busiest moment | Keep capacity windows uniform, or use units | +| `Cannot place this cart: ()` | `place` or `hydrate` could not keep a booking line; see the reason | Nothing was saved: `hydrate` with another window or without the line | | `InvalidRange` on a date far ahead | The window is past the policy's `advanceWindow` | Widen the policy, or cap the calendar at the shortest `advanceWindow` in the basket | | `CancellationWindowClosed` when removing a basket line | The window applies to holds that were never bought | Re-hydrate the cart without that line instead | +| `Unexpected error.` from `addCartItem` or `removeCartItem` | They refuse a booking line | `hydrate` with the new quantity, or without the line | | `BookablePoolKindChangeError` | Capacity → units while a capacity pool is published | `clearBookable`, publish, cancel or wait out open reservations, then set units | | `BookingPolicyInUseError` on delete | Products still reference the policy (`stats.referencingProductCount`) | Move those products to another policy and publish them | @@ -109,6 +116,8 @@ exists; the rest name themselves. capacity pools, snapshots and reapplying, deleting and clearing. - [references/booking-flow.md](references/booking-flow.md) — Shop API `/cart`: availability, holds, checkout, the double confirmation, cancelling and moving a booking. +- [references/booking-admin.md](references/booking-admin.md) — Shop API `/booking/admin`: maintenance + blocks, the reservation calendar and list, moving a reservation to another unit. - [references/modelling.md](references/modelling.md) — periods as variants, units as real machines, what Discovery serves, and how a rental relates to the product it is a rental of. diff --git a/use-crystallize/skills/bookable-resources/references/booking-admin.md b/use-crystallize/skills/bookable-resources/references/booking-admin.md new file mode 100644 index 0000000..47a47ba --- /dev/null +++ b/use-crystallize/skills/bookable-resources/references/booking-admin.md @@ -0,0 +1,78 @@ +# Booking administration (Shop API `/booking/admin`) + +For back-office tools: taking a unit out of service, reading who holds what, and changing what a shopper +cannot. Run it server-side only. + +The token needs both the `booking` and `booking:admin` scopes, so ask for +`"scopes": ["booking", "booking:admin"]` when you mint it (see [[mutation]]). Post to +`https://shop-api.crystallize.com/@{tenant}/booking/admin` yourself: `client.shopBookingApi` in +`@crystallize/js-api-client` posts to `/booking`, which serves nothing and answers 404. + +**Refusals are GraphQL errors here, not union members.** Branch on `extensions.reason`, under +`extensions.code: "BOOKING_REJECTED"`: `NotBookable`, `NotFound`, `Conflict`, `InvalidRange` or +`InvalidUnitId`. + +## Take a unit out of service + +Do not fake a cart hold, and do not remove the unit from the pool. Block it: + +```graphql +mutation { + block( + input: { + productId: "6ab3…" + sku: "RENT-GAS55-DAY" + start: "2026-10-05T00:00:00Z" + end: "2026-10-07T00:00:00Z" + unitId: "GAS55-OSL-1" + source: MAINTENANCE + sourceReason: "annual service" + language: "en" + } + ) { + id + unitId + state + } +} +``` + +- `source` is `MAINTENANCE`, `STAFF_HOLD`, `PARTNER_ALLOCATION` or `OTHER`. `sku` and `language` are + required because the pool is read through the variant, in one language. +- Without a `unitId`, `quantity` (default 1) takes that many of the pool, all or none. With a `unitId` it + must be 1. The answer is a list with one reservation per unit of quantity, and each needs its own + `cancel`. +- The `advanceWindow` does not apply to a block; the policy's buffers do. +- **A block never expires.** It reads `state: PENDING` with `expiresAt: null` and holds its window until + you `cancel` it. +- `Conflict` means something already holds that window. Move it with `assignUnit`, or cancel it. +- `bulkBlock(inputs:)` takes several and answers `results`, one `{ ok, reservations, reason }` per input + in request order. It is not atomic, so reissue only the failed ones. + +`checkBooking(input:)` is the dry run for a block. It takes the same fields as the `/cart` one plus a +required `source`, and refuses `source: CART`. + +## Read the reservations + +| Query | Use it for | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `calendar(filter:)` | A range, grouped by product. `rangeStart` and `rangeEnd` required; refused past 5,000 rows | +| `reservations(filter:, limit:, after:)` | A flat list, paged by `nextCursor`. `limit` defaults to 50, at most 5,000. Range optional | +| `reservationSummary(productId:, range:, states:)` | `total`, `byState` and `bySource` for one product | +| `reservation(id:)` | One row, with the `customerId`, `frozenPolicy` and `meta` the `/cart` query does not serve | + +- Filters take `productIds`, `states`, `sources`, `unitId`, `orderId`, `cartId` and `customerId`. +- Omit `states` and you get every state, finished ones included. Pass `[PENDING, CONFIRMED]` for what + actually holds the calendar. +- `orderId` only finds rows confirmed after the cart had its order. For the others, filter by `cartId`: + the order id is the cart id. +- Rows carry `productId` and `variantSku`, never names. Hydrate those from the Catalogue or Discovery. +- These queries serve open reservations and 90 days of finished ones. An older booking is on its order, + in the `reservations` meta. See [booking-flow](booking-flow.md#after-the-order). + +## Change a reservation + +`cancel` and `bulkCancel` are in [booking-flow](booking-flow.md#after-the-order). +`assignUnit(id:, unitId:, language:)` moves a reservation to another unit of the pool, keeping its window, +and is refused with `Conflict` if that unit is taken. `setMeta(id:, meta:, merge:)` writes the +reservation's own `meta`. All of these update the record on the order that bought the reservation. diff --git a/use-crystallize/skills/bookable-resources/references/booking-flow.md b/use-crystallize/skills/bookable-resources/references/booking-flow.md index dcee233..cd80b05 100644 --- a/use-crystallize/skills/bookable-resources/references/booking-flow.md +++ b/use-crystallize/skills/bookable-resources/references/booking-flow.md @@ -3,6 +3,10 @@ Everything the storefront does. All of it is on the `/cart` endpoint except the order itself, which is `/order`. See [[mutation]] for tokens and endpoints. +The Shop API reads products from the Catalogue with the credentials on the request, and the Shop token is +not one of them. On a tenant whose Catalogue requires authentication, also send its +`X-Crystallize-Static-Auth-Token`, or its access token pair, on every call that reads a product. + ## 1. What is free ```graphql @@ -18,20 +22,32 @@ query Availability($productId: String!, $sku: String!, $range: CartTimeRangeInpu } ``` -One slot per `granularitySec` across the range: `86400` for a day view, `3600` for hours. `free` is how -many units are open, `freeUnitIds` names them, and `reason` says why a slot is closed. - -- **`language` is required and it must be a language the item exists in.** The wrong one reads as - `NotBookable` rather than as an error. +One slot per `granularitySec` across the range: `86400` for a day view, `3600` for hours. A query is +refused past 10,000 slots, or past 100,000 slots × units. `free` is how many units or seats are open. +`freeUnitIds` names the units you may pin, and is always `[]` on a capacity pool. `bookable` is the time +check alone (not elapsed, inside the `advanceWindow`), and `reason` explains a `false`. So a sold-out +slot reads `{ free: 0, bookable: true, reason: null }`: a calendar needs both. + +- **`language` is required and it must be a language the item exists in.** The wrong one reads like an + unbookable product: a GraphQL error with `extensions.reason: "NotBookable"` from `availability` and + `nearestAvailability`, and `{ ok: false, reason: "NotBookable" }` from `checkBooking`. +- **`bookSkuItem` has no `language`.** It reads the product in the cart's `context.language`, and a + `hydrate` that sends no `context` means `en`. Hydrate the cart in the language you pass here. - Ask for the range the calendar shows, plus enough tail that the longest bookable period starting on the last visible day still fits. +- **A run of open days does not prove the whole window is open.** Each slot is counted alone, so one unit + can be free on Monday and another on Tuesday. Ask `checkBooking` before booking across several slots. `nearestAvailability(productId, sku, around, durationSec, n, language)` answers "the next `n` windows of -this length near this time" — the right query behind a "next free slot" button. +this length from this time" as `{ start, end }` — the right query behind a "next free slot" button. `n` +defaults to 5, at most 100. It only walks forward, up to 30 days, in steps of `durationSec`: a 7-day +window is tried once a week, not every day. A `durationSec` under 260 is refused. `checkBooking(input: CartCheckBookingInput!)` is a dry run of one exact booking and returns -`{ ok, reason }`, not a union. It takes `productId`, `sku`, `start`, `end`, `language`, and optionally -`unitId` and `quantity`. Debounce it: it runs the same gates the booking itself does. +`{ ok, reason }`, not a union. `reason` is `NotBookable`, `Conflict`, `InvalidRange` or `InvalidUnitId`: +`Conflict` here, where `bookSkuItem` says `ReservationConflict`. It takes `productId`, `sku`, `start`, +`end`, `language`, and optionally `unitId` and `quantity`. Debounce it: it runs the same gates the booking +itself does. ## 2. Hold it @@ -84,12 +100,12 @@ mutation Book($id: UUID, $input: CartBookingItemInput!) { This is the single thing most likely to produce a wrong price. Measured on a unit pool of 5, booking a **two-day** window on a **one-day** SKU priced at 39 net: -| Sent | Result | -| --------------------------------- | ------------------------------------------------------------- | -| `quantity: 1` | one reservation over the whole window, line **39** — not 78 | -| `quantity: 2` | **two** reservations, both over the whole window, line **78** | -| `quantity: 6` (pool holds 5) | `ReservationConflict` | -| `quantity: 2` **with** a `unitId` | `Output validation error` (`OUTPUT_VALIDATION_ERROR`) — a bug | +| Sent | Result | +| --------------------------------- | ------------------------------------------------------------------------------------------- | +| `quantity: 1` | one reservation over the whole window, line **39** — not 78 | +| `quantity: 2` | **two** reservations, both over the whole window, line **78** | +| `quantity: 6` (pool holds 5) | `ReservationConflict` | +| `quantity: 2` **with** a `unitId` | `OUTPUT_VALIDATION_ERROR`, with `booking.unitId requires quantity 1` in `extensions.errors` | So `quantity: 2` means _two machines_, or _two rooms_, for the same window — never "two days" or "two nights". **Nothing in the booking inputs prices by duration.** A window twice as long costs the same @@ -108,19 +124,25 @@ Two ways out, and the first is the one to reach for: **A `unitId` and `quantity: 1` belong together.** To hold two named units, book twice — once per unit. +**A bookable SKU with no window is a plain line.** `addSkuItem`, or a `hydrate` line with no `booking`, +reserves nothing, and `place` and `createFromCart` let it through: the order exists and the calendar never +heard of it. Nothing warns you, so every line of a bookable product needs a `booking`. + **Put the customer on the cart before booking.** The reservation records who holds it at `bookSkuItem`/`hydrate` time, and only when the cart's customer has an identifier. Nothing sets it later. Create the cart with `hydrate(input: { customer, items: [] })`, or `setCustomer` before the first booking. **On `ReservationConflict`, walk the other `freeUnitIds`.** Between the availability query and the -booking someone else may have taken that unit. Any other refusal is final — stop and tell the shopper. +booking someone else may have taken that unit. The exception is a `message` that says the cart changed: +see [below](#one-write-at-a-time-per-cart). Any other refusal is final — stop and tell the shopper. **A window past the policy's `advanceWindow` is refused as `InvalidRange`**, with nothing to say the policy is what stopped it. Products that are booked together need the same window: a room on a 120-day policy and a boat trip on a 60-day one means a summer booking can hold the room but not the trip. Cap the calendar at the shortest `advanceWindow` in the basket. (Observed in the Boutique Universe build.) -**The reservation id comes back on the line's `meta`**, not on the cart's: +**The reservation id comes back on the line's `meta`**, not on the cart's, comma-separated when the line +holds several: ```json "meta": { "reservationIds": "18a6b9d4-…", "booking.window": "2026-10-01T08:00:00.000Z/2026-10-01T16:00:00.000Z" } @@ -129,36 +151,37 @@ the calendar at the shortest `advanceWindow` in the basket. (Observed in the Bou `booking.window` is written for you. **Write the `unitId` into the line's `meta` yourself.** `CartItem` has no booking field, and a pinned line re-hydrated without its `unitId` does not match its hold: it is rebooked, possibly onto another unit. `reservation(cartId:, id:) { unitId }` can recover a lost one. +It is also how a storefront learns the unit of an unpinned booking, which takes the first free units in +pool order. `bookSkuItem` ignores `group` and `type` on a new line. To group a booking with its services, set `group` when you next `hydrate`. Read one hold with `reservation(cartId:, id:)`: -`{ id, productId, variantSku, unitId, start, end, state, source, cartLineId, orderId, expiresAt }`. +`{ id, productId, variantSku, unitId, start, end, state, source, cartLineId, orderId, expiresAt }`. It +answers `null` for a row this cart does not hold. **Look holds up one at a time.** Several `reservation(cartId:, id:)` fields aliased into a single query sometimes never answer — 3 of 10 attempts in the Boutique Universe build. One query per hold, with a timeout. -### One hold at a time per cart +### One write at a time per cart -Two `bookSkuItem` calls in flight against the same cart lose one of them, and **both answer `Cart`**. -Measured on a unit pool of 5, two different windows, no `unitId`, 3 of 3 attempts: +Every booking write is conditional on the version of the cart it read. Two `bookSkuItem` calls in flight +against one cart land one booking. The other answers `ReservationConflict`, its hold is released, and its +`message` says the cart changed and nothing was booked. A slot conflict has the bare `message` +`ReservationConflict`. `hydrate`, `place`, `cancelReservation` and `rebookReservation` lose the same race +with a GraphQL error: "The cart was changed by another request…". -- one line survives; the other booking is simply not in the cart -- the lost hold is orphaned — `reservation(cartId:, id:)` answers `null` while `availability` still counts - its unit as taken (`free: 4` of 5) -- emptying the cart releases the surviving line only. The orphan lets go by itself after - `pendingHoldDuration` (15 minutes on that policy), and `free` goes back to 5 - -So **serialize the booking writes for one cart** — a queue or a lock per cart id — and never read a -`Cart` reply as proof the line is there: read the cart back and find the line by your own `meta` key. -Reported to Crystallize engineering on 2026-09-28. +So **serialize the booking writes for one cart** — a queue or a lock per cart id — and when a write loses +that race, read the cart back before retrying: the other request may have made this same booking. ## 3. Keep it while they shop **`hydrate` is the whole cart.** A booking line you leave out is cancelled with it, and re-hydrating with -the line slides its expiry forward. There is no "extend the hold" mutation. +the line slides its expiry forward. There is no "extend the hold" mutation. Every hydrate gives all the +holds of the cart one deadline, set by the shortest `pendingHoldDuration` among them. `bookSkuItem` slides +nothing, not even the other holds on that cart. That cuts both ways, and the second half is the useful one: @@ -168,29 +191,39 @@ That cuts both ways, and the second half is the useful one: { "sku": "RENT-GAS55-DAY", "quantity": 1, - "lineId": "…", "booking": { "start": "2026-10-01T08:00:00Z", "end": "2026-10-01T16:00:00Z", "unitId": "GAS55-OSL-1" }, "meta": [{ "key": "unitId", "value": "GAS55-OSL-1" }] } ``` - A line is matched to its hold by window and unit, so resend both unchanged. `lineId` (selectable on - `CartItem`) is the line's server-minted handle; send it back when two lines share a SKU. + A line is matched to its hold per SKU, by window and unit, so resend both unchanged. Lines that match + nothing are paired in payload order and moved, so changing one date moves that line. `hydrate` does + not use `lineId` for this. `lineId` (selectable on `CartItem`) is the line's server-minted handle for + the mutations that target one line, such as `changeCartItemPricing`, which refuse a SKU shared by + several lines without it. + +- **To change a booking**, send it changed. A new line with a `booking` is reserved, and a new `quantity` + takes or releases the difference. A refusal is not a union member here: the whole hydrate fails with + `Cannot place this cart: (ReservationConflict)` and saves nothing. Use `bookSkuItem` for a new + booking when the shopper needs to know why. + +- **To remove one from the basket**, re-hydrate without it. `removeCartItem` refuses a booking line. Do + **not** use `cancelReservation`: the policy's cancellation window applies to a hold that was never + bought, so a rental starting tomorrow under a two-day window answers `CancellationWindowClosed` and the + shopper cannot empty their own basket. Verified against a live tenant. -- **To remove one from the basket**, re-hydrate without it. Do **not** use `cancelReservation`: the - policy's cancellation window applies to a hold that was never bought, so a rental starting tomorrow - under a two-day window answers `CancellationWindowClosed` and the shopper cannot empty their own - basket. Verified against a live tenant. +`abandon(id)` gives back every hold of a draft cart at once, rather than at their expiry. `cancelReservation(cartId:, reservationId:)` succeeds only while the start is at least `cancellationWindow` seconds away. It is right for a line that is far enough out, and `rebookReservation(cartId:, reservationId:, newBooking:)` moves a hold to a new window atomically. It -moves every reservation on that line, so a quantity-3 line moves as one. `ReservationConflict` is the -only outcome worth retrying. +moves every reservation on that line, so a quantity-3 line moves as one, each keeping its unit if that +unit is free in the new window. A `unitId` on such a line answers `InvalidUnitId`. Both mutations answer a +union, so check `__typename`. `ReservationConflict` is the only outcome worth retrying. **All of this is for a draft cart.** Once the cart is placed, `hydrate` throws "A placed cart cannot be -hydrated", and `cancelReservation`/`rebookReservation` throw `InvalidStateError`. Finish every change to -the bookings before `place`. +hydrated", and `cancelReservation`/`rebookReservation` fail with `INPUT_STATE_ERROR`. Finish every change +to the bookings before `place`. ## 4. Place, pay, order, confirm — in that order @@ -229,7 +262,9 @@ async function linkReservations(cartId: string) { it blocks the calendar until its window ends. It is what proves the holds still stand before the shopper is charged. If you keep it, cancel the reservations through `/booking/admin` when the payment fails, or the machine stays blocked with no order behind it. If you drop it, `place` is still the -last check before payment, and the holds live for `placedHoldDuration` while payment runs. +last check before payment, and the holds live for `placedHoldDuration` while payment runs. If that payment +fails, `remove(id)` deletes the cart and gives its unconfirmed holds back at once. It leaves `CONFIRMED` +rows alone, and `abandon` is refused on a placed cart. `confirmCartBooking` answers `Cart`, `NotPlaced`, `NothingToConfirm` or `ReservationNoLongerHeld`. The last one means a hold expired while payment was in flight; select its `missing` field for the ids. It is all or nothing: the surviving holds stay `PENDING`, and the shopper has to pick another window for @@ -257,12 +292,22 @@ cart id. } ``` - `bulkCancel(ids:, reason:)` takes up to 100 ids and answers per id; it is not atomic. + `bulkCancel(ids:, reason:)` takes up to 100 ids and answers per id; it is not atomic. See + [booking-admin](booking-admin.md) for the rest of that endpoint, including why + `client.shopBookingApi` cannot reach it. - **A hold lost after `place` cannot be re-picked on that cart.** A placed cart cannot be hydrated or rebooked. On `ReservationNoLongerHeld`, cancel the survivors (above), refund if the money has moved, and start a new cart for the new window. - A reservation's `state` is a string: `PENDING`, `CONFIRMED`, `COMPLETED`, `CANCELLED` or `EXPIRED`. A hold that runs out becomes `EXPIRED`, up to a minute late, while its line stays in the cart. The next - `hydrate` takes it again if the window is still free and throws `BookingNoLongerAvailable` if not; - `place` refuses it with `HoldNoLongerHeld`. + `hydrate` takes it again if the window is still free. If not it fails with + `Cannot place this cart: (ReservationConflict)`, saving nothing, so send the next one with another + window or without that line. +- `place` refuses a cart with `Cannot place this cart: ()`. `HoldNoLongerHeld` is a hold that + ran out or was cancelled, `UnitNoLongerInPool` a pinned unit that left the published pool, and + `NotBookable` a product no longer published or bookable. The cart stays a draft, so it can be fixed. +- `/order` carries what was booked: `items { lineId booking { start end unitId } }`, where `unitId` is the + pinned unit only. The order's `meta` adds `reservations`, a JSON string with one record per reservation, + real unit included, which outlives the reservation itself. A `reservations.unconfirmed` key, there only + when something went wrong, lists the ids no longer held. Watch for it: the order is created either way. diff --git a/use-crystallize/skills/bookable-resources/references/modelling.md b/use-crystallize/skills/bookable-resources/references/modelling.md index 5403663..60f12b9 100644 --- a/use-crystallize/skills/bookable-resources/references/modelling.md +++ b/use-crystallize/skills/bookable-resources/references/modelling.md @@ -44,7 +44,8 @@ meta, and show one line per location. Use a **capacity** pool instead when the units are interchangeable and nobody needs to know which one they got — seats on a course, bikes in a rack. You lose per-unit meta, so if the storefront must say -_which_ one, use units. +_which_ one, use units. Capacity is also only exact for bookings that share a window: see +[policies-and-pools](policies-and-pools.md#the-pool) before using it for hires of varying length. ## What Discovery serves, and what it does not @@ -87,6 +88,10 @@ Discovery carries the **static** bookable configuration on the item: } ``` +`bookable` also serves `policySnapshot { advanceWindow cancellationWindow … }`, so a calendar can be capped +at the `advanceWindow` without a Shop call. Product shapes can filter, facet and sort on `bookable_kind` +and `bookable_poolSize`, as in `filters: { bookable_kind: { equals: "units" } }`. + **Discovery never serves availability.** Listing pages can say "5 machines, 3 depots" from `poolSize` and the pool; anything about a date is a Shop API call. Design the page so the calendar loads after the product, not as part of the listing query — one `availability` call per visible card is a lot of calls. diff --git a/use-crystallize/skills/bookable-resources/references/policies-and-pools.md b/use-crystallize/skills/bookable-resources/references/policies-and-pools.md index 10731c8..db63e2b 100644 --- a/use-crystallize/skills/bookable-resources/references/policies-and-pools.md +++ b/use-crystallize/skills/bookable-resources/references/policies-and-pools.md @@ -60,11 +60,16 @@ mutation CreatePolicy($input: CreateBookingPolicyInput!) { A `placedHoldDuration` of `0`, or none, gives a placed cart a fresh `pendingHoldDuration`. Set it when payment takes longer than a live hold, such as an invoice or a bank transfer. +`pendingHoldDuration` must be at least 1, or the policy is refused with `InvalidNumberRangeError`. Do not +send `advanceWindow: 0` to mean "no limit": `bookSkuItem` then refuses every future window as +`InvalidRange` while `availability` reports them bookable. Send a long window instead. + **Read `humanized` back after writing.** It returns the same values as `{ value, unit }` in days, hours, minutes or seconds, which is the cheapest way to catch a duration that was sent in the wrong unit. The mistake is silent otherwise: a `cancellationWindow` of `2` is two seconds, not two days. -`updateBookingPolicy(id, input)` takes the same fields, all optional, and bumps `version`. +`updateBookingPolicy(id, input)` takes the same fields, all optional, and bumps `version`, even for a +rename, so a product's `policySnapshot.version` behind the policy's does not prove the terms differ. `bookingPolicies(first:)` returns a **connection** (`edges { node { … } }`), not a list. `bookingPolicy(id:)` reads one. `BookingPolicy.stats.referencingProductCount` says how many products use @@ -72,9 +77,10 @@ it, and `deleteBookingPolicy` refuses with `BookingPolicyInUseError` while that ## The pool -`setBookable` attaches a policy and says what can be booked. One product, one pool. `language` is -required, but the pool is not per language: every language of the product shares it, so set it and -reapply it once per product. +`setBookable` attaches a policy and says what can be booked. One product, one pool, and every SKU of the +product draws on it. `language` is required, but the pool is not per language: every language of the +product shares it, so set it and reapply it once per product. Mass operations have no intent for this: +it is one `setBookable` per product. ```graphql mutation SetBookable($id: String!, $language: String!, $input: GraphqlBookableInputInput!) { @@ -104,6 +110,18 @@ Two kinds, and a product has exactly one of them: storefront reads it back from Discovery and can show "the machine in Oslo". There is no other place to put it. +**A capacity pool counts every booking that overlaps the window, not the busiest moment.** With capacity +6 and 4 seats booked on each of seven nights in a row, a 7-night request is refused with +`ReservationConflict` although 2 seats are free every night, and `availability` undercounts the same way. +It never oversells, and it is exact when bookings share one window, like a class at 09:00. When windows +vary in length, use units, even for things nobody tells apart. + +**Do not rename or remove a unit that holds live reservations.** They keep the old id and still hold +their window. While one does, every pin on that window is refused with `ReservationConflict` and +`freeUnitIds` is empty even when `free` is not. A cart pinned to a unit that left the published pool fails +at `place` with `UnitNoLongerInPool`. Add the new id, publish, move the reservations with `assignUnit` (see +[booking-admin](booking-admin.md)), and only then drop the old id. + Units → capacity is allowed. Capacity → units answers `BookablePoolKindChangeError` while the **published** pool is a capacity pool, because capacity-era reservations carry no unit. Clear it with `clearBookable`, publish that, cancel or wait out the open reservations, then set the units. @@ -111,11 +129,13 @@ Units → capacity is allowed. Capacity → units answers `BookablePoolKindChang `clearBookable(id, language)` removes the pool, but only from the draft: the Shop keeps taking bookings until the product is published again. Unpublish to stop bookings at once. `bookableProducts` lists products with a **published** bookable configuration only. A product configured but never published -is not in it. +is not in it. `bookableProducts(filter: { policyId: "…" })` is how to find the products to reapply; each +node has `productId`, `poolSize`, `policySnapshot` and `product(language:)`. ## The snapshot -`setBookable` stamps the policy onto the product as a `policySnapshot` with its `version`: +`setBookable` stamps the policy onto the product as a `policySnapshot` with its `version`, on every call, +so a pool edit also takes the policy's current terms: ```graphql { @@ -158,10 +178,15 @@ terms. This is the step that makes "we changed the cancellation window and nothi Reservations already taken keep the terms they were admitted under. A policy edit never changes a booking a shopper already holds. +`Product.bookable` on the Core API always shows the draft, whatever `versionLabel` you ask for. A bookable +edit creates no item version either, so read `bookableHasUnpublishedChanges` on the product to see whether +a publish is still owed. `bookableProducts`, the Catalogue API and Discovery show what the Shop serves. + ## Publish The Shop reads the **published** bookable configuration only. A bookable product that is not -published answers `NotBookable` on every Shop API call, with no hint that publishing is what is -missing. `setBookable`, `reapplyBookablePolicy` and `clearBookable` all write to the draft, so each one -needs a publish before the Shop sees it. Publish per item and language with `publishItem`. See -[[mutation]]. +published answers `NotBookable` on every Shop API call. `setBookable`, `reapplyBookablePolicy` and +`clearBookable` all write to the draft, so each one needs a publish before the Shop sees it. Publish per +item and language with `publishItem`. See [[mutation]]. The publish of any one language promotes the +shared pool, but the Shop reads the product per language, so publish every language you sell in. +`publishItem` only queues the work: the Shop sees the change a moment after it answers. diff --git a/use-crystallize/skills/content-json/SKILL.md b/use-crystallize/skills/content-json/SKILL.md index 6db9adf..d5f1d7f 100644 --- a/use-crystallize/skills/content-json/SKILL.md +++ b/use-crystallize/skills/content-json/SKILL.md @@ -1,6 +1,6 @@ --- name: content-json -description: Generate Crystallize Rich Text Content JSON — the tree-based AST format used by Crystallize for structured rich text. Use this skill whenever the user needs to create, convert, or manipulate rich text content for Crystallize, including converting HTML/Markdown to Crystallize JSON, building content programmatically, or understanding the Content Transformer format. Also use when the user mentions "rich text JSON", "content nodes", "Crystallize content format", or needs to populate rich text components. +description: Generate Crystallize rich text JSON, the node tree stored in `richText` components, paragraph bodies and image captions. Use when creating, converting or editing rich text for Crystallize, converting HTML or Markdown to Crystallize JSON, or debugging rich text that opens empty in the App. Also use when the user mentions "rich text JSON", "content nodes", "Crystallize content format" or the Content Transformer format. --- # Crystallize Rich Text Content JSON @@ -13,76 +13,79 @@ The output must be a **JSON array of block nodes** (or a single node object when Every node has these properties: -| Property | Type | Required | Description | -|---------------|-----------------------|----------|-------------| -| `kind` | `"block"` or `"inline"` | yes | Block-level or inline | -| `type` | `string` or `null` | yes | Semantic content type (see tables below) | -| `textContent` | `string` | no | Raw text (leaf nodes only — mutually exclusive with `children`) | -| `children` | `node[]` | no | Child nodes (mutually exclusive with `textContent`) | -| `metadata` | `object` | no | Extra attributes (e.g. `{ "href": "..." }` for links, `{ "id": "..." }` for any element) | +| Property | Type | Required | Description | +| ------------- | ----------------------- | -------- | ---------------------------------------------------------------------------------------- | +| `kind` | `"block"` or `"inline"` | yes | Block-level or inline | +| `type` | `string` or `null` | yes | Semantic content type (see tables below) | +| `textContent` | `string` | no | Raw text (leaf nodes only — mutually exclusive with `children`) | +| `children` | `node[]` | no | Child nodes (mutually exclusive with `textContent`) | +| `metadata` | `object` | no | Extra attributes (e.g. `{ "href": "..." }` for links, `{ "id": "..." }` for any element) | A node is either a **leaf** (has `textContent`) or a **branch** (has `children`). Never set both. ## Block Node Types (`kind: "block"`) -| `type` | HTML equivalent | Notes | -|-------------------|-----------------|-------| -| `"paragraph"` | `

` | Most common block type | -| `"heading1"` – `"heading6"` | `

` – `

` | | -| `"unordered-list"`| `