# People selection

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

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

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](/en/graphql) for transport and development tooling.

## Edit personal information [#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](#members-edit-their-own-profile)).

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

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

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

| Input                   | Validation                                                                                                                                                                                                                                                                                                                                                 |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`, `clubId`          | Required UUIDs; the update matches both IDs atomically.                                                                                                                                                                                                                                                                                                    |
| `firstName`, `lastName` | Required strings, 1-100 characters after trimming.                                                                                                                                                                                                                                                                                                         |
| `email`                 | Required, 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.                                                                           |
| `phoneNumber`           | Optional string, at most 50 characters after trimming.                                                                                                                                                                                                                                                                                                     |
| `birthDate`             | Optional real calendar date in `YYYY-MM-DD`, year 0001 or later, no later than today in UTC.                                                                                                                                                                                                                                                               |
| `gender`                | Optional `female`, `male`, `nonbinary`, or `other`; blank, omitted, or null clears it.                                                                                                                                                                                                                                                                     |
| `address`               | Optional `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:

| Input   | Validation                                                                                                                                                                                     |
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`  | `individual` or `entreprise`; omitted or null preserves the stored type.                                                                                                                       |
| `siret` | Omitted 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. |
| `tva`   | VAT 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](/en/graphql/auth)).

### Edit legal status [#edit-legal-status]

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

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

## Manage member status [#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](/en/graphql/reservations#no-show-sanctions).

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

```graphql
mutation SuspendMember($input: UpdatePersonStatusInput!) {
  updatePersonStatus(input: $input) {
    person {
      id
      status
      statusReason
      statusStartsAt
      statusEndsAt
    }
    error
  }
}
```

```json
{
  "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 [#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](/en/graphql/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):

```graphql
mutation GrantMembership($input: AssignMembershipInput!) {
  assignMembership(input: $input) {
    person {
      id
      memberships {
        id
        plan {
          name
          type
        }
        expirationDate
      }
    }
    error
  }
}
```

```json
{
  "input": {
    "personId": "00000000-0000-4000-8000-000000000001",
    "clubId": "51000000-0000-4000-8000-000000000051",
    "planId": "52000000-0000-4000-8000-000000000052",
    "expirationDate": "2027-09-30"
  }
}
```

| Input                          | Validation                                                                                                                                                                                 |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `personId`, `clubId`, `planId` | Required 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.` |
| `expirationDate`               | Optional 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:

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

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

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

### Arguments [#arguments]

| Argument   | Type     | Default | Behavior                                                                                                     |
| ---------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------ |
| `page`     | `Int!`   | `1`     | One-based page number, from 1 through 1,000,000.                                                             |
| `pageSize` | `Int!`   | `10`    | Must be 10, 25, or 50.                                                                                       |
| `search`   | `String` | None    | Case- and accent-insensitive substring search over first name, last name, and email. Maximum 200 characters. |
| `clubId`   | `ID`     | None    | Restricts 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.

| Argument | Type           | Default | Behavior                                                                              |
| -------- | -------------- | ------- | ------------------------------------------------------------------------------------- |
| `minAge` | `Int`          | None    | Inclusive minimum completed years, nonnegative.                                       |
| `maxAge` | `Int`          | None    | Inclusive maximum completed years, at least `minAge` when both are supplied.          |
| `gender` | `PeopleGender` | None    | Exact stored value: `female`, `male`, `nonbinary`, `other`, or `unknown` (null only). |
| `status` | `PersonStatus` | None    | Effective status: `active`, `suspended`, `no_booking`, `banned`, `disabled`.          |

`attributes: [PersonAttributeFilter!]` filters on the club's typed member attributes; see
[Member attributes](/en/graphql/person-attributes#filter-people). `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](/en/graphql/person-tags#read-tags-on-people). All three require `clubId`.

### Person type [#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](/en/graphql/auth#activate-a-staff-created-account)).
`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 [#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.

### Membership links [#membership-links]

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 [#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 [#filters]

### Text search [#text-search]

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

## Sort order [#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 [#pagination-and-return-fields]

`peoplePage` returns a non-null `PeoplePage` object:

| Field      | Type         | Meaning                                                                                                                                                                                                                                                                                                                                                                                                 |
| ---------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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](/en/graphql/external-reservations)), and non-null `activated` (false while a staff-created account awaits [activation](/en/graphql/auth#activate-a-staff-created-account)). |
| `total`    | `Int!`       | Number of people matching all filters, before pagination.                                                                                                                                                                                                                                                                                                                                               |
| `page`     | `Int!`       | The actual page returned, which can be lower than the requested page.                                                                                                                                                                                                                                                                                                                                   |
| `pageSize` | `Int!`       | 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 [#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 [#find-clubs-for-the-filter]

```graphql
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 [#update-a-club-address]

```graphql
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 [#retrieve-a-selected-persons-details]

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

```graphql
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](/en/graphql/plans)
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](/en/graphql/guests) 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](/en/graphql/external-reservations); it has no inviter and the person is
deleted once the access ends.

### Sponsors and invited guests [#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.

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

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

## Validation errors [#validation-errors]

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

| Condition                                                        | Message                                                          |
| ---------------------------------------------------------------- | ---------------------------------------------------------------- |
| Page outside 1 through 1,000,000, or page size not in 10, 25, 50 | `Invalid pagination. Use a positive page and 10, 25 or 50 rows.` |
| Search longer than 200 characters                                | `Search must not exceed 200 characters.`                         |
| Malformed `clubId` or `selectedId`                               | `Invalid club ID.`                                               |
| Malformed person `id`                                            | `Invalid 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 [#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.

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

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

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

```graphql
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 [#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](/en/graphql/auth#individual-staff-permissions)) may list and download documents;
anonymous, applicant and member sessions, and staff without that permission, cannot use
these operations.

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