# Reservations

Member reservation requests, club-scoped categories and staff reservation lists.

Source: https://docs.champagne.dev-ukab.fr/en/graphql/reservations

## Floor plan imports [#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.

```graphql
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 [#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):

```json
{
  "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 [#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 [#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-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`:

```graphql
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 [#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 [#reservation-categories]

```graphql
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](/en/graphql/external-reservations).

### Plan prices [#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 [#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](/en/graphql/auth#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.

```graphql
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](/en/graphql/auth#individual-staff-permissions).

```graphql
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.

```graphql
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 [#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.

```graphql
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 [#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](/en/graphql/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.

```graphql
mutation DeleteReservationCategory($clubId: ID!, $id: ID!) {
  deleteReservationCategory(clubId: $clubId, id: $id) {
    deleted
    error
  }
}
```

### Read one category or resource [#read-one-category-or-resource]

```graphql
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 [#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.

```graphql
mutation CreateReservationResource($input: CreateReservationResourceInput!) {
  createReservationResource(input: $input) {
    resource {
      id
      categoryId
      name
      amount
    }
    error
  }
}
mutation DeleteReservationResource($clubId: ID!, $categoryId: ID!, $id: ID!) {
  deleteReservationResource(clubId: $clubId, categoryId: $categoryId, id: $id) {
    deleted
    error
  }
}
```

`CreateReservationResourceInput` takes `clubId`, `categoryId`, `name` and the settings of
`UpdateReservationResourceInput` (`amount` defaults to 0), validated the same way. A name
already used in the category, an unknown category, a plan price for a plan the club does not
offer or staff from another club return `resource: null` with an `error`, and nothing is
created. `deleteReservationResource` returns `deleted: false` with an `error` for an unknown
resource, a resource holding any reservation, or one placed on the floor plan of a category
with visualization enabled. Both require `catalog_manage`.

### Link categories on their own [#link-categories-on-their-own]

```graphql
mutation SetReservationCategoryLinks($clubId: ID!, $categoryId: ID!, $linkedCategoryIds: [ID!]!) {
  setReservationCategoryLinks(
    clubId: $clubId
    categoryId: $categoryId
    linkedCategoryIds: $linkedCategoryIds
  ) {
    linkedCategoryIds
    error
  }
}
```

Replaces the [linked categories](#moving-a-reservation-to-a-linked-category) 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 [#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 [#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.

```graphql
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.

```graphql
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 [#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.

```graphql
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.

```graphql
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 [#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**.

```graphql
mutation GoldGala($input: ReservationSessionInput!) {
  createReservationSession(input: $input) {
    sessionId
    error
  }
}
```

```json
{
  "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](#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 [#reservations]

### Participant and waitlist export [#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](#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 [#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 [#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.

```graphql
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 [#session-attendance]

```graphql
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 [#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.

```graphql
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](#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 [#members-upcoming-reservations]

```graphql
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](#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](/en/graphql/external-reservations). 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](#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 [#computed-reservation-fields]

The API decides these rules when the fields are read, so the back office and the member apps
display them instead of re-deriving them:

| Field                         | Meaning                                                                                                                                                                                                                                                        |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `requiresPayment: Boolean!`   | `amount + depositAmount > 0`. Accepting a pending request then books it (`booked`, payment due) instead of confirming it.                                                                                                                                      |
| `phase: ReservationPhase`     | For a dated `confirmed` reservation: `checkIn` from `checkInAt` (or the start) to the start, `ongoing` until the end, `checkOut` until `checkOutAt` (or the end), and `checkedOut` once staff recorded an [early check-out](#early-check-out). Null otherwise. |
| `canCheckOut: Boolean!`       | Staff can record the guest leaving now (see [Early check-out](#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](#departure-and-deposit-refunds).                                                                                                  |
| `timingChangesAt: String`     | The next instant, as an ISO 8601 UTC timestamp, when `phase`, `canCheckOut`, `canUndoCheckOut` or `depositSettleable` change; null when they no longer change.                                                                                                 |

Because these depend on the current time, clients keep only a timer: the web reservation page and
staff schedule, and the member reservation list and detail, refetch at `timingChangesAt`.

```graphql
query ReservationDecisions($clubId: ID!, $id: ID!) {
  reservation(clubId: $clubId, id: $id) {
    id
    requiresPayment
    phase
    canCheckOut
    canUndoCheckOut
    depositSettleable
    timingChangesAt
  }
}
```

## Create a category [#create-a-category]

```graphql
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](#housekeeping)
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](#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](/en/graphql/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](/en/graphql/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 [#number-of-people]

Each resource has a `maxOccupants`, 1 by default (see
[resource occupant limits](#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](/en/graphql/external-reservations) follow the member rules.

[Inviting people](#people-invited-to-a-reservation) 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 [#booking-rules-and-time-slots]

Each category carries booking rules that members and non-members must follow. Staff can always book
outside them.

| Field                    | Values                                                                                                                      | Effect                                                                                                                                                                                                               |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `openingHours`           | Up to 50 weekly windows (`weekday` 0 Sunday–6 Saturday, `startMinutes` 0–1,439, `endMinutes` up to 1,440) in club wall time | A booking must fit inside one window. Overlapping windows of a day are merged. An empty list means always open.                                                                                                      |
| `defaultDurationMinutes` | Null or 5–10,080                                                                                                            | When set, members and non-members cannot choose the end: it is the start plus this length, whatever `endsAt` they send. The staff editor prefills it for new bookings.                                               |
| `bookingWindowDays`      | Null or 1–730                                                                                                               | Members cannot book a start further ahead.                                                                                                                                                                           |
| `minNoticeMinutes`       | Null or 0–10,080                                                                                                            | Members cannot book a start sooner.                                                                                                                                                                                  |
| `slotBooking`            | Boolean                                                                                                                     | Members choose a party size and a start time instead of a resource (see below). Requires `defaultDurationMinutes` and cannot be combined with `requiresSession`, `conversationRequired` or `externalBookingEnabled`. |
| `slotIntervalMinutes`    | 5, 10, 15, 20, 30 or 60 (default 30)                                                                                        | Spacing of the start times offered in slot booking.                                                                                                                                                                  |
| `waitlistEnabled`        | Boolean, default true                                                                                                       | Members can join a waitlist for a full session or time slot.                                                                                                                                                         |
| `linkedCategoryIds`      | Up to 50 other categories of the club                                                                                       | Staff can move reservations to these categories, and members are offered them when a time slot is full. Links are one-way.                                                                                           |

`ReservationCategoryInput` accepts the same fields. On update, omitting `openingHours` or
`linkedCategoryIds` keeps them; any list replaces them. The other fields fall back to their defaults
when omitted. Out-of-range values, invalid windows, unknown ID formats and invalid slot booking
combinations receive `BAD_USER_INPUT`; linking the category to itself or to another club returns
`error: "Choose other reservation categories of this club."`.

Refusals returned by `createReservation`, `updateMyReservation` and
[`createExternalReservation`](/en/graphql/external-reservations):

* `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 [#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.

```graphql
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 [#time-slot-waitlist]

When a slot is full, members call `joinReservationWaitlist` without `resourceId`, with `startsAt` and
`guests` instead of a session occurrence:

```json
{
  "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:

```graphql
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 [#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]

**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 [#request-a-reservation]

```graphql
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):

```json
{
  "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](#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 [#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.

```graphql
query QuoteChange($input: UpdateMyReservationInput!) {
  myReservationChangeQuote(input: $input) {
    quote {
      previousAmount
      amount
      difference
      settledInCredits
    }
    error
  }
}
```

### Cancellation notice [#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 [#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:

```graphql
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](/en/graphql/credits)
for top-ups and balances. New cash Checkout requests are rejected.

## Staff booking and editing [#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](#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](#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 [#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](#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](/en/graphql/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.

```graphql
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 [#early-check-out]

```graphql
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](#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](#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 [#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.

```graphql
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](/en/graphql/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.

```graphql
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 [#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.

```graphql
query ReservationHistory($clubId: ID!, $id: ID!, $page: Int! = 1) {
  reservationHistory(clubId: $clubId, id: $id, page: $page) {
    page
    hasMore
    items {
      id
      occurredAt
      action
      actor
      actorName
      note
      changes {
        field
        before
        after
      }
    }
  }
}
```

`action` is one of:

| Action               | Recorded when                                         | `changes` fields                                                                                   |
| -------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `created`            | A member or staff creates the reservation             | none                                                                                               |
| `updated`            | Status, resource, dates, check-in/out or price change | `status`, `resource`, `startsAt`, `endsAt`, `checkInAt`, `checkOutAt`, `amount`, `refundForfeited` |
| `time_proposed`      | Staff propose another time                            | `proposedStartsAt`, `proposedEndsAt`, `proposedFree`, `proposedAmount`, `amount`                   |
| `proposal_declined`  | The member declines the proposed time                 | proposal fields cleared                                                                            |
| `proposal_withdrawn` | Staff withdraw the proposed time                      | proposal fields cleared                                                                            |
| `attendance_marked`  | Attendance changes                                    | `attendance`                                                                                       |
| `checked_out`        | Staff record an early check-out                       | `checkedOutAt`                                                                                     |
| `check_out_undone`   | Staff undo an early check-out                         | `checkedOutAt` (cleared)                                                                           |
| `deposit_refunded`   | The deposit is refunded, fully or partly              | `depositRefundedAmount`                                                                            |
| `invitee_added`      | Someone is invited to the reservation                 | `invitee` (email in `after`)                                                                       |
| `invitee_removed`    | An invitation is removed                              | `invitee` (email in `before`)                                                                      |

`before` and `after` are raw stored values: status and attendance enum values, ISO timestamps,
and `amount`/`proposedAmount` in the currency's minor unit. `refundForfeited` changes from
`false` to `true` when a late cancellation keeps the price. Only changed fields are listed. Updates are recorded
from every source, in the same transaction as the change: staff edits, member edits and
cancellations, accepted proposals, credit and card payments, refunds, product consumption,
session cancellations and session moves. Writes that change none of the listed fields add no
event.

`actor` is `staff`, `member` or `system` (payment webhooks and other automatic changes).
`actorName` is the staff member's current name, or the reservation holder's name when they acted
as a member; it is null for system events. `note` is the optional attendance note. Chat messages
and per-staff conversation archiving are not part of the timeline.

The back office shows this timeline in a **History** card on the reservation page and in the
calendar drawer. It refreshes every 30 seconds, on tab focus, and after staff edits, attendance
marks and invitation changes.

## Conversation API [#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.

```graphql
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:

```graphql
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 [#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 [#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.

```graphql
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.

```graphql
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 [#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 &#x2A;*+** 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:

```json
{
  "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 [#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:

```graphql
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 [#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:

```graphql
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.

```graphql
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
  }
}
```

```graphql
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 [#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 [#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](#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.

```graphql
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.

```graphql
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 [#resource-state]

The drawer's **Consumption** section reports consumed products: the resource's tracked
[resource products](#resource-products-and-reservation-quantities) replace the former minibar
inventory, and consumption is billed to a chosen stay with
[`reportProductConsumption`](#reporting-product-consumption-from-housekeeping).

```graphql
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 [#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`.

```graphql
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.

```graphql
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 [#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.

```graphql
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 [#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](/en/graphql/notifications) 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.

```graphql
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 [#session-cancellation-and-member-notifications]

See [Member notifications](/en/graphql/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 [#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 [#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 [#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 [#club-credits]

See [Club credits](/en/graphql/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 [#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](#departure-and-deposit-refunds)). For [external reservations](/en/graphql/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](#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 [#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.

```graphql
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](#session-guest-lists); 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 [#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 [#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.

```graphql
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 [#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.

```graphql
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 [#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](#number-of-people)).

```graphql
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:

```graphql
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 [#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.

```graphql
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 [#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`.

```graphql
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 [#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](#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 [#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.

```graphql
query ReservationPayment($clubId: ID!, $id: ID!) {
  myReservation(clubId: $clubId, id: $id) {
    creditPayment {
      balance
      creditName
      refundableAmount
      refundedAmount
    }
  }
}
```

### Retrying a member reservation request [#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 [#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.

```graphql
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 [#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.

```graphql
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 [#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 [#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:

```json
{
  "expected_version": 3,
  "operations": [{ "op": "move_layer", "id": "existing-instance-id", "direction": "back" }]
}
```
