Champagne

People selection

Search and filter people, understand sorting and pagination, and retrieve person details.

Use peoplePage to select people from the directory. Filters are applied before counting and pagination, and all supplied filters must match. The endpoint is /api/graphql; see the API overview for transport and development tooling.

Edit personal information

The back-office person profile provides Edit information, Save changes, and Cancel. Cancel discards the draft without sending a mutation. A successful save updates cached profiles and invalidates people lists so names, search, age/gender filters, totals, and sorting reflect the saved values. Failed saves keep the draft for correction and retry. Members can edit their own details from the consumer account (see Members edit their own profile).

Send updatePerson(input: UpdatePersonInput!): PersonResult! by POST:

mutation EditPerson($input: UpdatePersonInput!) {
  updatePerson(input: $input) {
    person {
      id
      firstName
      lastName
      email
      type
      siret
      tva
      phoneNumber
      birthDate
      gender
      address {
        line1
        line2
        city
        postalCode
        state
        country
      }
    }
    error
  }
}

Example variables (use an existing person and their actual club ID):

{
  "input": {
    "id": "00000000-0000-4000-8000-000000000001",
    "clubId": "51000000-0000-4000-8000-000000000051",
    "firstName": "Alex",
    "lastName": "Morgan",
    "email": "alex@example.com",
    "phoneNumber": "+33 1 23 45 67 89",
    "birthDate": "1990-06-15",
    "gender": null,
    "address": {
      "line1": "12 rue de la Démonstration",
      "line2": null,
      "city": "Reims",
      "postalCode": "51100",
      "state": null,
      "country": "France"
    }
  }
}
InputValidation
id, clubIdRequired UUIDs; the update matches both IDs atomically.
firstName, lastNameRequired strings, 1-100 characters after trimming.
emailRequired, trimmed and lowercased, at most 254 characters; must have a non-whitespace local part and domain with a dot. The same email can belong to different clubs; duplicates within one club are rejected when creating or editing a person, including case-only differences.
phoneNumberOptional string, at most 50 characters after trimming.
birthDateOptional real calendar date in YYYY-MM-DD, year 0001 or later, no later than today in UTC.
genderOptional female, male, nonbinary, or other; blank, omitted, or null clears it.
addressOptional AddressInput object (line1, line2, city, postalCode, state, country). line1, city, postalCode, and country are required together; blank, omitted, or null clears it. Fields are Unicode-normalized (NFKC) with collapsed whitespace; limits: line1/line2 200, city/state/country 100, postalCode 20 characters.

The input also accepts optional legal fields:

InputValidation
typeindividual or entreprise; omitted or null preserves the stored type.
siretOmitted preserves it. Supplied values are trimmed and must contain exactly 14 digits. Blank or null clears it only for an individual. An enterprise requires a valid supplied or stored SIRET.
tvaVAT identification number, at most 32 characters after trimming. Omitted preserves it; blank or null clears it.

This is a replacement of these personal fields, not a partial patch: omitted, null, or blank optional fields are cleared to null. Avatar, club assignment, memberships, and additional fields are not changed.

On success person contains the complete PersonDetail (the same selectable fields as the person query) and error is null. A missing person or wrong club returns person: null with an unavailable error. An email conflict returns person: null with a correction message, without changing the record. Invalid input produces a GraphQL BAD_USER_INPUT error before writing; unexpected failures are masked. Concurrent valid saves use last-write-wins semantics. Person management is staff-only and scoped to the administrator’s clubs (see Authentication).

The Legal status card above Memberships offers Edit legal status to staff with members_manage. Select Individual or Company; a company requires a 14-digit SIRET and may have a TVA number. Save updates the profile and refreshes the people list, including its type filter. Cancel discards the draft without writing. Switching to Individual hides and preserves identifiers so switching back restores them.

