---
title: "Favoris"
description: "Système de favoris privés style Pinterest. Chaque utilisateur peut :"
---

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

| 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

`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

- `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

- **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 (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é.

## Idempotence

- `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.

## `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](../src/Http/Action/Api/Users/Bookmark/ListMyBookmarkCollectionsAction.php)

```json
{
  "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`

Crée une collection vide. Body flat `{ "name": "…" }` ou JSON:API. Retourne `201`. [CreateBookmarkCollectionAction](../src/Http/Action/Api/Users/Bookmark/CreateBookmarkCollectionAction.php)

Erreurs (`meta.code`) : `422 bookmark.collectionNameMissing` / `bookmark.collectionNameTooLong` / `bookmark.collectionLimitReached`, `409 bookmark.collectionNameTaken`.

## `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](../src/Http/Action/Api/Users/Bookmark/UpdateBookmarkCollectionAction.php)

Erreurs : voir tableau global (`404 bookmark.collectionNotFound`, `409 bookmark.collectionNameTaken`, `422 bookmark.collectionNameTooLong` / `bookmark.coverNotInCollection`).

## `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](../src/Http/Action/Api/Users/Bookmark/DeleteBookmarkCollectionAction.php)

## `GET /api/users/me/bookmarks`

Liste les favoris du caller, paginés en keyset sur `(createdAt DESC, mediaId DESC)`. Auth requise. [ListMyBookmarksAction](../src/Http/Action/Api/Users/Bookmark/ListMyBookmarksAction.php)

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é).

```json
{
  "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`

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](../src/Http/Action/Api/Users/Bookmark/UpsertBookmarkAction.php)

Erreurs (`meta.code`) : `403 bookmark.selfBookmark` / `bookmark.actorNotConfirmed` / `bookmark.actorBanned`, `404 bookmark.mediaNotFound` / `bookmark.collectionNotFound`, `422 bookmark.collectionFull`.

```json
{
  "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`

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](../src/Http/Action/Api/Users/Bookmark/DeleteBookmarkAction.php)

---
