Champagne

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 are create, update, delete, reorder. Create takes element; update takes a stable id and element patch; delete takes id; reorder takes id and before_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 with library_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 are none or names. Optional internal source overlay uses the exact registered reference at opacity 0.35. PNGs are bounded to 2400 pixels per axis; the response includes viewport, pixels, pixelsPerUnit, units:world, and full. A null region counts as full inspection regardless of extent.
  • scene_transaction also accepts {op:"move_layer",id,direction}. Directions forward/backward move one layer; front/back move 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 current expected_version and are atomic. Both list_objects and transaction results return layer_order, listing instance IDs back-to-front (last is topmost). Existing reorder/before_id remains 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 returns library_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.

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.

  • resources lists the linked resources with categoryId and categoryName, ordered by category then name.
  • reservations lists non-canceled reservations ending after from, ordered by start, 30 per page.
  • requests lists undated pending or booked requests still waiting for a time.
  • hasMore is 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 (weekday 0 = Sunday, startMinutes and endMinutes in club wall time, end at most 1440 for midnight);
  • dated exceptions: available adds extra hours, unavailable takes 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 return BAD_USER_INPUT. The result holds the saved windows, or null windows and an error.
  • 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) returns removed: false when 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:

FieldMeaning
requiresPayment: Boolean!amount + depositAmount > 0. Accepting a pending request then books it (booked, payment due) instead of confirming it.
phase: ReservationPhaseFor 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: StringThe 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 maxOccupants of the selected resource: creation returns error: "Too many people for this resource.", and member edits and myReservationChangeQuote return 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.

FieldValuesEffect
openingHoursUp to 50 weekly windows (weekday 0 Sunday–6 Saturday, startMinutes 0–1,439, endMinutes up to 1,440) in club wall timeA booking must fit inside one window. Overlapping windows of a day are merged. An empty list means always open.
defaultDurationMinutesNull or 5–10,080When 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.
bookingWindowDaysNull or 1–730Members cannot book a start further ahead.
minNoticeMinutesNull or 0–10,080Members cannot book a start sooner.
slotBookingBooleanMembers 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.
slotIntervalMinutes5, 10, 15, 20, 30 or 60 (default 30)Spacing of the start times offered in slot booking.
waitlistEnabledBoolean, default trueMembers can join a waitlist for a full session or time slot.
linkedCategoryIdsUp to 50 other categories of the clubStaff 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 confirmed reservation without a collected deposit becomes completed automatically.
  • A confirmed reservation holding a deposit stays confirmed until 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:

ActionRecorded whenchanges fields
createdA member or staff creates the reservationnone
updatedStatus, resource, dates, check-in/out or price changestatus, resource, startsAt, endsAt, checkInAt, checkOutAt, amount, refundForfeited
time_proposedStaff propose another timeproposedStartsAt, proposedEndsAt, proposedFree, proposedAmount, amount
proposal_declinedThe member declines the proposed timeproposal fields cleared
proposal_withdrawnStaff withdraw the proposed timeproposal fields cleared
attendance_markedAttendance changesattendance
checked_outStaff record an early check-outcheckedOutAt
check_out_undoneStaff undo an early check-outcheckedOutAt (cleared)
deposit_refundedThe deposit is refunded, fully or partlydepositRefundedAmount
invitee_addedSomeone is invited to the reservationinvitee (email in after)
invitee_removedAn invitation is removedinvitee (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" }]
}

Sur cette page

Floor plan importsNative scene toolsBookability and save safetyHonest inspection and reviewReservation accessService failuresReservation categoriesPlan pricesStaff in charge of a resourceClub timezone settingsDelete a reservation categoryRead one category or resourceCreate and delete a resourceLink categories on their ownSessions and blocked periodsStaff scheduleStaff availabilitySession guest listsReservationsParticipant and waitlist exportConfirmation emailsQR ticketsSession attendanceStorage configurationMember's upcoming reservationsComputed reservation fieldsCreate a categoryNumber of peopleBooking rules and time slotsSlot bookingTime slot waitlistMoving a reservation to a linked categoryCategory settingsRequest a reservationRescheduling a paid reservationCancellation noticePay a booked reservationStaff booking and editingCancel a reservationEarly check-outDeparture and deposit refundsReservation historyConversation APIAdd a participant to a sessionFloor plansCustom objectsLive artwork progressArtwork debug tracesHousekeepingChecklistResource stateCustomer sheetsAttendance and member follow-upNo-show sanctionsSession cancellation and member notificationsResource calendars and open eventsMember place selectionMember reservation editingClub creditsSecurity-deposit payment stateCombining session schedulesEditing one sessionResource products and reservation quantitiesReporting product consumption from housekeepingPeople invited to a reservationRequire guest accountsInvitations in the consumer accountResource occupant limitsMember credit payment summaryRetrying a member reservation requestDecline or withdraw a replacement proposalCoach profiles in conversationsCoach qualificationsLayer order