Skip to content

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

TableColonnes notables
bookmark_collectionid, 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).

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

  • 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é).
  • Auto-bookmark interdit403 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 (collation utf8mb4_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é.
  • PUT /api/users/me/media/{mediaId}/bookmark est idempotent : 2 appels avec le même collectionId → 200 no-op. Un appel avec un collectionId différent de l’existant déplace le favori (compteurs swappés dans la même tx).
  • DELETE renvoie 404 bookmark.notFound si rien n’était bookmarké (vs 204 silencieux) — la cliente peut ainsi rafraîchir son état UI.

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 }
}

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

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