# Member attributes

Club-defined typed person attributes, how onboarding answers save to them, and how staff edit and filter them.

Source: https://docs.champagne.dev-ukab.fr/fr/graphql/person-attributes

Each club defines its own list of member attributes. An attribute has a stable `key`, a
`label`, a fixed `type` and, for choices, a list of `options`. Values live on the person
record under the key, as typed JSON values. Onboarding questions can save their answer to
an attribute or to a built-in person field, so renaming a question or asking the same thing
in several onboardings never splits the data.

## List attribute definitions [#list-attribute-definitions]

```graphql
query PersonAttributes($clubId: ID! = "51000000-0000-4000-8000-000000000051") {
  personAttributes(clubId: $clubId) {
    id
    key
    label
    type
    options
  }
}
```

`personAttributes(clubId)` returns the club's definitions in creation order, then by label. Any staff
member with access to the club can read them; they are used by the settings page, the
onboarding editor, the people filters and the person editor.

| Type      | Stored JSON value          | Canonical text in the API  |
| --------- | -------------------------- | -------------------------- |
| `TEXT`    | String, up to 10,000 chars | The text                   |
| `NUMBER`  | Number                     | Plain decimal, e.g. `42.5` |
| `DATE`    | `YYYY-MM-DD` string        | `YYYY-MM-DD`               |
| `BOOLEAN` | `true` or `false`          | `true` or `false`          |
| `CHOICE`  | One of `options`           | The option                 |

Numbers accept up to 15 integer digits and 6 decimals; a decimal comma is accepted on
input and stored as a number.

## Manage definitions [#manage-definitions]

```graphql
mutation CreatePersonAttribute($input: CreatePersonAttributeInput!) {
  createPersonAttribute(input: $input) {
    attribute {
      id
      key
      label
      type
      options
    }
    error
  }
}
```

```json
{
  "input": {
    "clubId": "51000000-0000-4000-8000-000000000051",
    "key": "preferred_language",
    "label": "Preferred language",
    "type": "CHOICE",
    "options": ["French", "English"]
  }
}
```

Keys start with a lowercase letter and contain up to 40 lowercase letters, digits or
underscores; they are unique per club. Labels contain 1–100 trimmed characters.
`CHOICE` attributes need 2–50 distinct options of 1–100 characters; other types take no
options. A key already used in the club returns `error`.

`updatePersonAttribute(input: { clubId, id, label, options })` changes the label and, for
choices, the options. The key and type cannot change. Updated options are copied into every
onboarding question of the club that saves to this attribute. Existing person values are
kept even when their option is removed.

`deletePersonAttribute(clubId, id)` returns an `error` naming the onboardings that still
save to the attribute. Otherwise it deletes the definition and removes the value from every
person of the club.

These three mutations require the `settings_manage` permission and record
`club.attribute_created`, `club.attribute_updated` or `club.attribute_deleted` activity.

## Read and edit a person's values [#read-and-edit-a-persons-values]

`PersonDetail.attributes` lists every attribute defined by the person's club, in definition
order, with `key`, `label`, `type`, `options` and the person's `value` as canonical text, or
`null` when unset. People without a club have no attributes.

```graphql
mutation SetPersonAttributeValues($input: SetPersonAttributeValuesInput!) {
  setPersonAttributeValues(input: $input) {
    person {
      attributes {
        key
        value
      }
    }
    error
  }
}
```

```json
{
  "input": {
    "id": "00000000-0000-4000-8000-000000000084",
    "clubId": "51000000-0000-4000-8000-000000000051",
    "values": [
      { "key": "newsletter", "value": "false" },
      { "key": "profession", "value": null }
    ]
  }
}
```

Only the listed keys change; `null` or blank clears a value. Each value is validated against
the attribute's type and the first invalid one returns `error` such as
`Shoe size: Enter a number.` without saving anything. At most 100 values are accepted at
once and each key may appear once. The mutation requires `members_manage` and records a
`person.updated` activity whose changes are keyed by attribute label.

## Save onboarding answers [#save-onboarding-answers]

Short answer, long answer and options questions accept either `personField` or
`attributeKey` on `OnboardingQuestionInput` (never both). The target fixes the question type
and the applicant's input format, returned as `OnboardingQuestion.format`:

| Target              | Question type                 | `format`  | Options                                |
| ------------------- | ----------------------------- | --------- | -------------------------------------- |
| `PHONE_NUMBER`      | `SHORT_ANSWER`                | `PHONE`   | None                                   |
| `BIRTH_DATE`        | `SHORT_ANSWER`                | `DATE`    | None                                   |
| `GENDER`            | `OPTIONS`                     | `GENDER`  | `female`, `male`, `nonbinary`, `other` |
| `TEXT` attribute    | `SHORT_ANSWER`, `LONG_ANSWER` | `TEXT`    | None                                   |
| `NUMBER` attribute  | `SHORT_ANSWER`                | `NUMBER`  | None                                   |
| `DATE` attribute    | `SHORT_ANSWER`                | `DATE`    | None                                   |
| `BOOLEAN` attribute | `OPTIONS`                     | `BOOLEAN` | `true`, `false`                        |
| `CHOICE` attribute  | `OPTIONS`                     | `CHOICE`  | The attribute's options                |

For options targets the server replaces the submitted options with the target's. Two
questions of one onboarding cannot save to the same target, and an unknown key or an
incompatible question type returns `error` from `saveOnboarding`. Questions without a target
keep `format: TEXT` (`CHOICE` for options) and their answers stay on the application only.

Applicants' answers are validated by format when each step is saved: numbers, real
`YYYY-MM-DD` dates, phone numbers of 4–50 digits, spaces, `+`, `(`, `)`, `.` or `-`, and
birth dates not in the future. Numbers are stored in canonical form.

On acceptance, each answer is written to its target: `phoneNumber`, `birthDate` and `gender`
columns, or the attribute under its key as a typed value. Answers whose attribute was deleted
since the application started are skipped.

## Filter people [#filter-people]

`peoplePage(attributes: [PersonAttributeFilter!])` combines up to 20 filters with the
other people filters. Each filter names a `key` and:

* `TEXT`: `value` matches a case-insensitive substring;
* `BOOLEAN` and `CHOICE`: `value` matches exactly (`true`/`false` for booleans);
* `NUMBER` and `DATE`: inclusive `from` and `to` bounds, either optional;
* without a value or bound, the filter keeps people who have any value.

An unknown key matches nobody. Values and bounds are limited to 200 characters.

```json
{
  "clubId": "51000000-0000-4000-8000-000000000051",
  "attributes": [
    { "key": "preferred_language", "value": "French" },
    { "key": "newsletter", "value": "true" }
  ]
}
```
