Le recensement des fichiers

Comment la base sait quels fichiers chaque contenu affiche : la table PageMediaReference, l’extraction exhaustive vérifiée par le compilateur, la synchronisation par différence dans la transaction d’enregistrement, et tout ce qui s’appuie dessus — la route publique, la corbeille, le changement de statut, la suppression.

Précédemment. Une page est rendue sur le serveur : tous ses fichiers résolus en une requête, une URL publique pour chacun, des blocs qui lisent une table préparée. La route publique ne sert pourtant un fichier que s’il est placé.

Les parcours du chapitre#

Trois parcours traversent ce chapitre : l’enregistrement d’une page, qui recense ses fichiers ; le visiteur qui charge une image ; l’administrateur qui range la bibliothèque et se heurte au recensement.

Acteur : Administrateur

  1. 1

    Le contenu est écrit, puis ses fichiers sont recensés dans la même transaction ; un fichier non validé annule tout

    sitePage.saveadmin

    • PageMediaReference créée
    • PageMediaReference supprimée
    • SitePage créée : content, slug, title
    • SitePage modifiée : content, slug, title

    Lit : MediaAsset, PageMediaReference

    Le chemin dans le code

    Chaque ligne est un appel, suivi de l'endroit où le lire (à ouvrir dans l'éditeur, par exemple avec la recherche de fichier).

    sitePage.save packages/backend/src/modules/sitePages/router.ts:45
    • syncPageMediaReferences packages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:47
      • tx.pageMediaReference.createMany packages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:130PageMediaReference créée
      • tx.pageMediaReference.deleteMany packages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:81PageMediaReference supprimée
      • tx.pageMediaReference.deleteMany packages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:122PageMediaReference supprimée
    • tx.sitePage.upsert packages/backend/src/modules/sitePages/router.ts:57SitePage crééeSitePage modifiée

Pourquoi recenser 📋#

Un contenu ne désigne ses fichiers que par leur identifiant, rangé quelque part dans un JSON. Pour savoir si un fichier est utilisé, il faudrait relire tous les contenus de toutes les entités et fouiller chaque bloc, chaque document de texte. Trois questions posent pourtant cette question à tout moment :

Question

Qui la pose

À quel moment

Ce fichier peut-il être servi à un visiteur ?
La route publique
À chaque image chargée par un visiteur.
Ce fichier peut-il être jeté ?
La corbeille
Quand un administrateur range la bibliothèque.
Ce fichier peut-il repasser en attente ?
Le changement de statut
Idem.

La réponse est donc précalculée : une table relie chaque fichier à chaque contenu qui l’affiche.

