Lire l’arbre
Comment le serveur fabrique, à chaque ouverture d’un dossier, l’arbre qu’affiche le Finder : la procédure storage.getTree passe par passe, l’enrichissement groupé, les espaces réinjectés, l’ordre des passes, la recherche, et le cache du navigateur.
Précédemment. Le stockage sait lister un niveau de clés ; il ignore les noms lisibles, les statuts, les dossiers vides et les droits. La base, par ses entités, sait ce qui doit exister. Le Finder est découpé en un composant, des adaptateurs et un backend.
Les parcours du chapitre#
Un parcours traverse ce chapitre : ouvrir la bibliothèque, y naviguer, y chercher. L’onglet en déroule les étapes, relevées dans le code ; la carte des entités montre ce que chaque étape lit en base.
Acteur : Administrateur, ou membre d’un groupe collaboratif
- 1
Le navigateur demande un dossier ; le serveur vérifie le droit de lire, lit le stockage, puis le complète avec la base
storage.getTreemembreLit : Category, Folder, FolderLabel, MediaAsset, MemberGroup, MemberGroupMembership, User
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).
storage.getTreepackages/backend/src/modules/storage/router.ts:423ensureContentRootsInTreepackages/backend/src/modules/storage/ensureContentRoots.service.ts:37requiredRootPathspackages/backend/src/modules/storage/ensureContentRoots.service.ts:11prisma.category.findManypackages/backend/src/modules/storage/ensureContentRoots.service.ts:15Category lue
resolvePersoBaseFolderpackages/backend/src/modules/media/services/resolvePersoBaseFolder.service.ts:25prisma.folder.findFirstpackages/backend/src/modules/media/services/resolvePersoBaseFolder.service.ts:38Folder lueprisma.mediaAsset.findFirstpackages/backend/src/modules/media/services/resolvePersoBaseFolder.service.ts:45MediaAsset lueprisma.user.findUniquepackages/backend/src/modules/media/services/resolvePersoBaseFolder.service.ts:53User lue
mergeGroupSpaceFoldersIntoTreepackages/backend/src/modules/storage/mergeGroupSpaceFolders.service.ts:105missingGroupSpaceFolderspackages/backend/src/modules/storage/mergeGroupSpaceFolders.service.ts:50resolveGroupBaseFolderpackages/backend/src/modules/media/services/resolveGroupBaseFolder.service.ts:27prisma.folder.findManypackages/backend/src/modules/media/services/resolveGroupBaseFolder.service.ts:47Folder lue
collaborativeGroupIdspackages/backend/src/modules/storage/mergeGroupSpaceFolders.service.ts:25prisma.memberGroup.findManypackages/backend/src/modules/storage/mergeGroupSpaceFolders.service.ts:32MemberGroup luecollaborativeEntriesForMemberpackages/backend/src/modules/memberGroups/collaborativeEntriesForMember.service.ts:12prisma.memberGroupMembership.findManypackages/backend/src/modules/memberGroups/collaborativeEntriesForMember.service.ts:27MemberGroupMembership lue
applyGroupSpaceNamesToTreepackages/backend/src/modules/storage/applyGroupSpaceNames.service.ts:76loadNameMapspackages/backend/src/modules/storage/applyGroupSpaceNames.service.ts:30prisma.folderLabel.findManypackages/backend/src/modules/storage/applyGroupSpaceNames.service.ts:35FolderLabel lue
enrichTreeWithStatuspackages/backend/src/modules/storage/services/enrichStatus.service.ts:105enrichFilesWithStatuspackages/backend/src/modules/storage/services/enrichStatus.service.ts:80metaByKeypackages/backend/src/modules/storage/services/enrichStatus.service.ts:23prisma.mediaAsset.findManypackages/backend/src/modules/storage/services/enrichStatus.service.ts:31MediaAsset lueprisma.user.findManypackages/backend/src/modules/storage/services/enrichStatus.service.ts:50User lue
assertCanReadPathpackages/backend/src/modules/memberGroups/assertCanReadPath.service.ts:15isAdminByGrouppackages/backend/src/modules/memberGroups/isAdminByGroup.service.ts:8prisma.memberGroupMembership.findFirstpackages/backend/src/modules/memberGroups/isAdminByGroup.service.ts:12MemberGroup lueMemberGroupMembership lue
resolveGroupAccessForUserpackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:15prisma.memberGroup.findUniquepackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:35MemberGroup lueprisma.memberGroupMembership.findUniquepackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:27MemberGroupMembership lue
- 2
Les libellés des dossiers, séparés de leur clé
storage.folderLabelsmembreLit : FolderLabel
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).
storage.folderLabelspackages/backend/src/modules/storage/router.ts:507ctx.prisma.folderLabel.findManypackages/backend/src/modules/storage/router.ts:508FolderLabel lue
- 3
Il cherche un fichier dans le dossier courant et ses sous-dossiers
media.searchRecursivemembreLit : MediaAsset, MemberGroup, MemberGroupMembership, User
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).
media.searchRecursivepackages/backend/src/modules/media/router.ts:256ctx.prisma.mediaAsset.findManypackages/backend/src/modules/media/router.ts:310MediaAsset lueUser luefetchBatchpackages/backend/src/modules/media/router.ts:309ctx.prisma.mediaAsset.findManypackages/backend/src/modules/media/router.ts:310MediaAsset lueUser lue
assertCanReadPathspackages/backend/src/modules/memberGroups/assertCanReadPaths.service.ts:14isAdminByGrouppackages/backend/src/modules/memberGroups/isAdminByGroup.service.ts:8prisma.memberGroupMembership.findFirstpackages/backend/src/modules/memberGroups/isAdminByGroup.service.ts:12MemberGroup lueMemberGroupMembership lue
resolveGroupAccessForUserpackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:15prisma.memberGroup.findUniquepackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:35MemberGroup lueprisma.memberGroupMembership.findUniquepackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:27MemberGroupMembership lue
La procédure qui fabrique l’arbre#
Chaque fois que le Finder ouvre un dossier, l’adaptateur appelle storage.getTree. La procédure ne relaie pas le stockage : elle traduit, en plusieurs passes, dans un ordre qui n’est pas indifférent.
storage.getTree — lire, puis compléter
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { z } from "zod";import { assertOutsideAvatars, avatarsPrefix, outsideBin } from "@backend/modules/storage/mediaPaths";import { TRPCError } from "@trpc/server";/** * Ce qui compte comme extension en fin de nom : 1 à 8 caractères * alphanumériques. Exclut les points internes d'un libellé (« (ep. BAZZE) »). */const EXTENSION_PATTERN = /\.[A-Za-z0-9]{1,8}$/;/** * La corbeille est une FEUILLE dans l'arbre du finder : on n'expose jamais son * sous-arbre physique `.trash/<uuid>/…`. On elague recursivement les enfants du * noeud dont le chemin est exactement `binPath`. (La vue plate lit trash.listBin, * le drill-down passe par `list` — aucun ne depend de getTree.) *//** * La corbeille (`<racine>/bin/…`) n'appartient qu'au routeur `trash` : ni renommage ni déplacement * n'y entre ou n'en sort par `storage`. Restaurer et vider ont leurs propres gardes (TrashEntry). */function assertOutsideBin(appRoot: string, paths: readonly string[]): void { const bin = `${appRoot}/bin`; if (paths.some((p) => p === bin || p.startsWith(`${bin}/`))) { throw new TRPCError({ code: "FORBIDDEN", message: "La corbeille se gère depuis sa propre vue (restaurer, supprimer).", }); }}function stripBinSubtree(node: StorageFolderNode, binPath: string): void { for (const child of node.children ?? []) { if (child.type !== "folder") continue; if (child.path === binPath) { child.children = []; child.hasChildren = false; } else { stripBinSubtree(child, binPath); } }}import { enrichFilesWithStatus, enrichTreeWithStatus,} from "@backend/modules/storage/services/enrichStatus.service";import { router, protectedProcedure } from "@backend/trpc";import { isAdmin } from "@backend/trpc/middleware";import { storageProviderSchema, storageMoveIntentSchema, createR2UploadAuthorizationSchema, type StorageFolderNode,} from "@contracts/storage";import { uploadDestinationSchema, type UploadDestination } from "@contracts/storage/upload-destination.schema";import { uploadTypeError } from "@contracts/storage/allowedUploadTypes";import { getAdapter } from "@backend/modules/storage/providerRegistry";import { VirtualStorage } from "@backend/modules/storage/virtualStorage";import { isProtectedEntityFolderPath } from "@backend/modules/storage/protectedEntityFolder";import { isRootFolder } from "@backend/modules/storage/services/ensureRootFolders.service";import { planMoveOperations, executeMoveOperations,} from "@backend/modules/storage/resolveMoveIntent.service";import { countPersoImages } from "@backend/modules/media/services/countPersoImages.service";import { PERSO_PHOTO_QUOTA } from "@backend/modules/media/services/persoPhotoQuota.constants";import { listCommonRepositoryFolders } from "@backend/modules/media/services/listCommonRepositoryFolders.service";import { listMyCommonRepositoryContainers } from "@backend/modules/media/services/listMyCommonRepositoryContainers.service";import { commonRepositoryContainerPath } from "@backend/modules/media/services/commonRepositoryContainerPath.service";import { resolvePendingUploadFolder } from '@backend/modules/storage/services/resolvePendingUploadFolder.service';import { assertCanReadPath } from "@backend/modules/memberGroups/assertCanReadPath.service";import { assertCanWritePaths } from "@backend/modules/memberGroups/assertCanWritePaths.service";import { resolveGroupBaseFolder } from "@backend/modules/media/services/resolveGroupBaseFolder.service";import { resolvePersoBaseFolder } from "@backend/modules/media/services/resolvePersoBaseFolder.service";import { scopePersoSpaceInTree } from "@backend/modules/storage/scopePersoSpace.service";import { collaborativeEntriesForMember } from "@backend/modules/memberGroups/collaborativeEntriesForMember.service";import { mergeGroupSpaceFolders, mergeGroupSpaceFoldersIntoTree,} from "@backend/modules/storage/mergeGroupSpaceFolders.service";import { ensureContentRootsInTree, missingContentRootFolders, requiredRootPaths,} from "@backend/modules/storage/ensureContentRoots.service";import { applyGroupSpaceNamesToFolders, applyGroupSpaceNamesToTree,} from "@backend/modules/storage/applyGroupSpaceNames.service";import { assertUploadDestinationAllowed } from '@backend/modules/storage/assertUploadDestinationAllowed.service';import { buildUploadFileName } from '@backend/modules/storage/services/buildUploadFileName.service';/** * storageRouter — finder (lecture, renommage, déplacement) et envois vers R2. *//* -------------------------------------------------------------------------- *//* Schema R2 Phase 2 — inline *//* -------------------------------------------------------------------------- *//** * Destination metier — discriminée pour gérer les deux cas : * - existing-discipline : on a categoryId + disciplineId direct * - new-discipline : on a categoryId + proposedDisciplineName (admin validera plus tard) * * Forme identique à ce que `DragNDropForm` construit côté frontend. *//** * Remplacer un fichier déjà connu, c'est publier un contenu SANS repasser par la validation : la * ligne garde son statut. Un dépôt ouvert à tout membre (discipline, stage, événement, dépôt commun) * ne doit donc pas permettre de remplacer un fichier publié, ou déposé par quelqu'un d'autre. * Autorisé : un administrateur ; un éditeur dans l'espace de son groupe (il peut déjà y mettre à la * corbeille ; son droit d'écriture est vérifié par assertUploadDestinationAllowed) ; l'auteur d'un * fichier encore en attente. Vérifié à la signature ET à l'enregistrement : l'enregistrement met à * jour la ligne existante, auteur compris. */function mayReplace( existing: { status: string; uploaderUserId: string }, user: { id: string; isAdmin: boolean }, destination: UploadDestination,): boolean { return ( user.isAdmin || destination.kind === "group" || (existing.status === "pending" && existing.uploaderUserId === user.id) );}const registerR2UploadInputSchema = z.object({ path: z.string().min(1), expectedBytes: z.number().int().positive(), expectedMimeType: z.string().min(1), // Phase 2 — nouveaux champs requis pour créer la row MediaAsset destination: uploadDestinationSchema, originalFileName: z.string().min(1).max(255),});/* -------------------------------------------------------------------------- *//* Router *//* -------------------------------------------------------------------------- */export const storageRouter = router({ /* ====================================================================== */ /* Lecture (inchangé) */ /* ====================================================================== */ /** * Compteurs « à traiter » de la bibliothèque, pour la cloche du header : * assets en attente de classement (MediaAsset.status "pending") et * entrées de corbeille (TrashEntry IN_BIN). Réservée aux administrateurs : ce sont des * compteurs de toute la bibliothèque, et la cloche qui les affiche n'existe que pour eux. */ getAttentionCounts: protectedProcedure.use(isAdmin).query(async ({ ctx }) => { const [pending, bin, commonRepositoryPending, persoCounts] = await Promise.all([ // Hors corbeille : un fichier jeté garde sa ligne (pour sa restauration), pas son tour. ctx.prisma.mediaAsset.count({ where: { appRoot: ctx.appRoot, status: "pending", ...outsideBin(ctx.appRoot) } }), ctx.prisma.trashEntry.count({ where: { status: "IN_BIN" } }), ctx.prisma.mediaAsset.count({ where: { status: "pending", appRoot: ctx.appRoot, fullPath: { startsWith: `${ctx.appRoot}/common-repository/` }, }, }), countPersoImages({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }), ]); return { pending, bin, commonRepositoryPending, persoPending: persoCounts.pending, }; }), /** * Ventilation des contenus « en attente » par dossier parent. * * Renvoie le `kind` du dossier plutôt qu'un libellé : la formulation * (« le stockage général », « votre stockage personnel »…) appartient à * l'UI, pas au backend. Les dossiers sont triés par volume décroissant. * Réservée aux administrateurs : elle nomme les dossiers de toute la bibliothèque, * espaces personnels compris. */ getPendingBreakdown: protectedProcedure.use(isAdmin).query(async ({ ctx }) => { const rows = await ctx.prisma.mediaAsset.findMany({ where: { appRoot: ctx.appRoot, status: "pending", ...outsideBin(ctx.appRoot) }, select: { fullPath: true }, }); const counts = new Map<string, number>(); for (const row of rows) { const segments = row.fullPath.split("/"); segments.pop(); // le nom du fichier const folder = segments.join("/"); if (!folder) continue; counts.set(folder, (counts.get(folder) ?? 0) + 1); } const commonRepositoryRoot = `${ctx.appRoot}/common-repository`; const persosRoot = `${ctx.appRoot}/personal-space`; const entries = Array.from(counts.entries()).map(([path, count]) => { let kind: "common_repository" | "perso" | "folder" = "folder"; if (path === commonRepositoryRoot || path.startsWith(`${commonRepositoryRoot}/`)) { kind = "common_repository"; } else if (path.startsWith(`${persosRoot}/`)) { // Personnel de l'utilisateur courant uniquement : le dossier porte // son id. Ceux des autres restent des dossiers ordinaires. kind = path.includes(ctx.sessionClient.user.id) ? "perso" : "folder"; } // Nom lisible : pour le dossier personnel d'un AUTRE utilisateur, le // dernier segment vaut « photos » et n'apprend rien — on prend le // segment qui identifie la personne. let name = path.split("/").pop() ?? path; if (kind === "folder" && path.startsWith(`${persosRoot}/`)) { name = path.slice(persosRoot.length + 1).split("/")[0] ?? name; } return { path, count, kind, name }; }); entries.sort((a, b) => b.count - a.count); return { total: rows.length, entries }; }), /** * Statut du quota d'images de l'espace perso de l'admin courant (lecture * seule). Le dossier perso est dérivé de `ctx.sessionClient.user.id`, jamais d'un input. */ getPersoPhotoQuota: protectedProcedure.query(async ({ ctx }) => { const counts = await countPersoImages({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); const remaining = Math.max(0, PERSO_PHOTO_QUOTA - counts.total); return { quota: PERSO_PHOTO_QUOTA, pending: counts.pending, published: counts.published, total: counts.total, remaining, }; }), /** * Sous-dossiers existants sous `general/` (pending + published), pour peupler * le select « dossier existant » de l'uploader général. */ listCommonRepositoryFolders: protectedProcedure.query(async ({ ctx }) => { return listCommonRepositoryFolders({ prisma: ctx.prisma, appRoot: ctx.appRoot }); }), // Sujets des dépôts de l'utilisateur courant (page membre) — scopé user. listMyCommonRepositoryContainers: protectedProcedure.query(async ({ ctx }) => { return listMyCommonRepositoryContainers({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); }), // Pose le libellé humain d'un conteneur de dépôt (appelé par le front après // un dépôt réussi). Le path est dérivé de ctx.sessionClient.user.id → un membre ne peut // labelliser QUE ses propres conteneurs. setCommonRepositoryLabel: protectedProcedure .input(z.object({ subject: z.string().trim().min(1).max(120), label: z.string().trim().min(1).max(200) })) .mutation(async ({ ctx, input }) => { const path = await commonRepositoryContainerPath({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, subject: input.subject, }); await ctx.prisma.folderLabel.upsert({ where: { path }, update: { displayName: input.label }, create: { path, displayName: input.label }, }); return { ok: true }; }), /** * Hiérarchie des espaces collaboratifs : chemin d'espace RÉEL + parent, * pour l'imbrication visuelle du finder admin (les chemins physiques * restent inchangés). Réservé aux admins. */ groupSpaceHierarchy: protectedProcedure.query(async ({ ctx }) => { if (!ctx.sessionClient.user.isAdmin) return []; const groups = await ctx.prisma.memberGroup.findMany({ where: { isCollaborative: true }, select: { id: true, name: true, parentGroupId: true }, }); return Promise.all( groups.map(async (g) => ({ groupId: g.id, name: g.name, parentGroupId: g.parentGroupId, path: await resolveGroupBaseFolder({ prisma: ctx.prisma, appRoot: ctx.appRoot, groupId: g.id, }), })), ); }), /** * Espaces des groupes collaboratifs accessibles à l'utilisateur courant * (racines du finder côté membre) : chemin + droit par groupe. * * Passerelle admin : un administrateur est d'emblée ÉDITEUR de TOUS les * espaces collaboratifs (au-dessus du système de groupes), sans * appartenance manuelle. */ myCollaborativeSpaces: protectedProcedure.query(async ({ ctx }) => { const isAdmin = ctx.sessionClient.user.isAdmin; const entries: { groupId: string; name: string; access: "VIEWER" | "EDITOR"; parentGroupId: string | null; }[] = isAdmin ? ( await ctx.prisma.memberGroup.findMany({ where: { isCollaborative: true }, select: { id: true, name: true, parentGroupId: true }, }) ).map((g) => ({ groupId: g.id, name: g.name, access: "EDITOR" as const, parentGroupId: g.parentGroupId })) : await collaborativeEntriesForMember(ctx.prisma, ctx.sessionClient.user.id); return Promise.all( entries.map(async (e) => ({ ...e, path: await resolveGroupBaseFolder({ prisma: ctx.prisma, appRoot: ctx.appRoot, groupId: e.groupId, }), })), ); }), list: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), cursor: z.string().optional(), limit: z.number().int().positive().optional(), }) ) .query(async ({ ctx, input }) => { await assertCanReadPath({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, path: input.path, }); assertOutsideAvatars(ctx.appRoot, [input.path]); const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const backend = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const reader = backend; const listed = await reader.list({ path: input.path, cursor: input.cursor, limit: input.limit, }); // Les avatars ne sont pas des médias de la bibliothèque : leur dossier n'est jamais listé. const result = { ...listed, folders: listed.folders.filter((f) => f.path !== avatarsPrefix(ctx.appRoot)), }; // Le statut vit en DB depuis l'aplatissement : on le rapatrie ici. await enrichFilesWithStatus(ctx.prisma, ctx.appRoot, result.files); // Espaces de groupe visibles même vides : R2 n'a pas de // vrais dossiers, un espace sans asset s'évaporerait du listing. if (input.path === `${ctx.appRoot}/collaborative-group-spaces`) { const merged = await mergeGroupSpaceFolders({ result, prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); return { ...merged, folders: await applyGroupSpaceNamesToFolders( merged.folders, ctx.prisma, ), }; } // Racine du finder : racines principales visibles même vides (grille). if (input.path === ctx.appRoot) { const missing = await missingContentRootFolders( result.folders.map((f) => f.path), ctx.prisma, ctx.appRoot, ); return { ...result, folders: await applyGroupSpaceNamesToFolders( [...result.folders, ...missing], ctx.prisma, ), }; } return result; }), getTree: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), depth: z.number().int().positive().optional(), }) ) .query(async ({ ctx, input }) => { await assertCanReadPath({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, path: input.path, }); assertOutsideAvatars(ctx.appRoot, [input.path]); const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const backend = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const reader = backend; // Perso : quand la grille demande le conteneur personal-space // (adapter.list = getTree depth1), on lit en réalité le sous-espace de // l'utilisateur courant → il ne voit QUE son contenu (privé + illusion). const persoContainer = `${ctx.appRoot}/personal-space`; let readPath = input.path; if (input.path === persoContainer) { try { readPath = await resolvePersoBaseFolder({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); } catch { readPath = input.path; } } const result = await reader.getTree({ path: readPath, depth: input.depth, }); // Corbeille = feuille : jamais de `.trash` dans l'arbre du finder. stripBinSubtree(result.root, `${ctx.appRoot}/bin`); // Avatars : hors de la bibliothèque, leur dossier (enfant direct de la racine) n'apparaît pas. if (result.root.children) { result.root.children = result.root.children.filter((c) => c.path !== avatarsPrefix(ctx.appRoot)); } await enrichTreeWithStatus(ctx.prisma, ctx.appRoot, result.root); // Espaces de groupe ET perso visibles même vides, dans l'arbre du finder. result.root = await mergeGroupSpaceFoldersIntoTree({ root: result.root, prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); // Racines principales visibles même vides (courses/seminars/events/ // personal-space/collaborative-group-spaces/common-repository). // Uniquement à la RACINE : la grille fait adapter.list = getTree // depth1 sur CHAQUE dossier ; sans cette garde on injecterait les // racines de contenu dans tous les dossiers. if (input.path === ctx.appRoot) { result.root = await ensureContentRootsInTree( result.root, ctx.prisma, ctx.appRoot, ); } // Perso : dans l'arbre racine, aplatir personal-space sur le contenu du // sous-espace de l'utilisateur courant (masque les autres admins). if (input.path === ctx.appRoot) { result.root = scopePersoSpaceInTree( result.root, ctx.appRoot, ctx.sessionClient.user.id, ); } // Nom EXACT des dossiers d'espace de groupe (accents) — c'est getTree // que lit l'adapter du finder (et le picker). return { ...result, root: await applyGroupSpaceNamesToTree(result.root, ctx.prisma), }; }), // Libellés d'affichage des dossiers (découplés du chemin physique). folderLabels: protectedProcedure.query(({ ctx }) => ctx.prisma.folderLabel.findMany({ select: { path: true, displayName: true }, }), ), // Racines de contenu (label-only) : le front y route « Renommer » vers // l'édition du FolderLabel plutôt que le rename physique. contentRootPaths: protectedProcedure.query(({ ctx }) => requiredRootPaths(ctx.prisma, ctx.appRoot), ), setFolderLabel: protectedProcedure .input( z.object({ path: z.string().min(1), displayName: z.string().trim().max(255), }), ) .mutation(async ({ ctx, input }) => { // Un libellé change ce que TOUT LE MONDE voit : même droit qu'un renommage. await assertCanWritePaths({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, paths: [input.path] }); assertOutsideAvatars(ctx.appRoot, [input.path]); const name = input.displayName.trim(); // Libellé vide = on retire l'override (retour au repli du listing). if (name === "") { await ctx.prisma.folderLabel.deleteMany({ where: { path: input.path } }); return { path: input.path, displayName: null as string | null }; } const row = await ctx.prisma.folderLabel.upsert({ where: { path: input.path }, create: { path: input.path, displayName: name }, update: { displayName: name }, }); return { path: row.path, displayName: row.displayName as string | null }; }), getNode: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), }) ) .query(async ({ ctx, input }) => { await assertCanReadPath({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, path: input.path, }); assertOutsideAvatars(ctx.appRoot, [input.path]); const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const backend = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const reader = backend; if (!reader.getNode) { throw new Error( `Provider "${input.provider ?? "virtual"}" does not support getNode().` ); } return reader.getNode(input.path); }), getMetadata: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), }) ) .query(async ({ ctx, input }) => { await assertCanReadPath({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, path: input.path, }); assertOutsideAvatars(ctx.appRoot, [input.path]); const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const backend = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const reader = backend; if (!reader.getMetadata) { throw new Error( `Provider "${input.provider ?? "virtual"}" does not support getMetadata().` ); } return reader.getMetadata(input.path); }), /** * Renomme un fichier ou un dossier — c'est-à-dire un `move` vers le même * parent sous un nouveau nom. * * `newBaseName` est le nom SANS extension : le client n'édite que la base * (l'extension affichée est reconstruite par `displayName`). L'extension de * la source est réappliquée telle quelle. */ rename: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), type: z.enum(["file", "folder"]).default("file"), newBaseName: z.string().min(1).max(255), }) ) .mutation(async ({ ctx, input }) => { // Écriture : un administrateur partout, un éditeur dans l'espace de son groupe. await assertCanWritePaths({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, paths: [input.path] }); assertOutsideBin(ctx.appRoot, [input.path]); assertOutsideAvatars(ctx.appRoot, [input.path]); const cleanName = input.newBaseName.trim(); if (!cleanName) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le nom ne peut pas être vide.", }); } if (cleanName.includes("/")) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le nom ne peut pas contenir « / ».", }); } // « . » et « .. » feraient un chemin que toutes les gardes refusent (`includes("..")`) : le // dossier renommé deviendrait illisible pour tout le monde. if (cleanName === "." || cleanName.includes("..")) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le nom ne peut pas contenir « .. ».", }); } const segments = input.path.split("/"); const currentName = segments.pop() ?? ""; const parent = segments.join("/"); if (!parent) { throw new TRPCError({ code: "BAD_REQUEST", message: "Impossible de renommer un élément racine.", }); } // Dossier-entité : nom PHYSIQUE immuable. Sa garde de suppression // repose sur son chemin ; le renommer (déplacement du binaire) // casserait cette protection. Refusé (un libellé d'affichage passera // par un mécanisme séparé). if (input.type === "folder" && isProtectedEntityFolderPath(input.path)) { throw new TRPCError({ code: "FORBIDDEN", message: "Ce dossier système ne peut pas être renommé.", }); } // Racine de contenu (catégorie / stages / events / espaces / dépôt) : // nom PHYSIQUE immuable aussi. Son « Renommer » édite le FolderLabel // côté client ; un rename physique casserait le lien catégorie↔dossier // et les gardes. Refusé ici (défense en profondeur). if (input.type === "folder") { const roots = await requiredRootPaths(ctx.prisma, ctx.appRoot); if (roots.includes(input.path)) { throw new TRPCError({ code: "FORBIDDEN", message: "Cette racine ne se renomme pas physiquement (libellé éditable).", }); } } // ─── FICHIER : on n'édite QUE le nom d'affichage ─────────────────── // // Depuis la slugification (increment 1), la clé de stockage est un slug // stable et opaque. Renommer un fichier ne déplace donc plus rien chez // le provider et ne devine plus d'extension (fin du bug historique) : // c'est un simple UPDATE de `displayName`, par le chemin exact. Deux fichiers peuvent // partager un nom d'affichage : pas de contrôle de collision ici, la clé reste unique. if (input.type === "file") { const result = await ctx.prisma.mediaAsset.updateMany({ where: { appRoot: ctx.appRoot, fullPath: input.path }, data: { displayName: cleanName }, }); if (result.count === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "Aucun média trouvé pour ce fichier.", }); } return { success: true, path: input.path }; } // Extension de la SOURCE, réappliquée telle quelle. // // ⚠️ Un point dans un nom n'est PAS forcément un séparateur // d'extension : « CNI recto PORQUET (ep. BAZZE) Yvonne » en contient // un. Couper au dernier point produirait une pseudo-extension // « . BAZZE) Yvonne » et mutilerait le fichier au renommage. On // n'accepte donc qu'un suffixe AYANT LA FORME d'une extension. // Un DOSSIER n'a pas d'extension : « photos.2024 » renommé en // « archives » doit donner « archives », pas « archives.2024 ». const extensionMatch = input.type === "folder" ? null : EXTENSION_PATTERN.exec(currentName); const extension = extensionMatch ? extensionMatch[0] : ""; const targetPath = `${parent}/${cleanName}${extension}`; // Renommage en son propre nom : rien à faire, mais pas une erreur. if (targetPath === input.path) { return { success: true, path: input.path }; } const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const adapter = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); // On n'écrase jamais un élément existant : un objet (ou un dossier non vide) chez le stockage, // une ligne MediaAsset, un dossier enregistré (même contrôle que storage.move). const [collision, asset, folder] = await Promise.all([ adapter.getNode ? adapter.getNode(targetPath) : Promise.resolve(null), ctx.prisma.mediaAsset.findUnique({ where: { fullPath: targetPath }, select: { id: true } }), ctx.prisma.folder.findFirst({ where: { appRoot: ctx.appRoot, fullPath: targetPath }, select: { id: true } }), ]); if (collision || asset || folder) { throw new TRPCError({ code: "CONFLICT", message: "Un élément porte déjà ce nom dans ce dossier.", }); } // `move` est optionnel sur l'interface d'adapter : on garde avant // d'appeler, comme pour `getNode` plus haut. if (!adapter.move) { throw new TRPCError({ code: "NOT_IMPLEMENTED", message: "Ce provider ne supporte pas le déplacement.", }); } await adapter.move({ source: { type: input.type, path: input.path }, target: { path: targetPath }, }); return { success: true, path: targetPath }; }), move: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), intent: storageMoveIntentSchema, }) ) .mutation(async ({ ctx, input }) => { // Écriture : la SOURCE et la CIBLE. Un éditeur ne sort rien de l'espace de son groupe, et // n'y fait rien entrer qui vienne d'ailleurs. { const source = input.intent.source; const sourcePaths = source.type === "selection" ? source.roots : [source.path]; const targetPaths = [input.intent.target.path]; await assertCanWritePaths({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, paths: [...sourcePaths, ...targetPaths], }); assertOutsideBin(ctx.appRoot, [...sourcePaths, ...targetPaths]); assertOutsideAvatars(ctx.appRoot, [...sourcePaths, ...targetPaths]); } // Dossier-entité : chemin immuable, pas de déplacement du binaire. // Même raison qu'au rename ; couvre aussi les sélections multiples. { const movedPaths = input.intent.source.type === "folder" ? [input.intent.source.path] : input.intent.source.type === "selection" ? input.intent.source.roots : []; // Les racines de contenu (cours, stages, événements, espaces…) et la corbeille non plus : // leur chemin est connu du code (destinations d'envoi, gardes, libellés). const roots = await requiredRootPaths(ctx.prisma, ctx.appRoot); if ( movedPaths.some( (pth) => isProtectedEntityFolderPath(pth) || roots.includes(pth) || isRootFolder(ctx.appRoot, pth), ) ) { throw new TRPCError({ code: "FORBIDDEN", message: "Ce dossier système ne peut pas être déplacé.", }); } } const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const adapter = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const planned = await planMoveOperations({ adapter, appRoot: ctx.appRoot, intent: input.intent }); // Une opération sur place (déposer un élément dans son propre dossier) n'a rien à faire : // l'adapter renommerait un objet sur lui-même, le stockage refuserait, et tout le geste échouerait. const effectiveOperations = planned.filter( (operation) => operation.source.path !== operation.target.path, ); // Rien ne s'écrase en silence : une copie S3 remplace l'objet qui porte déjà la clé cible, et // la ligne MediaAsset déplacée heurterait la contrainte d'unicité APRÈS la copie — le fichier // d'arrivée serait perdu. On vérifie donc chaque cible avant la moindre écriture : un objet // (ou un dossier non vide) chez le stockage, une ligne MediaAsset, un dossier enregistré. const targets = new Set<string>(); for (const operation of effectiveOperations) { const target = operation.target.path; // Deux éléments de la même sélection qui arriveraient au même nom. if (targets.has(target)) { throw new TRPCError({ code: "CONFLICT", message: `Deux éléments de la sélection s'appellent « ${target.slice(target.lastIndexOf("/") + 1)} ».`, }); } targets.add(target); const [node, asset, folder] = await Promise.all([ adapter.getNode ? adapter.getNode(target) : Promise.resolve(null), ctx.prisma.mediaAsset.findUnique({ where: { fullPath: target }, select: { id: true } }), ctx.prisma.folder.findFirst({ where: { appRoot: ctx.appRoot, fullPath: target }, select: { id: true } }), ]); if (node || asset || folder) { throw new TRPCError({ code: "CONFLICT", message: `« ${target.slice(target.lastIndexOf("/") + 1)} » existe déjà dans le dossier de destination.`, }); } } // Exécution séquentielle : des opérations concurrentes sur des // préfixes voisins pourraient se marcher dessus. await executeMoveOperations(adapter, effectiveOperations); return { operations: effectiveOperations }; }), /* ====================================================================== */ /* Upload R2 — Phase 2 (enrichi avec destination + originalFileName) */ /* ====================================================================== */ /** * Noms de fichiers qui existent DÉJÀ dans la destination (pour demander avant d'écraser). */ checkR2Conflicts: protectedProcedure .input(z.object({ destination: uploadDestinationSchema, fileNames: z.array(z.string().min(1).max(255)).max(100) })) .query(async ({ ctx, input }) => { await assertUploadDestinationAllowed({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, isAdmin: ctx.sessionClient.user.isAdmin, destination: input.destination }); const folder = await resolvePendingUploadFolder({ prisma: ctx.prisma, destination: input.destination, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); // Deux fichiers du même lot peuvent donner la même clé (« Photo 1.JPG » et « photo-1.jpg ») : // envoyés en parallèle, le second écraserait le premier sans que personne ne l'ait demandé. // On les signale par leur position dans le lot (deux noms identiques ne se distinguent pas). const byPath = new Map<string, string>(); const duplicateIndexes: number[] = []; input.fileNames.forEach((name, index) => { const path = `${folder}/${buildUploadFileName(name)}`; if (byPath.has(path)) duplicateIndexes.push(index); else byPath.set(path, name); }); const found = await ctx.prisma.mediaAsset.findMany({ where: { fullPath: { in: [...byPath.keys()] } }, select: { fullPath: true } }); return { existing: found.map((f) => byPath.get(f.fullPath)).filter((name): name is string => Boolean(name)), duplicateIndexes, }; }), createR2Upload: protectedProcedure .input(createR2UploadAuthorizationSchema) .mutation(async ({ ctx, input }) => { await assertUploadDestinationAllowed({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, isAdmin: ctx.sessionClient.user.isAdmin, destination: input.destination, }); // Le chemin se calcule ICI, une seule fois (jamais côté client). const folder = await resolvePendingUploadFolder({ prisma: ctx.prisma, destination: input.destination, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); const fileName = buildUploadFileName(input.originalFileName); const path = `${folder}/${fileName}`; // Formats autorisés seulement (extension ET type déclaré) : jamais de HTML, SVG ou // script, qu'un navigateur pourrait exécuter s'il était affiché. Le type est signé dans // l'URL d'envoi, puis revérifié par registerR2Upload sur l'objet réellement stocké. const typeError = uploadTypeError(input.originalFileName, input.mimeType); if (typeError) { throw new TRPCError({ code: "BAD_REQUEST", message: typeError }); } if (input.destination.kind === "perso" && !input.mimeType.startsWith("image/")) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le dossier perso n'accepte que des images." }); } // Pas d'écrasement silencieux : confirmation explicite requise. const existing = await ctx.prisma.mediaAsset.findUnique({ where: { fullPath: path }, select: { id: true, status: true, uploaderUserId: true }, }); if (existing && !input.allowOverwrite) { throw new TRPCError({ code: "CONFLICT", message: `Un fichier « ${fileName} » existe déjà à cet endroit.` }); } // Confirmer ne suffit pas : remplacer, c'est publier sans validation (mayReplace). if (existing && !mayReplace(existing, ctx.sessionClient.user, input.destination)) { throw new TRPCError({ code: "FORBIDDEN", message: `« ${fileName} » existe déjà et ne peut être remplacé que par un administrateur. Renommez votre fichier.`, }); } if (input.destination.kind === "perso" && !existing) { const { total } = await countPersoImages({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id }); if (total + 1 > PERSO_PHOTO_QUOTA) { throw new TRPCError({ code: "BAD_REQUEST", message: `Quota d'images perso atteint (max ${PERSO_PHOTO_QUOTA}).` }); } } const adapter = getAdapter("r2", { prisma: ctx.prisma, appRoot: ctx.appRoot, }); const auth = await adapter.createUploadAuthorization({ path, mimeType: input.mimeType, maxBytes: input.maxBytes, }); // Le client a besoin du chemin résolu : il le renverra tel quel à // `registerR2Upload`. Le chemin ne retraverse jamais une règle de calcul. return { ...auth, path }; }), /** * Phase 2 — l'input transporte maintenant `destination` + `originalFileName` * pour permettre à l'adapter R2 de créer la row MediaAsset après HeadObject. */ registerR2Upload: protectedProcedure .input(registerR2UploadInputSchema) .mutation(async ({ ctx, input }) => { await assertUploadDestinationAllowed({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, isAdmin: ctx.sessionClient.user.isAdmin, destination: input.destination, }); // Le chemin enregistré doit être celui que createR2Upload a autorisé pour cette destination. const expectedFolder = await resolvePendingUploadFolder({ prisma: ctx.prisma, destination: input.destination, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); const rest = input.path.slice(expectedFolder.length + 1); if (!input.path.startsWith(`${expectedFolder}/`) || rest === "" || rest.includes("/") || rest.includes("..")) { throw new TRPCError({ code: "FORBIDDEN", message: "Chemin d'envoi hors de la destination autorisée." }); } // L'enregistrement met à jour une ligne existante (auteur compris) : même règle qu'à la // signature, sans quoi un membre pourrait « adopter » le fichier en attente d'un autre, puis le // remplacer en tant qu'auteur. const existing = await ctx.prisma.mediaAsset.findUnique({ where: { fullPath: input.path }, select: { status: true, uploaderUserId: true }, }); if (existing && !mayReplace(existing, ctx.sessionClient.user, input.destination)) { throw new TRPCError({ code: "FORBIDDEN", message: "Ce fichier existe déjà et ne peut être remplacé que par un administrateur.", }); } const adapter = getAdapter("r2", { prisma: ctx.prisma, appRoot: ctx.appRoot, }); return adapter.registerUploadedAsset({ path: input.path, userId: ctx.sessionClient.user.id, expectedBytes: input.expectedBytes, expectedMimeType: input.expectedMimeType, destination: input.destination, originalFileName: input.originalFileName, }); }),});L’ordre des passes#
Le tutoriel Cloudinary construisait l’arbre dans l’ordre inverse : d’abord la charpente des dossiers enregistrés en base, puis l’injection des feuilles venues du fournisseur. C’était logique : Cloudinary ne rendait qu’un tableau plat, et seule la base savait dessiner des niveaux.
Avec un stockage S3, l’ordre s’inverse, et pour une bonne raison : le stockage sait désormais rendre un niveau d’arbre (Delimiter). Il fournit donc la vérité physique — ce qui existe vraiment — et la base vient compléter ce qu’il ne peut pas dire. La règle de fusion tient en une ligne : le physique prime. Un espace réinjecté depuis la base n’est ajouté que s’il manque :
/** Espaces de GROUPE manquants parmi `existingPaths`. */async function missingGroupSpaceFolders(params: { existingPaths: ReadonlyArray<string>; prisma: PrismaClient; appRoot: string; userId: string;}): Promise<StorageFolderNode[]> { const { existingPaths, prisma, appRoot, userId } = params; const groupIds = await collaborativeGroupIds(prisma, userId); const extra: StorageFolderNode[] = []; for (const groupId of groupIds) { if (existingPaths.some((p) => p.endsWith(`-${groupId}`))) continue; try { extra.push(folderNode(await resolveGroupBaseFolder({ prisma, appRoot, groupId }))); } catch { // groupe disparu entre deux requêtes : ignoré. } } return extra;}Le dédoublonnage se fait sur le suffixe stable -<identifiant du groupe>, pas sur le nom : un groupe renommé garde son espace, et n’en voit pas apparaître un second. Et collaborativeGroupIds applique les droits : un administrateur reçoit tous les groupes collaboratifs, un membre seulement les siens.
Situation | Le stockage | La base | L’arbre |
|---|---|---|---|
Espace avec des fichiers | le montre | le connaît | l’espace (une seule fois, par son suffixe) |
Espace tout juste créé | ne le montre pas | le connaît | l’espace, vide |
Dossier rangé par un membre | le montre | ne le connaît pas | le dossier |
Groupe auquel on n’a pas accès | — | le connaît | rien (droits appliqués) |
Showing entries1to4entries
Les racines de contenu suivent la même logique, à partir des catégories et d’une liste fixe :
export async function requiredRootPaths( prisma: PrismaClient, appRoot: string,): Promise<string[]> { const categories = await prisma.category.findMany({ select: { type: true, storageSegment: true } }); return [ ...categories.map((c) => categoryFolderPath(appRoot, c)), `${appRoot}/seminars`, `${appRoot}/events`, `${appRoot}/personal-space`, `${appRoot}/collaborative-group-spaces`, `${appRoot}/common-repository`, ];}Dans un arbre fusionné, chaque source a son domaine : le stockage dit ce qui existe, la base dit ce qui doit exister et comment le nommer. On ne laisse jamais la base inventer un fichier, ni le stockage décider d’un droit.
Ce que la base sait des fichiers#
metaByKey — deux requêtes pour tout un dossier
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import type { PrismaClient } from "@prisma/client";import type { StorageFileNode, StorageNode } from "@contracts/storage";/** * Enrichissement du listing avec le statut de cycle de vie. * * Le stockage ne connaît ni le statut, ni le nom d'origine, ni l'expéditeur d'un fichier : ils * vivent dans `MediaAsset`. Cette couche les rapatrie pour que `meta.status` soit renseigné côté * client (pastille de statut, filtres, règle `isPickable`) et que le nom lisible remplace le slug. * * La clé interrogée est le `path` du nœud : la clé de stockage EST `MediaAsset.fullPath`. * Deux requêtes pour tout un dossier (les lignes, puis les expéditeurs), jamais une par fichier. */type LifecycleStatus = "pending" | "published" | "bin";function isLifecycleStatus(value: string): value is LifecycleStatus { return value === "pending" || value === "published" || value === "bin";}type ResolvedMeta = { status?: LifecycleStatus; human?: string; uploaderName?: string };async function metaByKey( prisma: PrismaClient, appRoot: string, keys: string[],): Promise<Map<string, ResolvedMeta>> { const map = new Map<string, ResolvedMeta>(); if (keys.length === 0) return map; const rows = await prisma.mediaAsset.findMany({ where: { appRoot, fullPath: { in: keys }, }, select: { fullPath: true, status: true, displayName: true, originalFileName: true, uploaderUserId: true, }, }); // Résolution batchée des noms d'expéditeur (un lookup pour tout le lot). const uploaderIds = [ ...new Set(rows.map((r) => r.uploaderUserId).filter(Boolean)), ] as string[]; const users = uploaderIds.length ? await prisma.user.findMany({ where: { id: { in: uploaderIds } }, select: { id: true, firstName: true, lastName: true, pseudo: true }, }) : []; const nameById = new Map( users.map((u) => [ u.id, [u.firstName, u.lastName].filter(Boolean).join(" ").trim() || u.pseudo || "Utilisateur", ]), ); for (const row of rows) { // La cle de stockage est slugifiee : c'est ici qu'on // rend au finder le nom lisible. displayName cure sinon nom d'origine ; // l'extension/format est geree par les helpers d'affichage du front. const human = row.displayName?.trim() || row.originalFileName || undefined; const status = isLifecycleStatus(row.status) ? row.status : undefined; const uploaderName = row.uploaderUserId ? nameById.get(row.uploaderUserId) : undefined; const meta: ResolvedMeta = { status, human, uploaderName }; if (row.fullPath) map.set(row.fullPath, meta); } return map;}/** Pose `metadata.status` ET le nom d'affichage humain sur chaque fichier. */export async function enrichFilesWithStatus( prisma: PrismaClient, appRoot: string, files: ReadonlyArray<StorageFileNode>,): Promise<void> { if (files.length === 0) return; const byKey = await metaByKey(prisma, appRoot, files.map((file) => file.path)); for (const file of files) { const hit = byKey.get(file.path); if (!hit) continue; if (hit.status || hit.uploaderName) { file.metadata = { ...(file.metadata ?? {}), ...(hit.status ? { status: hit.status } : {}), ...(hit.uploaderName ? { uploaderName: hit.uploaderName } : {}), }; } // Le `name` stocké est le slug ; on affiche le nom lisible quand il est connu. if (hit.human) file.name = hit.human; }}/** Même chose, en parcourant récursivement un arbre. */export async function enrichTreeWithStatus( prisma: PrismaClient, appRoot: string, root: StorageNode,): Promise<void> { const files: StorageFileNode[] = []; const walk = (node: StorageNode): void => { if (node.type === "file") { files.push(node); return; } for (const child of node.children ?? []) walk(child); }; walk(root); await enrichFilesWithStatus(prisma, appRoot, files);}Un dossier de 200 photos coûte deux requêtes, pas 400. La version naïve — une lecture de par fichier, puis une lecture du membre par fichier — est le piège classique du « N + 1 » : invisible avec trois fichiers de test, elle devient la page lente dont personne ne comprend la lenteur.
Les libellés des dossiers#
Les dossiers suivent le même découplage que les fichiers : la clé ne change jamais, et donne un nom d’affichage. Le fil d’Ariane et l’arbre lisent ces libellés en une seule requête (storage.folderLabels), et « Renommer » une racine de contenu modifie son libellé, pas son chemin (storage.setFolderLabel, garde d’écriture comprise : un libellé change ce que tout le monde voit).
Partout dans le Finder, deux notions cohabitent : l’identité d’un nœud (sa clé, stable, qui sert aux opérations et aux jointures) et son nom affiché (nom d’origine, displayName, ). Une opération ne reconstruit jamais un chemin à partir d’un nom affiché.
L’espace personnel, une illusion assumée#
export function scopePersoSpaceInTree( root: StorageFolderNode, appRoot: string, userId: string,): StorageFolderNode { const persoContainer = `${appRoot}/personal-space`; const rebuild = (n: StorageNode): StorageNode => { if (n.type !== "folder") return n; if (n.path === persoContainer) { const mine = (n.children ?? []).find( (c): c is StorageFolderNode => c.type === "folder" && c.path.endsWith(`-${userId}`), ); return { ...n, children: mine?.children ?? [], hasChildren: mine?.hasChildren ?? (mine?.children?.length ?? 0) > 0, }; } if (!n.children) return n; return { ...n, children: n.children.map(rebuild) }; }; return rebuild(root) as StorageFolderNode;}Physiquement, personal-space contient un sous-dossier par utilisateur. À l’affichage, il montre directement le contenu de l’utilisateur courant : chacun a l’impression que « Espace personnel » est à lui. L’illusion est purement visuelle ; la garde, elle, est ailleurs — canReadStorageKey refuse la lecture d’un espace qui n’est pas le sien, quel que soit le chemin demandé.
Chercher dans un sous-arbre#
La recherche (media.searchRecursive) parcourt le dossier courant et tous ses sous-dossiers. Elle cherche dans le nom d’origine, le nom d’affichage, la description et le nom de l’expéditeur — autant de champs que le stockage ignore : la recherche lit donc la base, pas le stockage.
// 2. Lecture par paquets, triés par chemin, jusqu'à avoir assez de fichiers qui correspondent. // // Le filtre (nom, description, expéditeur, éventuellement une expression régulière) s'exécute // ici et non en base : lire « les N premiers » puis filtrer manquerait tout fichier qui // correspond au-delà de ces N. On lit donc tout le sous-arbre, paquet par paquet, dans une // limite de SCAN_MAX lignes (le résultat est alors signalé tronqué). const SCAN_BATCH = 500; const SCAN_MAX = 10_000; const candidates: Array<Awaited<ReturnType<typeof fetchBatch>>[number]> = []; async function fetchBatch(cursor: string | undefined) { return ctx.prisma.mediaAsset.findMany({ where: { appRoot: input.appRoot, OR: [{ fullPath: input.prefix }, { fullPath: { startsWith: `${input.prefix}/` } }], // Depuis la racine, la corbeille n'est pas fouillée : ses fichiers ont leur propre vue. ...(input.prefix.startsWith(`${input.appRoot}/bin`) ? {} : outsideBin(input.appRoot)), }, include: { uploader: { select: { id: true, firstName: true, lastName: true, pseudo: true } }, }, orderBy: { fullPath: 'asc' }, take: SCAN_BATCH, ...(cursor ? { cursor: { fullPath: cursor }, skip: 1 } : {}), }); } let scanTruncated = false; for (let cursor: string | undefined; ; ) { const batch = await fetchBatch(cursor); candidates.push(...batch); if (batch.length < SCAN_BATCH) break; if (candidates.length >= SCAN_MAX) { scanTruncated = true; break; } cursor = batch[batch.length - 1].fullPath; }La première version lisait « les 1 000 premières lignes du sous-arbre », puis filtrait. Tout fichier correspondant au-delà de ces 1 000 était introuvable, sans que rien ne le signale. La lecture se fait maintenant par paquets triés par chemin, avec un curseur (cursor + skip: 1 reprend juste après la dernière ligne lue), jusqu’à épuisement du sous-arbre ou à une borne de 10 000 lignes — auquel cas le résultat est marqué tronqué, et l’interface le dit.
Deux gardes précèdent la lecture : le droit de lire le dossier de départ, et l’expression régulière réservée aux administrateurs (une expression mal construite peut bloquer le serveur, chapitre 4 et fiche 1.8).
Côté navigateur : un cache par dossier#
Le composant garde son état dans un store Zustand (useFinderStore) : dossier courant, contenu, sélection, tri, mode d’affichage. Le hook useFinderData charge le contenu du dossier courant et le range dans un cache par chemin :
- revenir dans un dossier déjà visité l’affiche immédiatement, sans requête ni indicateur de chargement ;
- une réponse arrivée après qu’on a changé de dossier est ignorée (
cancelled) : sinon, un dossier lent à charger pourrait écraser l’affichage du dossier suivant ; - après une modification (envoi, déplacement, renommage),
reloadFolderContent()vide le cache et force une relecture :
Le tri suit la convention des explorateurs : les dossiers d’abord, puis les fichiers, avec une comparaison qui respecte les accents et les nombres :
« photo 2 » passe avant « photo 10 », et « Élève » se range avec « eleve ». Le comparateur est créé une fois : en fabriquer un par tri coûterait à chaque rendu.
Enfin, la lentille de statut (« Tous », « En attente », « Validés ») ne filtre que la grille, jamais l’arbre : un arbre qui change de forme quand on change de filtre rendrait indécidable la question « ce dossier a-t-il disparu parce qu’il est filtré, ou parce qu’il n’existe plus ? ».
Récapitulatif#
Question | Réponse du projet |
|---|---|
Qui fabrique l’arbre ? | Le serveur (storage.getTree), à chaque ouverture : droits, stockage, puis compléments de la base. |
Dans quel ordre ? | Le stockage d’abord (ce qui existe), la base ensuite (ce qui doit exister, et comment le nommer). Le physique prime. |
Comment éviter un espace en double ? | Dédoublonnage par le suffixe stable « -<identifiant> », jamais par le nom. |
Combien de requêtes pour enrichir un dossier ? | Deux, quel que soit le nombre de fichiers. |
Comment la recherche trouve-t-elle tout ? | Lecture par paquets triés, avec curseur, bornée à 10 000 lignes et signalée tronquée au-delà. |
Que garde le navigateur ? | Un cache par dossier, vidé après chaque modification ; les réponses périmées sont ignorées. |
Showing entries1to6entries
À suivre#
L’arbre se lit. Il reste à le modifier : renommer, déplacer, jeter, restaurer. Chaque geste touche deux mondes qui ne partagent aucune transaction — le stockage et la base — et le Finder ne vaut que si les deux restent d’accord.
Chapitre suivant : modifier sans casser. Renommer sans déplacer, déplacer en copiant, refuser d’écraser, la corbeille et sa quarantaine, et ce qui reste fragile.