---
title: "Profil utilisateur"
description: "Le champ sex exposé sur la ressource users n'est plus un entier brut mais un slug i18n stable :"
---

## Attribut `sex` (clé i18n)

Le champ `sex` exposé sur la ressource `users` n'est plus un entier brut mais un **slug i18n stable** :

| Slug API | Code interne (DB `user.sex`) | Clé i18n complète       |
|----------|------------------------------|-------------------------|
| `null`   | `NULL` (non renseigné)       | _(omis)_                |
| `male`   | `0`                          | `users.sex.male`        |
| `female` | `1`                          | `users.sex.female`      |
| `other`  | `2`                          | `users.sex.other`       |

Le client lit `attributes.sex === "male"` puis résout le label localisé via son bundle i18n (`users.sex.male` → « Homme » / « Male » / …). La colonne DB reste un INT (0/1/2) — c'est de la sérialisation pure. Changer un slug est un **breaking change** API.

## `PATCH /api/users/me/profile`

Édite en une seule requête les **informations personnelles** de l'utilisateur courant. Auth requise. Vrai `PATCH` : **toute clé absente est laissée inchangée** ; `null` ou `""` **efface** un champ nullable.

- **Action** : [UpdateMyProfileAction](../src/Http/Action/Api/Users/UpdateMyProfileAction.php).
- **Corps** (JSON plat ou `data.attributes`), toutes les clés **optionnelles** :

```json
{
  "name": "Doe", "firstname": "Jane", "bio": "Voyageuse curieuse.",
  "sex": "male", "birthdate": "1990-05-21",
  "birthplaceCityId": "0f4c…-uuid", "email": "new@example.com"
}
```

**Champs immédiats** :
- `name`, `firstname` — texte libre nullable, max **100** ;
- `bio` — texte libre nullable, max **255** (seul champ indexé Meili de ce lot) ;
- `sex` — slug `"male"` / `"female"` / `"other"` ou `null` ;
- `birthdate` — `YYYY-MM-DD`, **pas dans le futur**, ou `null` ;
- `birthplaceCityId` — UUID d'une ville ou `null`.