prisma/schema.prisma
· extrait
model PageMediaReference {  /// Identifiant CUID.  id           String             @id @default(cuid())  /// Fichier référencé.  mediaAssetId String  /// Type de contenu qui utilise le fichier (voir PageReferencerKind).  pageType     PageReferencerKind  /// Identifiant du contenu, en texte (les identifiants numériques sont  /// …  pageId       String  // …  mediaAsset   MediaAsset         @relation(fields: [mediaAssetId], references: [id], onDelete: Restrict)

Une ligne par fichier et par contenu. dit quelle colonne (chapitre 1 : SITE_PAGE, POST, DISCIPLINE, INSTRUCTOR_BIO…), quelle ligne — en texte, parce que certains contenus ont un identifiant numérique, d’autres un slug, d’autres un cuid. La relation vers le fichier est en onDelete: Restrict : la base refuse de supprimer un fichier encore recensé (partie « Données »).

Une table précalculée n’est utile que si elle est juste. Deux conditions : savoir extraire, sans en oublier, les fichiers d’un contenu ; et tenir la table à jour à chaque enregistrement, sans fenêtre où elle ment.

Extraire, sans en oublier#

L’extraction est une fonction pure du contrat, dans packages/contracts :

extractMediaIdsFromBlock — un cas par bloc

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

Les listes
TS
packages/contracts/src/page/extractMediaIds.ts
guided extract: lines 29-57focus lines 5-6
import type { PageBlockV1, PageContentV1 } from "@contracts/page/blocks.v1";/** * Extraction des références `mediaId` d'un bloc ou d'un contenu de page. * * Ces fonctions sont pures, indépendantes de toute couche de stockage, * et utilisées à deux endroits : * *   - Backend, au save d'une page (cf. routers course / stage / post) : *     calcul du diff entre références anciennes et nouvelles pour *     synchroniser la table `PageMediaReference`. * *   - Frontend, dans le builder : afficher à l'utilisateur quels *     mediaIds seront libérés si un bloc est supprimé (affordance UX). *//* -------------------------------------------------------------------------- *//*  API publique                                                              *//* -------------------------------------------------------------------------- *//** * Collecte les `mediaId` directement référencés par un bloc. * * Le `switch` est exhaustif sur `block.type` — ajouter un nouveau type au * `pageBlockSchemaV1` produit une erreur de compilation ici tant que le * `case` correspondant n'a pas été ajouté. C'est le principal garde-fou * contre les références fantômes au moment où le contrat évolue. */export function extractMediaIdsFromBlock(  block: PageBlockV1,): readonly string[] {  switch (block.type) {    case "image-gallery":      return block.items.map((item) => item.mediaId);    case "audio-collection":      return block.items.map((item) => item.mediaId);    case "document-list":      return block.items.map((item) => item.mediaId);    case "tiptap":      return walkProseMirrorForMediaIds(block.content);    case "media-text":    case "float-text":      // Même corps pour les deux : ils partagent le modèle de média (un seul      // média, bibliothèque OU avatar) et le texte riche. Seule leur mise en      // page diffère, ce qui ne regarde pas l'extraction des références.      //      // Seul un média de bibliothèque a un mediaId à résoudre ; une référence      // avatar est résolue dynamiquement ailleurs (via User.avatar).      return [        ...(block.media && block.media.kind === "library"          ? [block.media.mediaId]          : []),        ...walkProseMirrorForMediaIds(block.content),      ];    default:      return assertNever(block);  }}/** * Collecte tous les `mediaId` référencés par un contenu de page complet. * * Dédupliqué : un même asset peut apparaître dans plusieurs blocs (par * exemple la même photo dans deux galeries différentes), mais la table * `PageMediaReference` est uniquée par `(mediaAsset, page)`, donc on * dédup avant l'écriture pour éviter des erreurs d'unicité au save. */export function extractMediaIdsFromContent(  content: PageContentV1,): readonly string[] {  const ids = content.blocks.flatMap(extractMediaIdsFromBlock);  return Array.from(new Set(ids));}/** * Membres dont un contenu montre l'AVATAR (références logiques `{ kind: "avatar", userId }` des * blocs média-texte et texte enrobant). Dédupliqué. * * Sert à la règle « on ne pose que son propre avatar » (`assertOwnAvatars`, backend). */export function avatarUserIdsFromContent(content: PageContentV1): readonly string[] {  const ids = content.blocks.flatMap((block) =>    (block.type === "media-text" || block.type === "float-text") && block.media?.kind === "avatar"      ? [block.media.userId]      : [],  );  return Array.from(new Set(ids));}/* -------------------------------------------------------------------------- *//*  Walker ProseMirror                                                        *//* -------------------------------------------------------------------------- *//** * Traverse un arbre ProseMirror à la recherche des nodes `library-image` * et collecte leurs `mediaId`. * * Le node `library-image` est introduit par l'extension TipTap du builder * (sous-chantier 3 du plan). On le reconnaît par : * *   - `node.type === 'library-image'` *   - `node.attrs.mediaId` (string non-vide) * * Cette traversée est volontairement défensive : le contenu ProseMirror * est typé `Record<string, unknown>` dans le schema, donc on ne fait * aucune hypothèse forte sur la structure. Un payload corrompu ou * inattendu rend une liste vide plutôt que de jeter — le save d'une * page ne doit pas exploser à cause d'un artefact d'édition. */function walkProseMirrorForMediaIds(content: unknown): readonly string[] {  const ids: string[] = [];  function walk(node: unknown): void {    if (!node || typeof node !== "object") return;    const candidate = node as {      type?: unknown;      attrs?: { mediaId?: unknown };      content?: unknown;    };    if (      candidate.type === "library-image" &&      typeof candidate.attrs?.mediaId === "string" &&      candidate.attrs.mediaId.length > 0    ) {      ids.push(candidate.attrs.mediaId);    }    if (Array.isArray(candidate.content)) {      for (const child of candidate.content) walk(child);    }  }  walk(content);  return ids;}/* -------------------------------------------------------------------------- *//*  Garde d'exhaustivité                                                      *//* -------------------------------------------------------------------------- *//** * Vérifie à la compilation que tous les cas d'une union discriminée * sont couverts. Utilisée comme `default` dans le switch ci-dessus — * si une branche manque, TypeScript échoue parce que `value` n'est * pas `never`. */function assertNever(value: never): never {  throw new Error(    `Cas de bloc non couvert dans extractMediaIdsFromBlock : ${JSON.stringify(value)}`,  );}

La dernière étape est la garantie. assertNever n’accepte qu’une valeur de type never :

assertNever
TS
packages/contracts/src/page/extractMediaIds.ts
function assertNever(value: never): never {  throw new Error(    `Cas de bloc non couvert dans extractMediaIdsFromBlock : ${JSON.stringify(value)}`,  );}

Dans le default, TypeScript a éliminé un à un les types traités par les case : il ne reste rien, block est de type never, l’appel compile. Si l’on ajoute un septième bloc au contrat sans écrire son case, ce bloc arrive dans le default avec son type, qui n’est pas never : la compilation échoue, avec le nom du bloc oublié. Aucun type de bloc ne peut échapper au recensement.

Dans le texte riche, la descente est récursive et défensive :

Descendre dans un document ProseMirror
TS
packages/contracts/src/page/extractMediaIds.ts
function walkProseMirrorForMediaIds(content: unknown): readonly string[] {  const ids: string[] = [];  function walk(node: unknown): void {    if (!node || typeof node !== "object") return;    const candidate = node as {      type?: unknown;      attrs?: { mediaId?: unknown };      content?: unknown;    };    if (      candidate.type === "library-image" &&      typeof candidate.attrs?.mediaId === "string" &&      candidate.attrs.mediaId.length > 0    ) {      ids.push(candidate.attrs.mediaId);    }    if (Array.isArray(candidate.content)) {      for (const child of candidate.content) walk(child);    }  }  walk(content);  return ids;}

Elle ne suppose rien de la forme du document : un nœud qui n’est pas un objet est ignoré, une liste d’enfants absente aussi. Le document a déjà été assaini (chapitre 2), mais l’extraction est aussi appelée par d’autres chemins ; elle ne doit lever dans aucun cas.

extractMediaIdsFromContent sert au rendu (quels fichiers résoudre, chapitre 4) et au recensement (quels fichiers déclarer). Une seule fonction : ce que la page affiche et ce que la base croit qu’elle affiche ne peuvent pas diverger.

La synchronisation, dans la transaction#

Chaque procédure qui enregistre un contenu appelle la même fonction, dans sa transaction :

sitePage.save : écrire, puis recenser
TS
packages/backend/src/modules/sitePages/router.ts
    .mutation(async ({ ctx, input }) => {      assertOwnAvatars(ctx.sessionClient.user.id, input.content);      return ctx.prisma.$transaction(async (tx) => {        const saved = await tx.sitePage.upsert({          where: { slug: input.slug },          create: {            slug: input.slug,            title: input.title,            content: input.content as Prisma.InputJsonValue,          },          update: {            title: input.title,            content: input.content as Prisma.InputJsonValue,          },        });        // Sans cette synchronisation, les images d'une page de contenu ne        // seraient rattachées à rien et passeraient pour orphelines — donc        // éligibles au nettoyage. Le piège déjà rencontré sur le résumé de        // discipline.        await syncPageMediaReferences(tx, {          pageType: "SITE_PAGE",          pageId: input.slug,          newContent: input.content,        });        return saved;      });    }),

La fonction reçoit tx, le client de la transaction, et non prisma. Ce n’est pas un détail :

syncPageMediaReferences — une différence, pas une réécriture

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

Ce qui est déjà recensé
TS
packages/backend/src/modules/media/services/syncPageMediaReferences.service.ts
guided extract: lines 47-141focus lines 23-27
import type { Prisma, PageReferencerKind } from '@prisma/client';import { TRPCError } from '@trpc/server';import {  extractMediaIdsFromContent,  type PageContentV1,} from '@contracts/page';/** * 🔗 syncPageMediaReferences * * Synchronise les rows `PageMediaReference` après save (ou avant delete) * d'une page composite. Appelée transactionnellement par les mutations * qui sauvegardent un composite de page (cf. routers course / stage / * post). * * ─── Mécanique ───────────────────────────────────────────────────────── * *   1. Lire les références anciennes (= rows actuellement en DB pour la *      paire `(pageType, pageId)`) *   2. Extraire les références nouvelles depuis le composite via *      `extractMediaIdsFromContent` (déduplication incluse) *   3. Calculer le diff : *        toRemove = old \ new *        toAdd    = new \ old *   4. Valider que chaque mediaId à ajouter pointe vers un asset *      réellement présent en DB ET dont `status === 'published'` *   5. Appliquer le diff (DELETE puis INSERT) * * ─── Cas particulier : delete d'une page ────────────────────────────── * * Pour libérer toutes les références d'une page avant son delete, * passer `newContent: null`. Le helper supprime alors toutes les rows * `PageMediaReference` pour la paire `(pageType, pageId)` sans * valider quoi que ce soit. À appeler dans la même transaction que * le `course.delete` / `stage.delete` / `post.delete`. * * ─── Pourquoi un `tx` en paramètre et pas `prisma` ──────────────────── * * La sync DOIT s'exécuter dans la même transaction Prisma que la * mutation parente. Si une référence à ajouter pointe vers un asset * non-published, on jette `TRPCError(BAD_REQUEST)` — le throw propage * dans la transaction, le save de la page roll-back, et l'utilisateur * reçoit un diagnostic précis. Sans transaction commune, le save * pourrait réussir avec un état d'intégrité incohérent. */export async function syncPageMediaReferences(  tx: Prisma.TransactionClient,  args: {    pageType: PageReferencerKind;    pageId: string;    newContent: PageContentV1 | null;    /**     * Médias référencés par la page SANS passer par un bloc — par exemple     * l'image de carte d'une discipline, rangée dans une colonne à part.     *     * Sans eux, ces médias échapperaient au recensement et passeraient pour     * orphelins, donc éligibles au nettoyage. Ignorés quand `newContent` est     * `null` : ce cas signifie « la page disparaît », et tout doit alors     * partir avec elle.     */    extraMediaIds?: readonly string[];  },): Promise<{ added: string[]; removed: string[] }> {  const { pageType, pageId, newContent, extraMediaIds } = args;  /* ─── 1. Lecture des références anciennes ─────────────────────────── */  const oldRows = await tx.pageMediaReference.findMany({    where: { pageType, pageId },    select: { mediaAssetId: true },  });  const oldRefs = new Set(oldRows.map((r) => r.mediaAssetId));  /* ─── 2. Cas "delete" : on supprime tout et on s'arrête ───────────── */  if (newContent === null) {    if (oldRefs.size === 0) {      return { added: [], removed: [] };    }    await tx.pageMediaReference.deleteMany({      where: { pageType, pageId },    });    return { added: [], removed: [...oldRefs] };  }  /* ─── 3. Cas "save" : calcul du diff ──────────────────────────────── */  const newRefs = new Set([    ...extractMediaIdsFromContent(newContent),    ...(extraMediaIds ?? []),  ]);  const toRemove = [...oldRefs].filter((id) => !newRefs.has(id));  const toAdd = [...newRefs].filter((id) => !oldRefs.has(id));  /* ─── 4. Validation des nouvelles refs ────────────────────────────── */  if (toAdd.length > 0) {    const validAssets = await tx.mediaAsset.findMany({      // Ni brouillon, ni fichier jeté (sa ligne a suivi l'objet dans la corbeille).      where: { id: { in: toAdd }, status: 'published', NOT: { fullPath: { contains: '/bin/.trash/' } } },      select: { id: true },    });    const validIds = new Set(validAssets.map((a) => a.id));    const invalid = toAdd.filter((id) => !validIds.has(id));    if (invalid.length > 0) {      throw new TRPCError({        code: 'BAD_REQUEST',        message:          `Le composite référence ${invalid.length} mediaId(s) qui n'existent pas ` +          `ou qui ne sont pas en 'published'. Ids fautifs : ${invalid.join(', ')}.`,        cause: { invalidMediaIds: invalid },      });    }  }  /* ─── 5. Application du diff ──────────────────────────────────────── */  if (toRemove.length > 0) {    await tx.pageMediaReference.deleteMany({      where: { pageType, pageId, mediaAssetId: { in: toRemove } },    });  }  if (toAdd.length > 0) {    // `skipDuplicates` : deux enregistrements simultanés de la même page calculent le même diff ;    // le second ne doit pas échouer sur la contrainte d'unicité pour une ligne déjà écrite.    await tx.pageMediaReference.createMany({      data: toAdd.map((mediaAssetId) => ({        mediaAssetId,        pageType,        pageId,      })),      skipDuplicates: true,    });  }  return { added: toAdd, removed: toRemove };}

Pourquoi une transaction commune ? Sans elle, deux états faux deviennent possibles :

Si…

Alors…

Le contenu est écrit, puis le recensement échoue
La page montre un fichier que la base croit libre : la route publique le refuse aux visiteurs, et la corbeille accepte de le jeter.
Le recensement est écrit, puis le contenu échoue
La base croit affiché un fichier qui ne l’est pas : il ne peut plus être jeté, et il est servi au public sans raison.

Dans la transaction, les deux écritures réussissent ensemble ou échouent ensemble. Et la validation (« seulement des fichiers validés ») protège l’autre sens : on ne publie pas, en le posant dans une page, un fichier qu’un administrateur n’a pas validé.

Une procédure qui enregistre un contenu sans appeler syncPageMediaReferences ne provoque aucune erreur. Ses images s’affichent dans l’éditeur (route privée), et sont absentes pour les visiteurs. C’est arrivé avec la présentation courte des disciplines : la seule défense est de relire chaque procédure qui écrit une colonne de contenu.

Pour les contenus enregistrés avant l’existence d’une synchronisation, un rattrapage au démarrage du serveur recalcule les lignes ; il est idempotent, puisqu’une différence recalculée sur un état juste est vide. Celui des présentations d’instructeur applique en plus les règles du chapitre suivant.

Ce qui s’appuie sur le recensement#

La route publique. Un fichier n’est servi à un visiteur que s’il est validé, hors corbeille, et placé dans quelque chose de public : une galerie publique, ou un contenu recensé et publié :

La garde de la route publique
TS
apps/web/src/app/api/media/public/r2/[...path]/route.ts
empty code block

C’est ce qui empêche un visiteur de charger un fichier validé mais jamais publié dans une page (une photo de groupe rangée pour plus tard). Un recensement seul ne suffit pas : un brouillon recense ses fichiers dès son premier enregistrement, et ils étaient servis à qui connaissait leur URL alors que la page répondait 404. isPubliclyPlaced parcourt donc les lignes de recensement du fichier et demande, pour chacune, si son contenu est publié :

Publié, selon le contenu
TS
packages/backend/src/modules/media/services/publiclyPlaced.service.ts
async function isRefLive(prisma: PrismaClient, ref: Ref, now: Date): Promise<boolean> {  const n = Number(ref.pageId);  switch (ref.pageType) {    case "DISCIPLINE":      return Number.isInteger(n) && (await prisma.discipline.count({ where: { id: n, ...live(now) } })) > 0;    case "COURSE":      return Number.isInteger(n) && (await prisma.course.count({ where: { id: n, discipline: live(now) } })) > 0;    case "SEMINAR_DESCRIPTION":    case "SEMINAR_PROGRAM":    case "SEMINAR_SUMMARY":      return Number.isInteger(n) && (await prisma.seminar.count({ where: { id: n, ...live(now) } })) > 0;    case "EVENT":    case "EVENT_SUMMARY":      return Number.isInteger(n) && (await prisma.event.count({ where: { id: n, ...live(now) } })) > 0;    case "POST":      return Number.isInteger(n) && (await prisma.post.count({ where: { id: n, ...live(now) } })) > 0;    case "SITE_PAGE":      if (!isEditorialPageKey(ref.pageId)) return true;      return (await prisma.pageVisibility.count({ where: { key: ref.pageId, published: true } })) > 0;    case "INSTRUCTOR_BIO":      return true;    default: {      const never: never = ref.pageType;      return never;    }  }

Le switch est exhaustif sur PageReferencerKind, comme l’extraction l’est sur les blocs : un nouveau type de contenu recensé ne compile pas tant qu’on n’a pas dit ce que « publié » veut dire pour lui. Un cours n’a pas de date : il est publié avec sa discipline. Une page éditoriale (association, contacts, inscription) a son interrupteur ; une autre page de contenu (/infos/…, compléments des pages légales) n’en a pas et est publique.

La corbeille. Jeter un fichier encore affiché est refusé, avec la liste de ce qui l’affiche :

Ce qui empêche de jeter
TS
packages/backend/src/modules/trash/services/trashedAssets.service.ts
  const used = await prisma.mediaAsset.findMany({    where: {      AND: [        assetsWhere(appRoot, sources),        {          OR: [            { pageReferences: { some: {} } },            { galleryItems: { some: {} } },            { memberDocument: { isNot: null } },          ],        },      ],    },    select: {      fullPath: true,      displayName: true,      originalFileName: true,      pageReferences: { select: { pageType: true, pageId: true } },      galleryItems: { select: { gallery: { select: { title: true } } } },      memberDocument: { select: { id: true } },    },    take: 20,  });

L’administrateur lit par exemple « POST #42, galerie « Stage d’été » » : il sait quoi retirer d’abord. Une page ne perd jamais une image parce que quelqu’un a rangé la bibliothèque.

Le changement de statut. Repasser en attente un fichier affiché est refusé de la même façon (assertStatusChangeDoesntUnpublishReferencedAssets, partie « Stockage et médias ») : un fichier affiché ne devient pas, à l’insu de la page, un fichier que plus personne ne peut voir.

La suppression. La relation onDelete: Restrict fait refuser par la base elle-même la suppression d’un fichier recensé. C’est le dernier filet, si une procédure oubliait la vérification.

Garde

S’appuie sur

Protège contre

Route publique
Validé, recensé ET contenu publié
Un fichier servi à un visiteur sans être publié, ou celui d’un brouillon.
Corbeille
Recensé → refus
Une image qui disparaît d’une page parce qu’on a rangé la bibliothèque.
Statut
Recensé → refus de repasser en attente
Une image affichée qui devient invisible.
Base
onDelete: Restrict
La suppression d’un fichier encore affiché, si tout le reste a été oublié.

Récapitulatif#

Question

Réponse du projet

Comment la base sait-elle qu’un fichier est affiché ?
PageMediaReference : une ligne par fichier et par contenu (pageType, pageId).
Qui extrait les fichiers d’un contenu ?
extractMediaIdsFromContent, la même fonction que pour le rendu.
Comment être sûr de n’oublier aucun bloc ?
Un switch exhaustif terminé par assertNever : un bloc sans cas ne compile pas.
Quand la table est-elle mise à jour ?
À chaque enregistrement, par une différence, dans la transaction qui écrit le contenu ; un fichier non validé annule tout.
Qui s’en sert ?
La route publique, la corbeille, le changement de statut, et la base (Restrict).
Et les contenus plus anciens que la synchronisation ?
Un rattrapage idempotent au démarrage recalcule leurs lignes.

À suivre#

Jusqu’ici, un contenu est écrit par un administrateur, qui peut tout publier. La présentation d’un instructeur est différente : c’est le membre lui-même qui l’écrit, et ce qu’il y pose devient public. Le recensement, qui rend un fichier servi, devient alors une question de droits.

Chapitre suivant : un contenu écrit par un membre. Qui peut écrire une présentation, quels fichiers et quel avatar elle peut montrer, où s’ouvre son sélecteur, et pourquoi l’ordre de la page reste au club.

Sur cette page