Skip to content

Thèmes / centres d'intérêt

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).

  • 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.

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.

VariableDéfautEffet
TOPIC_MAX_RESULTS100Cap serveur sur /api/topics (meta.truncated = true + meta.maxResults si atteint).
USER_TOPICS_MIN5Nombre minimum de thèmes dans un PUT /api/users/me/topics. En-dessous → 422 userTopic.tooFew.
USER_TOPICS_MAX10Nombre maximum de thèmes. Au-dessus → 422 userTopic.tooMany.

Tous les guards du setMine produisent un 422 avec un meta.code stable côté front :

meta.codeDétail
userTopic.payloadInvalidLe tableau slugs est absent ou n’est pas une liste de strings.
userTopic.tooFewMoins de USER_TOPICS_MIN slugs envoyés.
userTopic.tooManyPlus de USER_TOPICS_MAX slugs envoyés.
userTopic.duplicatesAu moins un slug envoyé deux fois (échos dans meta.unknownSlugs).
userTopic.unknownSlugsAu moins un slug n’est pas dans le catalogue actif (idem).

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.

{
"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>.

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
  • Réponse 200 : identique à GET /api/topics (même forme, mêmes attributs, même tri (position, id)).

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
  • Corps accepté : forme aplatie OU forme JSON:API.
// 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 :

{
"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"] }
}]
}