**`email` — cas spécial (double opt-in)** : l'adresse n'est **jamais** modifiée ici. Une adresse valide, différente de l'actuelle et encore libre déclenche un lien de confirmation vers la **nouvelle** adresse ; le swap réel n'a lieu que via [`POST /api/auth/confirm-email-change`](/api/authentification/#post-apiauthconfirm-email-change). L'ancienne adresse reste active jusque-là. Une adresse identique = no-op silencieux.

**Réponse `200`** — la ressource `users` complète (mêmes attributs que `GET /api/auth/me`) :
- `meta.appliedKeys` — champs réellement modifiés ;
- `meta.emailChange` — présent **uniquement** si un changement d'email a été amorcé (`{ "status": "pending", "pendingEmail": "…" }`).

- **`400`** corps non-JSON · **`401`** token absent/invalide · **`422`** champ invalide (pointeur `/data/attributes/<champ>`, codes `users.profile.*` : `nameTooLong`, `bioTooLong`, `sexInvalid`, `birthdateInFuture`, `emailTaken`, …).
- **Notes** : envoi du mail **soft-fail** (demande persistée même si l'envoi échoue) ; `username` et `nickname` ont leurs **endpoints dédiés** (non couverts ici).

---

## `POST /api/users/me/username`

Permet à l'utilisateur courant de remplacer son username **placeholder** par un vrai username, et marque son profil comme complété (`profile_completed_at = NOW()`). Endpoint destiné en priorité aux comptes créés via Google OAuth (qui démarrent avec un username `g_<8-hex>` et `profile_completed_at = NULL`).

- **Auth** : **requise** (Bearer)
- **Action** : [SetUsernameAction](../src/Http/Action/Api/Users/SetUsernameAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{ "username": "havoc" }
```

- **Validation** : mêmes règles que [`POST /api/auth/register`](#post-apiauthregister) (voir [UsernamePolicy](../src/Domain/User/UsernamePolicy.php)).

- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "users",
    "id": "<user-uuid>",
    "attributes": {
      "username":           "havoc",
      "email":              "user@gmail.com",
      "isConfirmed":        true,
      "profileCompletedAt": "2026-06-07T10:00:00+00:00"
    }
  }
}
```

- **Réponse `400`** — corps non-JSON
- **Réponse `401`** — token absent / invalide
- **Réponse `409`** — `Profile already completed`, `meta.code = "profile.alreadyCompleted"`. Pour modifier un username déjà confirmé, utiliser [`POST /api/users/me/username/change`](#post-apiusersmeusernamechange).
- **Réponse `422`** — `username` manquant ou validation échouée. Codes possibles :
  - `username.tooShort`, `username.tooLong`, `username.invalidCharacters`, `username.invalidBoundary`, `username.consecutiveSeparators`, `username.reserved`, `username.alreadyTaken`

- **Notes** :
  - L'update est atomique (`username` + `profile_completed_at` + `updated_at` dans le même `UPDATE`).
  - Le username est normalisé (minuscules, trim) avant validation.
  - L'assignation est aussi enregistrée dans `username_history` (utilisée par `/username/change` pour le cooldown et la quarantaine).

---

## `POST /api/users/me/username/change`

Change le username d'un compte **dont le profil est déjà complété** (`profile_completed_at IS NOT NULL`). Pour les comptes OAuth dont le profil n'est pas encore complété (username placeholder `g_<hex>`), utiliser [`POST /api/users/me/username`](#post-apiusersmeusername).

- **Auth** : **requise** (Bearer)
- **Action** : [ChangeUsernameAction](../src/Http/Action/Api/Users/ChangeUsernameAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{ "username": "havoc2" }
```

### Règles ([UsernameChangeService](../src/Domain/User/UsernameChangeService.php))

Vérifications dans l'ordre, refus dès le premier échec :

1. **Profil complété** — sinon `409 username.profileIncomplete`. Utiliser l'endpoint de complétion du profil à la place.
2. **Format** — règles de [UsernamePolicy](../src/Domain/User/UsernamePolicy.php) (3–32 caractères, `[a-z0-9._-]`, bornes alphanumériques, pas de séparateurs consécutifs).
3. **Différent du username actuel** — sinon `409 username.sameAsCurrent`.
4. **Cooldown 30 jours** — au moins 30 jours doivent s'être écoulés depuis la dernière assignation de username pour cet utilisateur (toute la chaîne, pas seulement l'actuel) — sinon `409 username.onCooldown` avec `meta.cooldownUntil` (ISO-8601).
5. **Quarantaine 90 jours** — si ce username a été libéré par **un autre utilisateur** il y a moins de 90 jours, il est en quarantaine — sinon `409 username.quarantined`. **Exception** : l'utilisateur courant peut reprendre un username qu'il a lui-même tenu auparavant, sans attendre la quarantaine.
6. **Disponibilité immédiate** — le username ne doit pas être détenu actuellement par un autre utilisateur — sinon `409 username.alreadyTaken`.

### Effet de bord

L'update est atomique (transaction) : `user.username` + `user.updated_at` mis à jour, **et** `username_history` reçoit deux opérations :
- la ligne ouverte (où `released_at IS NULL`) est fermée avec `released_at = NOW()` ;
- une nouvelle ligne ouverte est insérée avec `assigned_at = NOW()`.

`profile_completed_at` n'est pas touché.

- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "usernameChanges",
    "id": "<user-uuid>",
    "attributes": {
      "previousUsername": "havoc",
      "username":         "havoc2",
      "cooldownDays":     30
    }
  }
}
```

- **Réponse `400`** — corps non-JSON
- **Réponse `401`** — token absent / invalide
- **Réponse `409`** — code dans `meta.code` :
  - `username.profileIncomplete`
  - `username.sameAsCurrent`
  - `username.onCooldown` (avec `meta.cooldownUntil` et `meta.cooldownDays`)
  - `username.quarantined` (avec `meta.quarantineDays`)
  - `username.alreadyTaken`
- **Réponse `422`** — `username` manquant ou format invalide. Codes : `username.tooShort`, `username.tooLong`, `username.invalidCharacters`, `username.invalidBoundary`, `username.consecutiveSeparators`, `username.reserved`

- **Notes** :
  - Les durées (`COOLDOWN_DAYS = 30`, `QUARANTINE_DAYS = 90`) sont définies en constantes sur [`UsernameChangeService`](../src/Domain/User/UsernameChangeService.php).
  - Le cooldown se calcule sur **la dernière assignation toutes valeurs confondues** (MAX `assigned_at`) — un user qui change pour `X` puis veut changer pour `Y` doit attendre 30 jours.

---

## `PUT /api/users/me/nickname`

Définit ou efface le **nickname** de l'utilisateur courant : le nom d'**affichage** libre, modifiable à volonté et **non unique** (contrairement au `username`, handle unique encadré). Auth requise.

Règle d'affichage : `displayName = nickname` quand il est renseigné, sinon `username` (voir [`User::displayName()`](../src/Domain/User/User.php)). Un nickname vide fait donc retomber l'affichage sur le username.

**Corps** (JSON plat ou `data.attributes`), la clé `nickname` doit être **présente** :

```json
{ "nickname": "Jane Doe" }   // définit
{ "nickname": "" }           // efface (retombe sur le username)
{ "nickname": null }         // efface
```

**Validation** ([NicknamePolicy](../src/Domain/User/NicknamePolicy.php)) — le nickname est volontairement permissif (lettres de tout script, chiffres, espaces, ponctuation/symboles, emojis) :
- normalisation : trim + espaces internes multiples réduits à un seul ; chaîne vide ⇒ `null` (efface) ;
- longueur 1..32 (colonne `user.nickname` = `VARCHAR(32)`) ;
- pas de caractères de contrôle / invisibles (retours ligne, zero-width…).

**Réponse `200`** — la ressource `users` complète (mêmes attributs que [`GET /api/auth/me`](#get-apiauthme)), donc le client voit immédiatement le nouveau `displayName`.

- **Réponse `400`** — corps non-JSON.
- **Réponse `401`** — token absent / invalide.
- **Réponse `422`** — clé `nickname` absente, type invalide (ni string ni null), ou format invalide. Codes : `nickname.tooShort`, `nickname.tooLong`, `nickname.invalidCharacters`.

- **Notes** :
  - **Pas de contrainte d'unicité** : deux utilisateurs peuvent partager un même nickname ; seul le `username` est unique.
  - La blocklist de noms réservés ([`username_blocklist.php`](../config/username_blocklist.php)) ne s'applique **pas** au nickname (choix produit : liberté d'affichage). À activer ici si la lutte contre l'usurpation par nom d'affichage devient nécessaire.

---

## `POST /api/users/me/password`

Définit ou change le mot de passe de l'utilisateur courant.

- **Auth** : **requise** (Bearer)
- **Action** : [SetPasswordAction](../src/Http/Action/Api/Users/SetPasswordAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{
  "currentPassword": "MyOldPassw0rd!",
  "newPassword":     "MyNewPassw0rd!"
}
```

- `currentPassword` est **requis** si l'utilisateur a déjà un mot de passe (`password_set_at IS NOT NULL`), **omis sinon** (compte OAuth qui n'en a jamais défini).
- `newPassword` est toujours requis et doit respecter la [PasswordPolicy](../src/Domain/Auth/PasswordPolicy.php) (mêmes règles que `/auth/register`).

### Effet de bord

Lors d'un changement réussi, **toutes les autres sessions sont révoquées** (déconnexion de tous les appareils sauf celui en cours). Le token courant reste valide.

- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "passwordChanges",
    "id": "<user-uuid>",
    "attributes": {
      "changedAt":             "2026-06-07T10:30:00+00:00",
      "otherSessionsRevoked":  3,
      "hadPasswordBefore":     true
    }
  }
}
```

- **Réponse `401`** :
  - `meta.code = "password.currentRequired"` — `currentPassword` manquant alors qu'il était attendu
  - `meta.code = "password.currentInvalid"` — `currentPassword` incorrect
- **Réponse `409`** — `meta.code = "password.sameAsCurrent"` (le nouveau mot de passe est identique à l'ancien)
- **Réponse `422`** — `newPassword` absent ou échec de la PasswordPolicy. Codes : `password.tooShort`, `password.tooLong`, `password.missingLowercase`, `password.missingUppercase`, `password.missingDigit`, `password.missingSpecial`, `password.invalidCharacters`, `password.invalidBoundary`, `password.containsUsername`, `password.containsEmail`

- **Notes** :
  - Le hash est bcrypt cost=12 (même paramètres que `/auth/register`).
  - `password_set_at` est mis à NOW() — utile pour audit ou logique métier ultérieure ("forcer renouvellement après N mois").
  - La colonne `password` reste NOT NULL même pour les comptes OAuth (qui stockent un hash bcrypt d'octets aléatoires inutilisable). Pour distinguer "a un vrai mot de passe" : `password_set_at IS NOT NULL` / `User::hasPassword()`.

---

## `POST /api/users/me/oauth/google`

Lie une identité Google **au compte courant déjà authentifié**. Utile pour résoudre le cas `409 oauth.linkRefused` retourné par `POST /api/auth/oauth/google` (auto-link refusé par les règles C3) : l'utilisateur se connecte d'abord par mot de passe, puis confirme volontairement le rapprochement.

- **Auth** : **requise** (Bearer)
- **Action** : [LinkGoogleAction](../src/Http/Action/Api/Users/OAuth/LinkGoogleAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{ "idToken": "<google id_token>" }
```

### Règles de linkage ([OAuthLinkService](../src/Domain/Auth/OAuth/OAuthLinkService.php))

Vérifications dans l'ordre, refus dès le premier échec :

1. **Email vérifié côté Google** (`email_verified = true`) — sinon `403 oauth.providerEmailNotVerified`.
2. **Email Google = email du compte courant** — sinon `409 oauth.emailMismatch`. Empêche un attaquant de coller son Google sur le compte d'une victime, et empêche une victime trompée de lier le Google d'un attaquant.
3. **Pas déjà une identité Google sur ce compte** — sinon `409 oauth.alreadyLinkedToThisUser`. Un seul compte Google par user.
4. **Le `sub` Google n'est pas déjà lié à un autre user** — sinon `409 oauth.alreadyLinkedToAnotherUser`. La contrainte UNIQUE `(provider, provider_user_id)` garantit aussi cette règle au niveau base.

- **Réponse `201`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "oauthIdentities",
    "id": "<identity-uuid>",
    "attributes": {
      "provider":    "google",
      "emailAtLink": "user@gmail.com",
      "createdAt":   "2026-06-07T10:45:00+00:00"
    }
  }
}
```

- **Réponse `400`** — corps non-JSON
- **Réponse `401`** — id_token non vérifiable (signature, iss, aud, exp...)
- **Réponse `403`** — `oauth.providerEmailNotVerified`
- **Réponse `409`** — un des trois codes : `oauth.emailMismatch`, `oauth.alreadyLinkedToThisUser`, `oauth.alreadyLinkedToAnotherUser`
- **Réponse `422`** — `idToken` manquant

- **Notes** :
  - Pas d'émission de session — l'utilisateur est déjà connecté avec son token Hydrogen, on ne fait que créer la liaison.
  - Une future requête `POST /api/auth/oauth/google` avec le même id_token signera le user via le chemin `existingIdentity` (200).

---

## `POST /api/users/me/oauth/apple`

Lie une identité Apple au compte courant déjà authentifié. C'est le chemin de récupération après un `409 oauth.linkRefused` retourné par `POST /api/auth/oauth/apple` (rappel : Apple n'est **jamais** auto-lié, politique E.a stricte).

- **Auth** : **requise** (Bearer)
- **Action** : [LinkAppleAction](../src/Http/Action/Api/Users/OAuth/LinkAppleAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{ "idToken": "<apple id_token>" }
```

### Règles de linkage

Vérifications dans l'ordre, refus dès le premier échec :

1. **`email_verified` Apple** doit être vrai — sinon `403 oauth.providerEmailNotVerified`.
2. **Email Apple = email du compte courant** — sinon `409 oauth.emailMismatch`. Une alias Private Relay (`@privaterelay.appleid.com`) est traité comme un email normal pour la comparaison stricte.
3. **Pas déjà une identité Apple sur ce compte** — sinon `409 oauth.alreadyLinkedToThisUser`.
4. **Le `sub` Apple n'est pas déjà lié à un autre user** — sinon `409 oauth.alreadyLinkedToAnotherUser`.

- **Réponse `201`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "oauthIdentities",
    "id": "<identity-uuid>",
    "attributes": {
      "provider":     "apple",
      "emailAtLink":  "abc123@privaterelay.appleid.com",
      "emailIsRelay": true,
      "createdAt":    "2026-06-07T10:45:00+00:00"
    }
  }
}
```

- **Réponses d'erreur** : `400`, `401`, `403`, `409` (trois codes), `422` — mêmes formes que pour Google avec `provider: "apple"` dans les messages.

---

## `POST /api/users/me/oauth/facebook`

Lie une identité Facebook au compte courant déjà authentifié. Identique en intention à `oauth/apple` mais accepte les deux flows Facebook.

- **Auth** : **requise** (Bearer)
- **Action** : [LinkFacebookAction](../src/Http/Action/Api/Users/OAuth/LinkFacebookAction.php)
- **Request body** :

```json
// Flux classique
{ "flow": "classic", "accessToken": "<facebook access token>" }

// Flux limited
{ "flow": "limited", "idToken": "<facebook OIDC id_token>" }
```

### Règles de linkage

1. **Facebook a fourni un email** — sinon `422 oauth.facebook.emailMissing`. Pas de signal `email_verified` côté Facebook (cf. matrice), donc l'étape Google « providerEmailNotVerified » est sans objet ici.
2. **Email Facebook = email du compte courant** — sinon `409 oauth.emailMismatch`.
3. **Pas déjà une identité Facebook sur ce compte** — sinon `409 oauth.alreadyLinkedToThisUser`.
4. **Le `sub` Facebook n'est pas déjà lié à un autre user** — sinon `409 oauth.alreadyLinkedToAnotherUser`.

- **Réponse `201`** — même forme avec `provider: "facebook"`, `emailIsRelay: false`.
- **Réponse `401`** — token non vérifiable.
- **Réponse `409`** — codes `oauth.emailMismatch` / `oauth.alreadyLinkedToThisUser` / `oauth.alreadyLinkedToAnotherUser`.
- **Réponse `422`** — `flow` invalide, token manquant pour le flux choisi, ou `oauth.facebook.emailMissing`.

---

## `GET /api/users/me/oauth-identities`

Liste toutes les identités OAuth liées au compte courant. Renvoie une **collection JSON:API**, possiblement vide pour un compte créé par mot de passe sans lien OAuth.

- **Auth** : **requise** (Bearer)
- **Action** : [ListOAuthIdentitiesAction](../src/Http/Action/Api/Users/OAuth/ListOAuthIdentitiesAction.php)
- **Pas de body** (GET)

- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": [
    {
      "type": "oauthIdentities",
      "id":   "<identity-uuid>",
      "attributes": {
        "provider":     "google",
        "emailAtLink":  "user@gmail.com",
        "emailIsRelay": false,
        "linkedAt":     "2026-06-07T10:45:00+00:00"
      }
    }
  ],
  "meta": { "count": 1 }
}
```

- **Réponse `401`** — token absent / invalide

- **Notes** :
  - Tri par `created_at ASC` (plus ancienne liaison en premier).
  - `provider_user_id` (le `sub` du provider) n'est **pas** exposé — il n'a aucun usage côté client.
  - `emailAtLink` est l'email retourné par le provider **au moment de la liaison** (peut différer de l'email actuel du user).
  - `emailIsRelay` vaut `true` uniquement pour les identités Apple créées avec un alias Private Relay (`@privaterelay.appleid.com`). Toujours `false` pour Google et Facebook.

---

## `GET /api/users/me/settings`

Retourne le snapshot complet des préférences de l'utilisateur courant : les valeurs par défaut **fusionnées** avec les éventuels overrides stockés en base. Le client n'a jamais à gérer le cas « préférence pas encore définie » — chaque clé connue est toujours présente.

- **Auth** : **requise** (Bearer)
- **Action** : [GetSettingsAction](../src/Http/Action/Api/Users/GetSettingsAction.php)
- **Pas de body** (GET)

- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "userSettings",
    "id":   "<user-uuid>",
    "attributes": {
      "locale":                "fr-FR",
      "timezone":              "Europe/Paris",
      "theme":                 "system",
      "currency":              "EUR",
      "profileVisibility":     "public",
      "notificationsEmail":    true,
      "notificationsPush":     true,
      "showSensitiveContent":  false,
      "emailVisibleOnProfile": false
    }
  }
}
```

- **Réponse `401`** — token absent / invalide

### Catalogue des clés

Définies dans [`UserSettingRegistry`](../src/Domain/User/Preference/UserSettingRegistry.php) :

| Clé                      | Type     | Défaut          | Valeurs autorisées |
| ------------------------ | -------- | --------------- | ------------------ |
| `locale`                 | locale   | `fr-FR`         | locale **effectivement supportée** par l'app (voir [GET /api/i18n/locales](#get-apii18nlocales)) |
| `timezone`               | timezone | `Europe/Paris`  | identifiant IANA (`DateTimeZone::listIdentifiers()`) |
| `theme`                  | enum     | `system`        | `light`, `dark`, `system` |
| `currency`               | currency | `EUR`           | code ISO 4217 présent dans la table `currency` |
| `profileVisibility`      | enum     | `public`        | `public`, `private` |
| `notificationsEmail`     | bool     | `true`          | — |
| `notificationsPush`      | bool     | `true`          | — |
| `showSensitiveContent`   | bool     | `false`         | — |
| `emailVisibleOnProfile`  | bool     | `false`         | — |

---

## `PATCH /api/users/me/settings`

Met à jour partiellement les préférences de l'utilisateur courant.

- **Auth** : **requise** (Bearer)
- **Action** : [UpdateSettingsAction](../src/Http/Action/Api/Users/UpdateSettingsAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) — toutes les clés sont **optionnelles**, seules les clés présentes sont touchées :

```json
{
  "theme":              "dark",
  "notificationsEmail": false
}
```

### Sémantique « tout ou rien »

Si **une seule** clé/valeur échoue à la validation, **aucune** écriture n'a lieu et la réponse `422` énumère toutes les erreurs. Le client ne se retrouve jamais avec un état mi-appliqué.

### Stockage « overrides only » (D.c)

La table `user_preference` ne stocke **que** les overrides — pas les défauts.
- Si la valeur écrite **égale** le défaut actuel, la ligne correspondante est **supprimée**.
- Sinon, elle est upsertée (`INSERT … ON DUPLICATE KEY UPDATE`).

Conséquence : changer un défaut dans le code propage automatiquement à tous les comptes qui n'ont pas d'override explicite.

- **Réponse `200`** — snapshot complet **post-update** + `meta.appliedKeys` listant les clés réellement écrites/supprimées :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "userSettings",
    "id":   "<user-uuid>",
    "attributes": {
      "locale":                "fr-FR",
      "timezone":              "Europe/Paris",
      "theme":                 "dark",
      "currency":              "EUR",
      "profileVisibility":     "public",
      "notificationsEmail":    false,
      "notificationsPush":     true,
      "showSensitiveContent":  false,
      "emailVisibleOnProfile": false
    }
  },
  "meta": { "appliedKeys": ["theme", "notificationsEmail"] }
}
```

- **Réponse `400`** — corps non-JSON ou non-objet
- **Réponse `422`** — un ou plusieurs `errors[]` avec `source.pointer = /data/attributes/<key>` et `meta.code` parmi :
  - `setting.unknown` — clé inconnue du registry
  - `setting.expectedBool` — la valeur d'une clé booléenne n'est pas un `true`/`false`
  - `setting.invalidEnumValue` — valeur absente de la liste autorisée
  - `setting.invalidLocale` — locale non supportée par l'app (cf. [GET /api/i18n/locales](#get-apii18nlocales))
  - `setting.invalidTimezone` — identifiant IANA inconnu
  - `setting.invalidCurrency` — code ISO 4217 inconnu de la table `currency`

- **Notes** :
  - Un body vide (`{}`) est accepté et retourne `200` avec `appliedKeys: []`.
  - Les booléens sont strictement typés : les chaînes `"true"`/`"false"` sont rejetées (`setting.expectedBool`).
  - Le ramassage des erreurs est exhaustif : tous les champs invalides sont signalés en un seul appel, pas seulement le premier.

---

## `POST /api/users/me/avatar`

Téléverse (ou remplace) l'avatar de l'utilisateur authentifié.

- **Auth** : requise (Bearer)
- **Action** : [UploadAvatarAction](../src/Http/Action/Api/Users/UploadAvatarAction.php)
- **Body** : `multipart/form-data` avec un unique champ fichier `avatar`.
  - Formats acceptés : JPEG, PNG, WebP, GIF, HEIC, HEIF.
  - Taille maximale : `AVATAR_MAX_UPLOAD_BYTES` (par défaut 256 000 octets / 256 ko).
- **Pipeline** ([AvatarUploadService](../src/Domain/User/Avatar/AvatarUploadService.php)) :
  1. Validation (taille, code d'erreur PSR-7, non vide).
  2. Décodage Imagick — un fichier non décodable ⇒ `422 invalidImage`.
  3. Whitelist du format source. Animations (GIF/HEIF) : seule la **première frame** est conservée.
  4. Auto-orientation EXIF puis **strip** complet des métadonnées (GPS, appareil, etc. — privacy).
  5. Redimension `bestfit` à `AVATAR_MAX_DIMENSION` (256px par défaut ; pas d'upscale).
  6. Encodage WebP qualité `AVATAR_WEBP_QUALITY` (80 par défaut, `webp:method=6`).
  7. Écriture atomique (`tmp + rename`) sous `<AVATAR_STORAGE_PATH>/user/AA/BB/CC/<uuid-hex>/avatar.webp` (3 niveaux de dossiers ventilés à partir des 6 premiers caractères hex de l'UUID).
  8. Mise à jour de `user.avatar_updated_at` (cache-buster).
  9. Re-hydratation de l'utilisateur.

- **Réponse `200 OK`** : ressource `users` complète avec `avatarUrl` actualisée (et son cache-buster `?v=<timestamp>`).

```json
{
  "data": {
    "type": "users",
    "id": "<uuid>",
    "attributes": {
      "avatarUrl": "http://hexatrip-static.dev.com/user/c2/1e/78/c21e7856eb524c8cb9a7786a2f80ce7e/avatar.webp?v=1812345678",
      "hasAvatar": true,
      "…": "…"
    }
  }
}
```

- **Réponses d'erreur** (mapping `AvatarErrorCode` → HTTP) :
  - `400` `avatar.uploadFailed` — échec côté transport multipart
  - `413` `avatar.tooLarge` — fichier > `AVATAR_MAX_UPLOAD_BYTES`
  - `415` `avatar.unsupportedFormat` — format hors whitelist
  - `422` `avatar.empty` — pas de fichier ou octets vides (`source.pointer = /data/attributes/avatar`)
  - `422` `avatar.invalidImage` — octets non décodables comme image
  - `500` `avatar.encodingFailed` — Imagick a refusé de produire le WebP
  - `500` `avatar.storageWriteFailed` — écriture disque échouée

- **Notes** :
  - L'avatar est **toujours écrit à la même URL** (`avatar.webp`). C'est `?v=<unix-ts>` (basé sur `avatar_updated_at`) qui force l'invalidation cache navigateur/CDN entre deux uploads.
  - Quand l'utilisateur n'a pas d'avatar, `avatarUrl` pointe vers `<AVATAR_PUBLIC_URL>/user/default-avatar.webp` (pas de cache-buster — c'est un fichier ops).

---

## `DELETE /api/users/me/avatar`

Supprime l'avatar de l'utilisateur authentifié (fichier sur disque + timestamp en base). L'utilisateur retombe sur l'avatar par défaut partagé.

- **Auth** : requise (Bearer)
- **Action** : [DeleteAvatarAction](../src/Http/Action/Api/Users/DeleteAvatarAction.php)
- **Body** : aucun
- **Idempotent** : appelé sur un compte qui n'a déjà plus d'avatar, retourne quand même `200`.
- **Réponse `200 OK`** : ressource `users` complète avec `avatarUrl` ré-orientée vers le default avatar et `hasAvatar: false`.

---

## `POST /api/users/me/cover`

Téléverse (ou remplace) la cover de profil de l'utilisateur authentifié.

- **Auth** : requise (Bearer)
- **Action** : [UploadCoverAction](../src/Http/Action/Api/Users/UploadCoverAction.php)
- **Body** : `multipart/form-data` avec un unique champ fichier `cover`.
  - Formats acceptés : JPEG, PNG, WebP, GIF, HEIC, HEIF.
  - Taille maximale : `COVER_MAX_UPLOAD_BYTES` (par défaut 600 000 octets / 600 ko).
- **Pipeline** ([CoverUploadService](../src/Domain/User/Cover/CoverUploadService.php)) : identique à l'upload d'avatar, à deux différences près :
  - Redimension `bestfit` à l'intérieur de `COVER_MAX_WIDTH × COVER_MAX_HEIGHT` (par défaut 1500 × 500 px), **aspect ratio conservé** — une image 3000 × 800 sera réduite à 1500 × 400 (pas d'upscale, pas de crop).
  - Le fichier est écrit dans le **même dossier ventilé que l'avatar**, mais sous le nom `cover.webp` :
    `<AVATAR_STORAGE_PATH>/user/AA/BB/CC/<uuid-hex>/cover.webp`.
- **Effet de bord** : `user.cover_updated_at` est mis à jour (cache-buster URL).

- **Réponse `200 OK`** : ressource `users` complète avec `coverUrl` actualisée (et son cache-buster `?v=<timestamp>`).

```json
{
  "data": {
    "type": "users",
    "id": "<uuid>",
    "attributes": {
      "coverUrl": "http://hexatrip-static.dev.com/user/c2/1e/78/c21e7856eb524c8cb9a7786a2f80ce7e/cover.webp?v=1812345678",
      "hasCover": true,
      "…": "…"
    }
  }
}
```

- **Réponses d'erreur** (mapping `CoverErrorCode` → HTTP) :
  - `400` `cover.uploadFailed` — échec côté transport multipart
  - `413` `cover.tooLarge` — fichier > `COVER_MAX_UPLOAD_BYTES`
  - `415` `cover.unsupportedFormat` — format hors whitelist
  - `422` `cover.empty` — pas de fichier ou octets vides (`source.pointer = /data/attributes/cover`)
  - `422` `cover.invalidImage` — octets non décodables comme image
  - `500` `cover.encodingFailed` — Imagick a refusé de produire le WebP
  - `500` `cover.storageWriteFailed` — écriture disque échouée

- **Notes** :
  - Comme pour l'avatar, l'URL est toujours `cover.webp` ; c'est `?v=<unix-ts>` qui force l'invalidation cache.
  - Sans cover, `coverUrl` pointe vers `<AVATAR_PUBLIC_URL>/user/default-cover.webp` (sans cache-buster).

---

## `DELETE /api/users/me/cover`

Supprime la cover de l'utilisateur authentifié (fichier sur disque + timestamp en base). L'utilisateur retombe sur la cover par défaut partagée.

- **Auth** : requise (Bearer)
- **Action** : [DeleteCoverAction](../src/Http/Action/Api/Users/DeleteCoverAction.php)
- **Body** : aucun
- **Idempotent** : appelé sur un compte qui n'a déjà plus de cover, retourne quand même `200`.
- **Réponse `200 OK`** : ressource `users` complète avec `coverUrl` ré-orientée vers la default cover et `hasCover: false`.

---
