Favoris
Système de favoris privés style Pinterest. Chaque utilisateur peut :
- bookmarker un média d’un autre utilisateur (auto-bookmark interdit) ;
- ranger ses favoris dans des collections nommées (1 média → 1 collection au plus) ;
- laisser des favoris “non-classés” (
collection_id IS NULL).
L’ensemble est strictement privé : aucun compteur public n’est exposé sur la ressource media, aucune notification au propriétaire du média n’est émise, aucune gamification (pas d’XP).
Modèle
Section titled “Modèle”| Table | Colonnes notables |
|---|---|
bookmark_collection | id, user_id, name (unique par user, case-insensitive), position, media_count (cache applicatif), cover_media_id (FK media, ON DELETE SET NULL), created_at, updated_at |
user_bookmark | (user_id, media_id) PK, collection_id (FK, ON DELETE SET NULL → garde le favori en “non-classé” si la collection est supprimée), created_at |
Index : idx_ub_user_created couvre les listings tous-favoris ; idx_ub_user_collection_created couvre les listings filtrés par collection et le bucket non-classé. La couverture cible toujours le tri (created_at DESC, media_id DESC).
Compteurs
Section titled “Compteurs”bookmark_collection.media_count est maintenu applicativement dans la même transaction que le user_bookmark (insert / move / delete). Le bucket non-classé n’a pas de compteur dédié — il s’obtient via COUNT(*) … WHERE collection_id IS NULL (indexé).
Variables d’environnement
Section titled “Variables d’environnement”BOOKMARK_COLLECTION_NAME_MAX_LENGTH(def.80) — longueur max du nom (en glyphes).BOOKMARK_MAX_COLLECTIONS_PER_USER(def.50) — plafond du nombre de collections par user.BOOKMARK_MAX_PER_COLLECTION(def.1000) — plafond du nombre de favoris dans une collection (le bucket non-classé n’est PAS plafonné).
Règles métier
Section titled “Règles métier”- Auto-bookmark interdit —
403 bookmark.selfBookmark. - Caller doit être confirmé + non banni —
403 bookmark.actorNotConfirmed/bookmark.actorBanned. - Le média cible doit exister —
404 bookmark.mediaNotFound. - La collection cible (si fournie) doit appartenir au caller —
404 bookmark.collectionNotFound(un id non-possédé renvoie 404 par sécurité, pour ne pas révéler l’existence des collections d’autrui). - Nom de collection : trimmed-non-empty, ≤
BOOKMARK_COLLECTION_NAME_MAX_LENGTH, unique par user (collationutf8mb4_unicode_ci). - Cover pinned : le média doit être membre de la collection (sinon
422 bookmark.coverNotInCollection). À chaque retrait du média (move ou unbookmark), la couverture pinned est automatiquement effacée et le serializer retombe sur le dernier média ajouté.
Idempotence
Section titled “Idempotence”PUT /api/users/me/media/{mediaId}/bookmarkest idempotent : 2 appels avec le mêmecollectionId→ 200 no-op. Un appel avec uncollectionIddifférent de l’existant déplace le favori (compteurs swappés dans la même tx).DELETErenvoie404 bookmark.notFoundsi rien n’était bookmarké (vs204silencieux) — la cliente peut ainsi rafraîchir son état UI.
GET /api/users/me/bookmarks/collections
Section titled “GET /api/users/me/bookmarks/collections”Liste les collections du caller + une entrée virtuelle id = "unclassified" exposant le mediaCount du bucket non-classé. Pas de pagination (cap env). Auth requise. ListMyBookmarkCollectionsAction
{ "jsonapi": { "version": "1.1" }, "data": [ { "type": "bookmarkCollections", "id": "0193…", "attributes": { "userId": "0193…", "name": "Restos à tester", "position": 0, "mediaCount": 12, "coverMediaId": "0193…", "pinnedCover": false, "createdAt": "2026-06-10T10:00:00+00:00", "updatedAt": null } }, { "type": "bookmarkCollections", "id": "unclassified", "attributes": { "userId": "0193…", "name": null, "position": -1, "mediaCount": 4, "coverMediaId": null, "pinnedCover": false, "createdAt": null, "updatedAt": null }, "meta": { "virtual": true } } ], "meta": { "total": 2 }}POST /api/users/me/bookmarks/collections
Section titled “POST /api/users/me/bookmarks/collections”Crée une collection vide. Body flat { "name": "…" } ou JSON:API. Retourne 201. CreateBookmarkCollectionAction
Erreurs (meta.code) : 422 bookmark.collectionNameMissing / bookmark.collectionNameTooLong / bookmark.collectionLimitReached, 409 bookmark.collectionNameTaken.
PATCH /api/users/me/bookmarks/collections/{collectionId}
Section titled “PATCH /api/users/me/bookmarks/collections/{collectionId}”Patch partiel : tout sous-ensemble de { name, position, coverMediaId } (envoyer coverMediaId = null efface explicitement la couverture pinned). Retourne 200 avec la ressource. UpdateBookmarkCollectionAction
Erreurs : voir tableau global (404 bookmark.collectionNotFound, 409 bookmark.collectionNameTaken, 422 bookmark.collectionNameTooLong / bookmark.coverNotInCollection).
DELETE /api/users/me/bookmarks/collections/{collectionId}
Section titled “DELETE /api/users/me/bookmarks/collections/{collectionId}”Hard-delete de la collection. Ses favoris sont conservés et passés dans le bucket “non-classé” via ON DELETE SET NULL. Retourne 204. DeleteBookmarkCollectionAction
GET /api/users/me/bookmarks
Section titled “GET /api/users/me/bookmarks”Liste les favoris du caller, paginés en keyset sur (createdAt DESC, mediaId DESC). Auth requise. ListMyBookmarksAction
Filtres mutuellement exclusifs :
?collectionId=<uuid>— uniquement cette collection (404 si non-possédée).?unclassified=true— uniquement le bucket non-classé.- (rien) — tous les favoris du caller.
Paramètres : ?limit=… (def 20, max 100), ?cursor=… / ?before=….
Chaque ressource est une media (mêmes attributs que les autres listings de médias, avec viewerReaction et stats) enrichie d’un meta.bookmark = { collectionId, createdAt }. meta.total est exact (COUNT indexé).
{ "jsonapi": { "version": "1.1" }, "data": [ { "type": "media", "id": "0193…", "attributes": { "blurHash": "…", "url": "…", "likesCount": 7, "viewerReaction": "like", … }, "meta": { "bookmark": { "collectionId": "0193…", "createdAt": "2026-06-10T11:32:01+00:00" } } } ], "links": { "self": "/api/users/me/bookmarks", "next": "…", "prev": null }, "meta": { "limit": 20, "total": 142 }}PUT /api/users/me/media/{mediaId}/bookmark
Section titled “PUT /api/users/me/media/{mediaId}/bookmark”Bookmark idempotent. Body optionnel :
- vide → bucket non-classé.
{ "collectionId": "<uuid>" }→ range dans cette collection.{ "collectionId": null }→ bucket non-classé.
Si un favori existait déjà, l’opération déplace le favori vers la nouvelle cible (compteurs swappés). Retourne 200 avec la ressource bookmarks. UpsertBookmarkAction
Erreurs (meta.code) : 403 bookmark.selfBookmark / bookmark.actorNotConfirmed / bookmark.actorBanned, 404 bookmark.mediaNotFound / bookmark.collectionNotFound, 422 bookmark.collectionFull.
{ "jsonapi": { "version": "1.1" }, "data": { "type": "bookmarks", "id": "0193…userHex:0193…mediaHex", "attributes": { "userId": "0193…", "mediaId": "0193…", "collectionId": "0193…", "createdAt": "2026-06-10T11:32:01+00:00" } }}DELETE /api/users/me/media/{mediaId}/bookmark
Section titled “DELETE /api/users/me/media/{mediaId}/bookmark”Retire le favori. 204 si retiré, 404 bookmark.notFound si rien n’était bookmarké. La couverture pinned de la collection est automatiquement effacée si elle pointait sur ce média. DeleteBookmarkAction