---
title: "Thèmes / centres d'intérêt"
description: "Catalogue de thèmes éditorialisé en back-office (≈ 50 entrées) parmi lesquels chaque utilisateur en choisit 5 à 10 lors de l'onboarding. Sert ensuite à rec…"
---

Catalogue de thèmes éditorialisé en back-office (≈ 50 entrées) parmi lesquels chaque utilisateur en choisit **5 à 10** lors de l'onboarding. Sert ensuite à recouper le feed (médias / établissements taggés sur les mêmes thèmes).

## Modèle de données

- **`topic`** — entrée du catalogue. `id` BINARY(16), `slug` VARCHAR(64) UNIQUE (identité publique stable), `icon`, `position` (ordre d'affichage), `is_active` (un thème retiré du catalogue reste référencé par les utilisateurs qui l'avaient coché — il est juste invisible dans le picker).
- **`topic_translation`** — `(topic_id, locale)` → `label`, `description`. Résolution avec **double LEFT JOIN** côté repo : locale demandée + `SupportedLocales::DEFAULT`. Le `label` retombe sur le slug si aucune traduction n'existe.
- **`user_topic`** — `(user_id, topic_id)` PK composite, index inverse `(topic_id, user_id)` pour les futures requêtes "utilisateurs intéressés par le thème X". FK ON DELETE CASCADE des deux côtés.

## Identité côté API

L'**identifiant JSON:API d'une ressource `topics` est le `slug`**, pas l'UUID hex. L'UUID interne reste exposé en attribut `internalId` pour le debug / cross-référence outils admin uniquement.

## Variables d'environnement

| Variable             | Défaut | Effet                                                                                                                |
|----------------------|--------|----------------------------------------------------------------------------------------------------------------------|
| `TOPIC_MAX_RESULTS`  | `100`  | Cap serveur sur `/api/topics` (`meta.truncated = true` + `meta.maxResults` si atteint).                              |
| `USER_TOPICS_MIN`    | `5`    | Nombre minimum de thèmes dans un `PUT /api/users/me/topics`. En-dessous → `422 userTopic.tooFew`.                    |
| `USER_TOPICS_MAX`    | `10`   | Nombre maximum de thèmes. Au-dessus → `422 userTopic.tooMany`.                                                       |

## Codes d'erreur `userTopic.*`

Tous les guards du `setMine` produisent un `422` avec un `meta.code` stable côté front :

| `meta.code`                | Détail                                                              |
|----------------------------|---------------------------------------------------------------------|
| `userTopic.payloadInvalid` | Le tableau `slugs` est absent ou n'est pas une liste de strings.    |
| `userTopic.tooFew`         | Moins de `USER_TOPICS_MIN` slugs envoyés.                            |
| `userTopic.tooMany`        | Plus de `USER_TOPICS_MAX` slugs envoyés.                             |
| `userTopic.duplicates`     | Au moins un slug envoyé deux fois (échos dans `meta.unknownSlugs`). |
| `userTopic.unknownSlugs`   | Au moins un slug n'est pas dans le catalogue actif (idem).          |

---

## `GET /api/topics`

Catalogue public des thèmes actifs, localisé. Aucun paramètre de query, aucune pagination (le catalogue est borné par produit). Tri systématique `(position, id)` — c'est le même ordre que `GET /api/users/me/topics`, donc le picker d'onboarding peut cocher la sélection courante par simple correspondance.

- **Auth** : aucune
- **Action** : [ListTopicsAction](../src/Http/Action/Api/Topics/ListTopicsAction.php)
- **Réponse `200`** :

```json
{
  "data": [
    {
      "type": "topics",
      "id":   "outdoor",
      "attributes": {
        "slug":        "outdoor",
        "label":       "Plein air",
        "description": "Randonnée, kayak, escalade…",
        "icon":        "mountain",
        "position":    10,
        "isActive":    true,
        "internalId":  "0190f4b5-1c2a-7a3d-b3f1-1e4a8b2c0011",
        "createdAt":   "2026-06-10T12:00:00+00:00",
        "updatedAt":   null
      }
    }
  ],
  "links": { "self": "https://api.example/api/topics" },
  "meta":  { "total": 42 }
}
```

- **Cap serveur atteint** (`meta.total === TOPIC_MAX_RESULTS`) : la réponse ajoute `meta.truncated = true` et `meta.maxResults = <cap>`.

---

## `GET /api/users/me/topics`

Sélection courante de l'utilisateur authentifié (de 0 à `USER_TOPICS_MAX` entrées). Avant onboarding, retourne un tableau vide. La réponse **inclut les thèmes que le BO a depuis désactivés** (`isActive = false`) — la sélection historique reste lisible, le front peut les griser ou les filtrer côté UI.

- **Auth** : requise
- **Action** : [GetMyTopicsAction](../src/Http/Action/Api/Users/Topic/GetMyTopicsAction.php)
- **Réponse `200`** : identique à `GET /api/topics` (même forme, mêmes attributs, même tri `(position, id)`).

---

## `PUT /api/users/me/topics`

Remplace **toute** la sélection de l'utilisateur. Pas d'`add` / `remove` granulaires : le picker UX est "coche les thèmes voulus, sauvegarde la liste entière". L'opération est atomique (DELETE + INSERT en une transaction) — pas de fenêtre où l'utilisateur se retrouve sans sélection.

- **Auth** : requise
- **Action** : [SetMyTopicsAction](../src/Http/Action/Api/Users/Topic/SetMyTopicsAction.php)
- **Corps accepté** : forme aplatie OU forme JSON:API.

```json
// flat
{ "slugs": ["outdoor", "gastronomie", "famille", "culture", "sport"] }

// JSON:API
{ "data": { "attributes": { "slugs": ["outdoor", "gastronomie", "famille", "culture", "sport"] } } }
```

- **Réponse `200`** : la sélection persistée, hydratée dans la locale active — même forme que `GET /api/users/me/topics`. Le front peut donc remplacer son état local par cette réponse sans GET de suivi.

- **Pipeline de validation** (l'ordre fixe quel `meta.code` sort en premier) :
  1. **Shape** — chaque entrée doit être une string non vide après trim ;
  2. **Duplicates** — détectés avant la cardinalité, pour qu'un envoi `["outdoor","outdoor","outdoor"]` retourne `userTopic.duplicates` plutôt que `userTopic.tooFew` ;
  3. **Cardinalité** — `count(slugs) ∈ [USER_TOPICS_MIN, USER_TOPICS_MAX]` ;
  4. **Catalogue** — chaque slug doit pointer un `topic` `is_active = 1`. Les inactifs / inconnus sont énumérés dans `meta.unknownSlugs` ;
  5. **Persistance** — DELETE intégral des `user_topic` du user puis INSERT multi-VALUES, le tout dans une transaction unique (pattern tx-join : rejoint la tx en cours si une est ouverte par un middleware).

- **Réponses d'erreur** : toutes en `422` avec `source.pointer = "/data/attributes/slugs"` et un `meta.code` parmi la table plus haut. Exemple :

```json
{
  "errors": [{
    "status": "422",
    "title":  "Unknown slugs",
    "detail": "One or more slugs are not in the active catalogue.",
    "source": { "pointer": "/data/attributes/slugs" },
    "meta":   { "code": "userTopic.unknownSlugs", "unknownSlugs": ["foo", "bar"] }
  }]
}
```

---