Use the same updatePerson mutation and include the current personal fields with, for example, "type": "entreprise", "siret": "01234567890123", "tva": "FR12012345678". Legal fields are partial updates, unlike the personal fields. The database checks the effective type and SIRET under a row lock, so omitting a SIRET cannot bypass the requirement for an enterprise. A missing effective SIRET returns person: null and error: "SIRET must contain exactly 14 digits." without changing any fields. Malformed supplied identifiers return BAD_USER_INPUT before writing. Photo-only updates and member self-service preserve legal status without requiring these fields.

PersonDetail.application is the nullable summary of the application that created the person. It is populated only when a submitted application is accepted; people created by other flows return null. The back office links this summary to the full application review.

Members edit their own profile

A signed-in member edits their own details with updateMyProfile(input: UpdateMyProfileInput!): PersonResult!. The input contains clubId (the member's session club) plus firstName, lastName, phoneNumber, birthDate, gender and address, validated exactly like updatePerson. The person ID always comes from the session. Email, photo, status and memberships stay managed by staff. Staff sessions and other clubs are refused with UNAUTHENTICATED. Changes are recorded in the activity log as person.updated.

mutation UpdateMyProfile($input: UpdateMyProfileInput!) {
  updateMyProfile(input: $input) {
    person {
      id
      firstName
      lastName
      phoneNumber
    }
    error
  }
}

Manage member status

Every person starts with status: active. The back-office profile can suspend a member between required start and end dates, block a member's bookings (no_booking) between required start and end dates, ban a member indefinitely with a required reason, or disable a member. Suspensions and booking blocks may include an optional reason. Suspensions, booking blocks and bans can be ended early; disabled members can be re-enabled. These actions return the member to active and clear the stored reason and dates. Billing state belongs to each membership and never changes a person's access status.

A no_booking member keeps club access (building entry, credits, add-ons, guests and sponsorship) but cannot create reservations or join waitlists: member reservation requests return "Your bookings are currently blocked.", and waitlisted entries are not promoted. Staff can still book for them with createStaffReservation; the back-office booking form shows a warning for any member who cannot book for themselves. Clubs can also suspend, block bookings or ban members automatically after repeated no-shows; see No-show sanctions.

Send updatePersonStatus(input: UpdatePersonStatusInput!): PersonResult! by POST:

mutation SuspendMember($input: UpdatePersonStatusInput!) {
  updatePersonStatus(input: $input) {
    person {
      id
      status
      statusReason
      statusStartsAt
      statusEndsAt
    }
    error
  }
}
{
  "input": {
    "id": "00000000-0000-4000-8000-000000000001",
    "clubId": "51000000-0000-4000-8000-000000000051",
    "status": "suspended",
    "reason": "Repeated no-shows",
    "startsAt": "2026-10-01",
    "endsAt": "2026-10-15"
  }
}

id and clubId are required UUIDs and both must match the stored person. For suspended and no_booking, startsAt and endsAt are required real YYYY-MM-DD dates. Neither can be before the current UTC date, and the end date cannot precede the start date. The back office defaults startsAt to today. reason is optional and limited to 2,000 characters. Before a future start date and after the end date, the effective status is active. For banned, a non-blank reason is required and both dates must be omitted. For active and disabled, the reason and both dates must be omitted or null. Unexpected failures are masked, and invalid combinations return BAD_USER_INPUT.

Membership records expose clubId (nullable for an unscoped membership). Member clients must match it to their club or accept null, require a subscription plan (or no plan), and compare expirationDate inclusively with the current UTC date before offering a new reservation. An existing Stripe price does not prove current membership validity.

Manage membership plans

The back-office profile's Memberships card lists every membership with its plan name, subscription/add-on type, price, expiration date, and payment status, plus a Manage plans action for full control. Plan catalog data loads only when the dialog opens. Paying for a plan is deliberately not part of this flow: members subscribe and pay through the consumer app, which owns offer selection, Stripe Checkout, and webhook confirmation (see Sandbox membership billing). The back office edits the membership record itself: complimentary grants, corrections, and removals.

Send assignMembership(input: AssignMembershipInput!): PersonResult! by POST to grant a plan (complimentary access, correction, or onboarding fix):

mutation GrantMembership($input: AssignMembershipInput!) {
  assignMembership(input: $input) {
    person {
      id
      memberships {
        id
        plan {
          name
          type
        }
        expirationDate
      }
    }
    error
  }
}
{
  "input": {
    "personId": "00000000-0000-4000-8000-000000000001",
    "clubId": "51000000-0000-4000-8000-000000000051",
    "planId": "52000000-0000-4000-8000-000000000052",
    "expirationDate": "2027-09-30"
  }
}
InputValidation
personId, clubId, planIdRequired UUIDs; the person must belong to the club and the plan must be active and offered by the club's merchant catalogue, otherwise error is This plan is not offered by this club.
expirationDateOptional real calendar date in YYYY-MM-DD; omit or pass null for no expiry (typical for add-ons).

The grant creates a manual membership with no Stripe linkage (stripePriceId: null), or updates the expiry when the same person already holds that plan manually. It never touches Stripe subscriptions and never collects payment: members who must pay do so through the consumer app.

Send revokeMembership(input: RevokeMembershipInput!): PersonResult! by POST to remove a membership after confirming in the dialog:

mutation RevokeMembership($input: RevokeMembershipInput!) {
  revokeMembership(input: $input) {
    person {
      id
      memberships {
        id
      }
    }
    error
  }
}

membershipId, personId, and clubId are required UUIDs; the membership must belong to the person, and a club-scoped membership must match the club. Removing a Stripe-paid subscription row does not cancel the Stripe subscription itself, so a later invoice.paid webhook may project it again; removing a manual membership is final. Both mutations return the complete PersonDetail and refresh cached profiles and people lists like other person edits. A missing person, wrong club, or unknown plan/membership returns person: null with an unavailable error; malformed IDs or dates produce BAD_USER_INPUT.

Select people

Edit the example with the pencil, expand Variables to enter a JSON object, then select Run query. Without variables, this query returns the first ten people across all clubs and membership states.

query SelectPeople(
  $sort: PeopleSortField
  $direction: SortDirection
  $page: Int! = 1
  $pageSize: Int! = 10
  $search: String
  $clubId: ID
  $minAge: Int
  $isMember: Boolean
  $maxAge: Int
  $gender: PeopleGender
  $type: PersonType
  $status: PersonStatus
  $attributes: [PersonAttributeFilter!]
) {
  peoplePage(
    sort: $sort
    direction: $direction
    page: $page
    pageSize: $pageSize
    search: $search
    clubId: $clubId
    minAge: $minAge
    isMember: $isMember
    maxAge: $maxAge
    gender: $gender
    type: $type
    status: $status
    attributes: $attributes
  ) {
    items {
      id
      firstName
      lastName
      email
      status
    }
    total
    page
    pageSize
  }
}

For example, these variables select up to 25 people whose searchable text contains alex:

{
  "page": 1,
  "pageSize": 25,
  "search": "alex"
}

Arguments

ArgumentTypeDefaultBehavior
pageInt!1One-based page number, from 1 through 1,000,000.
pageSizeInt!10Must be 10, 25, or 50.
searchStringNoneCase- and accent-insensitive substring search over first name, last name, and email. Maximum 200 characters.
clubIdIDNoneRestricts results to people whose club ID equals this UUID.

sort: PeopleSortField accepts name (default), email, or status. direction: SortDirection accepts asc (default) or desc. Omit either argument or pass null to use its default. Unsupported enum values are rejected by GraphQL. Sorting applies to all matching people before pagination.

Omit an optional filter or pass null to disable it. Omit page and pageSize to use their defaults; explicitly passing null is invalid for these non-null arguments.

ArgumentTypeDefaultBehavior
minAgeIntNoneInclusive minimum completed years, nonnegative.
maxAgeIntNoneInclusive maximum completed years, at least minAge when both are supplied.
genderPeopleGenderNoneExact stored value: female, male, nonbinary, other, or unknown (null only).
statusPersonStatusNoneEffective status: active, suspended, no_booking, banned, disabled.

attributes: [PersonAttributeFilter!] filters on the club's typed member attributes; see Member attributes. tagIds: [ID!] keeps people carrying at least one of the club's tags and untagged: true keeps only people without any tag; see Member tags. All three require clubId.

Person type

The optional type: PersonType filter accepts individual or entreprise. Omit it or pass null for both types; unsupported values are rejected by GraphQL. For example, {"type": "entreprise"} selects enterprises. It combines with all other filters before counting and pagination, without changing sort order. Existing records and new rows without an explicit type default to individual. Both Person and PersonDetail expose a non-null type: PersonType! field. Both also expose activated: Boolean!, true once the person has a password (see account activation). PersonDetail also exposes nullable siret: String and tva: String identifiers. These fields are text, not numeric values; leading zeroes are preserved. The back-office Legal status card displays them only for entreprise records, showing "Not specified" for missing values. Editing an enterprise requires a 14-digit SIRET; TVA stays optional and has no country-specific format validation.

Age and gender

Ages use today's UTC date. Either age bound excludes missing or future birthdates. Bounds may be supplied independently; zero and equal bounds are valid. Negative or reversed bounds produce BAD_USER_INPUT; GraphQL rejects fractional values, strings, values outside its 32-bit signed integer range, and unsupported genders. For example, variables {"minAge": 18, "maxAge": 65, "gender": "female"} select women aged 18 through 65. These filters combine with club and text search before counting and pagination.

The optional isMember: Boolean argument defaults to no membership restriction. Pass true to select people with at least one linked membership, or false to select people with none. Omit it or pass null to include both groups. For example, {"isMember": true, "minAge": 18} selects adults with a linked membership. Expired memberships and memberships without an expiration date or Stripe Price ID still count: this checks record existence, not active or paid status. Multiple linked memberships never duplicate a person or inflate the total. This filter combines with the other filters before counting and pagination.

Account status

The optional status: PersonStatus filter selects a person's effective account status. An elapsed suspension is returned and filtered as active, even if the stored suspension has not yet been cleared. A future suspension is also active until its start date. Payment state does not affect this filter. Status combines with every other filter before counting and pagination.

Filters

The API trims leading and trailing whitespace, then searches the concatenation of first name, last name, and email, separated by spaces. Matching is case- and accent-insensitive (lea finds Léa, LÉA finds Lea; œ, æ, ß and ø match oe, ae, ss and o, as in name sorting) and does not require a prefix match. An empty or whitespace-only search applies no text filter.

The text is one substring, not a set of independently matched words. For example, Alex Morgan can match a full name. %, _, and backslash are escaped and treated as literal characters, not SQL wildcard syntax. The 200-character limit is checked before trimming.

Club

Pass a club UUID from clubDirectory as clubId. Without this filter, peoplePage uses the administrator’s first authorized club. A valid but nonexistent club UUID returns an empty selection; a malformed UUID produces a validation error.

Every person operation enforces ownership or administrator club access (see Authentication).

Sort order

By default, results use ascending database order by:

  1. firstName
  2. lastName
  3. id, to break ties between identical names

Set sort to email or status to sort by that column instead, and set direction to desc for descending order. Name sorting uses first name then last name in the requested direction; every order ends with ID as a stable tie-breaker. Text comparisons ignore case, accents, and equivalent composed Unicode characters; equivalent text is resolved by ID. The unpaginated people query retains its fixed ascending name order.

Pagination and return fields

peoplePage returns a non-null PeoplePage object:

FieldTypeMeaning
items[Person!]!The current page; each person has non-null id, type, firstName, lastName, email, and effective status, plus nullable guestExpiresAt and externalAccount (true for temporary accounts from external reservations), and non-null activated (false while a staff-created account awaits activation).
totalInt!Number of people matching all filters, before pagination.
pageInt!The actual page returned, which can be lower than the requested page.
pageSizeInt!The requested, validated page size.

The last page is max(1, ceil(total / pageSize)). A valid request beyond the last page is clamped to that page. If nothing matches, the response has items: [], total: 0, and page: 1. The final page can contain fewer than pageSize items.

Use the returned page to update pagination controls. Reset the requested page to 1 when changing filters or page size to start at the beginning of the new selection.

The count and items are read in one read-only, repeatable-read transaction, so they share a consistent database snapshot within a request. Pagination uses offsets, not cursors; changes between requests can move people between pages.

Bounded people list

people { id firstName lastName email status } requires staff authentication and returns at most 100 people from clubs where the caller has members_read. The club scope is applied before ordering by first name, last name and ID, and before the 100-row limit. People without a club are excluded. This query has no pagination metadata; use peoplePage for a paginated list within one club.

Find clubs for the filter

query FindClubs($search: String, $selectedId: ID) {
  clubDirectory(search: $search, selectedId: $selectedId) {
    items {
      id
      name
      address {
        line1
        line2
        city
        postalCode
        state
        country
      }
    }
    selected {
      id
      name
    }
    hasMore
  }
}
  • search optionally matches a case-insensitive substring across the club name and the address line1, city and postalCode fields, with the same trimming, literal wildcard handling, and 200-character limit as people search. line2, state and country are not searched.
  • address is a nullable Address object, the same type as person and application addresses.
  • Anonymous and member callers use the public club directory. Staff callers see only their authorized clubs; this scope is applied before the limit and also to selected.
  • items contains at most 100 clubs, ordered by name and then ID ascending.
  • hasMore is true when more than 100 clubs match within that scope. Narrow the search; there is no page or cursor argument.
  • selectedId optionally looks up one club independently of the search and 100-item limit, so a selector can retain its current label.
  • selected is null when no ID is supplied or the club does not exist or is outside the staff caller’s scope. A malformed selectedId UUID is rejected.

Update a club address

mutation UpdateClubAddress($clubId: ID!, $address: AddressInput) {
  updateClubAddress(clubId: $clubId, address: $address) {
    id
    address {
      line1
      line2
      city
      postalCode
      state
      country
    }
  }
}
  • updateClubAddress(clubId: ID!, address: AddressInput): Club requires the settings_manage permission in the requested club; other callers receive UNAUTHENTICATED.
  • address uses the same AddressInput and validation as person addresses: values are trimmed and whitespace-collapsed, blank fields become null, and line1, city, postalCode and country are required once any field is filled. Limits are 200 characters for line1 and line2, 100 for city, state and country, and 20 for postalCode. Invalid input returns BAD_USER_INPUT.
  • Passing null or an all-blank address clears it.
  • The mutation returns the updated club. A malformed clubId or a club that does not exist returns BAD_USER_INPUT. Changes are recorded in the activity log as club.address_updated with the formatted before and after addresses.

Retrieve a selected person's details

Pass an ID from peoplePage.items to person. Replace the example ID with a person from your selection when using a different dataset.

query SelectedPerson {
  person(id: "00000000-0000-4000-8000-000000000001") {
    id
    type
    siret
    tva
    firstName
    lastName
    email
    avatar
    gender
    birthDate
    address {
      line1
      line2
      city
      postalCode
      state
      country
    }
    phoneNumber
    note
    status
    statusReason
    statusEndsAt
    club {
      id
      name
      avatar
      description
      address {
        line1
        city
        postalCode
        country
      }
      billingEnabled
    }
    memberships {
      id
      planId
      plan {
        id
        name
        type
      }
      expirationDate
      stripePriceId
      paymentStatus
    }
    attributes {
      key
      label
      type
      value
    }
  }
}

club.billingEnabled is false when the platform turned the club's billing module off; the member area then hides prices, credits and payments.

person(id: ID!, clubId: ID) returns PersonDetail or null if the person does not exist. When clubId is supplied, it also returns null if the person does not belong to that club. Both IDs must be valid UUIDs.

The basic identity fields and status are non-null. Profile fields (avatar, gender, birthDate, phoneNumber, and note) and club are nullable; address is a nullable Address object with line1, line2, city, postalCode, state, and country fields. club.address uses the same nullable Address type. memberships, attributes and tags are non-null lists with non-null entries, and may be empty; tags is always empty for member sessions. New memberships have a planId and expose the matching nullable plan; the nullable values preserve historical records created before the plan catalog existed. The plan's type distinguishes subscriptions from add-ons. Membership expiration dates and the selected Stripe price ID (stripePriceId) are nullable strings. A non-null price identifies a Stripe-backed membership, and multiple memberships may share a price ID. paymentStatus is paid when a subscription membership's expiration date is today or later, and unpaid once that date is in the past. It is null for add-ons, legacy memberships without a catalog plan, and memberships without an expiration date. This billing state is independent from the person's status: a banned member can remain unpaid, and a paid renewal does not lift the ban. Extra-field keys and values are non-null strings. Date fields are exposed as GraphQL strings, not a dedicated date scalar. Query the global plans catalog for the full club catalog and Stripe product IDs.

statusReason is nullable and present only for a scheduled/current suspension, booking block or ban. statusStartsAt and statusEndsAt are nullable and present only for a scheduled or current suspension or booking block. On PersonDetail, scheduledStatus names a dated sanction (suspended or no_booking) that starts later while status is still active; it is null otherwise.

guestExpiresAt is the derived ISO expiry of the latest guest access grant and null for regular people. A past value marks a former guest. guestAccess exposes that latest grant's start, expiry, optional revocation and structured inviter. The inviter kind remains available if the inviter account is deleted, while its ID and current display name become null. Kind external marks a temporary account created by an external reservation; it has no inviter and the person is deleted once the access ends.

Sponsors and invited guests

personRelations(personId: ID!, clubId: ID!): PersonRelations! requires the members_read permission and returns:

  • sponsors: members who sponsored this person, recorded when their application was accepted;
  • sponsoredPeople: people this person sponsored;
  • invitedGuests: the 50 most recent guest accesses granted by this person, newest first, each with the access id, the guest's personId and name, startsAt, expiresAt and nullable revokedAt.

Sponsors and sponsored people contain id, name and email; only people of the requested club are listed. The back-office profile links every entry to its person page, and a guest's Invited by links to the inviting member.

query PersonRelations($personId: ID!, $clubId: ID!) {
  personRelations(personId: $personId, clubId: $clubId) {
    sponsors {
      id
      name
    }
    sponsoredPeople {
      id
      name
    }
    invitedGuests {
      personId
      name
      expiresAt
    }
  }
}

Determine whether a person has a membership by checking whether memberships contains any records. Expired memberships still count as records; existence does not imply an active or paid membership. peoplePage(isMember: true) uses the same rule.

Edit a person's note

Send mutations over POST. updatePersonNote(id: ID!, clubId: ID, note: String!) updates only the note and returns the updated PersonDetail. Both IDs must be valid UUIDs. When supplied, clubId is enforced on the database update itself. A missing person or club mismatch returns null without changing any record. Omit clubId or pass null to update without club filtering.

mutation SavePersonNote($id: ID!, $clubId: ID, $note: String!) {
  updatePersonNote(id: $id, clubId: $clubId, note: $note) {
    id
    note
  }
}

note is plain text with a maximum length of 10,000 UTF-16 code units. Line breaks and whitespace are preserved. Pass an empty string to clear it (stored and returned as null); passing null as the mutation input is invalid. Oversized notes and null characters produce BAD_USER_INPUT. Notes are not included in directory search or sorting. Notes are available only to authorized club administrators; member reads return null.

Send a manual notification

sendPersonNotification lets a club administrator send a received-package, lost-object, or custom message to a member from their profile. See Member notifications.

Validation errors

The resolvers reject invalid arguments with GraphQL errors whose extensions.code is BAD_USER_INPUT:

ConditionMessage
Page outside 1 through 1,000,000, or page size not in 10, 25, 50Invalid pagination. Use a positive page and 10, 25 or 50 rows.
Search longer than 200 charactersSearch must not exceed 200 characters.
Malformed clubId or selectedIdInvalid club ID.
Malformed person idInvalid person ID.

GraphQL also validates argument types and required values before resolver execution. GraphQL rejects non-boolean isMember values, including the string "true". Check the response's errors array rather than treating every HTTP response as a successful selection. An empty items list or a null person without errors is a valid no-match result, not a validation failure.

Membership names and validity

person(id).memberships returns id, planId, planName, expirationDate, and stripePriceId. planName is the current name of the linked plan, including inactive plans; it is null when no plan is linked. expirationDate is a nullable YYYY-MM-DD date. A missing date does not imply a lifetime membership.

query MemberMemberships($id: ID!) {
  person(id: $id) {
    memberships {
      id
      planId
      planName
      expirationDate
      stripePriceId
    }
  }
}

This adds no arguments, filtering, sorting, or pagination changes.

Profile photos

People list and detail queries expose nullable avatar. To upload or replace a photo, pass avatar to updatePerson(input: ...) as a PNG or JPEG base64 data URL (maximum 512,000 decoded bytes). The API checks the format, file signature and size. Pass null to remove the photo; omit the field to preserve it when editing other details. The photo is stored on the person and is also displayed by linked applications.

The clickable avatar uses updatePersonPhoto(id: ID!, clubId: ID!, avatar: String). It returns PersonResult and updates only the photo, preserving all other personal details. It accepts the same image format/size rules; a null avatar removes the photo.

Membership history

The staff-only query membershipHistory(personId: ID!, clubId: ID!, page: Int! = 1) returns { items, page, hasMore }, or null when the person does not belong to the requested club. Each page contains at most 20 events, newest first, ordered by occurredAt and id. page must be between 1 and 100,000. New events can shift offset pages; refresh page 1 to see recent changes.

Items expose id, membershipId, action (granted, updated, renewed, removed), occurredAt, actorType (staff, member, system), nullable actorId, actorName, reason, and nullable before/after snapshots. Snapshots contain planId, planName, amount (minor units), currency, interval, expirationDate, paidPeriodStart and paidPeriodEnd. Dates are ISO strings; missing values are null.

AssignMembershipInput and RevokeMembershipInput accept an optional reason (trimmed, at most 2,000 characters, no NUL). The server supplies actor identity from the staff session. History is recorded atomically with the membership change and survives membership removal. Plan details are snapshotted when the event is written. Identical manual grants and ignored Stripe replays do not add events. Paid renewals and add-on fulfilment are attributed to the system. Existing seed data and changes made before this feature have no reconstructed history. Membership, status and attendance histories are views over the shared activity log described in Staff activity history.

The back-office person page displays this history and its pagination. Removing a membership still only removes local access; it does not cancel a Stripe subscription. Neither the consumer nor an administrator of another club can read this staff journal.

Email consistency

Staff profile edits update the profile email and an existing member login email in one transaction. A conflict in either table rolls back the entire edit. Photo-only edits do not change login details. The new email is used for subsequent sign-ins and password recovery; a reset link issued to a different email cannot change the password after the update. Existing sessions and passwords are preserved. Staff should verify the replacement address before saving; this operation does not send a new email-verification challenge. An accepted applicant choosing their first password uses the current profile email. Likewise, an activation link sent before a staff email change cannot activate the account; resendPersonActivation sends a new link to the current address.

Both profile and member-credential tables enforce per-club uniqueness on trimmed, case-insensitive emails, including writes outside the API. Different clubs may reuse an email. Schema installation fails if existing rows conflict; it does not merge or delete people automatically.

Membership validity filter

peoplePage(membershipValidity: current | expired) supplements the legacy isMember filter. current means at least one subscription with an expiry on or after today (UTC), or no expiry. expired means at least one subscription and no current subscription. Add-ons alone do not satisfy either filter. A person with both an expired and a current subscription appears only under current. These filters describe membership dates, not payment settlement or account access status. A suspended person can still have a current subscription. If supplied together with isMember, the filters combine with AND.

The back-office Membership selector offers Valid membership, Expired memberships only, Any membership record, and No membership records. The last two retain the isMember semantics, including add-ons. The URL stores membershipValidity for the date filters.

Status history

personStatusHistory(personId: ID!, clubId: ID!, page: Int! = 1) is staff-only and club-scoped. It returns null for an unavailable person, otherwise { items, page, hasMore } with 20 entries per page, newest first. Each entry contains id, occurredAt, actorId, actorName, previous and current. Both snapshots contain status, reason, startsAt and endsAt. Actor identity comes from the authenticated staff session, not mutation input.

Changes to status, reason or suspension dates are recorded atomically with the profile update. Identical repeated writes do not add events. Reactivation clears the current profile reason and dates but preserves them in history. Scheduled suspensions retain their configured dates: the automatic effective-status change at a date boundary is not a new staff action and does not produce a separate event. Earlier actions are not reconstructed. The visible profile reloads this history every 30 seconds and on tab focus; successful status edits refresh it automatically. This journal does not change billing behavior.

Profile history

personProfileHistory(personId: ID!, clubId: ID!, page: Int! = 1) is staff-only and club-scoped, and requires members_read in the person's club. It returns null for an unavailable person, otherwise an ActivityPage { items, page, hasMore } with 20 entries per page, newest first.

query PersonProfileHistory($personId: ID!, $clubId: ID!, $page: Int!) {
  personProfileHistory(personId: $personId, clubId: $clubId, page: $page) {
    page
    hasMore
    items {
      id
      occurredAt
      action
      actorName
      changes {
        field
        before
        after
      }
    }
  }
}

Each entry is one recorded edit of the person. action is one of person.created, person.updated (personal details, legal status or additional information), person.photo_updated, person.note_updated and person.tags_updated. changes lists each modified field with its value before and after; it is empty for creation, photo and note entries, and the note text is never included. actorName is the staff member who made the edit, or null for an automatic change. Status and membership changes are not repeated here: use personStatusHistory and membershipHistory. Edits that change nothing add no entry.

Signed documents

The back-office profile lists completed document-signature steps from the person's linked application, including both initial questions and post-approval steps. The page automatically refreshes this section every 30 seconds and on tab focus. Staff with members_read in the person's club (club administrators included; see staff permissions) may list and download documents; anonymous, applicant and member sessions, and staff without that permission, cannot use these operations.

query SignedDocuments($personId: ID!, $clubId: ID!) {
  personSignedDocuments(personId: $personId, clubId: $clubId) {
    configured
    items {
      id
      name
      signedAt
    }
  }
}

query SignedPdf($personId: ID!, $clubId: ID!, $id: ID!) {
  downloadPersonSignedDocument(personId: $personId, clubId: $clubId, id: $id) {
    filename
    pdf
    error
  }
}

personId and clubId must be UUIDs. id is the opaque document ID returned by the list (1–256 characters). Listing matches both the person's and application's club. It returns an empty list when no linked signatures match. Items use the application's document-name snapshot and the server-recorded completion date, sorted newest first, with no filters or pagination (bounded by onboarding steps). Existing server-confirmed Yousign answers remain readable; new completions also persist the provider request ID separately from the answer's display text.

configured indicates whether Yousign credentials are available. Listing does not contact Yousign and remains available without credentials. On download the server verifies that the provider request belongs to this club, application and step and has reached done. It requests version=completed, never the unsigned original, and accepts only a PDF up to 20 MiB. The provider timeout is 25 seconds. Successful responses contain the base64 PDF and a sanitized *-signed.pdf filename; errors return null PDF/filename and a safe retry message. Credentials and provider IDs are not returned. Ownership is checked again after the provider responds.

PDFs are retrieved on demand from Yousign; this is not a separate document archive. A removed provider document or unfinished request remains unavailable for download. Testing a real download requires a configured Yousign account and a finalized signature request; fixtures do not prove provider access.

Club credits

See Club credits for credit packs, balances, refill history, credit-priced add-ons and reservations, and cancellation refunds. Membership subscriptions stay billed in money.

On this page