Reservations
Member reservation requests, club-scoped categories and staff reservation lists.
Floor plan imports
startFloorPlanImport, floorPlanImport and cancelFloorPlanImport require staff
catalog_manage permission. UUID clubId and categoryId scope the category;
read/cancel additionally take UUID id. Jobs belong to the exact user, club and
category. Wrong ownership returns null; unauthorized callers are denied before
service dispatch. Start checks category existence and visualization availability.
Start returns a BAD_USER_INPUT error when the image is not a PNG, JPEG or WebP
under 6 MB, when an import is already running for the same scope or the server's
24 concurrent imports are in use, or when plan import is not configured.
mutation ImportPlan($clubId: ID!, $categoryId: ID!, $image: String!) {
startFloorPlanImport(clubId: $clubId, categoryId: $categoryId, image: $image) {
id
status
step
version
}
}
query ImportProgress($clubId: ID!, $categoryId: ID!, $id: ID!) {
floorPlanImport(clubId: $clubId, categoryId: $categoryId, id: $id) {
id
status
step
version
warnings
reviewNotes
referenceImage
registration {
sourceWidth
sourceHeight
cropX
cropY
cropWidth
cropHeight
scale
offsetX
offsetY
}
cursor {
x
y
}
elements {
id
spaceId
kind
label
objectId
resourceId
bookable
labelVisible
x
y
width
height
rotation
color
strokeColor
strokeWidth
outlineOnly
chairs
points {
x
y
arc
radius
}
locked
}
objects {
id
name
category
reservable
resourceId
image
artworkRef
}
}
}
mutation CancelImport($clubId: ID!, $categoryId: ID!, $id: ID!) {
cancelFloorPlanImport(clubId: $clubId, categoryId: $categoryId, id: $id) {
id
status
}
}image is a base64 PNG/JPEG/WebP data URL under 6 MB, not a remote URL or SVG.
Magic bytes and decoded image validity are checked, including a 16-million-pixel
limit. Original pixels remain available for regional source inspection. The first
model image is resized to at most 1600 by 1600; no automatic claim is made that
phone chrome or annotations have been removed.
The configured OPENROUTER_API_KEY powers openai/gpt-6.1-sol. The loop is bounded
by 32 turns, 200 instances, 40 operations per transaction, four custom templates
and ten minutes. One import may run per exact scope; at most 24 retained/starting
jobs exist per process. Jobs expire after 30 minutes and do not survive restart.
Poll every 500 ms while running and stop on terminal state or error. There are no
filters, sorting or pagination; queries return one bounded snapshot.
Statuses: running, review, failed, cancelled. step is the actual tool;
version increments on successful atomic scene transactions, including reorder
and deletion. Failed operations do not change geometry, version or cursor. Cursor
coordinates identify the last affected instance center (former center for deletion).
Cancellation aborts provider/generation requests, prevents late changes and removes
scope-matched pending artwork. Cleanup failures are logged; accepted library images
are never removed.
Native scene tools
register_source({crop:{x,y,width,height},world:{x,y,width,height}|null})uses ORIGINAL source pixels and returns a high-resolution crop plus the uniform affine source-to-canvas mapping. It must precede geometry. The optional world box places/scales the source mapping; it is not a placement boundary. Choose a larger proportional mapping when furniture needs more clearance. Registration locks while any instances exist. The aligned raster stays bounded to 1800 pixels per axis.view_reference({region:{x,y,width,height}|null})returns real original-pixel regional PNGs, at most 1800 by 1800, without upscaling small inputs.scene_transaction({expected_version,operations:[...]})validates all operations before publishing. Supported operations arecreate,update,delete,reorder. Create takeselement; update takes a stableidandelementpatch; delete takesid; reorder takesidandbefore_id(null means front). Duplicate/unknown IDs, stale versions, invalid fields, bounds or resource references reject the entire transaction. Responses include resolved records, created key/ID mappings, deleted IDs, warnings and remaining time/turn budget. Errors include code, field and the operation index where applicable.list_objects({})reports scene version, instance IDs/geometry, the active library withlibrary_object_id, name and reservability, registration, resource assignment status, warnings and remaining budget.view_objects({region,scale,labels,overlay})returns a real draft PNG. Region is in WORLD units or null for a dynamic full-scene fit; scale is 1..3; labels arenoneornames. Optional internal source overlay uses the exact registered reference at opacity 0.35. PNGs are bounded to 2400 pixels per axis; the response includesviewport,pixels,pixelsPerUnit,units:world, andfull. A null region counts as full inspection regardless of extent.scene_transactionalso accepts{op:"move_layer",id,direction}. Directionsforward/backwardmove one layer;front/backmove to the top/bottom within the object's space. Locked layers are rejected; boundary moves are no-ops. Geometry, resource bindings and other spaces are unchanged. Like other transactions, moves require the currentexpected_versionand are atomic. Bothlist_objectsand transaction results returnlayer_order, listing instance IDs back-to-front (last is topmost). Existingreorder/before_idremains available for precise positioning.create_template({name,shape_description?,reservable})calls the same name-based AI object generator as the editor. No source crop is accepted or sent. The optional internal description (up to 1,000 characters) specifies silhouette, proportions and physical parts; standard styling remains authoritative. The name is limited to 100 characters. It returnslibrary_object_id, reservability, intrinsic dimensions, aspect ratio, geometry format and a preview. Up to four attempts are allowed after early layout inspection and before turn 24. The resulting object joins the draft library and is persisted only on human acceptance.finish({expected_version,review_notes})submits an unsaved draft for human review. It is never a save or an assertion of reconstruction accuracy.
Library placement example (a new reservable table proposed for human review):
{
"expected_version": 0,
"operations": [
{
"op": "create",
"element": {
"key": "terrace-table-01",
"library_object_id": "builtin:ellipse",
"bookable": true,
"label": "Terrace table",
"x": 615,
"y": 203,
"width": 47,
"height": 47,
"rotation": 0,
"resource_id": null,
"chairs": 4,
"outline_only": false,
"label_visible": false,
"points": null
}
}
]
}The importer and editor share the active builtin library: eight editable reservable
shapes and eleven architectural/decorative objects. The agent also receives the
current category's saved library objects. Every placement selects one
library_object_id; separate kind and template_id arguments are rejected.
Deleted or unknown IDs are rejected. Generated objects use this same selector.
key is an agent-chosen creation correlation key; subsequent edits use the returned
instance id, not a library ID. Storage and GraphQL retain the editor's existing
kind/objectId rendering fields, resolved internally from library selection.
Doors use the builtin door artwork. Walls are prohibited except essential room
separation; the model must not surround a plan with perimeter geometry.
Shared artwork, paints and fixed outlines are used in the editor, consumer and
server inspection. Polygon points use LOCAL 0..100 coordinates with editor
arc/radius semantics (up to 64 points); arbitrary SVG and holes are unsupported.
Instance coordinates, dimensions, rotation and local anchors preserve fractional world units. Negative positions and
expansion beyond the initial 1000 by 700 view are supported. Numeric safety limits
are absolute positions at most 1 billion and dimensions at most 1 million, not a fixed canvas.
Non-bookable dimensions may be 1 unit; reservables require width >= 20 and
height >= 10. Reservables and native furniture with chairs must have at least 12 world units of clearance,
including conservative rotated bounds and chair footprints. Insufficient spacing
returns SPACING and rejects the entire transaction; a larger canvas alone is not
a spacing guarantee. Rotation is clockwise about the local center and normalizes
-360..360 to 0..359. Native table shapes support 0..16 chairs. Custom template zones
retain aspect via contain-fit. Array order is persisted z-order.
The import modal omits the reference toggle and crop metadata; agent reference tools remain internal. Scene-version updates trigger one 900ms shine across the preview, not a moving cursor or perpetual pulse. Reduced-motion users get no animation. The editor dynamically fits expanded plans, supports wheel zoom, Alt/middle-button pan and Home to refit; existing selection, snapping, rotation and undo remain. Read-only previews support touch pan and pinch zoom without triggering booking clicks after a drag. Member rendering fits the same world bounds before its existing pinch/pan gestures.
Bookability and save safety
The agent must place visible dining tables and standalone seats/loungers as
reservable proposals. bookable:true with resource_id:null requests a new resource
when the reviewed plan is saved. Native reservable library shapes default to this
flag; generated objects declare reservable and use the same placement behavior.
Decorative objects use bookable:false; non-reservable library objects cannot
receive a booking binding. A non-null resource_id must identify an available
resource in this category and requires bookable:true. Already-assigned resources
cannot be duplicated. An attached dining chair belongs to its table booking unit;
a standalone seat is a separate placement. Import generation never writes resources.
Persisted FloorPlanElement fields now include nullable bookable, labelVisible,
strokeColor, strokeWidth. Labels are hidden when labelVisible:false. Styling,
points, chairs, dimensions, rotations, identities and ordering round-trip in plan
JSON and are rendered by both frontends. bookable:false is rejected with a
resource ID. Only explicit bookable:true on a supported reservable shape can
create a missing resource during save; omitted/null/false flags never create
resources merely from labels. The editor's manual resource creation/assignment
choices explicitly set the flag, and “No booking binding” clears it.
The importer preserves existing editor changes. “Use draft” accepts only placed
pending artwork through the existing library flow, maps temporary object IDs and
adds an undoable new space. When the editor contains exactly one empty space,
the draft fills that space and preserves its identity, name and styling. Multiple
spaces or any existing objects are preserved and the draft gets a new space.
It preserves reservable proposals, including generated library object reservability,
checks combined capacity/assignment conflicts, and does not save the plan.
“Save plan” still uses saveReservationFloorPlan revision/conflict validation.
After saving, the selected space remains active in the read-only plan view,
including a newly added or imported space. A failed save retains the editor draft
and selected space for retry.
Closing cancels/discards the import. Test and diagnostic harnesses never save user
plans/resources.
Honest inspection and review
An early full structural inspection must precede finishing or generating custom templates. Three mutation batches require an inspection before further edits. Finishing requires two distinct regional reviews and a full, non-overlay PNG of the latest version after all corrections. Changing geometry/order invalidates the previous full inspection. Four final turns and a three-minute time reserve are recommended explicitly in model feedback; late custom generation is prohibited.
Duplicate geometry and unintended bookability prevent finishing. Unplaced
created templates are detected/warned and not saved. reviewNotes must describe
remaining omissions/uncertainty and appears in the review modal. warnings gives
objective scene hygiene findings. Receiving a PNG alone does not establish visual
fidelity: the model is instructed to compare regions, correct discrepancies and
avoid inventing clipped-off geometry. The human still reviews before acceptance
and explicit save. The source crop and overlay are private transient review data,
not saved into or published with consumer plans.
Reservation access
Reservation category management and direct booking require an owner or admin session.
Staff can list, edit, accept, cancel, propose times for, and chat on reservations only
when their account is assigned to that reservation category. Their back office exposes this
restricted workflow as Planning.
Members may read their own club's categories and request a reservation for themselves.
They may also read their own upcoming reservations through myReservations; the authenticated
person identity always comes from the member session.
Anonymous callers and callers from another club receive UNAUTHENTICATED.
Members can read only their own club's IANA timezone with memberClubTimezone:
query MemberClubTimezone($clubId: ID!) {
memberClubTimezone(clubId: $clubId)
}The required clubId is a UUID matching the member session; staff and unauthenticated
callers cannot use this query. It returns null when the club no longer exists and
otherwise returns its configured timezone (Europe/Paris by default). No other club
settings are returned. Reservation timestamps remain UTC ISO instants; members display
and enter civil times in this club timezone, including across daylight saving changes.
The back office starts with a persisted reservation-category list. Selecting a category opens its configuration, upcoming reservations, and calendar.
Internally, validated session inputs retain their schedule variant: weekly sessions require a weekday, start time and duration; daily sessions require a start time and duration; custom sessions require start and end dates. Category session drafts use the same contract. Nullable database rows are validated before occurrence expansion; an invalid schedule produces no bookable occurrence.
Service failures
Empty lists and null results represent domain outcomes returned by the reservation
service. They are not substitutes for a missing implementation. All methods in the
internal ReservationServices contract are required at composition time. Unexpected
service failures use the standard masked GraphQL error (Unexpected error.), rather
than a successful empty response. Authentication and input validation remain unchanged.
Reservation categories
query ReservationCategories($clubId: ID!) {
reservationCategories(clubId: $clubId) {
id
clubId
name
description
conversationRequired
createTicket
resources {
id
name
amount
priceUnit
depositAmount
defaultCheckInMinutes
defaultCheckOutMinutes
cancellationNoticeDays
staffUserIds
planPrices {
planId
amount
}
hasReservations
}
currency
requiresSession
visualizationEnabled
housekeepingEnabled
customerSheetsEnabled
externalBookingEnabled
onboardingOnly
waitlistEnabled
slotBooking
defaultDurationMinutes
slotIntervalMinutes
bookingWindowDays
minNoticeMinutes
openingHours {
weekday
startMinutes
endMinutes
}
linkedCategoryIds
}
}Returns all categories for admins and owners, or only assigned categories for staff, for the required UUID clubId, ordered by name then ID. conversationRequired
controls whether reservations of that category expose a chat. Categories group alike resources and define shared modules: sessions, conversations, tickets, floor plans, housekeeping and customer sheets. currency is a shared lowercase three-letter billing code (the UI uses EUR). There is no pagination or additional filtering.
Prices, optional security deposits and arrival defaults belong to resources. Nullable depositAmount is a fixed security-deposit amount in hundredths of a club credit, from 0 to 100,000,000, independent of the reservation price. New resources have no deposit. Each booking snapshots this amount (null becomes zero) and collects it in credits together with its price. Later resource edits do not change an existing booking’s deposit. amount is the price in hundredths of a club credit, from 0 to 100,000,000; zero means free. priceUnit (ReservationPriceUnit) says how the charged price (the default amount or the member's plan price) applies to a booking: once charges it a single time, hour charges it for every started hour of the booking, day charges it for every club-local calendar day the booking touches (a booking ending exactly at midnight does not count the following day), and night charges it for every club-local midnight crossed. Hour, day and night counts are at least one, and bookings without dates are charged one unit. Nullable defaultCheckInMinutes and defaultCheckOutMinutes are integer offsets before the start and after the end, from 0 to 10,080. Nullable cancellationNoticeDays is the cancellation notice, an integer from 0 to 365 days before the start; null or zero means members can always cancel with a refund. New resources start free and once-priced with no arrival defaults and no cancellation notice. Category edits preserve individual resource settings. externalBookable and nullable externalAmount expose a resource to non-members and set their price; see external reservations.
Plan prices
planPrices lists prices that replace amount for members of a given plan: planId is a subscription or add-on plan offered by the club and amount is in hundredths of a club credit, from 0 to 100,000,000. Zero makes the resource included in that plan; the member app shows any zero price as “Included in membership”. Staff receive every plan price; members always receive an empty list. When a signed-in member queries reservationCategories for their club, each resource’s amount is already their price: the lowest plan price among their unexpired subscription and add-on memberships, or the base amount when none of their plans prices the resource. The same rule prices member requests, staff bookings for that person, and resource changes on unpaid bookings. When a plan is split, its successor inherits the retained plan’s resource prices.
Staff in charge of a resource
A resource can be linked to several staff members, such as the coaches giving a private
lesson, and one staff member can be linked to several resources in any category of the club.
ReservationResource.staffUserIds returns the linked staff users for staff callers and an
empty list for members. UpdateReservationResourceInput.staffUserIds replaces the list: omit
it to keep the current staff, pass an empty list to remove them all. Duplicates are ignored and
at most 50 are accepted. Every staff member must have access to the resource's club; otherwise
the result error is Choose staff members of this club. and nothing changes. A staff member who
loses access to the club is unlinked from its resources automatically (see
admin and staff management). A new list may not
take away the staff a booking relies on: when a future, non-canceled booking on the resource was
covered by the availability of at least one linked staff member (or the resource had no staff) and
would no longer be covered, the result is resource: null with
error: "Some bookings on this resource would have no available staff member. Move or cancel them first."
and nothing changes, since booked session occurrences would otherwise disappear from the agenda.
Bookings already outside the linked staff's availability do not block the change. Removing a staff
account removes its links. Linking staff does not grant planning access: category assignments
on the Team page still decide what staff can see.
clubStaff(clubId) lists the staff members with access to the club (id, name, email),
ordered by name then email, for the resource editor's Staff picker. It requires
catalog_manage. A name equal to the email is returned as an empty string.
query ClubStaff($clubId: ID!) {
clubStaff(clubId: $clubId) {
id
name
email
}
}Clicking any resource opens its editor. Without housekeeping it shows the resource fields directly, with no tab selector. Enabling housekeeping adds a separate Housekeeping tab with its checklist, state, minibar and history. Administrators and staff with catalog_manage can edit resource configuration; assigned staff can view it with planning_read and use housekeeping with housekeeping_manage. See individual permissions.
mutation UpdateResource($input: UpdateReservationResourceInput!) {
updateReservationResource(input: $input) {
resource {
id
categoryId
name
amount
priceUnit
depositAmount
defaultCheckInMinutes
defaultCheckOutMinutes
cancellationNoticeDays
staffUserIds
planPrices {
planId
amount
}
}
error
}
}The input requires UUID clubId, categoryId, id, a nonblank name of at most 100 characters without control characters, and integer amount. Omitting priceUnit preserves the current unit. Omitting depositAmount preserves the current deposit; explicit null clears it. Omitted or null arrival offsets clear the defaults. Omitting cancellationNoticeDays preserves the current notice; explicit null clears it. Omitting planPrices preserves the plan prices; a list replaces all of them, and an empty list removes them. Up to 100 plan prices are accepted, each plan at most once. Invalid values return BAD_USER_INPUT; plans the club does not offer return resource: null and an error without partial changes. The mutation is administrator-only and checks resource/category/club ownership. Duplicate names within a category or conflicting floor-plan library names return resource: null and an error without partial changes. Renaming preserves assignments and synchronizes reservation and floor-plan labels. Updating one resource does not affect its siblings or existing reservation prices and deposits.
New reservations snapshot the selected resource’s price and use its check-in and check-out offsets; null defaults remain null. Staff may override arrival times. Category currency is snapshotted with the booking. Resource price edits apply to future bookings; existing booking charges remain unchanged. Changing an unpaid booking’s resource selection recalculates its price from the new resource. Changing the dates of a booking whose resources are priced per day or night recalculates the resource price for the new period, keeping product lines. Either change on a paid/completed booking or an existing checkout is rejected when it would change the stored charge. The total must not exceed 100,000,000 credit hundredths.
ReservationCategoryInput.resourceIds optionally pairs each name in resources with its existing resource ID, or null for a new resource. The arrays must have the same length, with distinct IDs belonging to the edited category. Omitting an existing ID removes that resource subject to usage checks. Without resourceIds, names are reconciled as before. Creation only accepts null resource IDs.
Renaming by ID preserves reservation assignments, blocked periods, housekeeping history, and floor-plan placements. It updates reservation display labels and linked library names and advances the saved plan revision when a placement label changes. Name swaps are atomic; a collision with another library object is rejected without partial changes. reservationCategories.resources.hasReservations reports whether any reservation (including historical or canceled reservations) protects a resource from deletion. The server checks again on save.
Each category owns one or more named resources. Resource names are unique case-insensitively within each reservation category; different categories in the same club may use the same name. Admins add and remove resource names in the Resources section of the category editor; a resource already assigned to a reservation cannot be removed. When visualization is disabled, an unused resource can be removed even if the saved plan still references it. The same transaction removes its placements, advances the plan revision, and keeps any linked library artwork as a non-reservable object. An enabled plan still protects its linked resources.
Both createReservationCategory and updateReservationCategory accept optional newSessions on
ReservationCategoryInput. This is an append-only array of at most 50 ReservationSessionDraftInput
objects, with the same schedule fields and validation as createReservationSession, but without
clubId or categoryId: these are derived from the category being saved. Every session takes place
on exactly one resource of its category. Because a category save can create its resources, a draft
names its resource with the required resourceName instead of a resourceId: it must match one of
the input's resources case-insensitively after trimming, otherwise the mutation returns
BAD_USER_INPUT. The name is resolved after the category's resources are created or reconciled, so
a draft can use a resource added or renamed in the same save; a draft naming a resource removed by
that save returns error: "Choose a resource of this category for each session.". Standalone
session creation and session editing take a required resourceId. A nonempty array requires
requiresSession: true. Weekly drafts require weekday (0–6), startMinutes (0–1,439), and
durationMinutes (1–10,080); custom drafts require strict UTC startsAt and endsAt, with the
end after the start. Optional capacity accepts 1–10,000. Invalid drafts return BAD_USER_INPUT
before persistence. Drafts follow the same one-session-per-resource rule as
createReservationSession, against the category's existing sessions and the other drafts of the
same save: an overlap returns reservationCategory: null and
error: "Another session uses this resource at that time.", and a dated draft is also refused over
a resource block or an active booking. The category and all new sessions are saved in one
transaction; a failure rolls back the entire change. Existing sessions remain unchanged when editing or omitting the array.
The mutation's return fields are unchanged; query reservationAvailability to read the sessions.
mutation CreateScheduledType($clubId: ID!) {
createReservationCategory(
input: {
clubId: $clubId
name: "Group lesson"
description: ""
conversationRequired: false
requiresSession: true
resources: ["Main studio"]
newSessions: [
{
kind: weekly
resourceName: "Main studio"
weekday: 1
startMinutes: 540
durationMinutes: 60
capacity: 8
}
]
}
) {
reservationCategory {
id
name
requiresSession
}
error
}
}This query returns only category metadata, never another member's reservations.
Demo seeding supplies Hotel suite, Pool seating, and Private lesson for each
demo club. Reservations are seeded only in the Ukab Clubs demo club, the only one with seeded
members: every seeded reservation links to one of those members, whose name is its booking
contact. That club gets four realistic reservations per category. Repeated seeding
does not overwrite existing records.
Startup also adds three reservations per category for the current UTC date, derived
from the runtime clock rather than a hardcoded date. Their IDs are date-specific,
so restarting on the same day does not duplicate or overwrite them; a later day's
startup adds that day's bookings while preserving historical examples.
Group class is seeded with requiresSession: true and one default session per club-local weekday
at 15:00 in its Main studio resource, lasting 60 minutes and limited to eight members.
Club timezone settings
Each club has a timezone (IANA identifier, default Europe/Paris). Staff with
access to the club can read it with clubSettings(clubId: ID!): Club; only a club
owner/admin can change it with updateClubTimezone(clubId: ID!, timezone: String!): Club.
Both operations require access to the requested club; other callers receive UNAUTHENTICATED.
The query returns the club, including timezone and billingEnabled (false when the
platform turned the club's billing module off), or null if it does not exist;
the mutation returns the updated club or null if it does not exist. clubId must
be a UUID; timezone must be a supported IANA zone (or UTC), otherwise the
mutation returns BAD_USER_INPUT. There are no filters, sorting or pagination.
query ClubSettings($clubId: ID!) {
clubSettings(clubId: $clubId) {
id
name
timezone
}
}
mutation UpdateClubTimezone($clubId: ID!, $timezone: String!) {
updateClubTimezone(clubId: $clubId, timezone: $timezone) {
id
timezone
}
}Delete a reservation category
deleteReservationCategory(clubId: ID!, id: ID!) requires an owner/admin in the requested club.
It returns { deleted: Boolean!, error: String }. Invalid UUIDs return BAD_USER_INPUT;
unauthorized, member, planning-only staff, and foreign-club calls return UNAUTHENTICATED.
A missing category returns deleted: false with an unavailable error. Types with any existing
reservations (including cancelled or completed ones) cannot be deleted, preserving booking history.
A category used by an onboarding booking step, before or after approval, returns
error: "An onboarding booking step uses this category. Remove the step first.".
Otherwise the category, session definitions, blocked periods, and staff assignments are deleted together.
The edit wizard requires confirmation before calling this mutation; failed deletion keeps the
confirmation open with its error.
mutation DeleteReservationCategory($clubId: ID!, $id: ID!) {
deleteReservationCategory(clubId: $clubId, id: $id) {
deleted
error
}
}Read one category or resource
query CatalogEntry($clubId: ID!, $categoryId: ID!, $resourceId: ID!) {
reservationCategory(clubId: $clubId, id: $categoryId) {
id
name
resources {
id
name
}
}
reservationResource(clubId: $clubId, id: $resourceId) {
id
categoryId
name
amount
}
}Both return null when the club has no such entry and require catalog_manage. They return
the same fields as reservationCategories.
Create and delete a resource
A category update lists the resources to keep. Tools that manage each resource separately
leave resources out of ReservationCategoryInput instead: a category created that way
starts without resources, and updating it never adds, renames or deletes one.
mutation CreateReservationResource($input: CreateReservationResourceInput!) {
createReservationResource(input: $input) {
resource {
id
categoryId
name
amount
}
error
}
}
mutation DeleteReservationResource($clubId: ID!, $categoryId: ID!, $id: ID!) {
deleteReservationResource(clubId: $clubId, categoryId: $categoryId, id: $id) {
deleted
error
}
}CreateReservationResourceInput takes clubId, categoryId, name and the settings of
UpdateReservationResourceInput (amount defaults to 0), validated the same way. A name
already used in the category, an unknown category, a plan price for a plan the club does not
offer or staff from another club return resource: null with an error, and nothing is
created. deleteReservationResource returns deleted: false with an error for an unknown
resource, a resource holding any reservation, or one placed on the floor plan of a category
with visualization enabled. Both require catalog_manage.
Link categories on their own
mutation SetReservationCategoryLinks($clubId: ID!, $categoryId: ID!, $linkedCategoryIds: [ID!]!) {
setReservationCategoryLinks(
clubId: $clubId
categoryId: $categoryId
linkedCategoryIds: $linkedCategoryIds
) {
linkedCategoryIds
error
}
}Replaces the linked categories of one category
without sending the rest of it, which lets two categories link to each other: call it once
per category. It takes at most 50 distinct ids of other categories of the club and requires
catalog_manage. An unknown category, a link to itself or to a category of another club
return linkedCategoryIds: null with an error.
Sessions and blocked periods
reservationAvailability(clubId, categoryId, from, to) returns expanded session occurrences and
the total sessionCount of configured session definitions for this club and category, independent
of the requested date range or blocked periods. A count of zero triggers the back-office
Manage sessions warning; it never prevents continuing the category wizard. It also returns
blocked periods overlapping a strict UTC range of at most 62 days. Weekly definitions are expanded
into dated occurrences; one-off custom sessions retain their stored date. Each session returns its
opaque occurrence id, definition ID, non-null resourceId, dates, optional capacity, optional remaining places,
waitlistSize, and the signed-in member's optional myWaitlistEntryId and
myWaitlistPosition.
Weekly weekday and startMinutes use the club's current wall timezone. A local
time skipped by the spring DST transition produces no occurrence; an ambiguous
time at the autumn transition uses the earlier instant. durationMinutes is elapsed
time, so an occurrence can end at a different wall-clock offset. Changing the club's
timezone affects future availability and booking resolution, not existing reservations.
All returned startsAt and endsAt values remain UTC instants. Weekly IDs encode
the club-local calendar date, including when the instant falls on another UTC date.
Cancelled reservations do not consume capacity. Sessions in the past or overlapping a blocked
period are not returned.
Staff schedule
staffSchedule(clubId, userId, from, page) lets an administrator of the selected club
read a coach's upcoming reservations and undated requests. userId is the staff
account ID. It returns the same fields and uses the same pagination, ordering,
resource assignments and date validation as myStaffSchedule below. Non-admin staff and
member accounts cannot use this query, even to view their own schedule. Results
are scoped to the selected club; canceled reservations and those ending on or before
from are excluded. The admin interface exposes it under Team → staff dossier →
Reservations, with a club selector for staff assigned to multiple clubs.
query CoachBookings($clubId: ID!, $userId: ID!, $from: String!, $page: Int!) {
staffSchedule(clubId: $clubId, userId: $userId, from: $from, page: $page) {
resources {
id
name
categoryId
categoryName
}
reservations {
id
categoryId
guestName
startsAt
endsAt
status
}
requests {
id
categoryId
guestName
proposedStartsAt
status
}
hasMore
}
}myStaffSchedule(clubId, from, page) returns the upcoming work of the signed-in staff member
on every resource linked to them, across categories. Any staff member with access to the club
may call it; it never returns other resources. from is a UTC ISO timestamp (the back office
sends the start of the current club day) and page goes from 1 to 1000; other values return
BAD_USER_INPUT.
resourceslists the linked resources withcategoryIdandcategoryName, ordered by category then name.reservationslists non-canceled reservations ending afterfrom, ordered by start, 30 per page.requestslists undatedpendingorbookedrequests still waiting for a time.hasMoreis true when another page follows.
An account with no linked resource in the club receives empty lists. The back office's My schedule page groups these reservations by day under the requests, with a Load more button, and links each one to its planning when the staff member can open that category. It refreshes every 30 seconds and when the window regains focus.
query MyStaffSchedule($clubId: ID!, $from: String!, $page: Int!) {
myStaffSchedule(clubId: $clubId, from: $from, page: $page) {
resources {
id
name
categoryName
}
reservations {
id
guestName
startsAt
endsAt
status
}
requests {
id
guestName
}
hasMore
}
}Staff availability
Each staff member has their own availability per club, independent of resources:
- a default week of recurring windows (
weekday0 = Sunday,startMinutesandendMinutesin club wall time, end at most 1440 for midnight); - dated exceptions:
availableadds extra hours,unavailabletakes time off.
staffAvailability(clubId, userId, from, to) returns windows, the exceptions overlapping the
range, and periods: the effective availability in the range, computed as the default week
plus extra hours minus time off, clipped to the range. Weekly windows follow the club's
timezone across daylight-saving changes. configured is false until the staff member has a
default week or extra hours. Omit userId for the signed-in staff member; reading another
staff member requires the administrator role in that club. The range follows the 62-day rule.
query StaffAvailability($clubId: ID!, $userId: ID, $from: String!, $to: String!) {
staffAvailability(clubId: $clubId, userId: $userId, from: $from, to: $to) {
configured
windows {
id
weekday
startMinutes
endMinutes
}
exceptions {
id
kind
startsAt
endsAt
}
periods {
startsAt
endsAt
}
}
}Staff with availability_manage edit only their own availability:
setMyAvailabilityWeek(clubId, windows)replaces the default week. Overlapping or touching windows of a day are merged. At most 100 windows; invalid windows returnBAD_USER_INPUT. The result holds the savedwindows, or nullwindowsand anerror.addMyAvailabilityException(clubId, kind, startsAt, endsAt)adds extra hours or time off (UTC timestamps, at most 62 days long). It replaces the part of any earlier exception it overlaps, so the latest dated change wins; the earlier exception keeps its remaining parts.removeMyAvailabilityException(clubId, id)returnsremoved: falsewhen the exception is not theirs.
None of these changes may take away the availability a booking relies on. When a future,
non-canceled booking on a resource linked to the staff member fits the effective availability of
its linked staff before the change but would not afterwards, the change is not saved and the
result carries error: "Some bookings would fall outside your availability. Move or cancel them first."
(with null windows, null exception or removed: false). This keeps booked session
occurrences on the agenda, since availability hides occurrences while none of their staff is
available. Bookings that were already outside availability, for example on a resource whose staff
was linked later, do not block unrelated changes. A booking stays covered while another linked
staff member is available.
mutation SetMyAvailabilityWeek($clubId: ID!, $windows: [StaffAvailabilityWindowInput!]!) {
setMyAvailabilityWeek(clubId: $clubId, windows: $windows) {
windows {
id
weekday
startMinutes
endMinutes
}
error
}
}When a resource has linked staff, every dated booking on it must fit entirely within the
effective availability of at least one of them; a linked staff member without configured
availability counts as available except during explicit time off. This applies to member requests, staff bookings, proposals, proposal acceptance and moving a booking to another time.
Otherwise the result error is The staff member is not available at this time. (proposals
return This time is unavailable.). Session-based bookings enforce the same staff availability check. Resource-scoped session
occurrences are hidden from availability while none of their linked staff is available; a booking
using a previously displayed occurrence is checked again before saving. Editing a booking without
changing its time and resources without a linked staff member remain unrestricted.
The back office's My availability page shows the staff member's week as a single grid: available hours in green and time off as dashed blocks. Dragging on empty time adds hours and dragging over green hours removes them; a dialog then asks whether the change applies every week (it edits the default week) or only on that date (it adds an exception). Clicking green hours opens the same choice for removing them, and clicking time off restores the usual hours. An Add hours button offers the same dialog with date and time fields. Administrators see a staff member's week read-only in the Availability tab of the Team page. Both refresh every 30 seconds and when the window regains focus.
Session guest lists
A session can optionally be restricted to a guest list. ReservationSessionInput accepts
guestPersonIds (up to 500 people) and guestPlanIds (up to 50 plans). With both lists empty,
which is the default, every member can see and book the session. Otherwise only listed people and
members holding an unexpired membership on a listed plan in that club can. Both lists are
deduplicated; malformed IDs or larger lists are rejected as BAD_USER_INPUT.
Guests must be people of the session's club and plans must be offered by that club's merchant.
createReservationSession and updateReservationSession otherwise return
error: "Choose guests from this club." or error: "Choose plans offered by this club.".
Omitting a list in updateReservationSession keeps the current one, including when a recurring
occurrence is moved to a new one-off session; pass an empty list to remove a restriction.
For members, reservationAvailability omits sessions they are not invited to.
createReservation, joinReservationWaitlist and member rescheduling recheck the guest list under
the session lock and return error: "This session is reserved for its guest list." for anyone
else. Staff availability and staff bookings are not restricted. Session category drafts
(newSessions) are always open; add a guest list afterwards from Manage sessions.
mutation GoldGala($input: ReservationSessionInput!) {
createReservationSession(input: $input) {
sessionId
error
}
}{
"input": {
"clubId": "10000000-0000-4000-8000-000000000001",
"categoryId": "73000000-0000-4000-8000-000000000004",
"resourceId": "74000000-0000-4000-8000-000000000001",
"title": "Gold gala",
"kind": "custom",
"startsAt": "2026-12-12T19:00:00Z",
"endsAt": "2026-12-12T23:00:00Z",
"guestPlanIds": ["60000000-0000-4000-8000-000000000102"],
"guestPersonIds": ["00000000-0000-4000-8000-000000000001"]
}
}When a capacity-limited occurrence is full and the category's waitlistEnabled is true (the
default), an authenticated member can call
joinReservationWaitlist(input) with its occurrence ID. The entry always uses the session's
resource; resourceId can be omitted and, when given, must be that resource. With
waitlistEnabled false the mutation returns error: "This reservation category has no waitlist.".
Time slot waitlists are described in Booking rules and time slots. The server
rechecks active membership, occurrence scope, capacity, resources and blocks under the session
lock, then returns a FIFO ReservationWaitlistEntry in ReservationWaitlistResult.waitlistEntry
(id, clubId, categoryId, sessionOccurrenceId, startsAt, endsAt, guests and
position; sessionOccurrenceId is null for a time slot waitlist), or waitlistEntry: null with
the refusal in error. Joining the same occurrence again is
idempotent and returns the existing entry and current position. leaveMyReservationWaitlist
removes only the signed-in member's entry. When a place opens, a background worker books waiting
members in FIFO order through the normal member reservation checks, removes their entry and emits
waitlist.joined, which notifies and emails them. An entry whose booking is refused stays in the
queue without blocking later members. Each pass scans entries in bounded pages, including entries
beyond a page of refused bookings; entries joining during the pass are handled on the next pass.
Admins manage availability with createReservationSession, deleteReservationSession,
createReservationBlock, and deleteReservationBlock. A session input is either weekly, with a
club-local weekday (0 Sunday through 6 Saturday), start minute (0–1,439), and duration; daily, with the same time/duration and no weekday; or custom, with
strict UTC start/end timestamps. Every session input requires the resourceId of one resource of
its category. Capacity is optional and accepts 1–10,000 members.
createReservationSession returns a CreateReservationSessionPayload with the new sessionId
or an error, and creates nothing on error. A resource hosts one session at a time: a session
overlapping a future, uncanceled occurrence of another session on the same resource returns
error: "Another session uses this resource at that time.", whether either one is dated or
recurring (recurring sessions are compared over all their future occurrences). Like an edited
session, a new dated session is also refused over a resource block
("The resource is blocked during this session.") or an active booking on that resource
("The resource is already reserved during this session."). A recurring session only checks
other sessions; blocks and bookings hide its individual occurrences instead. Flexible
reservation categories use blocked periods, for example while a hotel suite is under renovation. The
back office exposes the matching Manage sessions or Block periods action on each category.
deleteReservationSession returns a DeleteReservationSessionResult with deleted and an
error. It deletes nothing and returns
error: "This session has bookings or a waitlist. Cancel the session to notify its members." while
the session has a pending, booked or confirmed reservation, an unrefunded payment or a waitlist
entry; cancel the occurrence with cancelReservationSession instead. An unknown session returns
error: "Session not found.".
A block input carries an optional resourceId. Omitted or null blocks every resource of the
category; a resource ID limits the block to that resource, for example closing the pool while its
deck chairs stay bookable. The resource must belong to the category, otherwise the mutation returns
error: "Choose a resource from this reservation category.". Each returned block exposes its
resourceId, null for category-wide blocks.
Blocked periods reject overlapping pending or confirmed reservations that use a blocked resource;
those reservations must be cancelled first, while reservations on other resources do not prevent
a resource-scoped block. Customer and staff reservation creation, member rescheduling and
proposal acceptance all reject a block that is category-wide or that covers one of the chosen
resources. Only category-wide blocks hide session occurrences from reservationAvailability.
The calendar shows a category-wide block on every resource row and a resource-scoped block on its own row only, so other resources keep their add action. The Block periods dialog has an Applies to choice that defaults to all resources; the add dialog opened from a resource's plus action has a Block period tab whose choice defaults to that resource. Calendar events keep their exact times across midnight instead of moving multi-day reservations into a separate all-day band. Clicking a blocked event opens the removal confirmation, which names the resource for a resource-scoped block.
Reservations
Participant and waitlist export
The staff session panel switches between Participants and Waitlist. Each view offers a CSV
download for that occurrence using the same authorized, unpaginated data as its table.
It contains one row per reservation with the person's name, reservation status,
attendance, reserved places (including the booking contact), session name and start/end
instants. Labels use the interface language; timestamps use ISO 8601 UTC, with explicitly
labeled UTC columns. Filenames include a sanitized session name and its UTC date/time.
ReservationRosterEntry.guests exposes the total number of reserved places in both
roster queries and attendance mutation results. Pending and canceled reservations are excluded.
The export is disabled while loading, refreshing, saving attendance, on query errors,
or when the roster is empty. The UTF-8 CSV includes a BOM, semicolon separators,
quoted cells and spreadsheet formula protection for names.
Staff with reservations_manage can remove a participant from the roster. After confirmation,
the web app cancels that reservation with cancelReservation, so the usual staff
cancellation refunds apply; completed participants cannot be removed. Each roster entry's nullable
refundDeadline is the session's cancellation notice deadline for a
paid confirmed booking, and null when nothing was collected. Once it has passed, the removal
dialog asks whether to refund the price and passes the answer as refundLateCancellation. The
reservation detail asks the same only for a paid confirmed reservation.
The waitlist export contains FIFO position, person, selected resource, join time, session name,
and session start/end instants. Its export is likewise disabled while unavailable or empty.
Confirmation emails
Creating a confirmed reservation for a member, confirming a pending request, or accepting
an alternative time for a free reservation queues a confirmation email. Paid reservations
queue the email only after payment confirms them; repeated payment notifications do not
send duplicate confirmations. Pending requests and edits that keep
an already confirmed status do not send another confirmation. Cancelling and confirming
again creates a new confirmation; an unsent previous confirmation is discarded.
Existing reservations and demo seeds are not emailed retroactively. The email links to the
consumer's /account/reservations/<id>?club=<clubId> detail page.
The email includes the club, reservation category, resource, dates and a link to the reservation in the member account. Dates use the club's configured timezone. The link requires the member to sign in; it does not contain an authentication token.
Delivery runs in the background with the configured mail provider and CONSUMER_ORIGIN.
The durable reservation_mail queue commits with the reservation change. The worker
checks for work every minute, skips cancelled or ended reservations and missing members,
and retries definite delivery failures after one hour. An uncertain provider outcome or
an interrupted sending attempt is retained as unknown and is not automatically resent,
to avoid duplicate emails. Provider acceptance does not guarantee inbox delivery.
For local testing, create a future confirmed reservation for a test member, then open Mailpit after at most one minute. Confirming a pending request should also create one email; saving the confirmed reservation again should not. Email links use the consumer origin configured for the running stack.
QR tickets
The category modal's Create QR code ticket switch sets ReservationCategoryInput.createTicket
(optional Boolean, defaults to false). Type queries return createTicket; each reservation
also returns its effective createTicket flag. Type changes propagate to existing reservations.
Tickets are private, cover the whole reservation, and are available only for confirmed,
member-owned reservations with tickets enabled and an end time strictly in the future.
The booking contact and every accepted invitee download the same ticket with the same code.
mutation DownloadMyReservationTicket($clubId: ID!, $id: ID!) {
downloadMyReservationTicket(clubId: $clubId, id: $id) {
filename
pdf
error
}
}
mutation VerifyReservationTicket($clubId: ID!, $code: String!) {
verifyReservationTicket(clubId: $clubId, code: $code) {
valid
message
categoryName
attendance
guests {
id
name
status
}
reservation {
id
categoryId
guestName
guestAvatar
startsAt
endsAt
guests
resource {
id
name
}
}
}
}Download requires the session of the owning member or of an accepted invitee; the server
derives the person ID from that session. id is the reservation ID. Pending or declined
invitees receive the same unavailable error as unrelated members. filename and pdf (base64-encoded PDF bytes) are nullable and are returned only on
success, with error: null. Unavailable reservations and generation/storage failures instead
return a safe error with null file fields. UUID arguments are validated; no filtering,
sorting or pagination applies to these mutations.
Verification requires staff access to the selected club. The scanner is available from the reservations/planning toolbar; no reservation ID is needed. The server resolves the code within that club. code is a six-digit
number represented as a string, including leading zeros. Club administrators can verify all
reservation categories in their club; planning staff can verify only their assigned reservation categories.
The QR contains only xxx-xxx;
manual verification accepts xxx-xxx or xxxxxx, with surrounding whitespace ignored.
Invalid, expired, cancelled, disabled or unknown or out-of-scope tickets return valid: false
with null reservation, categoryName and attendance and an empty guests list. A valid
ticket returns the matching Reservation (whose guestName is the booking contact), its
category name, current attendance, and guests: the reservation's ReservationInvitee
entries with status accepted or pending, ordered by invitation time. Declined invitees are
omitted.
Verification never checks in guests or consumes a ticket.
It never changes attendance; a successful scan must be followed by an explicit
markReservationAttendance call if staff wants to record presence.
Session attendance
query SessionRoster($clubId: ID!, $categoryId: ID!, $occurrenceId: ID!) {
reservationRoster(clubId: $clubId, categoryId: $categoryId, occurrenceId: $occurrenceId) {
reservationId
personId
personName
status
attendance
refundDeadline
}
}
query SessionWaitlist($clubId: ID!, $categoryId: ID!, $occurrenceId: ID!) {
reservationWaitlist(clubId: $clubId, categoryId: $categoryId, occurrenceId: $occurrenceId) {
id
personId
personName
position
joinedAt
resource {
id
name
}
}
}
mutation MarkAttendance(
$clubId: ID!
$categoryId: ID!
$occurrenceId: ID!
$reservationId: ID!
$attendance: ReservationAttendance!
) {
markReservationAttendance(
clubId: $clubId
categoryId: $categoryId
occurrenceId: $occurrenceId
reservationId: $reservationId
attendance: $attendance
) {
reservationId
personName
attendance
}
}Use the opaque id returned by reservationAvailability.sessions as occurrenceId:
custom sessions use their definition UUID; weekly sessions use <definition UUID>:<YYYY-MM-DD>
in the club's local timezone. All club, category and reservation IDs must be UUIDs. Owners/admins can access
their club's roster; planning staff must additionally be assigned to the selected
reservation category. Members, anonymous users, foreign clubs and unassigned staff are denied.
The roster has no pagination, filters or client sorting; it contains booked,
confirmed and completed reservations for that exact occurrence, ordered by booking
contact name and reservation ID. Pending and canceled bookings are excluded. personName is
the linked person's current first and last name, falling back to the booking contact
when the person is no longer linked. personId may be null. Ticket codes are never
returned. Attendance is stored separately from the check-in window (checkInAt),
defaults to unmarked, and may be set to present, late, absent or unmarked. Marking
returns null if the occurrence or eligible booking does not match the selected club/category;
the roster returns an empty list for an unknown occurrence. A ticket verification
does not mark attendance, including on success.
reservationWaitlist uses the same occurrence and staff authorization rules. It returns active
entries in FIFO order with one-based positions, the selected resource, and ISO 8601 join times.
An unknown occurrence returns an empty list. Waitlisted people are not attendance records.
Codes are issued once, transactionally, on first download, and remain unchanged after edits, cancellation or category configuration changes. Issuance serializes per club; a database unique index prevents two reservations in the same club sharing a code, and concurrent downloads share the same persisted code. Codes are not reused while the reservation remains stored. The six-digit namespace has a maximum of one million issued reservations per club; exhaustion returns an issuance error rather than reusing another reservation's code.
Current status, expiry and ticket-enabled configuration always determine validity. Database-backed
limits allow at most 100 verification attempts per club in a one-minute
window, counting both valid and invalid submissions. Limits apply across staff sessions and API
instances and survive restarts; excess attempts return valid: false with a retry message.
PDF details refresh independently of the code. Content-derived storage keys include names,
dates, guest counts, club name and timezone. The server rechecks eligibility and content after storage
I/O. The centered PDF shows the stable code, natural-language dates in the club timezone and additional guests
(guests - 1, excluding the main person), with no exposed UUIDs or payload identifiers.
The back office ticket dialog switches between a camera scanner (cross-browser decoding,
permission-error retry) and manual code entry. A valid result shows the guest, category, period,
resources, guests and attendance, with a Mark present action that calls
markReservationAttendance. Closing the scanner releases its camera and decoder. When tickets
are disabled, the entire verifier is absent.
Storage configuration
Configured S3 storage uses TICKET_S3_BUCKET, TICKET_S3_ENDPOINT,
TICKET_S3_ACCESS_KEY_ID, TICKET_S3_SECRET_ACCESS_KEY, and optional TICKET_S3_REGION
(default fr-par). Objects use private ACLs and content-specific keys under
reservation-tickets/. Credentials belong in the runtime's existing secret configuration,
never in frontend bundles or another worktree's files.
For isolated development only (NODE_ENV=development), when no S3 settings are supplied,
the adapter persists private files under TICKET_STORAGE_DIRECTORY, defaulting to
/app/.local/reservation-tickets. The Compose stack sets it to the ticket_storage named volume
at /var/lib/champagne/reservation-tickets, keeping container-owned files out of the worktree. Writes
are atomic, survive API restarts, and never fall back to ephemeral storage in production or
when an S3 configuration is incomplete. Production requires a configured private S3 provider.
query Reservations(
$clubId: ID!
$categoryId: ID!
$page: Int
$pageSize: Int
$from: String
$to: String
$undated: Boolean
) {
reservations(
clubId: $clubId
categoryId: $categoryId
page: $page
pageSize: $pageSize
from: $from
to: $to
undated: $undated
) {
items {
id
clubId
categoryId
personId
guestName
resource {
id
name
}
startsAt
endsAt
checkInAt
checkOutAt
proposedStartsAt
proposedEndsAt
proposedFree
proposalVersion
proposedAmount
paymentVersion
guests
amount
currency
status
}
total
page
pageSize
}
}Both IDs are required UUIDs. Results match both club and category; an unknown category
or a category from another club returns an empty page, never another club's records.
page defaults to 1 (range 1–1,000,000); pageSize defaults to 25 (range 1–100).
Explicit null uses the same defaults. Invalid values receive BAD_USER_INPUT.
Results are ordered by start time descending, then ID ascending, with no
client-selectable sorting. total counts all matching rows, including date filters;
out-of-range pages have an empty items array and retain the matching total.
Optional from and to must be supplied together as UTC ISO timestamps, for
example 2026-09-01T00:00:00.000Z and 2026-10-01T00:00:00Z. Seconds are required;
exactly three fractional digits are optional. The range must increase and span
at most 62 days. Missing endpoints, invalid dates, offsets other than Z, and
invalid durations receive BAD_USER_INPUT. Omitted or both-null endpoints leave
the list unbounded by date.
The range uses overlap semantics: startsAt < to AND endsAt > from. A stay that
began before the range is included if it continues into it. Bookings ending
exactly at from or starting exactly at to are excluded. Undated requests never match a
date range.
undated defaults to false. When true, only pending requests without a time (see
Request a reservation) are returned; the back office lists them as
Requests awaiting a time above the calendar of conversation-enabled categories.
Member's upcoming reservations
query MyReservations($clubId: ID!) {
myReservations(clubId: $clubId) {
categoryName
conversationRequired
hostedSlug
reservation {
id
resource {
id
name
}
startsAt
endsAt
checkInAt
checkOutAt
proposedStartsAt
proposedEndsAt
proposedFree
proposalVersion
proposedAmount
paymentVersion
guests
status
phase
timingChangesAt
}
editable
}
}
query MyReservationWaitlist($clubId: ID!) {
myReservationWaitlist(clubId: $clubId) {
id
clubId
categoryId
categoryName
sessionOccurrenceId
startsAt
endsAt
joinedAt
guests
position
resource {
id
name
}
}
}Only a member session belonging to clubId can call this query. It returns that member's pending,
booked, and confirmed reservations whose end is still in the future or that do not have a time
yet, ordered by start time (undated requests last) then ID. It
never accepts a person ID and never returns another customer's bookings. hostedSlug is nullable;
it remains available for other club-hosted experiences, but it does not imply a reservation chat
route. Member apps use conversationRequired to decide whether to expose their own conversation UI.
editable on CustomerReservation (also on myReservation) tells whether updateMyReservation
would currently accept a change of dates, resource or people: a dated pending request, a dated
booked reservation outside conversation-enabled categories, or a confirmed one before its
cancellation notice (refundDeadline), always before the reservation starts: once started,
editable is false and updateMyReservation fails with
This reservation has already started. Contact the club. It is computed when read, so apps never re-derive the
notice policy.
myReservationWaitlist has the same member-only club scope and returns the member's future
waitlist entries with their current FIFO positions and party size (guests). resource is the
session's resource, or null for a time slot waitlist, like sessionOccurrenceId. The consumer combines them with bookings in
the upcoming area and lets the member leave directly from the entry.
The back office offers Day, Week and Month views (Month by default), with a Monday-first week and six weeks in the month grid. Previous/next navigation moves by the selected view, and Today returns to the current date. It converts local midnight boundaries to UTC, including daylight-saving transitions, and fetches every matching page at 100 rows per request. It does not render a partial first page as a full calendar. Query caches include club, category and date range. A later-page failure shows an error and retains a previously completed cached result, if available. Multi-day reservations appear on every overlapping local date and in a separate multi-day band above the day/week time grid. Timed events occupy proportional quarter-hour slots with separate lanes for overlaps; their exact times remain visible in event details. Click an event to view read-only details. Month cells show two events and an overflow button that opens that date's Day view. Week grids scroll horizontally on small screens. Weekends are shaded and today is highlighted. There is no table view. Click the month/year heading to open a modal containing a standalone HeroUI Calendar (with year navigation). Selecting a date navigates the current agenda view and closes the modal; there is no date input field.
The UI uses an Agenda-style composition of existing HeroUI primitives and standard layout utilities, not the separately licensed HeroUI Pro package.
guestName is the booking contact (renaming the linked person with updatePerson or
updateMyProfile renames it on all their reservations in the club), personId is the linked customer when retained, and resource is the suite/facility/lesson,
startsAt and endsAt are ISO 8601 UTC timestamps, both null only for a pending or canceled
request whose time the club has not proposed yet, and guests is the number of people, an
integer from 1 to 500 including the booking contact (see number of people). amount and currency snapshot the total resource price and category currency when the reservation is created, so later
category edits do not alter an existing booking. status is pending, booked, confirmed,
completed, or canceled. source (ReservationSource) records where the reservation was
created and never changes: staff when booked by staff in the back office (createStaffReservation, including session
participants), member for the member area (createReservation and waitlist promotions), and
external for a non-member's external reservation. The back-office
detail shows it in a Reservation source card. The web table displays
times in the viewer's local timezone. The database enforces a positive duration
and a category belonging to the same club. Nullable checkInAt and checkOutAt must
respectively be at or before the start and at or after the end. Nullable checkedOutAt is when
staff recorded the guest leaving early (see Early check-out). proposedStartsAt and
proposedEndsAt are either both null or a valid alternative window; the requested window stays
unchanged until the member accepts the proposal. proposedFree is true when staff offered the
proposed session for free; it is always false without a proposal.
Computed reservation fields
The API decides these rules when the fields are read, so the back office and the member apps display them instead of re-deriving them:
| Field | Meaning |
|---|---|
requiresPayment: Boolean! | amount + depositAmount > 0. Accepting a pending request then books it (booked, payment due) instead of confirming it. |
phase: ReservationPhase | For a dated confirmed reservation: checkIn from checkInAt (or the start) to the start, ongoing until the end, checkOut until checkOutAt (or the end), and checkedOut once staff recorded an early check-out. Null otherwise. |
canCheckOut: Boolean! | Staff can record the guest leaving now (see Early check-out). |
canUndoCheckOut: Boolean! | Staff can clear the recorded departure now. |
depositSettleable: Boolean! | The reservation is confirmed, its deposit was paid and its departure has passed, so staff can refund or keep the deposit. |
timingChangesAt: String | The next instant, as an ISO 8601 UTC timestamp, when phase, canCheckOut, canUndoCheckOut or depositSettleable change; null when they no longer change. |
Because these depend on the current time, clients keep only a timer: the web reservation page and
staff schedule, and the member reservation list and detail, refetch at timingChangesAt.
query ReservationDecisions($clubId: ID!, $id: ID!) {
reservation(clubId: $clubId, id: $id) {
id
requiresPayment
phase
canCheckOut
canUndoCheckOut
depositSettleable
timingChangesAt
}
}Create a category
mutation CreateReservationCategory($input: ReservationCategoryInput!) {
createReservationCategory(input: $input) {
reservationCategory {
id
clubId
name
description
conversationRequired
resources {
id
name
amount
depositAmount
defaultCheckInMinutes
defaultCheckOutMinutes
}
currency
}
error
}
}input includes clubId, name, description, conversationRequired, currency, resources, and module switches. Names must be nonblank, at most 100 characters before trimming, and contain no
ASCII control characters. Invalid names/IDs receive BAD_USER_INPUT. Names are
trimmed before persistence and unique case-insensitively within each club.
Duplicate names return reservationCategory: null with a user-facing error;
success returns the new category with error: null. The database uniqueness index
also protects concurrent requests. Unexpected failures are masked by GraphQL.
updateReservationCategory(id, input) accepts the same configuration and club scope.
housekeepingEnabled defaults to false and turns on the housekeeping module
for the category. The back-office wizard sets it in Options using the Housekeeping module switch.
Checklist configuration belongs to individual resources, not this wizard.
Disabling it hides the module and refuses new entries without deleting logged tasks or reports.
customerSheetsEnabled defaults to false and turns on customer sheets for the
category, using the Customer sheets switch in Options. Disabling it hides existing sheets
and refuses edits without deleting them; enabling it again restores them.
externalBookingEnabled defaults to false and opens the category to
external reservations through the External booking switch.
It cannot be combined with conversationRequired or requiresSession.
onboardingOnly defaults to false. When true, the category is only booked through an
onboarding booking step, set with the Book only through
onboarding switch in Options. Members do not see it: reservationCategories leaves it out,
reservationSlots returns null for it, it is never offered as an alternative to a full slot, and
createReservation returns error: "This reservation category is not available in your club.".
Staff still list it and see its reservations in planning like any other. It cannot be combined
with externalBookingEnabled; sending both receives BAD_USER_INPUT.
Number of people
Each resource has a maxOccupants, 1 by default (see
resource occupant limits). guests on CreateReservationInput,
StaffReservationInput, UpdateReservationInput and UpdateMyReservationInput is the number of
people coming, from 1 to 500 including the booking contact; other values receive
BAD_USER_INPUT. When the selected resource takes more than one person, the member booking and
edit forms and the staff editor ask for a Number of people; otherwise the field is hidden and
the booking is for one person.
- Members cannot exceed the
maxOccupantsof the selected resource: creation returnserror: "Too many people for this resource.", and member edits andmyReservationChangeQuotereturn the same error. The member forms show the limit and disable booking above it. - Staff can exceed
maxOccupants, for example to seat 10 people at a table for 4. The editor and the staff reservation page show a warning but do not block the save. - In session categories, the number of people counts against session capacity.
- The price does not depend on the number of people.
- External bookings follow the member rules.
Inviting people is optional and gives them access to the
reservation. Members are offered invitations only when guests is above 1: the booking contact
plus pending and accepted invitees cannot exceed guests, and a further member invitation fails
with Everyone in this booking has already been invited. Staff always see the people list: when
the reservation is full, the staff page warns that inviting adds a person, and a staff invitation
raises guests by one (recorded in the reservation history) instead of failing. For a session
booking, that extra person must still fit in the session: a staff invitation that would take the
session past its capacity fails with This session does not have enough places left. Lowering guests
below the current invitees returns
error: "The number of people cannot be lower than the people already invited.".
Booking rules and time slots
Each category carries booking rules that members and non-members must follow. Staff can always book outside them.
| Field | Values | Effect |
|---|---|---|
openingHours | Up to 50 weekly windows (weekday 0 Sunday–6 Saturday, startMinutes 0–1,439, endMinutes up to 1,440) in club wall time | A booking must fit inside one window. Overlapping windows of a day are merged. An empty list means always open. |
defaultDurationMinutes | Null or 5–10,080 | When set, members and non-members cannot choose the end: it is the start plus this length, whatever endsAt they send. The staff editor prefills it for new bookings. |
bookingWindowDays | Null or 1–730 | Members cannot book a start further ahead. |
minNoticeMinutes | Null or 0–10,080 | Members cannot book a start sooner. |
slotBooking | Boolean | Members choose a party size and a start time instead of a resource (see below). Requires defaultDurationMinutes and cannot be combined with requiresSession, conversationRequired or externalBookingEnabled. |
slotIntervalMinutes | 5, 10, 15, 20, 30 or 60 (default 30) | Spacing of the start times offered in slot booking. |
waitlistEnabled | Boolean, default true | Members can join a waitlist for a full session or time slot. |
linkedCategoryIds | Up to 50 other categories of the club | Staff can move reservations to these categories, and members are offered them when a time slot is full. Links are one-way. |
ReservationCategoryInput accepts the same fields. On update, omitting openingHours or
linkedCategoryIds keeps them; any list replaces them. The other fields fall back to their defaults
when omitted. Out-of-range values, invalid windows, unknown ID formats and invalid slot booking
combinations receive BAD_USER_INPUT; linking the category to itself or to another club returns
error: "Choose other reservation categories of this club.".
Refusals returned by createReservation, updateMyReservation and
createExternalReservation:
The club is closed at this time.when the booking does not fit in the opening hours.This time is too soon to book online. Contact the club.before the minimum notice.This time is not open for booking yet.beyond the booking window.
Session categories only apply the booking window; sessions define their own times.
Slot booking
A restaurant is a slot-booking category whose tables are resources with their maxOccupants.
Members send no resource: createReservation omits resourceId and takes startsAt and guests, and
the server assigns the smallest free resource that holds the party (then by name), under the
category lock so concurrent bookings cannot take the same table. When none is free the mutation
returns error: "Nothing is available at this time for this party.". updateMyReservation keeps
the current table when it still fits and is free, otherwise it assigns another one. Staff still
book and move specific tables from the calendar.
query ReservationSlots($clubId: ID!, $categoryId: ID!, $date: String!, $guests: Int!) {
reservationSlots(clubId: $clubId, categoryId: $categoryId, date: $date, guests: $guests) {
partyFits
waitlistEnabled
slots {
startsAt
endsAt
available
alternativeCategoryIds
waitlistEntryId
}
}
}date is a club-local YYYY-MM-DD day and guests 1 to 500; other values receive
BAD_USER_INPUT. The query returns null when the category does not use slot booking. Start times
run every slotIntervalMinutes through each opening window, stopping when the booking length no
longer fits, and exclude past times and times outside the booking window. partyFits is false, with
no slots, when no resource holds the party. available is true when a fitting resource is free and
not blocked for the whole slot. For a full slot, alternativeCategoryIds lists linked slot-booking
categories where the same start time is free for the party. waitlistEntryId is the signed-in
member's waitlist entry for that time. Members of the club and staff with planning access can
query it.
Time slot waitlist
When a slot is full, members call joinReservationWaitlist without resourceId, with startsAt and
guests instead of a session occurrence:
{
"input": {
"clubId": "51000000-0000-4000-8000-000000000051",
"categoryId": "6a000000-0000-4000-8000-000000000001",
"startsAt": "2026-10-02T17:30:00.000Z",
"guests": 4
}
}The time must be one of the category's slots. The entry is refused with
A place is available. Book it instead. while a fitting resource is free, with
You already booked this time. when the member has a booking at that time, and with
Too many people for this resource. when no resource holds the party. Joining again returns the
existing entry. Returned entries have a null sessionOccurrenceId and carry guests. The waitlist
worker books waiting parties in joining order when a table frees up, using the normal member rules,
and emits waitlist.joined.
Staff list upcoming entries with:
query ReservationSlotWaitlist($clubId: ID!, $categoryId: ID!, $from: String!, $to: String!) {
reservationSlotWaitlist(clubId: $clubId, categoryId: $categoryId, from: $from, to: $to) {
id
personName
startsAt
guests
position
joinedAt
}
}The range spans at most 62 days. Entries are ordered by start time, then position. The back office shows them from the category's Waitlist button.
Moving a reservation to a linked category
UpdateReservationInput.categoryId moves a reservation to a category linked from its current one,
for example from a restaurant to its rooftop. resourceId must then belong to the target category.
Moving to an unlinked category returns error: "This reservation cannot be moved to that category.".
Session reservations, session categories and categories in another currency return
error: "Only reservations without sessions can move to a category with the same currency.".
The staff reservation editor offers the current and linked categories.
Category settings
Category settings opens a dialog with tabs, laid out like the club settings. Edits made in any tab are saved together with the dialog's Save button, and Delete category sits in its footer.
- General: name.
- Resources: the resource list with each resource's capacity, a button opening its settings drawer, and the floor plan switch.
- Booking: How members book is one choice between a resource and a time, a party size and
a time (
slotBooking), a session (requiresSession) or a request to discuss (conversationRequired), with the waitlist and non-member switches that apply to it. Then the booking length and start times, the booking window, and linked categories. - Hours: opening hours.
- On site: QR tickets, housekeeping and customer sheets.
The Add category wizard offers presets (Restaurant, Group class, Treatment, Room or suite) that fill these settings once; nothing records which preset was used and every setting stays editable.
Request a reservation
mutation CreateReservation($input: CreateReservationInput!) {
createReservation(input: $input) {
reservation {
id
clubId
categoryId
guestName
resource {
id
name
}
startsAt
endsAt
guests
status
}
error
}
}Example variables (replace IDs with your club and one of its reservation categories):
{
"input": {
"clubId": "51000000-0000-4000-8000-000000000051",
"categoryId": "73000000-0000-4000-8000-000000000001",
"resource": "Garden suite",
"startsAt": "2090-09-21T15:00:00.000Z",
"endsAt": "2090-09-22T11:00:00.000Z",
"guests": 2
}
}startsAt and endsAt are optional, but must be supplied together. They may be omitted (or null)
only for conversation-enabled categories without sessions: the request is then created as pending
with null dates, and staff propose its time. Other flexible categories without both dates return
error: "Choose a start and end time.". sessionOccurrenceId is optional for flexible
categories and required when the selected category has requiresSession: true. Only a member session for clubId can call
this mutation; staff cannot create requests on behalf of members. The person ID
comes from the session and the booking contact name comes from the database.
Neither identity nor status can be supplied by the client. The persisted request
retains its member ID and a snapshot of the contact name.
The account must still be active and belong to this club. Activity uses the effective
account status on today’s UTC date: future and elapsed suspensions allow requests;
suspensions block them from their start date through their end date, inclusive.
Banned and disabled accounts remain blocked. It must have a current
subscription membership (or legacy membership without a plan), scoped to the club
or legacy unscoped, with no expiration or an expiration on/after today's UTC date.
An add-on alone, an expired membership, and a membership explicitly scoped to
another club do not qualify. Membership eligibility is checked at request time,
not against the requested stay dates. The category must exist in the same club.
These eligibility failures return reservation: null and an actionable error.
Session-based bookings must select a future occurrence from that category; the server replaces the
submitted dates with the authoritative session dates and books the session's resource. resourceId
can be omitted for a session; when given, it must be the session's resource, otherwise the result
error is Choose the resource assigned to this session. Capacity is serialized transactionally and
counts the guests of pending and confirmed reservations. Flexible bookings are rejected when
their interval overlaps an admin-defined blocked period.
IDs must be UUIDs. Every reservation has exactly one resource: inputs take a single resourceId
from the selected reservation type, and outputs return a non-null resource.
updateReservation requires it. createStaffReservation and createReservation require it except
for session bookings, which always use the session's resource, and, for createReservation, in
slot-booking categories, where it is omitted and the server assigns a table. updateMyReservation
requires it except for session bookings, which keep the session's resource, and in slot-booking
categories. Session waitlist joins may omit it; time-slot waitlist joins omit it. A missing
required resourceId or a malformed ID receives BAD_USER_INPUT. guests must be an integer from 1
to 500; see number of people. Timestamps use the same strict UTC format as date filters. The end
must be strictly later than the start. Past dates and reservations longer than
62 days are accepted; booking duration and distance into the future are not capped.
Invalid inputs receive BAD_USER_INPUT; unexpected failures remain masked.
Success returns the persisted reservation with UTC timestamps and error: null. With chat disabled (conversationRequired: false), an available resource is booked immediately: paid reservations start as booked and free reservations as confirmed. With chat enabled, reservations remain pending until the club workflow approves them. Sessions and blocked
periods and overlapping resource reservations constrain availability; external provider inventory is not synchronized. Paid bookings must be paid in club credits before confirmation. Repeated API calls create separate requests; the consumer disables
submission while pending and after success and does not automatically retry writes.
After an ambiguous network failure, contact the club before resubmitting.
The consumer account page loads categories for the signed-in member's club. Flexible categories offer
a resource calendar of occupied and blocked periods, plus editable start/end inputs converted
from club-local time to UTC; session-required categories offer dated occurrences in a weekly planning. Conversation-enabled
categories without sessions omit start/end inputs and submit an undated request; the member discusses
availability in the reservation conversation. It
shows loading skeletons, retryable category errors, validation/creation errors, and
a receipt with the returned booking status and a reference. It never queries the staff reservation list.
Creation and editing forms display non-blocking warnings when the start is in the
past, more than 365 days away, or the duration exceeds 62 days. These warnings do
not require confirmation and do not disable saving. The calendar query's 62-day
fetch-window limit is independent of booking duration and remains unchanged.
The form prefills the first available category, its name as an editable resource
suggestion, tomorrow's local 10:00–12:00 slot, and one guest. These defaults do not
claim availability. Resource suggestions follow category selection until manually
edited. Background refreshes preserve the draft; a removed category requires a new
selection. Changing club/member or starting another request resets the defaults.
The same page lists the member's upcoming pending, booked, and confirmed reservations. Selecting one opens
myReservation(clubId, id), which returns only a reservation owned by the authenticated member.
Pending, booked, and confirmed reservations that already have a time can be edited with
updateMyReservation(input); the same statuses can be cancelled with cancelMyReservation(clubId, id)
until the reservation starts. From its startsAt on, cancelMyReservation returns
reservation: null and error: "This reservation has already started. Contact the club." without
any refund, and only staff can cancel it.
A member update replaces the requested resource, window, and guest count, clears any stale staff
proposal, and issues a new paymentVersion. Edits are rejected with an error when the reservation is
completed or canceled ("This reservation can no longer be edited."), when a conversation-enabled
category has accepted terms ("Accepted reservation terms cannot be edited. Contact the club to change them."), or when a confirmed reservation has reached its refundDeadline ("The cancellation notice has passed. Contact the club to change this reservation."). An unavailable window or resource returns
"This reservation cannot be changed to this time or place.".
Rescheduling a paid reservation
Editing the window or resource reprices the reservation with the same rules as creation: hourly,
daily, and nightly resources charge per unit of the new window, while once prices stay unchanged.
For booked reservations the new price simply replaces the unpaid amount. For confirmed and
completed reservations, staff and member edits settle the difference on the member's credit balance
in the same transaction: a higher price debits the difference (a reservation credit entry) and a lower
price refunds it (a refund credit entry). An insufficient balance rejects the edit with
"Not enough credits. Top up your balance and try again." and leaves the reservation unchanged. A later
cancellation refunds the net amount actually paid. Edits that would change a collected security deposit,
or the price of a legacy Stripe-paid reservation, are rejected.
myReservationChangeQuote(input: UpdateMyReservationInput!) prices the same member edit without
saving it, applying the same ownership, status, notice, availability and conflict checks as
updateMyReservation. It returns ReservationChangeQuoteResult { quote, error } where quote has
previousAmount, amount, difference (amount - previousAmount, in hundredths of a credit) and
settledInCredits, which is true when saving would immediately debit or refund the difference. The
member app shows this summary with the resulting balance and asks for confirmation before saving a
booked or confirmed reservation whose price changes.
query QuoteChange($input: UpdateMyReservationInput!) {
myReservationChangeQuote(input: $input) {
quote {
previousAmount
amount
difference
settledInCredits
}
error
}
}Cancellation notice
Reservation.refundDeadline is the UTC instant before which a cancellation is fully
refunded: the booking start minus the longest cancellationNoticeDays among its resources. It is
null for undated bookings and resources without a notice. When cancelMyReservation runs at or
after this deadline, the price (including credit-priced products) is not refunded and
Reservation.refundForfeited becomes true; a collected security deposit is still returned.
Only a paid booking (confirmed, or with a recorded card payment) can forfeit its price: a
booked reservation canceled late before any payment keeps refundForfeited false. Legacy
Stripe payments are not refunded either, and a later session cancellation does not refund them.
myReservation.creditPayment.refundableAmount already excludes the forfeited price once the
deadline has passed. A member cannot cancel a reservation that has already started, so a no-show
can still be marked. Staff cancelling with cancelReservation or updateReservation at or after
the deadline get the same outcome by default, and refund the price too with
refundLateCancellation: true, for example when the club caused the cancellation. Before the
deadline, staff cancellations refund in full whatever the argument. Session cancellations always
refund in full.
Pay a booked reservation
Staff moves an accepted paid request from pending to booked. For conversation-enabled categories,
only the member's acceptance of a paid proposal makes the reservation booked: staff cannot move
those pending requests to booked or confirmed (error: "Propose a time and wait for the member to accept it before booking."), and a reservation without a time cannot leave pending except by
cancellation. The authenticated reservation owner can pay with their club credits:
mutation PayReservation($clubId: ID!, $reservationId: ID!, $paymentVersion: ID!) {
payReservationCredits(
clubId: $clubId
reservationId: $reservationId
paymentVersion: $paymentVersion
)
}All three IDs must be UUIDs. Send the paymentVersion returned on the displayed reservation.
The transaction rejects a changed version or an outstanding replacement proposal with
Reservation changed. Review it before paying. and leaves the wallet unchanged. Refresh the
reservation and require another explicit action; never retry with a newer version automatically.
The server verifies ownership, club, active account and
booked status. It debits both the snapshotted price and security deposit in one transaction and confirms the booking atomically. This also applies to a zero-price booking with a nonzero deposit. Insufficient credit leaves both the booking and wallet unchanged.
Retries cannot debit a confirmed reservation twice. See Club credits
for top-ups and balances. New cash Checkout requests are rejected.
Staff booking and editing
createStaffReservation(input: StaffReservationInput!) creates a reservation directly for a
club customer. Staff supplies the club, category, customer, resource, start/end, guests, and
optionally status. Without status the reservation is booked when its computed price or deposit is
positive and confirmed otherwise; the back office always omits it. A session booking (sessionOccurrenceId) may omit the resource: it always uses the
session's resource. Omitted check-in/check-out values inherit the category offsets; explicit timestamps override
them.
The customer is either personId, a person of the same club, or guestName for a walk-in guest
without a person record. Pass exactly one: both together, or a blank or longer than 100 characters
guestName without personId, fail with BAD_USER_INPUT. A walk-in reservation stores the
trimmed name as guestName with a null personId, uses the resource's base price, and sends no
confirmation email. Walk-in guests can join sessions only when the session has no guest list;
restricted sessions return This session is reserved for its guest list. A personId outside the
club returns This customer is not in this club. When personId is a guest account, the member
or staff user who invited that guest also receives an email with the resource and dates, unless
they created the reservation themselves; a failed email never blocks the booking. updateReservation(input: UpdateReservationInput!) edits the booking window, resource,
guests, status, and can set either check timestamp to null to remove it. Creation and updates
accept elapsed booking windows and durations longer than 62 days. Timestamps must still be
valid and increasing. The editor shows the same non-blocking date warnings as the consumer.
For a booking linked to a session, updateReservation preserves its occurrence dates and the
resource assigned to that session. Saving unchanged terms or canceling one participant remains
allowed when other participants share the same occurrence. Raising guests must fit the session
capacity alongside the other participants, as when staff add a participant; otherwise error is
This session does not have enough places left. A smaller party is always accepted. To move the
whole course, use updateReservationSession, which moves its participants and waitlist together.
The workflow is pending → booked → confirmed → completed, with cancellation from any
nonterminal stage. Staff cannot set completed with updateReservation; a reservation completes when
its guest leaves (see Departure and deposit refunds). Reservations with both zero price and zero deposit may move directly from pending to confirmed. A paid
reservation cannot be manually confirmed; its credit payment confirms it. Staff can edit the window,
resource, and products of confirmed and completed reservations outside conversation-enabled categories;
price differences are settled as described in Rescheduling a paid reservation. Cancellation
returns the original price and deposit debits once, in the same transaction as the status change. Uncollected deposits are never refunded.
Existing cash payments retain their original Stripe refund flow. Completed and
canceled reservations are terminal. A canceled reservation cannot be saved again, even unchanged:
updateReservation returns reservation: null and
error: "A canceled reservation can no longer be edited.", so a forfeited price or a refund is
never replayed.
Cancel a reservation
cancelReservation(clubId: ID!, id: ID!, refundLateCancellation: Boolean! = false): ReservationResult!
cancels a pending, booked or
confirmed reservation and changes nothing else: an edit another staff member saved in the
meantime is kept, unlike resending every field through updateReservation. It requires
reservations_manage, and planning staff only reach reservations in their assigned categories.
The refunds are those of a staff cancellation through updateReservation: a card payment is
refunded through Stripe before the status changes, and a failed refund returns its message in
error with the reservation unchanged. Credit debits for the price and deposit are returned in the
same transaction. At or after the cancellation notice deadline only the
deposit is returned and refundForfeited becomes true, unless refundLateCancellation is true.
UpdateReservationInput.refundLateCancellation makes the same choice when updateReservation
cancels. The pending confirmation email is dropped, the outstanding proposal is
cleared, and paymentVersion changes. An open Stripe Checkout is then expired; see
external reservations. Canceling an already-canceled reservation
succeeds and returns it unchanged, without refunding, returning credits or expiring a checkout again.
Completed or unknown reservations return reservation: null with
This reservation cannot be canceled. When updateReservation cancels a reservation whose full
card refund already canceled it, it returns the canceled reservation without error.
mutation CancelReservation($clubId: ID!, $id: ID!, $refundLateCancellation: Boolean!) {
cancelReservation(clubId: $clubId, id: $id, refundLateCancellation: $refundLateCancellation) {
reservation {
id
status
}
error
}
}reservation(clubId, id) loads one staff-visible booking. Its nullable guestAvatar is the linked
member's photo, so staff without access to member profiles can still show it; other reservation
queries return it as null. The back office links upcoming rows to
that detail, where staff can edit the reservation. Conversation-enabled categories show a conversation
tile on the same page with a fixed-height message history and composer. A pending request can be
accepted by setting its status to confirmed, unless its category is conversation-enabled. For those,
staff call proposeReservationTime(clubId, id, startsAt, endsAt, free). Proposals are limited to
pending or booked conversation-enabled reservations without fixed sessions or a cash checkout. Dates must be valid, increasing
UTC timestamps with a future start. Before sending, the server locks the resources and rejects
blocked or already occupied windows; proposed windows are not holds. The same availability checks
run again at acceptance. Long future windows remain allowed.
Every proposal exposes a nullable proposalVersion UUID on Reservation, alongside
proposedStartsAt, proposedEndsAt, and proposedFree. Sending a proposal calculates amount
from the proposed duration, member pricing and snapshotted products. proposedAmount holds this
quote. For a booked reservation, amount and the accepted dates stay unchanged until acceptance.
The existing deposit is retained.
Identical resends preserve the version; changed dates, price or free choice replace it.
Editing a pending request clears the outstanding proposal and its version.
For conversation-enabled reservations, accepted dates, resources and product quantities cannot be
changed through ordinary editing. Before payment, send a new time proposal for the same resource.
After payment, a change to these terms requires a new request. Administrative edits and cancellation
remain available. All dated staff edits check resource and category blocks as well as other bookings.
Cancellation and completion do not require the period to remain available.
Members call acceptReservationProposal(clubId, id, proposalVersion) with the version they displayed.
The version is checked under the reservation lock. A stale version returns a null reservation and
Proposal changed. Review it before accepting.; clients refresh and require another explicit acceptance.
The mutation promotes the proposed dates and price, changes paymentVersion, and clears the proposal. Its price stays fixed even if
catalogue prices change after the proposal was sent. free: true waives the price and product charges,
but not the deposit. A positive price plus deposit produces booked (payment due); zero produces
confirmed. Credit payment atomically debits the price and deposit before confirming.
ReservationResult.errorCode is a nullable ReservationErrorCode enum. For
acceptReservationProposal, declineReservationProposal and
withdrawReservationProposal, PROPOSAL_CHANGED means the displayed version
no longer matches; PROPOSAL_UNAVAILABLE means the proposal cannot be acted on
(for example, expired acceptance or an already removed proposal). Both return
reservation: null and the public error message. Unavailable does not disclose
whether a reservation exists outside the caller's scope. Other business refusals
and successful results have a null code. Validation and authorization errors
still use GraphQL errors. Clients should read errorCode, not classify the
English message; the web and consumer transports preserve it as
ApiResponseError.code. These codes are result fields, not GraphQL
extensions.code values.
The member sees one proposal card with its dates, total, deposit when applicable, and acceptance action. Staff see the same total after sending. Past proposals cannot be sent from the editor or API. Errors leave the previous proposal unchanged. Creating a category never creates a reservation.
Early check-out
mutation SetReservationCheckedOut($clubId: ID!, $reservationId: ID!, $checkedOut: Boolean!) {
setReservationCheckedOut(
clubId: $clubId
reservationId: $reservationId
checkedOut: $checkedOut
) {
reservation {
id
checkedOutAt
}
error
}
}Staff with attendance_manage on the reservation's category record that a guest has left before
the scheduled end. checkedOut: true stores the current time in checkedOutAt and
immediately counts as the reservation's end for the resource's
after-reservation checklist, so housekeeping can start before the booked end.
It is accepted only for a confirmed reservation between its check-in (or start) and its
scheduled check-out (or end); otherwise reservation is null and error is
Only a confirmed reservation in progress can be checked out. A reservation without a collected
deposit becomes completed at check-out; one holding a deposit stays confirmed until the deposit
is refunded. checkedOut: false clears checkedOutAt and returns the reservation to confirmed. It
is accepted while the reservation is still confirmed, or completed by a check-out without deposit
before its scheduled check-out (or end); otherwise it returns This check-out can no longer be undone.
Repeating the current value returns the reservation unchanged without a history entry. The booked
dates, price and attendance are not changed. Changes are recorded in
reservation history as checked_out and check_out_undone, alongside an
updated entry when the status changes.
On the reservation page, staff with this permission see Check out while canCheckOut is true and
Undo check-out while canUndoCheckOut is true; the phase chip and a Checked out date card
show the recorded time.
Departure and deposit refunds
A reservation's departure is its checkedOutAt, otherwise its scheduled checkOutAt, otherwise
endsAt. Once it has passed:
- A
confirmedreservation without a collected deposit becomescompletedautomatically. - A
confirmedreservation holding a deposit staysconfirmeduntil the deposit is refunded, by staff or automatically.
mutation RefundReservationDeposit(
$clubId: ID!
$reservationId: ID!
$amount: Int!
$message: String
) {
refundReservationDeposit(
clubId: $clubId
reservationId: $reservationId
amount: $amount
message: $message
) {
reservation {
id
status
depositRefundedAmount
depositRefundNote
}
error
}
}Staff with reservations_manage on the reservation's category refund the deposit of a departed
confirmed reservation. amount is in the deposit's unit (credit hundredths, or cents for card-paid
external reservations), from 0 to the collected deposit; the
rest is kept by the club. message is optional, trimmed, at most 1,000 characters, and stored as
depositRefundNote only when part of the deposit is kept. Negative amounts and longer messages
receive BAD_USER_INPUT. The refund is credited to the member's wallet, or refunded to the card
first for external reservations, then depositRefunded becomes true, depositRefundedAmount
records the amount and the reservation becomes completed, in one transaction. A deposit that is
not held, a reservation that has not departed or is already settled returns
This deposit cannot be refunded yet or has already been settled.; an amount above the deposit
returns The refund cannot exceed the deposit., and card failures return the payment error.
Concurrent requests settle the deposit once. The history records deposit_refunded with the
depositRefundedAmount change and the message as its note.
The customer is told by email, sent to the member's address or, once a temporary external account
has been removed, to the email kept in externalCustomer. It gives the refunded and kept amounts and
the message. Members also get an in-app Security deposit refunded notification linked to the
reservation; the email respects their reservation email preference.
Deposits still held are refunded in full automatically, without a message, once the club's delay after departure has passed. A background worker checks every minute.
query ReservationDepositRefundDays($clubId: ID!) {
reservationDepositRefundDays(clubId: $clubId)
}
mutation SetReservationDepositRefundDays($clubId: ID!, $days: Int!) {
setReservationDepositRefundDays(clubId: $clubId, days: $days)
}The delay defaults to 7 days and ranges from 0 (refund at departure) to 365; other values receive
BAD_USER_INPUT. Both operations require settings_manage; changes are recorded in the activity log.
Club administrators edit it under Settings → Reservations → Security deposits.
On the reservation page, staff with reservations_manage see Refund deposit next to the deposit
while depositSettleable is true. The dialog proposes the full amount; lowering it shows how much is kept
and a Message to the customer field.
Reservation history
reservationHistory(clubId: ID!, id: ID!, page: Int! = 1) returns one reservation's timeline,
newest first by time and ID, 20 events per page. It uses the same assigned-category staff access
as reservation (planning_read). IDs must be UUIDs, page must be between 1 and 1,000,000, and
a missing reservation or one from another club returns null.
query ReservationHistory($clubId: ID!, $id: ID!, $page: Int! = 1) {
reservationHistory(clubId: $clubId, id: $id, page: $page) {
page
hasMore
items {
id
occurredAt
action
actor
actorName
note
changes {
field
before
after
}
}
}
}action is one of:
| Action | Recorded when | changes fields |
|---|---|---|
created | A member or staff creates the reservation | none |
updated | Status, resource, dates, check-in/out or price change | status, resource, startsAt, endsAt, checkInAt, checkOutAt, amount, refundForfeited |
time_proposed | Staff propose another time | proposedStartsAt, proposedEndsAt, proposedFree, proposedAmount, amount |
proposal_declined | The member declines the proposed time | proposal fields cleared |
proposal_withdrawn | Staff withdraw the proposed time | proposal fields cleared |
attendance_marked | Attendance changes | attendance |
checked_out | Staff record an early check-out | checkedOutAt |
check_out_undone | Staff undo an early check-out | checkedOutAt (cleared) |
deposit_refunded | The deposit is refunded, fully or partly | depositRefundedAmount |
invitee_added | Someone is invited to the reservation | invitee (email in after) |
invitee_removed | An invitation is removed | invitee (email in before) |
before and after are raw stored values: status and attendance enum values, ISO timestamps,
and amount/proposedAmount in the currency's minor unit. refundForfeited changes from
false to true when a late cancellation keeps the price. Only changed fields are listed. Updates are recorded
from every source, in the same transaction as the change: staff edits, member edits and
cancellations, accepted proposals, credit and card payments, refunds, product consumption,
session cancellations and session moves. Writes that change none of the listed fields add no
event.
actor is staff, member or system (payment webhooks and other automatic changes).
actorName is the staff member's current name, or the reservation holder's name when they acted
as a member; it is null for system events. note is the optional attendance note. Chat messages
and per-staff conversation archiving are not part of the timeline.
The back office shows this timeline in a History card on the reservation page and in the calendar drawer. It refreshes every 30 seconds, on tab focus, and after staff edits, attendance marks and invitation changes.
Conversation API
reservationChat(clubId, reservationId) and postReservationMessage are the professional operations used
by the Champagne back office. The API enforces club access and, for staff, the reservation-category assignment. The reservation
detail loads and posts messages without leaving the back office.
For staff-role accounts, sending the first professional message also records that account as a
participant. reservationChats(clubId, archived) powers the staff-only Chats page and returns
only conversations in which the authenticated staff account has participated and whose category
is still assigned to that account. Removing a category assignment also removes its conversations
from both the active and archived inbox on subsequent requests. Each item includes
the reservation category, booking member name, resource, start date, reservation status, archive time,
per-staff unread state, and latest message. Owners and admins cannot use this personal inbox API.
query ReservationChats($clubId: ID!, $archived: Boolean!) {
reservationChats(clubId: $clubId, archived: $archived) {
id
categoryId
categoryName
guestName
resource
startsAt
status
archivedAt
unread
lastMessage {
senderName
body
createdAt
}
}
}
mutation ArchiveReservationChat($clubId: ID!, $reservationId: ID!) {
setReservationChatArchived(clubId: $clubId, reservationId: $reservationId, archived: true)
}
mutation ReadReservationChat($clubId: ID!, $reservationId: ID!) {
markReservationChatRead(clubId: $clubId, reservationId: $reservationId)
}Archiving is reversible and scoped to the staff account: it never deletes messages or changes another participant's inbox. Sending another professional reply restores the chat to that account's active list. A new message restores the chat for every other participating staff account and marks it unread until that account opens it (the sender's own professional message is immediately read). The demo coach account starts with two active conversations, including one unread conversation, and one archived conversation.
Member-facing applications use the customer-scoped authenticated API surface:
query MyReservationChat($clubId: ID!, $reservationId: ID!) {
myReservationChat(clubId: $clubId, reservationId: $reservationId) {
id
clubName
categoryName
reservation {
id
resource {
id
name
}
startsAt
endsAt
status
}
messages {
id
author
senderName
body
createdAt
}
}
}
mutation PostMyReservationMessage($clubId: ID!, $reservationId: ID!, $body: String!) {
postMyReservationMessage(clubId: $clubId, reservationId: $reservationId, body: $body) {
id
messages {
id
author
senderName
body
createdAt
}
}
}Send the token returned by loginMember as Authorization: Bearer <token>. Both customer
operations require that the session belongs to clubId and that the authenticated person owns the
requested reservation. A valid member cannot read another customer's conversation. The query also
returns null when the reservation category does not require a conversation.
Bodies are trimmed and limited to 4,000 characters. The server assigns the author role and uses the
stored guest name for customer messages, so callers cannot impersonate a professional. Messages are
ordered by creation time and ID. Club subdomains do not expose a /chat/<reservation-id> route;
consumer apps embed these operations in their own experience.
Add a participant to a session
Pass the occurrence id from reservationAvailability.sessions as
sessionOccurrenceId in createStaffReservation, together with the customer
personId or walk-in guestName and the selected resourceId. Session-based categories require this field.
The server uses the occurrence start and end times, validates that it is in the
future, and checks capacity atomically. Staff participants in the same occurrence
can share resources; conflicting reservations and blocked periods still prevent
booking. Full, missing, past, or out-of-scope sessions return an error without
creating a reservation. Refresh availability after saving to update remaining places.
Floor plans
Each reservation category can have an optional saved floor plan, shared by its club staff. Administrators check Enable visualization in the reservation category create/edit form only for suitable categories, such as pool loungers or restaurant tables. Types without an enabled plan display only the calendar. The back office displays an enabled plan directly below the calendar in Reservations → category (or Planning → category for assigned staff), without a separate display mode. Club administrators create and edit plans; staff assigned to the category can read them. Members and staff from other clubs cannot access these operations. The calendar cursor synchronizes the plan with the selected time range.
reservationFloorPlan(clubId: ID!, categoryId: ID!): FloorPlan returns null before
configuration. A plan contains clubId, categoryId, revision, spaces and elements.
Each element has id, kind, label, optional resourceId, objectId and points, x, y, width,
height, rotation, spaceId and locked. Coordinates use an expandable world with a 1000 × 700 initial viewport. Simple shapes (rectangle, roundedRectangle, ellipse, triangle, lShape, hexagon, capsule, semicircle, uShape, polygon) bind to a resource of the same club
and category. A new resource is created on save only when bookable: true and the resource is omitted; bookable: false keeps the shape decorative. The active cosmetic library contains Wall, Column, Single door, Window, Stairs, Potted plant, Leafy tree, Hedge, Parasol, Pool and Counter. Legacy furniture and rectangular-planter assets have been removed. Generated library designs retain their artwork and can be placed as reservable objects. Line and rounded rectangle are no longer offered in the library; saved instances still render. Simple shapes support independent width and height, rotation and the plan palette; an ellipse with equal dimensions is a circle. Set outlineOnly: true on any closed shape for a transparent interior and colored outline. Lines have no fill.
Each resource can appear only once per plan. Removing it from a plan does not
remove the resource or any reservation. Deleting an unused resource through the category settings while visualization is disabled also removes its saved placements and advances the plan revision. Unrelated placements and resources are preserved. Linked library illustrations are retained as non-reservable objects. Resources with any reservation history cannot be deleted.
query PoolPlan($clubId: ID!, $categoryId: ID!) {
reservationFloorPlan(clubId: $clubId, categoryId: $categoryId) {
revision
spaces {
id
name
surface
palette
}
elements {
id
spaceId
locked
kind
label
resourceId
objectId
x
y
width
height
rotation
points {
x
y
}
}
}
}
mutation SavePoolPlan($input: SaveFloorPlanInput!) {
saveReservationFloorPlan(input: $input) {
error
plan {
clubId
categoryId
revision
spaces {
id
name
surface
palette
}
elements {
id
spaceId
locked
kind
label
resourceId
objectId
x
y
width
height
rotation
chairs
chairPositions
points {
x
y
arc
radius
}
}
}
}
}SaveFloorPlanInput requires clubId, categoryId, revision and the complete
spaces and elements arrays. Each space requires a unique UUID id, a distinct
trimmed name (1–50 characters, case-insensitive), and a surface. Every element
must reference an existing spaceId and supply a Boolean locked flag.
There must be 1–12 spaces; resource bindings are unique across all spaces. Start with revision 0; each successful save increments it.
Stale revisions return an error without overwriting newer edits. An empty elements array
clears the drawing while retaining at least one space. Saving is transactional and serialized with reservation-category
edits. Validation allows at most 200 elements, unique UUID element IDs, labels
of 1–100 trimmed characters, finite positions within ±1 billion, widths/heights up to
1 million (minimum 20/10 for reservables, 1 for non-bookable geometry), rotations
of 0–359 degrees, and finite geometry. The world expands beyond the initial
1000×700 viewport; trimmed and rotated visible geometry still determines labels
and editing handles. Positions, dimensions, rotations and shape-point coordinates
use GraphQL Float, preserving fractional geometry and artwork aspect ratios.
Resource and library-object references must belong to the selected category.
When an objectId is supplied, its resourceId and internal kind must match the saved
library object (rectangle for reservable, zone for decorative). An illustrated decorative
object has no reservation resource; a reservable design may have a linked resource or defer resource creation to individual placements on save.
Default cosmetic kinds are partition, column, singleDoor, window,
pottedPlant, leafyTree, hedge, parasol, swimmingPool,
barCounter, and stairs. Their artwork was generated through the same Sunburst
backend generation and Auto-lines vectorization pipeline as user-created objects.
They are real SVG assets shared by staff and member views, have no objectId, and
create no reservation resource. They support proportional scaling and palette
recoloring, but do not accept custom points or chairs. Existing legacy furniture
artwork still renders; it is no longer offered as a cosmetic default.
Native shapes use hand-coded SVG geometry, not image generation. Capsule, semicircle, U-shape and freeform polygon presets are editable with the shape editor; Rounded rectangle, Capsule, Line / polyline, and Rectangular planter are no longer offered in the library; their saved kinds remain supported. All native shapes use constant-width outlines. Freeform polygon placement opens its shape editor immediately, and double-clicking an unlocked native shape in edit mode opens the same editor. Clicking its body keeps editing active. Reset shape requires confirmation, restores the preset outline, preserves position/dimensions/rotation, and is undoable.
Points can move outside the current object frame: the editor expands and repositions the frame, then normalizes the resulting anchors back into 0–100 coordinates. The shape can expand beyond the initial viewport within numeric safety limits. Crossing or overlapping non-adjacent edges are rejected; curved outlines are checked using sampled arc geometry.
Generated PNGs are cropped to their visible alpha bounds with a four-pixel transparent safety margin before vectorization. Default generated SVG viewBoxes and their PNG companions are cropped too. Existing built-in artwork placements are fitted to their actual aspect ratio while keeping their visible center and rotation unchanged. View and edit modes use the same canvas width at each viewport size.
Basic shapes (rectangle, roundedRectangle, ellipse, triangle, line, lShape, hexagon, capsule, semicircle, uShape, polygon) accept points: 3–64 anchors, or 2–64 for open lines, with floating-point x and y coordinates from 0 to 100 relative to the object's dimensions. Closed shapes must enclose an area. Omitted points use the kind's preset. Rectangle, triangle, L-shape and hexagon presets have softly rounded corners (8% of the shorter dimension); circles retain their natural curvature and lines have round caps. Explicit custom points and corner radii are preserved. The staff and member views share the same geometry and color definitions. Other kinds and library artwork reject custom geometry.
Each anchor optionally stores radius (0–100, a floating-point percentage of the shorter object dimension) or arc: true for a circular bulge through the neighboring endpoints. Adjacent bulges and simultaneous radius/bulge settings are rejected. Radius geometry is clamped to adjacent edge lengths. Points retain their relative positions when dimensions change.
Shape point coordinates, corner radii and element rotation use GraphQL Float in both inputs and responses, as do element positions and dimensions. Fractional geometry is preserved through save and reload without rounding. Existing ranges still apply, including rotation from 0 to 359 degrees. chairs and plan revision remain integers.
Reservable shapes (rectangle, roundedRectangle, ellipse, triangle, lShape, hexagon, capsule, semicircle, uShape, polygon), including reservable library placements, accept chairs, an integer from 0 to 16. Null, omitted or zero means no attached chairs on every kind, including imported lines and decorative furniture. Furniture and other kinds reject positive chair counts. An explicit chairPositions array must still match the count; only an empty array is valid for zero chairs. Chairs follow the shape outline and remain fixed-size when dimensions change; they are visual seats, not independent reservation resources. Library placements use their rectangular frame rather than artwork alpha. The former pool kind and ladder setting remain removed; the new swimmingPool kind is cosmetic artwork.
FloorPlanElement and FloorPlanElementInput accept nullable chairPositions: [Float!].
Omitted or null uses the automatic layout; existing automatic positions are preserved
when collision-safe. An explicit array contains exactly chairs finite numbers in
[0, 1), in chair identity order, measuring normalized perimeter arc length. For
example, a sharp-cornered 100-by-100 square with four chairs can use
chairs: 4, chairPositions: [0.125, 0.375, 0.625, 0.875]. An empty array is valid only
when the chair count is zero. Ellipses start at the top and proceed clockwise; custom
outlines start at their first segment after the first rounded corner and follow point
order. Fractions are local to the object and independent of translation and rotation.
They persist unchanged in the plan's JSON elements and are returned by staff/member
plan reads and save responses when selected.
Saving rejects mismatched counts, out-of-range fractions, overlapping chairs and chairs intersecting the object's outline, including concave and curved portions. Automatic layouts are also checked: unsafe defaults are relocated, and insufficient space returns a validation error without saving a partial plan. Explicit layouts are never silently repaired. Changing dimensions, points or chair count requires a valid updated array, or null to restore automatic placement. Collision checks conservatively enclose chair artwork and strokes; they concern the parent object and its attached chairs, not unrelated plan objects. Very complex/large curves can exceed the bounded geometry budget and are rejected rather than placed unsafely.
Plan spaces retain their palette (lagoon, terracotta, grove, dusk). Simple shapes, built-in illustrations and generated objects all use the same surface, secondary, accent and outline colors. Generated previews, library thumbnails, canvas objects and the member plan follow the active space theme. Vector fills are mapped directly to those colors; raster fallbacks use an alpha-preserving palette lookup filter rather than approximate hue rotation. Original artwork bytes are unchanged, so the same object can follow different themes in different spaces without regeneration.
query PoolOccupancy($clubId: ID!, $categoryId: ID!, $from: String!, $to: String!) {
reservationFloorPlanOccupancy(clubId: $clubId, categoryId: $categoryId, from: $from, to: $to) {
resourceId
guestNames
bookings {
guestName
startsAt
endsAt
}
}
reservationAvailability(clubId: $clubId, categoryId: $categoryId, from: $from, to: $to) {
sessionCount
sessions {
id
startsAt
endsAt
remaining
}
blocks {
id
resourceId
startsAt
endsAt
reason
}
}
}Occupancy accepts an increasing timestamp range of at most 31 days. It returns
occupied resource IDs, guest names and individual bookings with their scheduled
startsAt and endsAt timestamps (without check-in/check-out margins), with no pagination or client-side sorting
arguments. Every non-canceled reservation overlapping the range counts, including
its check-in/check-out margins. Intervals are half-open: a reservation ending
at the range start does not occupy that range. The plan combines occupancy with
blocked periods that are category-wide or scoped to the resource and, for session-based categories, a session covering the whole range
with remaining capacity. Pending or failed data appears as unknown, never free.
The screen refreshes availability automatically every 30 seconds.
It displays times in the browser's local time zone. The studio supports
up to twelve named plans (stored in spaces) per category, drag positioning, multi-selection, alignment,
duplication with unused resource bindings, locking and undo/redo. The library has Reservables and Customizable tabs, with Reservables selected by default. Switching tabs cancels any pending placement. A fixed-height header reserves space for the Customizable generation button, keeping library spacing stable when it appears. Reservables contains Rectangle, Circle, Triangle, L-shape and Hexagon; Customizable contains generated furniture, palm trees and parasols. Built-in artwork uses simple flat orthographic plan views. User-generated artwork follows the Gemini Pro top-down prompt and transparency-only matting described below. The object inspector exposes width and height sliders for all objects and a chair count for reservables. Both sliders use the same stable display scale (0–1000 for ordinary objects, expanded for larger imported objects), so equal dimensions align and moving an object does not shift their thumbs. Actual dimensions can expand up to the numeric safety limit without clamping at the viewport edge or changing position. Changes preview live and each adjustment is one undo operation. Shapes offer Keep aspect ratio, saved as the optional keepAspectRatio boolean on each element (enabled by default; null or omitted is treated as enabled in the editor, while explicit false unlocks dimensions). When enabled, either dimension slider or the resize handle scales both dimensions together, bounded by the numeric dimension safety limit while preserving fractional dimensions and exact aspect ratios. Furniture images preserve their proportions and have baked-in chairs.
Edit shape, at the bottom of the inspector after dimensions, chairs and resource assignment, toggles editing directly on the plan. Selecting another object exits this mode. Drag anchors to reshape with horizontal/vertical snapping, drag the inset circular handles to round corners, and double-click an anchor to toggle a circular bulge. Plus controls add points on straight edges; Delete removes a focused anchor, respecting the minimum point count. Arrow keys move focused anchors or adjust a focused radius handle. Plan undo/redo includes these changes. Reset shape restores the selected kind's preset. The lock action displays an unlock icon when the selection contains locked objects.
The floor-plan view reuses the resource calendar (day/week). Its optional time cursor selects an instant in 15-minute increments. The UI queries a one-millisecond half-open interval at that instant, without a duration control. A single vertical guide aligns with the existing reservation rows. Calendar navigation preserves the selected hour, and opening the plan carries the calendar date and view. Cursor dragging is settled for 150 ms before occupancy queries; old availability is marked unknown while the selected time is changing. Layout editing remains independent of time navigation. No GraphQL operation or authorization changes are required for this calendar control.
The Reservations section keeps its resource-category tabs and settings. The calendar spans the full width, with the floor plan immediately below it only when enabled for this category. The calendar remains visible while editing the plan. The URL retains the calendar date and day/week view. Creating and opening reservations remain available from the embedded calendar.
The ReservationCategoryInput.visualizationEnabled boolean defaults to false and is returned on ReservationCategory. It is saved with the other category settings by the existing create/update mutations. Disabling visualization hides both the plan and time cursor without deleting the saved layout. Re-enabling restores that layout.
In the editor, drag a furniture tile onto the canvas. A ghost preview follows the pointer in canvas coordinates. Dropping outside the visible SVG cancels; valid drops are centered in world coordinates without a fixed placement boundary. Selecting a tile then tapping a location is also supported, as are Enter on the canvas to place centrally and Escape to cancel. Every placement is undoable and a resource cannot be placed twice. New built-in shapes and reservable library objects start with the object's name and no resource binding. Placing a reservable immediately focuses a resource-name field beneath the shape. Enter or blur commits a trimmed name; Escape cancels the edit, and blank names retain the previous value. The name is used for resource creation on save. When an unplaced resource is available, the resource dropdown offers it but defaults to creating a new resource; existing resources precede that option. Every reservable placement shows the resource dropdown, including its current resource, unused resources, and the create-on-save option. The inspector assigns resources instead of renaming them. Selecting an existing resource changes the placement label to its resource name.
The category modal exposes the visualization checkbox but does not edit resources. The editor uses the built-in object library. Existing stored custom objects remain visible and manageable, but the editor no longer creates or generates new ones. The separate always-visible ListBox displays each placed object's chosen label, library name and thumbnail. Existing category update operations still manage ordinary resources.
Reservable floor-plan elements may omit resourceId. Saving creates their
reservation resources atomically with the plan and returns the assigned IDs.
Resource names use the element label, with a #2, #3, etc. suffix when that name is
already used in the same reservation category. Other categories may reuse the name. Subsequent saves must retain the returned IDs; supplied
IDs must belong to the same reservation category. Revision conflicts create no resources.
The editor creates places by dropping reservable shapes onto the plan. Existing unplaced ordinary resources are reused first; newly created places appear in the calendar after saving. A resource with no assignment shows a create-on-save note; assigned resources show no note.
Custom objects
The following staff-scoped read returns previously stored custom objects for the club and reservation
category; it has no filter, sort or pagination arguments. The + button appears only under Customizable and opens the object generator.
Club administrators can rename or delete existing custom objects and rename ordinary reservation resources.
If an artwork object is missing or temporarily unavailable, its image is null;
the plan and its other objects remain available with a standard-kind illustration.
Built-in, existing, and newly generated artwork all follow the active space palette at render time,
including artwork marked data-plan-render="raw". That marker preserves editing provenance,
not a display-color override. Simple shapes and floor areas use the palette's secondary
interior tone, with outline reserved for their strokes and surface for light trim.
generateFloorPlanObjectArtwork(clubId: ID!, categoryId: ID!, name: String!, traceId: ID, autoLines: Boolean! = true, generationModel: FloorPlanArtworkGenerationModel! = SUNBURST)
requires catalog_manage permission, valid UUIDs and a trimmed subject name of
1–100 characters without null bytes. It checks the category, duplicate names and
200-object limit before calling OpenRouter. There is no initial reference-image
argument or drawing step. The modal shows Object name, Auto-lines,
Generation model, Brush model and Generate object, with the standard cancel action.
Auto-lines defaults on: source-guided centerlines produce non-scaling outlines over themed
colored fills. With autoLines: false, Potrace traces colored fills and opaque navy
ink ribbons instead; this is a different vectorization algorithm, not hidden outlines
or embedded raster. The choice applies to new generation and legacy embedded-raster
vectorization during refinement. Existing vector previews are not retraced or changed
when the switch changes. The modal says Applies to next generation beside an
existing preview. Changing either control never calls a paid provider. Settings
remain local to the modal across attempts, and conflicting controls are disabled
while pending. Failed attempts retain the preview and undo/redo history.
Initial generation defaults to GPT Image 2.5 Sunburst (openai/gpt-image-2.5-sunburst)
or uses GPT Image 2.5 Flare (openai/gpt-image-2.5-flare) with generationModel: FLARE.
The FloorPlanArtworkGenerationModel enum allows only SUNBURST and FLARE;
null and arbitrary model IDs are rejected before provider calls. Both IDs and image
output support are verified against /api/v1/models?output_modalities=image.
The generation selector persists within the modal, is disabled while pending, and
applies only on the next Generate object request. It never triggers generation
on selection or changes the preview, brush model, or DeepSeek classifier.
Both use the same prompt and medium-quality, 1K, square PNG request through
OpenRouter, with transparency-only backdrop removal.
Before returning the initial preview, the server traces the PNG with native
Potrace into real multicolor vector paths. The original PNG remains a private
companion; it is never embedded in the returned SVG.
Only magic-brush refinement uses Gemini 3.8 Flash (google/gemini-3.8-flash) by default,
or Gemini 3.5 Flash Lite (google/gemini-3.5-flash-lite) selected with
editorModel: GEMINI_FLASH_LITE, or GPT-6 Luna (openai/gpt-6-luna) with
editorModel: GPT_LUNA, or GLM 5.3 Flash (z-ai/glm-5.3-flash) with
editorModel: GLM_FLASH, or GPT-6.1 Sol (openai/gpt-6.1-sol) with
editorModel: GPT_SOL. Sol uses the existing Low/Medium/High thinking choices and
throughput-sorted compatible-provider routing. The server enum FloorPlanArtworkEditorModel
allows only GEMINI_FLASH, GEMINI_FLASH_LITE, GPT_LUNA, GLM_FLASH, and GPT_SOL; arbitrary model IDs and null
choices are rejected by GraphQL before provider calls. All support image inputs,
required tools and low reasoning (verified against OpenRouter's live catalog).
Gemini 3.8 Flash is pinned to Google Vertex Priority (google-vertex/global/priority)
with required parameter support and provider fallbacks disabled on every edit and review turn.
This does not change the chosen thinking effort or routing for the other models.
GLM uses throughput-sorted routing for its first call with provider fallbacks disabled.
The provider reported by OpenRouter is then pinned via provider.only for all subsequent
turns of that attempt, including token-budget retries and visual review. Missing or
changed provider identity fails safely. This uses OpenRouter's current fastest-compatible
routing rather than an unverified fixed provider ranking; a new attempt selects anew.
GLM supports LOW, HIGH, and MAX thinking effort, not MEDIUM. Other editor models
retain LOW, MEDIUM, and HIGH. Unsupported model/effort combinations fail before
model dispatch, and switching models resets an incompatible selection to Low in the UI.
Generation, tracing, brush-model, thinking-effort, and classification controls are inside the create-object modal's collapsed Expert mode section between the name field and generation button. If it is closed when Generate is clicked, the entire section is hidden during generation and with its result; if open, it stays available. Changing the name or a failed initial generation restores access to it. The action becomes Regenerate object when a generated preview exists. The Show more information switch has its label on the right, and both its track and label are clickable. Its value is captured when Generate is clicked: if on, Debug appears beside Cancel, and detailed stages/timing are available in the visible expert section. Toggling the switch alone does not reveal Debug, even after an older generation. These details are hidden by default. The preview skeleton appears immediately at request start rather than waiting for the mutation state update. Loading and the first browser render use a preview skeleton with a 45-degree shimmer, not a progress bar. Clear sketch and Apply changes sit inside the preview, as does the brush tool, with a native Reshape by drawing on the object tooltip; the visible box edges are the drawing limits. A four-percent margin around the artwork is applied consistently to the UI and backend reference/review PNG framing. The information icon in the modal's top-right corner is reserved for a future help modal and is currently disabled.
The brush model choice does not change the generation model or interpretation setting. Editing runs
through OpenRouter's Chat Completions API, with providers sorted by highest
throughput and required parameter support, after a DeepSeek V4.1 Flash
(deepseek/deepseek-v4.1-flash) vision intent decision by default.
The Classify brush intent switch is on by default (DeepSeek + Gemini).
Turning it off (Gemini only, classifyBrush: false) skips DeepSeek entirely,
including its geometric evidence computation. For Luna the labels are DeepSeek + GPT-6 Luna
and GPT-6 Luna only. The selected editor model infers
and edits in the same initial update_svg request; there is no extra editor
classification call. Its comprehensive SVG prompt covers cut, fill (extension),
erase, reshape and cut_fill, prioritizing scribbles, then broad surrounding trails,
then local inside/outside edges. It preserves unmarked geometry and opaque surfaces,
uses real transparency for exterior space and gaps, and receives the original PNG,
red brush guide and current SVG, not just scalar measurements.
The request-local Boolean is non-null and defaults to true for omitted arguments;
null or non-Boolean values are rejected before dispatch. The modal retains the
setting across attempts and disables it with other conflicting controls while pending.
Changing it never generates, refines or replaces a preview. All five editor models
retain the identical bounded render/review loop below, in either interpretation mode.
The SVG agent uses update_svg(operations: SvgOperation[]), which
applies up to 64 atomic, ID-targeted operations to the current SVG, then renders
it to a transparent 1024-by-1024 PNG for validation. Brush
editing requires bounded visual review: Gemini receives the actual rendered
PNG in a subsequent request, separately labelled alongside the unchanged original
source image, brush guide and current SVG. It must explicitly finish({}) after
inspection, or correct concrete visible damage (collapsed geometry, unintended
disappearance, broken fills/holes or lost unmarked features), not regenerate for
aesthetic polish. The server permits one initial valid batch and at most two
valid corrective batches, with a required final inspection after every correction.
There are at most eight model calls, including validation repairs, output-length
retries and acceptance. Transient HTTP retries remain bounded separately.
Invalid batches leave both the committed SVG and PNG unchanged; a rejected
correction must be repaired before acceptance. Premature finish is rejected.
No new batch is committed on the last call or with less than 15 seconds left in
the 600-second agent budget. Generation and refinement workflows and browser requests each have a ten-minute (600-second) deadline.
Budget exhaustion, cancellation or missing acceptance produces a typed failure,
never an automatically accepted last render. The previous editor preview stays intact.
Vision review reduces obvious damage but does not guarantee detection of every defect.
The editor shows the visually accepted result as one undoable preview
revision; it does not start a second generation/review request. While waiting,
it retains the previous preview and disables duplicate actions. Repeated delivery
of the same preview token does not add duplicate undo entries. Provider calls and
SVG validation remain on the server; no model credentials enter the browser.
The Thinking effort selector sets thinkingEffort: LOW | MEDIUM | HIGH | MAX
(FloorPlanArtworkThinkingEffort, default LOW, choices depend on the model). It applies to all five editor models
for every edit, repair, and visual-review turn. Changing it starts no request and
does not affect Sunburst/Flare generation or DeepSeek classification. Controls lock
while pending. Higher effort can increase latency and cost; deadlines are unchanged.
Brush editing uses a 32,768-token combined output budget and the selected reasoning effort (default low),
matching the benchmark configuration rather than requesting a large reasoning
budget that can exhaust the interactive deadline. A finish_reason: length response can retry once
with 65,536 output tokens without applying partial output. Exhaustion fails
rather than accepting unfinished artwork. Every element receives a deterministic
unique ID; existing IDs are preserved across edits. Duplicate source IDs fail
before model use. The model no longer copies old path strings to locate changes.
Supported operations are:
set:{ op: "set", id, attributes }updates supported attribute strings.unset:{ op: "unset", id, names }removes supported attributes to restore inheritance.remove:{ op: "remove", id }deletes an element and its descendants.append:{ op: "append", id, parentId, tag, attributes?, beforeId? }adds a new uniquely identified primitive/group, optionally before a direct child of the parent.
For example, a batch can resize a known rectangle and delete its superseded outline:
{
"operations": [
{ "op": "set", "id": "seat", "attributes": { "width": "700" } },
{ "op": "remove", "id": "old-outline" }
]
}IDs must come from the actual current SVG. Append IDs must be new and cannot be reused within the same batch, including after a removal. IDs, namespace, viewBox and engine metadata are immutable; the root and its display dimensions cannot be removed or changed. Initial generation appends geometry to the empty root ID. There are no raw XML insertions or search/replace operations.
Each batch is limited to 100 KB. An invalid operation, invalid SVG or failed
rendering preserves the whole previous SVG/PNG. The server enforces the 64-operation
and attribute-name count limits; tool-schema descriptions state those maxima rather
than using nested maxItems, which Gemini rejects for this schema. Validation
feedback includes a
zero-based operation index, error code, affected ID, match count when applicable,
and a bounded summary of the relevant committed element. A render-level error
uses a null operation index instead of blaming an arbitrary operation.
Every request is a fresh compact context with the current SVG exactly once,
the original task/reference and only the latest failed batch/error. Long failure
payloads are explicitly truncated, with a focused failed-operation excerpt.
Previous rejected batches and signed reasoning history are not replayed; review
mode keeps only the current rendered preview. This avoids accumulating repeated
copies of the SVG during repairs. Edits are instructed to
match the object's existing corner radii, rounded joins/caps and inner-border rounding.
The renderer accepts geometric primitives, groups, and root-local geometric clip definitions, at most 100 KB and
1000 elements, preserving the source viewBox and rendering into a 1024-square canvas.
Scripts, external resources, embedded images,
CSS, entities and document declarations are rejected before rendering.
The tool schema, system contract and renderer share one attribute allowlist.
Geometry/paint attributes, transforms, stroke caps/joins, fill rules, vector-effect,
and fill/outline roles are supported. Safe display values are inline and none;
opacity, fill-opacity and stroke-opacity accept decimal strings from 0 to 1.
Use remove for deletion rather than hiding geometry when possible. Attribute
values are strings, numeric geometry must be finite, and dimensions/radii/stroke
widths cannot be negative. Only clip-path="url(#local-id)" references to validated
source-space path definitions are allowed. Style attributes, classes, external URL references and event
handlers remain forbidden even though these safe presentation attributes work.
Generation asks for one simple, flat, strict
90-degree top-down object with
large pale-blue regions, no surroundings, props, perspective, shadows or texture.
Rectilinear parts have crisp edges and only tiny fillets; naturally curved
silhouettes stay curved. Configure server-side OPENROUTER_API_KEY and floor-plan
storage. No OpenAI key is required, including for scribble removal.
Provider credentials never reach the browser. Initial generation and brush refinement
each have a ten-minute (600-second) workflow deadline, including preprocessing,
optional classification, model requests and vector conversion. Provider requests and
the SVG loop cannot outlive that shared workflow budget; there is no shorter model
or classification deadline. Native tracing/rendering retains its own safety limits.
Browser calls allow 600 seconds including network and response overhead, so a browser
timeout can occur slightly before the server workflow deadline. Preview storage and
authorization are outside the model workflow budget but inside the browser deadline.
GraphQL disables Bun's per-request socket idle timeout (which cannot exceed 255
seconds), without replacing the incoming request signal. Disconnecting or cancelling
the browser request still interrupts the Effect and aborts provider fetches.
Workflow timeout failures return The artwork request timed out. Please try again.;
the browser deadline shows a translated timeout danger toast with Retry.
Intentional cancellation does not produce an error toast, keeps the previous
preview/sketch, and ignores late results. Cancel stays available for generation and
brush refinement; creating/saving an object remains non-cancellable. Artwork mutations do not automatically
retry on the client; errors use the shared toast with an explicit Retry action.
Spending-limit failures return Image generation spending limit reached. and do
not change billing settings or create preview files.
After generation, Magic brush enables painting over the generated artwork. The user paints an approximate new edge and chooses Apply changes; there is no written refinement prompt. Space starts/finishes keyboard strokes, arrow keys draw and Escape leaves the tool. Undo/redo restores older private previews without calling the image provider again. Palette changes also require no generation.
refineFloorPlanObjectArtwork(clubId: ID!, categoryId: ID!, name: String!, artworkRef: ID!, strokes: [FloorPlanArtworkStrokeInput!]!, traceId: ID, autoLines: Boolean! = true, editorModel: FloorPlanArtworkEditorModel! = GEMINI_FLASH, classifyBrush: Boolean! = true, thinkingEffort: FloorPlanArtworkThinkingEffort! = LOW)
uses the same permission and exact club/category/name scope as generation. It
reads the existing private preview; clients cannot supply replacement image data
or remote URLs. Missing, consumed, foreign or malformed preview tokens are rejected before
any image request. A stroke has width: Float! = 0.08 and points containing
x: Float! and y: Float!. Points are normalized to the padded square artwork
workspace, in [0, 1]; widths are in [0.015, 0.3]. There must be 1–16 strokes
with 2–512 finite points each.
The workspace is the entire preview box, not the inset artwork viewport. Artwork
keeps its centered 68% viewport with 4% internal padding (62.56% longest-side
coverage of the box). Server reference rendering uses the identical expanded
square frame; margin strokes remain outside the artwork rather than being clamped
to its edges. Only gestures beyond the box are clamped to [0, 1]. The editor
submits width 0.0544 to retain its previous on-screen brush footprint. The stored
SVG viewBox and geometry are unchanged by this display transform.
The brush trail is red (#ff0000) in the editor and unmasked model references.
With classification enabled, the server computes alpha-relative trail measurements and sharp direction reversals
from the unannotated artwork. DeepSeek's chat-completions API receives the current
reference PNG and the red annotated PNG, with these measurements as supplementary
evidence. The exact model's live OpenRouter catalog explicitly lists image input
(verified October 8, 2026). A strict JSON schema and server validation select one of
five intents: inward cut, outward fill, scribble erasure, broad outline reshape,
or combined cut and fill. Invalid classifier responses stop the edit; no silent
fallback or paid retry occurs. Its confidence is not a calibrated quality guarantee.
Each intent selects a separate operation-specific prompt. Erasure explicitly
distinguishes restoring an object's surface from removing detached exterior noise
without changing the object's outline.
All five intents send the actual stored SVG text as the committed initial patch
state, plus one annotated PNG rendered from that same SVG with the red trail.
The model edits existing vector geometry rather than reconstructing it from pixels.
Name-only generation uses Sunburst or Flare raster artwork, never an LLM-drawn input vector.
Existing true vector companions retain their geometry and fills.
A legacy SVG containing one full-canvas embedded PNG is converted automatically,
without a model call: the validated image and vector overlays are rasterized in
their existing coordinate frame, then partitioned into connected flat-color regions.
Coherent source-color areas are averaged before their fill pixels are quantized to
the four canonical theme tones and relabeled for tracing. This avoids turning
texture noise into palette stippling or separate vector regions for shades that
display identically.
Equal-color regions merge, and small enclosed color islands are reassigned to their
surrounding tone without deleting detached silhouettes or transparent cutouts.
Bounded categorical smoothing protects painted veins and exterior boundaries.
Auto-lines uses a shared fitted boundary graph for fills: neighboring colors reuse
the same curve with reversed controls instead of independent approximations.
When a shared graph would exhaust the SVG byte budget or cleaned fills fail fidelity,
the uncleaned, merged theme partition can be traced by Potrace instead; the inspector
records vectorization-fill-fallback with the size or fidelity reason.
The same SVG size and fidelity limits still apply, with no raster fallback.
Auto-lines off retains Potrace 1.16 fill-mask tracing and filled painted-ink ribbons.
Compound paths use even-odd fills for holes. New generation defaults to Auto-lines.
Enclosed patches below the tracing alpha cutoff become opaque light-blue theme
surfaces when their connected component contains no fully transparent pixels and
does not reach the exterior. Fully transparent cutouts and exterior antialiasing
remain untouched. Fidelity checks compare against this explicitly repaired input.
The source-centerlines-v1 TypeScript pipeline extracts a pixel skeleton and
recenters it between opposing source ink edges. It preserves long straight runs,
fits curves and circular contours, and keeps branch endpoints shared. Source alpha
boundaries close incomplete exterior paint without filling genuine transparent cutouts.
Ink detection adapts to the darkest supported colors, so soil remains a surface.
Painted ink becomes real SVG paths with no fill, a 2-pixel stroke, round caps/joins,
and vector-effect="non-scaling-stroke". Fills are partitioned against these actual
fitted lines. Their closed graph faces supply the exact same curves to a source-space
clip path; fills cannot spill outside the visible border. Strokes are not clipped.
The staff and member renderers namespace clip IDs per artwork instance and render
strokes inline at a fixed thickness. Local clipping references are validated against
plain path definitions; external references, recursive clips and executable SVG stay forbidden.
Debug vectorization events identify source-centerlines-v1 and preserve before/after evidence.
Saved vector previews are not rewritten. Refitting is bounded by the existing
fidelity check; ambiguous source marks can still produce spurs or imperfect junctions.
Two bounded curve tolerances are available for silhouette validation.
Tracing frames are limited to two million pixels; larger/over-complex legacy
wrappers fail safely rather than silently changing their coordinate frame.
The result must fit the SVG size/element limits and pass the existing
bidirectional silhouette/detail check. Failure stops before paid classification;
the edit loop never receives an embedded image or a silent raster fallback.
No native mask or OpenAI image call is made. The erasure prompt distinguishes deleting a separate component, deleting
an attached protrusion, restoring an underlying surface and clearing detached noise.
The original and gestures register to the same centered square workspace.
No undo history or separately competing source images are sent.
The common rendering instructions
explicitly treat the trail as a quick freehand suggestion, not an exact path or
mask. The model infers the intended boundary or feature change, straightens shaky
runs and eases curves. The name scopes the private preview but is not included in
the edit prompt, so a name describing the original shape does not override the trail.
The editable SVG is rasterized locally with genuine transparency. There is no
model-generated chroma-key backdrop or palette recoloring. Deterministic tracing
is used after initial image generation and for legacy raster-backed input. Provider and
protocol errors fail safely.
Each refinement normally makes one DeepSeek vision request (zero with classifyBrush: false),
one Gemini edit request and at least one subsequent Gemini rendered inspection request.
Additional model turns are for bounded visual corrections or validation repairs, up to eight
Gemini calls total. Progress in direct mode starts with rendering, then
inferring-edit ("Interpreting and editing brush strokes"), never a fictional
classifying step; subsequent inspection/correction/repair stages are unchanged.
Successful and failed traces record the interpretation choice and editor model,
without credentials or private reasoning. It leaves the
preceding SVG preview available for undo. Model edits can still change unmarked
details; it is not a pixel-exact preservation guarantee.
Gemini HTTP 408, 429 and 5xx responses retry up to twice per request with
exponential backoff/jitter or Retry-After. A requested wait above 30 seconds
fails rather than retrying prematurely; authentication, billing, validation and
other permanent HTTP errors do not retry. Provider error completions returned
inside HTTP 200 are normalized to their embedded HTTP error code, or 502 when
none is supplied, and use the same bounded retry policy. Backoff and retries honor cancellation
and the shared deadline. HTTP and token-budget retries can add paid requests;
usage for failed provider calls is not always returned.
The browser receives validated, actual vector geometry marked vector-v3 and
data-plan-render="raw", not an embedded-PNG wrapper. Both staff and member viewers
map its fills and existing outlines into the active palette; grouped/raster display fallbacks
use the same alpha-preserving palette filter. They do not invent extra outlines. The SVG retains its source
viewBox and geometry, with square display dimensions matching the brush workspace.
A rendered PNG remains a private companion, not the editable source. SVG IDs may
contain safe underscores, hyphens, periods and colons for stable patch targets.
The editable SVG and rendered PNG companion are retained while pending and after confirmation, and deleted together with the library object. Existing non-raw SVG objects retain their established local normalization path. Bounds are 12 MB and 16 million decoded pixels at PNG decoding and two million at tracing; generated SVGs are limited to 100 KB and 1,000 elements. Tracing averages colors within regions and approximates raster edges: texture, gradients, subpixel details and very narrow gaps are not pixel-exact. Silhouette validation is not a semantic or full color-fidelity guarantee. Failures never return a fake raster-backed SVG.
Generation and refinement return artwork (a safe SVG data URL), artworkRef
(an opaque UUID), error, and nullable durationMs (server processing time). The private preview does not expire and is bound to
the exact club, category and trimmed name. The modal contains a name field and generation action, then a generated preview
with the optional Magic brush and undo/redo controls. There is no category field or Can be reserved
switch. Objects created through this modal are decorative and appear in Customizable. Changing the name invalidates the preview; creation
requires confirmation. The plan object list shows only the placed resource name,
without the duplicate object name/type subtitle.
Magic-brush edge trails reshape the indicated boundary. Dense back-and-forth,
crossing or looping scribbles remove the targeted feature (for example, stairs)
and restore the surrounding surface, without cutting a hole or changing unmarked
features. Previous preview revisions remain available for undo/redo until consumed
by creation. Missing, consumed or out-of-scope previews return Invalid artwork preview.
createFloorPlanObject(input: CreateFloorPlanObjectInput!) accepts clubId,
categoryId, name, artworkRef, optional category (default Customizable,
trimmed 1–50 characters), optional reservable (default false), and optional
deferResourceCreation (default false), never raw
image data. The modal omits category and reservable, using the server defaults. Creation consumes
the preview and returns object and error. Decorative objects have
resourceId: null and appear in Customizable; reservable objects create a linked
resource by default and appear in Reservables. Import acceptance passes
deferResourceCreation:true to save a reusable reservable design with
resourceId:null; each unbound bookable:true placement then creates its own
resource when the plan is saved. The flag does not alter authorization, preview
ownership, name validation or the library object limit. Artwork remains attached
to its placements. Creation does not
automatically place an object on the canvas. Generation, refinement and creation require the same staff
permission; members and staff from another club cannot call them. Creation records
floor_plan.object_created in the staff activity log.
Live artwork progress
While generation or refinement is pending, a compact elapsed-seconds timer updates
every second independently of progress responses. It measures total request time
from the mutation's submission timestamp, resets for each attempt (including manual
retry), and never resets on stage changes or refetches. Updates calculate the clock
difference rather than accumulating interval ticks. The interval is cleared on
success, failure or unmount, and the timer disappears when the request settles.
Its aria-live="off" avoids announcing every second; only stage labels use polite
live announcements. The progress steps and debug request show the actual model ID.
The loading UI polls floorPlanArtworkProgress every second only while the current
attempt is pending. Unlike debug traces, this query never serializes or downloads
prompts, images, SVG, responses, credentials or private reasoning:
query ArtworkProgress($clubId: ID!, $categoryId: ID!, $traceId: ID!) {
floorPlanArtworkProgress(clubId: $clubId, categoryId: $categoryId, traceId: $traceId) {
id
status
steps {
sequence
elapsedMs
stage
model
turn
}
}
}Pass the same client-generated UUID traceId to generation/refinement. Reads require
staff catalog_manage permission and match the authenticated user, club, category
and trace ID. Missing, expired, evicted or other-owner traces return null. A null
result before mutation startup is retried while pending. Polling stops on terminal
status, request failure, mutation settlement or unmount; unmount cancels in-flight
reads. Missing progress never causes another generation call.
status is running, succeeded or failed (including cancellation). Ordered
steps contain sequence, elapsedMs, a safe stage, optional actual model ID
or tracer name, and optional editor turn. Stages are preparing, generating,
vectorizing, classifying, editing, inferring-edit, rendering, inspection, correction,
repair, saving, completed and failed. Only stages actually reached are
emitted, before expensive work; no estimated percentage or simulated timer stages.
The UI displays stage history and an indeterminate indicator. Provider failures
use the existing mutation toast, never raw provider diagnostics in the loading UI.
Progress has its own bounded 100-step buffer, independent of debug payload truncation,
with the same 30-minute/process-local retention and eviction limits as debug traces.
There are no filters, sorting arguments or pagination; the small history is chronological.
Artwork debug traces
The create-object modal's Debug button lists generation and refinement attempts,
including failures, independently of preview undo/redo. Each attempt supplies a fresh
client-generated UUID as the optional traceId argument. Omit it to disable capture.
Retrying a completed ID appends a retry event without overwriting the earlier
failure; simultaneous reuse of a running ID is rejected. Retrieve a trace even when the
mutation returns an error or throws a masked GraphQL failure:
query ArtworkTrace($clubId: ID!, $categoryId: ID!, $traceId: ID!) {
floorPlanArtworkTrace(clubId: $clubId, categoryId: $categoryId, traceId: $traceId) {
id
startedAt
status
truncated
events {
sequence
elapsedMs
stage
data
}
}
}All three IDs must be UUIDs. Access requires the same authenticated staff
catalog_manage permission as generation (club administrators also qualify).
The owner is derived from the session, never supplied by the client. Even another
administrator cannot retrieve the owner's trace. Missing, expired, evicted, or
different-user/category traces return null; foreign-club and unauthorized callers
are denied before retrieval. Validation and authorization failures before an attempt
starts do not create traces.
startedAt is an ISO timestamp; status is running, succeeded, or failed.
Events are returned in chronological sequence, with elapsed milliseconds and a stage
(input, retry, brush-interpretation, source, vectorization-before, vectorization-after, request,
response, validation, render, visual-review, artwork, result, or error). Vectorization
records include the original raster, vector preview, real SVG text, byte size,
algorithm and duration; already-vector inputs explicitly report skipped: true.
Tracing failures have a failed after-event rather than silently disappearing.
data contains serialized observable data: the actual Sunburst or Flare image
prompt and parameters, DeepSeek vision images/evidence/instructions/answer when enabled, Gemini
system/user messages and tool schemas, HTTP retries, tool operations, validation
repairs, source SVG/strokes, reference images, and results. Provider-private reasoning,
encrypted reasoning, credentials and HTTP headers are excluded. SVG is displayed in
an image element, never inserted as untrusted SVG markup.
Traces are ephemeral and process-local: 30-minute retention, at most eight per user
and 24 globally, with oldest-first eviction. Each trace is limited to 100 events and
4,000,000 UTF-16 code units, with 1,500,000 per event. Bounded data is explicitly
labelled and sets truncated; truncated event data may be plain text rather than
valid JSON. Restarting the API loses traces, not artwork previews. There is no
cross-replica trace persistence, pagination, sorting or filtering. The modal retains
the latest 20 attempt identifiers, fetches traces on inspection, and polls running
attempts. Capture is not a public log or durable audit record and does not alter the
image generation or Gemini bounded visual-review editing policy.
mutation GenerateObject(
$clubId: ID!
$categoryId: ID!
$name: String!
$generationModel: FloorPlanArtworkGenerationModel! = SUNBURST
) {
generateFloorPlanObjectArtwork(
clubId: $clubId
categoryId: $categoryId
name: $name
generationModel: $generationModel
) {
artwork
artworkRef
error
durationMs
}
}
mutation RefineObject(
$clubId: ID!
$categoryId: ID!
$name: String!
$artworkRef: ID!
$strokes: [FloorPlanArtworkStrokeInput!]!
$classifyBrush: Boolean! = true
$thinkingEffort: FloorPlanArtworkThinkingEffort! = LOW
$editorModel: FloorPlanArtworkEditorModel! = GEMINI_FLASH
) {
refineFloorPlanObjectArtwork(
clubId: $clubId
categoryId: $categoryId
name: $name
artworkRef: $artworkRef
strokes: $strokes
classifyBrush: $classifyBrush
thinkingEffort: $thinkingEffort
editorModel: $editorModel
) {
artwork
artworkRef
error
durationMs
}
}
mutation CreateObject($input: CreateFloorPlanObjectInput!) {
createFloorPlanObject(input: $input) {
object {
id
name
category
reservable
resourceId
image
}
error
}
}query LibraryObjects($clubId: ID!, $categoryId: ID!) {
floorPlanLibraryObjects(clubId: $clubId, categoryId: $categoryId) {
id
name
category
reservable
resourceId
image
}
}
mutation RenameLibraryObject($clubId: ID!, $categoryId: ID!, $id: ID!, $name: String!) {
renameFloorPlanObject(clubId: $clubId, categoryId: $categoryId, id: $id, name: $name) {
object {
id
name
category
reservable
resourceId
image
}
error
}
}
mutation DeleteLibraryObject($clubId: ID!, $categoryId: ID!, $id: ID!) {
deleteFloorPlanObject(clubId: $clubId, categoryId: $categoryId, id: $id) {
deleted
error
}
}
mutation RenameOrdinaryResource($clubId: ID!, $categoryId: ID!, $id: ID!, $name: String!) {
renameFloorPlanResource(clubId: $clubId, categoryId: $categoryId, id: $id, name: $name) {
resource {
id
name
}
error
}
}Rename requires valid UUIDs and a trimmed name of 1–100 characters without null bytes. It updates linked resource names
and matching saved-plan labels. When any placement label changes, the saved plan revision increments
once, so an editor still holding the previous revision gets a stale-revision error instead of
overwriting the new labels. Deleting a placed custom object
removes its placements from the saved plan and increments its revision once;
other elements remain. Delete returns deleted: false and an error for
premade/unknown objects. A successful delete removes its private image and
associated resource only when it has no bookings, blocked periods, or other
placements. Existing bookings and assignments remain attached to a retained
resource. Staff assigned to the category can
read objects but cannot mutate them; members and other clubs have no access.
Deleting a reservation category containing custom library objects is rejected until those
objects have been removed through their own deletion mutation, so private images are
not orphaned by a database cascade.
Each stored space and element still accepts an optional color, formatted as six-digit
hexadecimal (#RRGGBB); the back office does not expose this field. Colors are
returned by both plan queries and saves. Removing a named plan removes its elements
only; reservation resources and bookings remain. The editor asks for confirmation,
always shows the main plan, and does not allow deleting the main or final plan.
Housekeeping
When a category has housekeepingEnabled, resource names in its day and week calendar become
buttons, in Reservations → category for administrators and Planning → category for assigned staff.
Staff also get a Housekeeping page in their navigation listing every resource of their
housekeeping categories with its current state and the checklist items still to do. Clicking a resource
in either place opens a side drawer for that resource with separate sections: Checklist (hidden
when the resource defines none for staff, and each category hidden when empty), State, Consumption and
History, listing its ten latest state and consumption entries. The drawer uses the operations below.
Members, anonymous callers, staff from other clubs and staff not assigned to the category receive
UNAUTHENTICATED.
Checklist
Each resource owns an independent checklist. Club administrators and owners configure it with
Edit checklist in the selected resource's drawer from Reservations, using
setResourceChecklist. Assigned staff can complete tasks but cannot configure them. Reservation
category create/update inputs do not contain a checklist. Each item has a client-generated UUID id,
a category and a label; returned items also include their owning resourceId:
daily: resets at the start of every day.afterReservation: resets whenever a reservation of the resource ends, using its early check-out time when recorded, otherwise its scheduled check-out time, otherwise its end time. Canceled reservations are ignored.
The mutation takes SetResourceChecklistInput: clubId, categoryId, resourceId, and required
items. It replaces only that resource's checklist in the given order; an empty list clears it.
Removed items and their checks are deleted. Editing a reservation category does not change its
resources' checklists. Labels are trimmed, must be 1–200 characters without control characters,
and unique case-insensitively within a category. A resource holds at most 100 items. IDs must be
valid, distinct UUIDs; an existing ID owned by another resource is refused before any write.
The resource must belong to the given club/category and housekeeping must be enabled. The result
contains checklist (including resourceId) and nullable error. Staff, members, anonymous
callers, and administrators outside the club cannot configure checklists.
mutation SetResourceChecklist($input: SetResourceChecklistInput!) {
setResourceChecklist(input: $input) {
error
checklist {
id
resourceId
category
label
}
}
}For example, supply items: [{ id: "76000000-0000-4000-8000-000000000001", category: "daily", label: "Air the room" }]
alongside the selected club, category, and resource IDs. The same task label in a different room
uses a different item ID. No checklist filter, sort, or pagination arguments are exposed;
items retain their saved order within each resource.
query Housekeeping($clubId: ID!, $categoryId: ID!, $dayStart: String!) {
housekeeping(clubId: $clubId, categoryId: $categoryId, dayStart: $dayStart) {
currency
checklist {
id
resourceId
category
label
}
checks {
itemId
resourceId
checkedAt
checkedByName
}
reports {
id
resourceId
reservationId
guestName
condition
note
minibar {
item
quantity
}
createdAt
reportedByName
}
}
}
mutation SetHousekeepingCheck(
$clubId: ID!
$categoryId: ID!
$resourceId: ID!
$itemId: ID!
$checked: Boolean!
) {
setHousekeepingCheck(
clubId: $clubId
categoryId: $categoryId
resourceId: $resourceId
itemId: $itemId
checked: $checked
) {
error
check {
itemId
checkedAt
checkedByName
}
}
}dayStart is the caller's local midnight as an ISO timestamp; the back office currently
sends the viewer's own day start. housekeeping returns all resource-owned checklist items
for the category (filter by resourceId to show one resource),
the checks still valid in each item's current period, and the 50 most recent reports, newest
first. It still returns stored entries after the module is disabled. There is no pagination or
filtering.
Assigned staff and administrators check or uncheck an item for one resource with
setHousekeepingCheck. Checking records the current time and the signed-in staff member's name
and returns the check; unchecking returns check: null. Only the latest check per item and
resource is kept. The item must belong to that exact resource, not merely the same category.
Resource state
The drawer's Consumption section reports consumed products: the resource's tracked
resource products replace the former minibar
inventory, and consumption is billed to a chosen stay with
reportProductConsumption.
mutation LogHousekeepingReport($input: LogHousekeepingReportInput!) {
logHousekeepingReport(input: $input) {
error
report {
id
condition
note
createdAt
reportedByName
}
}
}LogHousekeepingReportInput takes clubId, categoryId, resourceId, a required condition
(clean, dirty, maintenance or outOfService) and an optional note (default ""). Each
report is an immutable log entry; the latest report with a condition is the resource's current
state. Notes are trimmed and limited to 1,000 characters; line breaks are allowed. Reports are
deleted with their resource.
housekeeping.reports mixes these state reports with the consumption entries recorded by
reportProductConsumption. A consumption entry has condition: null, the consumed product names
and quantities in minibar { item quantity }, and the stay in reservationId and guestName;
a state report has an empty minibar list and null reservationId and guestName.
Invalid input returns BAD_USER_INPUT. The mutations return a user-facing error with a null
payload when housekeeping is disabled for the category, when resourceId does not belong to the
category, or when the task does not belong to the given resource. Checklist configuration errors
return an empty checklist and an error without changing saved items. The author name comes from
the staff session, never from the input.
Customer sheets
When a category has customerSheetsEnabled, every customer gets one staff-only sheet for that
category, shared by all their reservations in it. A sheet holds preferences, contraindications,
a free note, and the customer's care history in that category. It appears on the reservation page when the reservation is linked to a person,
and on the person's page, with one tab per category when the person has several sheets. Guest
reservations without a person have no sheet. Members never see sheets.
Every staff member of the club can read sheets. Club administrators and owners can always edit
them. Other staff can edit a sheet while they are assigned to the resource of one of the customer's
reservations in that category, from its creation until one day after it ends (its check-out time
when recorded, otherwise its end time); undated requests stay editable. Canceled reservations count.
Assignments are evaluated when the sheet is read or saved, so resource staff changes apply
immediately. Callers outside the club receive UNAUTHENTICATED.
query CustomerSheets($clubId: ID!, $personId: ID!) {
customerSheets(clubId: $clubId, personId: $personId) {
personId
categoryId
categoryName
preferences
contraindications
note
updatedAt
updatedByName
editable
history {
reservationId
startsAt
resource
attendance
}
visitCount
}
}history lists the customer's ten latest reservations in the category that have already started,
newest first, excluding canceled ones. Each visit gives its reservationId, startsAt, resource
name and attendance; the back office links each one to its reservation page.
visitCount counts all of them, beyond the ten listed. The back office shows contraindications as a
warning, the three latest visits with an option to show all, and leaves out the reservation being viewed.
customerSheets returns one sheet per enabled category where the person has a reservation in the
club or a saved sheet, ordered by category name. Categories without a saved sheet return empty
fields with null updatedAt and updatedByName. editable tells whether the caller may save it.
reservationCustomerSheet(clubId, reservationId) returns the same fields for the reservation's
person and category, or null when the module is disabled, the reservation has no person, or it is
not in the club. Neither query accepts filters, sorting or pagination.
mutation SaveCustomerSheet($input: SaveCustomerSheetInput!) {
saveCustomerSheet(input: $input) {
error
sheet {
categoryId
preferences
contraindications
note
updatedAt
updatedByName
editable
}
}
}SaveCustomerSheetInput takes clubId, categoryId, personId, preferences,
contraindications and note, and replaces the whole sheet. Fields are trimmed and limited to 4000
characters; control characters other than tabs and line breaks are refused. Invalid IDs or fields
return BAD_USER_INPUT. error is Customer sheet not found. when the module is disabled or the
person has no reservation in the category, and You can no longer edit this customer sheet. when
the caller's edit window is closed. Each saved change records a customer_sheet.updated activity
entry on the person with the changed fields, and stores the editor's name and time on the sheet.
Attendance and member follow-up
markReservationAttendance accepts an optional occurrenceId. Omit it to mark an
individual reservation; when supplied, the booking must match that exact occurrence.
The required UUID clubId, categoryId, and reservationId always scope the write.
Only booked, confirmed and completed reservations can be marked. Pending and canceled
bookings return null. Attendance never changes payment, booking status or the scheduled
check-in/check-out window. No-shows are marked explicitly; an unmarked booking is never
counted as absent. Late is a distinct attendance state and counts as present in the
session's attendance total. late and absent are refused with BAD_USER_INPUT "Mark late or
absent once the reservation has started." before the reservation's start time, so no no-show can
be counted in advance; present stays open for early arrivals and unmarked clears a mark at any
time.
The optional note is trimmed, limited to 500 characters and stored with that change.
Every state change records the previous/new state, authenticated staff identity and time,
atomically with the attendance update and webhook. Repeating the current state is a no-op,
including its note: it creates neither another event nor another webhook. To correct a mark,
choose another state or unmarked. The UI toggles a selected state back to unmarked.
mutation MarkIndividualAttendance($clubId: ID!, $categoryId: ID!, $id: ID!) {
markReservationAttendance(
clubId: $clubId
categoryId: $categoryId
reservationId: $id
attendance: late
note: "Arrived after roll call"
) {
reservationId
attendance
}
}
query AttendanceHistory($clubId: ID!, $id: ID!, $page: Int! = 1) {
reservationAttendanceHistory(clubId: $clubId, id: $id, page: $page) {
attendance
page
hasMore
items {
id
occurredAt
actorName
previous
attendance
note
}
}
}
query MemberAttendance(
$clubId: ID!
$personId: ID!
$days: Int! = 90
$page: Int! = 1
$attendance: ReservationAttendance
) {
personAttendance(
clubId: $clubId
personId: $personId
days: $days
page: $page
attendance: $attendance
) {
present
late
absent
unmarked
page
hasMore
items {
id
categoryId
categoryName
startsAt
attendance
}
}
}Reservation attendance history uses the same assigned-category staff access as the booking.
Member follow-up requires the same club administrator access as the person's profile;
planning-only staff and members cannot read it. IDs must be UUIDs. Missing or mismatched
records return null. History pages contain up to 20 changes, newest first by time and ID.
page defaults to 1 and must be between 1 and 1,000,000; hasMore controls pagination.
There is no client sort. The optional attendance filter accepts present, late,
absent, or unmarked; omitted or null returns all attendance states. It filters
rows before pagination and leaves the full-period summary counters unchanged.
days accepts 30, 90 (default), or 365. The period is the rolling UTC interval from
now minus that many days (inclusive) to now (exclusive), applied to reservation start time.
Future, undated, pending and canceled reservations are excluded. Summary counters cover
the full period and count reservations, not accompanying guests; the four counters are
mutually exclusive. Corrections update these current-state totals without erasing history.
Rows are paginated by 20 and ordered by start time descending, then ID descending.
Times are displayed in the club timezone. Each member row links to its reservation,
where the correction history is available.
The optional onlyIfUnmarked argument of markReservationAttendance updates only
reservations whose attendance is still unmarked. The check happens under the row
lock: an existing attendance is returned unchanged, with no new audit event or webhook.
The roster uses this guard to mark remaining participants present without overwriting
concurrent corrections. A failed batch can be retried; completed changes remain saved.
No-show sanctions
Each club can warn, suspend, block bookings or ban members automatically after repeated no-shows. The policy has
a resetAfterDays counting window (1–3,650 days, default 90) and up to 10 rules. A no-show
counts while its reservation started within the last resetAfterDays days; older no-shows
no longer count, so the counter returns to zero once the window passes without a new one.
Only booked, confirmed and completed reservations marked absent count.
Each rule names a distinct noShows threshold (1–100) and an action: warn, suspend or
no_booking for suspensionDays days (1–3,650, starting on the current UTC date), or ban.
no_booking sets the member's status to no_booking, which only blocks new reservations.
suspensionDays is ignored for warnings and bans and returned as null. Rules are returned sorted by threshold. An empty
rules list disables sanctions.
The sanction is applied in the same transaction as markReservationAttendance when it marks
a member's reservation absent and the member's count inside the window becomes exactly a
rule's threshold. Marking a reservation older than the window never triggers a sanction.
Banned and disabled members are left unchanged. A suspension or booking block never shortens
an ongoing one of the same kind: it extends the end date when longer and otherwise leaves it
untouched. A suspension replaces an ongoing booking block, but a booking block never replaces
an ongoing suspension. The applied status
records the reason "<count> no-shows within <days> days" and appears in the person's status
history with the staff member who marked the no-show. Unmarking or correcting a no-show does
not lift a sanction; staff end it from the member profile.
Every sanction queues a club category member notification that
states the no-show count and the window: "No-show warning" for warn, "Club access
suspended" and "Bookings blocked" with the end date for suspend and no_booking, and "Club
access revoked" for ban. A warn rule leaves the member's status unchanged and also reaches
suspended members; a sanction that leaves the status unchanged sends nothing. Notifications
follow the member's in-app and email preferences for that category, are sent at most once per
no-show reservation, and are recorded as person.notified activity.
Staff with settings access read the policy with noShowSanctionPolicy(clubId: ID!) and
replace it with configureNoShowSanctions(clubId: ID!, resetAfterDays: Int!, rules: [NoShowSanctionRuleInput!]!). Both return NoShowSanctionPolicy!. Out-of-range values,
duplicated thresholds, a missing suspension or booking block length or more than 10 rules return
BAD_USER_INPUT; other callers receive UNAUTHENTICATED. Saving an unchanged policy records
no activity.
query NoShowSanctionPolicy($clubId: ID!) {
noShowSanctionPolicy(clubId: $clubId) {
resetAfterDays
rules {
noShows
action
suspensionDays
}
}
}
mutation ConfigureNoShowSanctions($clubId: ID!) {
configureNoShowSanctions(
clubId: $clubId
resetAfterDays: 60
rules: [
{ noShows: 1, action: warn }
{ noShows: 2, action: no_booking, suspensionDays: 7 }
{ noShows: 3, action: suspend, suspensionDays: 14 }
{ noShows: 5, action: ban }
]
) {
resetAfterDays
rules {
noShows
action
suspensionDays
}
}
}Session cancellation and member notifications
See Member notifications for cancelReservationSession, notification
preferences, the inbox and reminder rules. Session deletion is rejected while active bookings,
waitlist entries or unrefunded payments remain. Cancel individual occurrences from their roster
before deleting the definition. Confirmations continue using the existing email journal, now
respecting the member's reservation email preference.
Resource calendars and open events
The member planning shows scheduled activities together by default, with an activity filter. Cards show the time, activity and registration/open-access status. Selecting a card opens a compact detail dialog with availability, price and one primary action. Session-based categories include collective classes, provider appointments and dated events. Direct booking uses the existing session-based category configuration; the demo category Cours particulier retains its conversation-based request examples. Conversation-based undated requests remain an explicit category configuration, not a requirement for coaching.
Both session creation inputs accept optional title (trimmed, at most 100 characters),
resourceId (a resource belonging to the same category), and reservationRequired
(default true). Availability occurrences return those fields. A resource-scoped
session only accepts that single resource for member bookings, updates and waitlists;
resource blocks and overlapping bookings from other occurrences hide conflicting slots.
Reservations sharing one occurrence consume its capacity without blocking each other.
Staff booking creation rejects a second active reservation for the same person and session occurrence, just like member booking creation. Canceling an inscription allows that person to book the occurrence again.
A member can hold only one non-canceled reservation per occurrence; concurrent duplicate
requests return You have already reserved this session. without creating another booking.
This check runs before the capacity check, so a person who already holds a place in a full
occurrence gets this message rather than This session does not have enough places left.
Setting reservationRequired: false publishes an open-access event. GraphQL normalizes
its capacity to null. Members cannot book or join a waitlist for it. The consumer instead
offers an explicit calendar download in the event dialog: an iCalendar file with a stable
occurrence UID, UTC times, title and location.
This export does not create an internal reservation or subscribe to future event updates.
reservationAvailability.busyPeriods returns resource IDs and overlapping UTC start/end
times for non-canceled bookings. It contains no member, price, message, or reservation ID.
Flexible activities display these occupied periods and resource/global blocks in the
weekly calendar; a day selection prefills editable start/end dates for a request.
An empty day does not guarantee availability or represent published opening hours.
No Mews or Book4Time synchronization is provided by this change.
Member place selection
Members can read reservationFloorPlan(clubId, categoryId) and
floorPlanLibraryObjects(clubId, categoryId) for their authenticated club when the
reservation category has visualizationEnabled. Disabled or unknown categories return
null for the plan and an empty object list. Other clubs and anonymous callers
remain unauthorized. Saving plans and reading named occupancy remain staff-only.
For flexible categories with a plan, the consumer asks for the date and time, then
shows the club's placed objects. Members select one resource directly on the plan.
Anonymous reservationAvailability.busyPeriods and resource/global blocks grey out
places overlapping the chosen interval. Selection clears when dates change, and
submission is disabled while availability loads or the selected place is unavailable.
The existing booking mutation still validates conflicts and membership at submission.
The member planning matches myReservations.reservation.sessionId plus the start
instant and category to each scheduled occurrence. Pending requests show “Request sent”;
booked or confirmed reservations show “Already booked”. Selecting either opens the
existing reservation. Canceled reservations do not mark the slot as already booked.
Member reservation editing
CustomerReservation exposes optional sessionTitle, the scheduled activity title; clients fall back to categoryName when absent.
reservationAvailability accepts optional excludeReservationId: ID. Only busy periods belonging to that reservation and to the authenticated member in the queried club/category are omitted. Other members’ reservations remain occupied, and anonymous/staff requests do not apply the exclusion. Session capacity counts are unchanged. Date range validation remains unchanged. Saving an edit still validates conflicts on the server.
Basic shapes use solid palette fills. The texture field, enum, pattern renderer and validation have been removed. Seeded palm trees and parasols use the generated plant and umbrella artwork, preserve their placement, and remain decorative rather than creating reservation resources.
Placement and object selection are mutually exclusive. Selecting an existing object cancels placement, and placement previews are hidden over objects. Choosing a library item exits shape editing. Clicking a shape body exits its shape editor; handles remain interactive. Clicking empty canvas clears object selection and returns to the plan palette controls without saving or discarding changes.
Club credits
See Club credits for credit packs, balances, refill history, credit-priced add-ons and reservations, and cancellation refunds. Membership subscriptions stay billed in money.
Security-deposit payment state
Reservation.depositAmount is the snapshotted deposit in hundredths of a credit.
depositPaid becomes true only when price and deposit payment commits;
depositRefunded becomes true when the collected deposit is settled. Both are
false when no deposit was collected. Nullable depositRefundedAmount is the amount returned when the
deposit was settled after departure, and depositRefundNote the staff message sent when part of it was
kept (see Departure and deposit refunds). For external reservations,
amounts are in cents and are paid and refunded by card; source is external and
externalCustomer keeps the visitor's contact details after their temporary account is deleted. They are exposed on staff and member detail
and list results, including canceled/completed bookings.
Paid bookings cannot be reassigned to resources with a different price or deposit. Unpaid resource reassignment updates both snapshots. Waiving a reservation price in a chat proposal does not waive the deposit. The consumer shows the price, deposit, combined total, and deposit state; payment cannot omit the deposit. Cancellation (member, staff, or entire session) refunds both paid components, except that a member cancellation within the cancellation notice keeps the price; after departure, staff or the automatic refund return all or part of the deposit to the original member's credit wallet. Refunds retain the original credit name and exact amount, with unique ledger sources preventing repeated or concurrent requests from refunding twice.
Combining session schedules
Session-enabled categories display sessions in their calendar; bookings belong to a session's
participant list. Creating a booking in these categories requires a sessionOccurrenceId.
The editor starts with a dated (custom) session. Recurrence is optional: add as many weekly
or daily definitions as needed, including several on the same day. Definitions are additive.
For example, Monday at 15:00 and 18:00, Friday at 15:00, and daily at 16:00 are four schedules.
ReservationSessionInput and ReservationSessionDraftInput accept kind: daily with
startMinutes (0–1439), durationMinutes (1–10080), and an optional capacity. For daily
sessions, weekday, startsAt, and endsAt must be omitted/null. Times follow the club's
timezone. Daily occurrence IDs use <definition UUID>:<YYYY-MM-DD>, as weekly ones do.
A dated session uses UTC startsAt and endsAt and its definition UUID as the occurrence ID.
Several dated sessions on the same date are supported, independently of recurring schedules.
Ticket verification has a persistent club-wide limit of 100 attempts per minute, including malformed and unknown codes. Staff category restrictions are applied after code lookup; tickets outside those categories return the same invalid result as unknown codes.
Session management uses the staff-admin-only reservationSessionDefinitions(clubId: ID!, categoryId: ID!): [ReservationSessionDefinition!]! query. It lists the category's stored
schedules, including past and distant dated sessions and recurrences with no currently
available occurrence. Unlike reservationAvailability, it does not apply booking capacity,
blocked periods, cancellations or a date window. Both IDs are validated UUIDs; foreign-club
categories return an empty list. Members and planning-only staff cannot access this management query.
There is no filtering, sorting or pagination; the back office sorts and separates dated and
recurring definitions and hides past dated sessions until requested.
query ManageSessions($clubId: ID!, $categoryId: ID!) {
reservationSessionDefinitions(clubId: $clubId, categoryId: $categoryId) {
id
title
kind
resourceId
reservationRequired
weekday
startMinutes
durationMinutes
startsAt
endsAt
capacity
guestPlanIds
guestPeople {
id
name
}
}
}guestPlanIds and guestPeople describe the guest list; both are empty for
sessions open to every member. A deleted person no longer appears in guestPeople, but the
restriction remains. For dated definitions, startsAt and endsAt contain UTC ISO timestamps and recurrence fields
are null. Recurring definitions have null timestamps; daily definitions also have null
weekday. title, resourceId, and capacity are optional. Creation returns to the matching
schedule list. Category options only toggle modules for saved categories; management is on
the category page. The category tabs occupy their own row. The Tickets action appears only
for a category with tickets enabled and opens camera/manual verification choices; verification
still resolves codes across authorized categories in the selected club.
Editing one session
Administrators can call updateReservationSession(occurrenceId: ID!, input: ReservationSessionInput!).
Use an occurrence ID from availability (or a one-off definition ID), the same club/category,
kind: custom, a resource in that category, and the new UTC startsAt/endsAt.
Title, capacity, access and the guest list are also editable. The result contains sessionId and error.
New sessions created with createReservationSession also require a staff-selected resourceId.
Only future, uncanceled occurrences can be edited. Resource blocks, other bookings and sessions
at the target location are checked before any writes. Capacity cannot drop below existing
participants; open access cannot replace a session with participants or a waitlist.
An invalid scope or business conflict returns a null sessionId and an error without changing data.
Malformed IDs, dates, missing resources and non-custom input are rejected as BAD_USER_INPUT.
A one-off retains its definition ID. Editing one recurring occurrence suppresses that original
date and creates a one-off, leaving the rest of the series unchanged. Its active bookings,
resource assignments and waitlist move atomically to the new session/time/location. Booking
IDs, payment amounts, ticket codes and waitlist order are preserved; reservation update events
are recorded through the existing notification pipeline. Use the returned sessionId for
the updated roster and waitlist.
The back office exposes Edit session on future calendar session details and one-off rows in Manage sessions. The edit form uses the club timezone and changes only that occurrence.
Resource products and reservation quantities
Each resource can have up to 50 products, configured through
updateReservationResource(input: UpdateReservationResourceInput!). Pass products
to replace its catalog atomically, [] to clear it, or omit the field to retain it.
Products appear in a compact list; Edit opens one product at a time.
Each product has a stable UUID id, trimmed name (1–100 characters), amount
in hundredths of club credits (0–100,000,000), required image, optional
description (up to 1,000 characters), and customerVisible. Images are PNG or
JPEG data URLs, validated by file signature, at most 500 KB each and 5 MB combined.
Product IDs and case-insensitive names must be distinct within a resource.
Only club administrators can configure the catalog.
mutation ResourceProducts($input: UpdateReservationResourceInput!) {
updateReservationResource(input: $input) {
resource {
id
products {
id
name
amount
image
description
customerVisible
}
}
error
}
}Resource reads expose products through ReservationResource. Staff see the
whole catalog in their authorized club/type; member responses omit staff-only
products at the GraphQL field boundary, including nested resource reads.
trackConsumption (input default false) makes a product available in the housekeeping
Consumption tab. It cannot be combined with customerVisible: true; such input is rejected.
The resource editor presents two billing choices: Reservation add-on (customerVisible: true),
or Minibar consumption (customerVisible: false, trackConsumption: true).
Staff-only catalog inputs are normalized to minibar consumption; there is no separate staff-extra mode.
Minibar products are never offered for advance purchase, including when stale catalog data has both flags set.
Products are available with or without housekeeping. Configure minibar items here as staff-only products; record quantities on the guest reservation. There is one catalog and one credit billing path.
createReservation, createStaffReservation, and updateReservation accept an
optional products: [{ productId, quantity }] input. Quantities are integers from
1 to 99, with at most 50 distinct products. Products must belong to the selected
resource. Customers can select only customer-visible products; staff can add
staff-only consumption such as minibar items. Omit products on update to retain
existing lines, or use [] to remove them. Remove existing product lines before
moving a reservation to another resource.
The server calculates the total from resource pricing plus product quantities;
clients cannot submit unit prices. Reservation.products contains immutable
price/name snapshots (productId, resourceId, name, unitAmount, quantity,
customerVisible). Catalog repricing and deletion do not alter existing charges.
Staff can adjust quantities of existing snapshots, including deleted products.
Member responses omit staff-only line details while the reservation total
includes all billed products. Catalog and line arrays preserve configured order;
there are no separate product filtering, sorting, or pagination arguments.
Pending/booked reservations include products in their eventual credit payment. For confirmed/completed reservations, saving changed quantities atomically posts the debit/refund difference to the customer's credit ledger and updates the reservation. Insufficient credit leaves both unchanged. Repeating a save with the same quantities does not charge again. Cancellation refunds the original credit payment plus the net product adjustments once. A free proposal waives both the resource and products by recording zero unit prices. Canceled reservations and reservations with a legacy Stripe checkout cannot have their charged total changed. Product editing is staff-only after submission; members can select quantities when requesting a reservation.
Reporting product consumption from housekeeping
housekeepingConsumption(clubId: ID!, categoryId: ID!, resourceId: ID!) is restricted to
administrators and assigned planning staff. It returns the resource's products with
trackConsumption: true in catalog order, and at most 50 eligible reservations, sorted
by end time descending and ID descending. Each reservation returns id, guestName,
startsAt, endsAt and its billed products snapshots. Eligible reservations are confirmed
or completed, have already started, belong to the selected resource, still have a member,
and use credits rather than a legacy Stripe checkout. There are no further filter or
pagination arguments. Invalid or disabled housekeeping scopes return empty lists.
mutation ReportProductConsumption($input: ReportProductConsumptionInput!) {
reportProductConsumption(input: $input) {
reportId
error
}
}The input requires a new UUID id, clubId, categoryId, resourceId, reservationId,
and a nonempty products: [{ productId, quantity }] list. Quantities represent newly consumed
items, not replacements for previously billed quantities. Up to 50 distinct products are
accepted, each quantity 1–99; the accumulated reservation quantity is also limited to 99 per
product. Only flagged products belonging to that resource can be reported. Staff choose the
stay explicitly; the system does not infer which guest consumed an item from the current room.
Prices come from an existing reservation snapshot when present, otherwise from the resource
catalog. Credit debits, added quantities and the attributed housekeeping history entry commit
in one transaction. Insufficient balance leaves all three unchanged. Reservation cancellation
refunds these charges through the existing product adjustment ledger. The report exposes
reservationId and guestName alongside the consumed item names, quantities, author and time.
logHousekeepingReport remains the condition/note operation.
Keep the same request id when retrying the same submission: it returns the original reportId
without adding quantities or debiting again. Reusing that ID with a different reservation,
resource or product payload is refused. Invalid IDs or quantities return BAD_USER_INPUT;
unavailable stays, untracked products and insufficient credits return a user-facing error
with reportId: null. Anonymous callers, customers and unassigned staff cannot read or submit
housekeeping consumption.
People invited to a reservation
A reservation can have up to 50 invitees in addition to its booking contact.
The booking owner, club administrators, and staff assigned to its category can manage
these people. Existing guests and members are linked by normalized email within the
same club, both when sending and accepting. The consumer shows the people card directly,
including an account-linked indicator and the linked person’s current name.
With the default club policy, outside email addresses do not create an account or grant club access.
Invitation management does not change the resource booking or price. Only a staff invitation to a
full reservation raises guests by one, within the session capacity for a session booking (see
Number of people).
query ReservationInvitees($clubId: ID!, $reservationId: ID!) {
reservationInvitees(clubId: $clubId, reservationId: $reservationId) {
id
name
email
personId
status
}
}
mutation InviteToReservation(
$clubId: ID!
$reservationId: ID!
$email: String!
$durationHours: Int
) {
inviteToReservation(
clubId: $clubId
reservationId: $reservationId
email: $email
durationHours: $durationHours
)
}
mutation RemoveReservationInvitee($clubId: ID!, $reservationId: ID!, $id: ID!) {
removeReservationInvitee(clubId: $clubId, reservationId: $reservationId, id: $id)
}clubId and reservationId scope all management operations; id identifies the invitee
when removing one. Only an email is needed to invite someone. email must
be valid and is normalized before matching. Duplicate email addresses (including case
variants) and the booking contact's email are rejected with BAD_USER_INPUT.
Canceled, completed, and past reservations cannot receive new invitations. Lists are
ordered by creation time then ID, with no filters, sorting arguments, or pagination
because each reservation is limited to 50 invitees. personId is nullable and status
is pending, accepted, or declined.
Sending returns true after the mail service accepts the email. Delivery failure removes
the pending invitee and returns a retryable error. Remove an invitee to invalidate their
link; removing and inviting again sends a fresh invitation. Invitations do not grant
payment controls, tickets, or access to the booking's private conversation.
Recipients use the email's /reservation-invitation?token=… link without signing in:
query ReservationInvitation($token: String!) {
reservationInvitation(token: $token) {
name
hostName
clubName
resource
startsAt
endsAt
status
expired
requiresAccount
hasStoredName
}
}
mutation RespondReservationInvitation($token: String!, $accept: Boolean!, $name: String) {
respondReservationInvitation(token: $token, accept: $accept, name: $name)
}The random 64-character hexadecimal token is stored only as a hash and is never returned
by management queries. Preview returns null for unknown or removed tokens. Links expire
after seven days or at the reservation's end, whichever comes first; cancellation or
completion also makes them unavailable. A pending invitee can accept or decline once;
repeated, expired, removed, or canceled responses fail with BAD_USER_INPUT. Preview
shows the current reservation dates (nullable for an unscheduled request), the club’s IANA
timezone, and the response
status. A response does not change the booking's confirmation status.
Acceptance uses the linked person’s current name automatically. Without an account, it reuses
the most recent accepted name for the same normalized email within this club. Preview exposes
hasStoredName and name; recipients with a stored name can accept without entering or
submitting one. Otherwise name is required on acceptance (trimmed, 1–100 characters).
Declining never requires a name. Supplied names do not rename account profiles or override
stored names. Unknown guests display their email until a name is available.
Require guest accounts
The club admin Settings page offers Require guests to create an account, off by default. Members in the club and assigned staff can read the policy; only a club administrator can change it.
query ReservationGuestPolicy($clubId: ID!) {
reservationGuestsRequireAccount(clubId: $clubId)
}
mutation SetReservationGuestPolicy($clubId: ID!, $required: Boolean!) {
setReservationGuestsRequireAccount(clubId: $clubId, required: $required)
}Both return a Boolean, with no filters or pagination. Unknown clubs fail with BAD_USER_INPUT.
The policy is captured when sending an invitation; changing it does not rewrite existing invitations.
When enabled, inviting an email without login credentials requires durationHours of 12, 24, or 48.
The inviter must also satisfy the existing guest-invitation permissions (active non-guest member or administrator).
The invitation appears in the consumer Guests tab and uses the existing guest account form.
reservationInvitation.requiresAccount tells the recipient UI to continue to
/guest-invitation?token=… with the same token. Direct acceptance through
respondReservationInvitation fails until an account exists; declining remains available.
acceptGuestInvitation creates or completes the person’s account, creates the chosen temporary
access starting at acceptance, starts their session, and marks the reservation invitation accepted
with that person ID, all in one transaction. Removed, declined, expired, completed, and canceled
reservation invitations cannot create guest accounts. Delivery failure removes both pending records.
People already holding login credentials in the same club use the simple reservation acceptance
flow, without a duplicate account, password change, or additional access grant. An account created
after sending is detected again on preview and acceptance. Matching never links another club’s person.
Invitations in the consumer account
The Reservations tab includes an Invited reservations section, separate from owned bookings.
It shows pending, accepted, and declined invitations, host, resource, dates, timezone, and the
booking’s current status. Pending available invitations can be accepted or declined directly.
Accepted invitations to a confirmed, upcoming reservation with createTicket show a
Download ticket action that calls downloadMyReservationTicket with reservationId.
query MyReservationInvitations($clubId: ID!) {
myReservationInvitations(clubId: $clubId) {
id
reservationId
createTicket
hostName
resource
startsAt
endsAt
timezone
status
reservationStatus
expired
}
}
mutation RespondMyReservationInvitation($clubId: ID!, $id: ID!, $accept: Boolean!) {
respondMyReservationInvitation(clubId: $clubId, id: $id, accept: $accept)
}Both require member authentication in clubId (including active guest accounts). The recipient
ID comes from authentication, never a client argument. Invitations match a linked person ID,
or the current normalized email if not yet linked, within that club. Existing links survive
account email changes. Lists return at most the latest 100 invitations, ordered by creation
time then ID descending, without filters or pagination arguments. Removed invitations disappear.
The result exposes no invitation tokens, payment data, private conversation, or owner controls.
expired means the response deadline passed or the booking ended, was canceled, or completed;
accepted invitations remain visible after the response deadline. Dates are nullable.
The response mutation returns true and uses the stored account name. It enforces the same
single-response, expiry, booking-state, and account requirements as email-token acceptance.
An unrelated invitee ID or club fails without changing the invitation. Viewing an invited
reservation does not grant access to the owner’s booking detail or mutations.
Resource occupant limits
ReservationResource.maxOccupants is the number of people one booking of the resource can hold,
an integer from 1 to 51 that defaults to 1. Set it in the resource editor, for example 2 for a
pool lounger or 4 for a hotel suite. UpdateReservationResourceInput.maxOccupants keeps the
current value when omitted.
The limit decides whether bookings ask for a number of people and caps what
members can book. Invitations are then limited by the reservation's guests, not by the
resource: the booking contact counts as one, pending and accepted invitations reserve places, and
declined or removed invitations do not. Invitation creation checks this transactionally. The
consumer reservation query selects resources { id name maxOccupants } and guests, shows the
occupant count, and disables sending invitations when everyone has been invited.
Member credit payment summary
myReservation(clubId: ID!, id: ID!) also returns nullable creditPayment with
balance, creditName, refundableAmount, and refundedAmount. Amounts are integer
hundredths of credits. The summary is available only for the authenticated owner's
detail query; list results do not load it. UUID validation and ownership checks are
unchanged; another member's reservation returns null.
refundableAmount is the remaining net debit recorded for the reservation, product
adjustments, and deposit after previous refunds. refundedAmount is the actual
reservation and deposit refunds recorded in the credit ledger. Unpaid or free
reservations have no credit refund; externally paid amounts are not credit refunds.
For a reservation paid by card through Stripe Checkout, Reservation.cardRefundedAmount is
the amount Stripe refunded to the card, in cents: the price on a full refund plus the deposit
once it is refunded (only the deposit after a late cancellation). It is null while nothing was
refunded by card, and always null for credit payments. The member site shows it on the canceled
reservation as {amount} refunded to your payment method. and keeps "No credits were debited"
for reservations payable in credits.
The balance is informational; payment always checks the current balance atomically.
query ReservationPayment($clubId: ID!, $id: ID!) {
myReservation(clubId: $clubId, id: $id) {
creditPayment {
balance
creditName
refundableAmount
refundedAmount
}
}
}Retrying a member reservation request
CreateReservationInput.requestId is an optional UUID, scoped to the authenticated
member and club. Reuse it with the same category, resource, dates, session occurrence
and products when retrying createReservation after an uncertain network response.
Concurrent retries return the same reservation, including its current status,
without repeating creation side effects. Changing the payload with the same UUID
returns a validation error. Use a new UUID for a new request; requests without a
UUID do not have this retry guarantee. Invalid UUIDs are rejected before creation.
The consumer form keeps the same UUID for retries of the same submitted values while that form remains open. Starting another request generates a new UUID. This guarantee does not deduplicate separately opened forms or browser reloads.
Accepting a time proposal rechecks resource availability and rejects proposals whose start time has passed, leaving the request pending for another proposal.
Decline or withdraw a replacement proposal
declineReservationProposal(clubId: ID!, id: ID!, proposalVersion: ID!): ReservationResult!
lets the authenticated member keep their accepted booking. withdrawReservationProposal
has the same arguments and requires staff reservations_manage permission in the club.
All IDs must be UUIDs. Both return reservation and error, like proposal acceptance.
These actions only apply to a booked reservation with an outstanding replacement proposal,
including an expired proposal. The exact displayed proposalVersion is checked under the
reservation lock; a stale version, another member's reservation or a completed acceptance
cannot be dismissed. The accepted dates, resource, price and deposit remain unchanged.
Only the proposal is cleared. paymentVersion changes, so clients refresh the reservation
before offering payment again. Declines and withdrawals are recorded in activity history.
mutation KeepAcceptedTime($clubId: ID!, $id: ID!, $proposalVersion: ID!) {
declineReservationProposal(clubId: $clubId, id: $id, proposalVersion: $proposalVersion) {
reservation {
id
startsAt
endsAt
amount
proposalVersion
paymentVersion
}
error
errorCode
}
}Coach profiles in conversations
ReservationMessage.coach is a nullable profile containing id, name, bio
and avatar. Professional messages record the authenticated sender's staff ID;
clients cannot choose the sender. Profiles show the latest saved name, biography
and photo, not a snapshot taken when the message was sent.
query ConversationCoach($clubId: ID!, $reservationId: ID!) {
myReservationChat(clubId: $clubId, reservationId: $reservationId) {
id
messages {
id
author
senderName
coach {
qualifications {
id
kind
title
issuer
issuedOn
expiresOn
}
id
name
bio
avatar
}
body
createdAt
}
}
}The member must own the reservation in the authenticated club. There is no public
staff directory or unscoped coach lookup. coach is null for customer messages,
legacy messages without a sender ID, deleted staff accounts, or staff whose club
access has been removed. senderName remains available in these cases. Profiles
exclude email addresses, account permissions and staff documents. The field is
also available on messages returned by the staff conversation query and message
mutations. Existing conversation ordering and pagination behavior are unchanged.
Coach qualifications
CoachProfile.qualifications lists diploma and professional-card metadata (id, kind,
title, issuer, issuedOn, expiresOn) from the staff dossier in the reservation club's
space. The coach must still belong to that space and have access to the club. Dates are
ISO calendar dates or null. Expired records retain their expiry date; inclusion does not
mean a document has been verified. Other supporting documents, registration numbers,
filenames, storage keys and original files are not exposed to members. Access remains
limited to participants in the member's own reservation conversation.
Layer order
The staff plan editor exposes Send to back, Send backward, Bring forward
and Bring to front for a single selected object. Controls are disabled for
locked objects, pending saves and stack boundaries. Each move is one undoable edit;
other spaces, geometry and booking bindings are preserved. The object list shows
frontmost items first; elements is saved in the opposite, back-to-front draw
order. The consumer renders that same persisted order.
Agent example:
{
"expected_version": 3,
"operations": [{ "op": "move_layer", "id": "existing-instance-id", "direction": "back" }]
}