Les espaces collaboratifs
Un espace de fichiers par groupe collaboratif, un seul dossier pour toute sa vie, des gardes qui déduisent tout du chemin lu par position, un dépôt qui devient document du groupe, et un finder réservé aux membres sur une racine virtuelle.
Précédemment. Les groupes forment un arbre dont Administrateurs est la racine immobile ; l’accès d’un membre à un espace se calcule en remontant l’arbre, ses espaces en le descendant.
Les documents des membres descendent du club vers ses membres. Certains groupes ont aussi besoin de travailler ensemble : le bureau prépare l’assemblée générale, les instructeurs partagent leurs programmes. Il leur faut un espace où déposer leurs propres fichiers, les ranger et les consulter, sans voir ceux des autres groupes.
Les parcours du chapitre#
Un parcours traverse ce chapitre : le membre éditeur qui dépose un fichier dans l’espace de son groupe, et le partage.
Acteur : Membre éditeur d'un groupe
- 1
Le finder des membres liste les espaces auxquels il a accès
storage.myCollaborativeSpacesmembreLit : Folder, MemberGroup, MemberGroupMembership
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.myCollaborativeSpacespackages/backend/src/modules/storage/router.ts:326resolveGroupBaseFolderpackages/backend/src/modules/media/services/resolveGroupBaseFolder.service.ts:27prisma.folder.findManypackages/backend/src/modules/media/services/resolveGroupBaseFolder.service.ts:47Folder lueprisma.memberGroup.findUniquepackages/backend/src/modules/media/services/resolveGroupBaseFolder.service.ts:34MemberGroup lue
ctx.prisma.memberGroup.findManypackages/backend/src/modules/storage/router.ts:336MemberGroup luecollaborativeEntriesForMemberpackages/backend/src/modules/memberGroups/collaborativeEntriesForMember.service.ts:12prisma.memberGroup.findManypackages/backend/src/modules/memberGroups/collaborativeEntriesForMember.service.ts:19MemberGroup lueprisma.memberGroupMembership.findManypackages/backend/src/modules/memberGroups/collaborativeEntriesForMember.service.ts:27MemberGroupMembership lue
- 2
Le serveur autorise l'envoi, après avoir vérifié qu'il est éditeur
storage.createR2UploadmembreLit : Category, Discipline, Event, Folder, MediaAsset, MemberGroup, MemberGroupMembership, Seminar, 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.createR2Uploadpackages/backend/src/modules/storage/router.ts:886resolvePendingUploadFolderpackages/backend/src/modules/storage/services/resolvePendingUploadFolder.service.ts:41prisma.category.findUniquepackages/backend/src/modules/storage/services/resolvePendingUploadFolder.service.ts:111Category lueprisma.discipline.findUniquepackages/backend/src/modules/storage/services/resolvePendingUploadFolder.service.ts:123Discipline lueprisma.event.findUniquepackages/backend/src/modules/storage/services/resolvePendingUploadFolder.service.ts:80Event lueresolvePersoBaseFolderpackages/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
resolveGroupBaseFolderpackages/backend/src/modules/media/services/resolveGroupBaseFolder.service.ts:27prisma.folder.findManypackages/backend/src/modules/media/services/resolveGroupBaseFolder.service.ts:47Folder lueprisma.memberGroup.findUniquepackages/backend/src/modules/media/services/resolveGroupBaseFolder.service.ts:34MemberGroup lue
prisma.seminar.findUniquepackages/backend/src/modules/storage/services/resolvePendingUploadFolder.service.ts:66Seminar luecommonRepositoryContainerPathpackages/backend/src/modules/media/services/commonRepositoryContainerPath.service.ts:17prisma.user.findUniquepackages/backend/src/modules/media/services/commonRepositoryContainerPath.service.ts:24User lue
ctx.prisma.mediaAsset.findUniquepackages/backend/src/modules/storage/router.ts:917MediaAsset luecountPersoImagespackages/backend/src/modules/media/services/countPersoImages.service.ts:21prisma.mediaAsset.groupBypackages/backend/src/modules/media/services/countPersoImages.service.ts:28MediaAsset lue
assertUploadDestinationAllowedpackages/backend/src/modules/storage/assertUploadDestinationAllowed.service.ts:25assertCanWriteGroupSpacepackages/backend/src/modules/memberGroups/assertCanWriteGroupSpace.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
- 3
Le fichier envoyé est enregistré dans la bibliothèque
storage.registerR2Uploadmembre- EventDiscipline créée
- MediaAsset créée : appRoot, bytes, categoryId, disciplineId, duration, eventId, format, fullPath, height, mimeType, originalFileName, proposedDisciplineName, status, uploaderUserId, width
- MediaAsset modifiée : appRoot, bytes, categoryId, disciplineId, duration, eventId, format, fullPath, height, mimeType, originalFileName, proposedDisciplineName, status, uploaderUserId, width
Lit : Category, Discipline, Event, Folder, MediaAsset, MemberGroup, MemberGroupMembership, Seminar, 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.registerR2Uploadpackages/backend/src/modules/storage/router.ts:957adapter.registerUploadedAssetpackages/backend/src/modules/storage/adapters/r2/r2StorageAdapter.ts:358prisma.eventDiscipline.createManypackages/backend/src/modules/storage/adapters/r2/r2StorageAdapter.ts:476EventDiscipline crééeprisma.mediaAsset.upsertpackages/backend/src/modules/storage/adapters/r2/r2StorageAdapter.ts:484MediaAsset crééeMediaAsset modifiée
- 4
Il le partage avec son groupe, comme document du groupe
memberDocument.depositToGroupSpacemembre- MemberDocument créée : audience, groups, mediaAssetId, publishedById, title
- MemberDocumentGroup créée : groupId
Lit : MediaAsset, MemberDocument, MemberGroup, MemberGroupMembership
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).
memberDocument.depositToGroupSpacepackages/backend/src/modules/memberDocuments/router.ts:359ctx.prisma.memberDocument.createpackages/backend/src/modules/memberDocuments/router.ts:399MemberDocument crééeMemberDocumentGroup créée
Un dossier pour toute sa vie 📁#
L’espace d’un groupe collaboratif est un dossier du stockage, sous collaborative-group-spaces. Son chemin est calculé une fois :
const container = `${appRoot}/collaborative-group-spaces/`; const existing = await prisma.folder.findMany({ where: { appRoot, fullPath: { startsWith: container, endsWith: `-${groupId}` } }, orderBy: { createdAt: "asc" }, select: { fullPath: true }, }); // Un espace est un enfant DIRECT du conteneur (pas un sous-dossier homonyme). const space = existing.find((f) => !f.fullPath.slice(container.length).includes("/")); if (space) return space.fullPath; const groupSlug = slug(group.name) || `group-${groupId}`; return `${container}${groupSlug}-${groupId}`;Le chemin se termine par l’identifiant du groupe. C’est lui qui compte : toutes les vérifications de droits le lisent, et le finder affiche le nom du groupe à la place du segment technique.
Un espace a un seul dossier pour toute sa vie. Le nom lisible au début du chemin est celui du groupe à la création de l’espace, et il ne change plus : si le dossier existe déjà, la fonction le renvoie tel quel, trouvé par son suffixe -<identifiant>. Renommer le groupe renomme l’espace pour tout le monde, par le nom affiché, sans déplacer un seul fichier. Le finder refuse par ailleurs de renommer directement le dossier d’un espace : son nom ne change que par celui du groupe.
Un chemin stable et un nom affiché séparé : renommer devient gratuit. Les déplacements de fichiers coûtent cher dans un stockage objet (une copie par fichier, partie « Stockage et médias ») ; le nom d’un groupe change, lui, à volonté.
Les gardes, tout se déduit du chemin#
Quatre opérations touchent un espace : lire, déposer, mettre à la corbeille, rendre un fichier visible aux membres du groupe. Chacune a sa garde, et toutes suivent la même règle : le groupe concerné se lit dans le chemin du fichier ou du dossier visé, jamais dans une information séparée que le navigateur pourrait choisir librement.
function spaceOwnerId(path: string, container: string): string | null { const parts = path.split("/").filter(Boolean); if (parts.some((p) => p === "." || p === "..")) return null; if (parts[1] !== container || parts.length < 3) return null; const segment = parts[2]; const dash = segment.lastIndexOf("-"); if (dash === -1) return null; const id = segment.slice(dash + 1); return id.length > 0 ? id : null;}/** Groupe dont l'espace collaboratif contient ce chemin (`<racine>/collaborative-group-spaces/<slug>-<groupId>/…`). */export function groupIdFromLogicalPath(path: string): string | null { return spaceOwnerId(path, "collaborative-group-spaces");}Le chemin est lu par position : le conteneur doit être le deuxième segment (AKFC/collaborative-group-spaces/<slug>-<id>/…). Un dossier qui porterait ce nom ailleurs dans la bibliothèque n’ouvre rien, et un chemin contenant . ou .. est refusé. L’identifiant est la fin du troisième segment, après le dernier tiret : le nom du groupe peut contenir des tirets, l’identifiant jamais. La même lecture donne le propriétaire d’un espace personnel (personalSpaceOwnerId).
Opération | Garde | Exige |
|---|---|---|
Parcourir un dossier | assertCanReadPath | un accès au groupe du chemin (VIEWER ou EDITOR), héritage compris |
Ouvrir un fichier | canReadStorageKey | un accès au groupe de la clé de stockage |
Déposer | assertCanWriteGroupSpace (destination « group ») | EDITOR sur le groupe ; le serveur calcule lui-même le dossier de dépôt |
Valider, mettre à la corbeille | assertCanTrashPaths | EDITOR sur le groupe de chaque chemin ; hors d’un espace de groupe, réservé aux administrateurs |
Rendre visible au groupe | depositToGroupSpace | EDITOR sur le groupe, et un fichier situé dans l’espace de ce même groupe |
Showing entries1to5entries
Pour déposer, le navigateur ne donne pas de chemin : il donne une destination (« l’espace du groupe X »), et le serveur calcule le dossier (partie « Stockage et médias », signer un envoi). La garde ne porte donc que sur le droit :
export async function assertCanWriteGroupSpace(params: { prisma: PrismaClient; userId: string; groupId: string;}): Promise<void> { const { prisma, userId, groupId } = params; if (await isAdminByGroup(prisma, userId)) return; const access = await resolveGroupAccessForUser(prisma, userId, groupId); if (access !== "EDITOR") { throw new TRPCError({ code: "FORBIDDEN", message: "Dépôt réservé aux éditeurs de ce groupe.", }); }}L’administrateur est vérifié d’abord, sans remonter l’arbre ; puis l’accès effectif, héritage compris (chapitre 4). Un lecteur est refusé.
Un dépôt devient un document du groupe#
Après l’envoi, le fichier est dans l’espace. Pour qu’il bénéficie du suivi de lecture, des badges et de la cloche (chapitre 3), il est publié comme document des membres ciblé sur le groupe :
memberDocument.depositToGroupSpace
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { isDemoEmail } from "@backend/lib/demoAccounts";import { z } from "zod";import { TRPCError } from "@trpc/server";import { router, protectedProcedure } from "@backend/trpc/core";import { isAdmin } from "@backend/trpc/middleware";import { collaborativeEntriesForMember } from "@backend/modules/memberGroups/collaborativeEntriesForMember.service";import { assertCanWriteGroupSpace } from "@backend/modules/memberGroups/assertCanWriteGroupSpace.service";import { groupIdFromLogicalPath } from "@backend/modules/memberGroups/assertCanTrashPaths.service";import { memberDocumentReadableBy, memberDocumentVisibleTo } from "@backend/modules/memberDocuments/visibleTo";/** * Documents membres : fichiers du finder mis à disposition des membres, avec * statut de lecture par membre. * * Modèle : `MemberDocument` (surcouche sur un MediaAsset) ; audience * ALL_MEMBERS (tous, nouveaux inclus d'office) ou SPECIFIC (destinataires * explicites = perso aujourd'hui, bureau demain) ; `DocumentReceipt` = lu/non * lu par membre (absence de ligne ou readAt null = non lu). */const adminProcedure = protectedProcedure.use(isAdmin);/** Condition « ce document concerne l'utilisateur » (partagée avec la route de livraison). */const visibleToUser = memberDocumentVisibleTo;export const memberDocumentRouter = router({ // ─── Membre ──────────────────────────────────────────────────────────── /** Documents visibles par le membre courant, avec son statut de lecture. */ listForMe: protectedProcedure.query(async ({ ctx }) => { const userId = ctx.sessionClient.user.id; // Lisibles : ce qui le concerne, plus les dépôts des espaces collaboratifs hérités. const readable = await memberDocumentReadableBy(ctx.prisma, userId); const myGroupIds = new Set([ ...( await ctx.prisma.memberGroupMembership.findMany({ where: { userId }, select: { groupId: true }, }) ).map((m) => m.groupId), ...readable.collaborativeGroupIds, ]); const docs = await ctx.prisma.memberDocument.findMany({ where: readable.where, orderBy: { publishedAt: "desc" }, select: { id: true, title: true, audience: true, publishedAt: true, mediaAsset: { select: { displayName: true, originalFileName: true, mimeType: true, format: true, }, }, receipts: { where: { userId }, select: { readAt: true } }, recipients: { where: { userId }, select: { userId: true } }, groups: { select: { group: { select: { id: true, name: true } } } }, }, }); return docs.map((d) => ({ id: d.id, title: d.title ?? d.mediaAsset.displayName ?? d.mediaAsset.originalFileName, audience: d.audience, publishedAt: d.publishedAt, mimeType: d.mediaAsset.mimeType, format: d.mediaAsset.format, // Pas de reçu, ou reçu à readAt null (re-marqué non lu) = non lu. readAt: d.receipts[0]?.readAt ?? null, // Destinataire direct (document personnel) ? personal: d.recipients.length > 0, // Groupes visés dont ce membre fait partie (par quel groupe il l'a reçu). groups: d.groups.map((g) => g.group).filter((g) => myGroupIds.has(g.id)), })); }), /** Nombre de documents non lus (pour la cloche). */ unreadCountForMe: protectedProcedure.query(async ({ ctx }) => { const userId = ctx.sessionClient.user.id; const me = await ctx.prisma.user.findUnique({ where: { id: userId }, select: { memberSince: true }, }); // Borne = 1er janvier de l'ANNÉE d'adhésion (pas la date exacte) : un membre // qui adhère après la dernière AG doit quand même être invité à lire le // compte rendu de la même année. Les documents antérieurs restent listés // sur la page (lecture volontaire). memberSince null → pas de borne. const yearStart = me?.memberSince ? new Date(Date.UTC(me.memberSince.getUTCFullYear(), 0, 1)) : null; return ctx.prisma.memberDocument.count({ where: { ...visibleToUser(userId), // Non lu = aucun reçu daté pour ce membre. receipts: { none: { userId, readAt: { not: null } } }, ...(yearStart ? { publishedAt: { gte: yearStart } } : {}), }, }); }), /** * Compteur de la CLOCHE collaborative : documents non lus ciblant les * groupes collaboratifs accessibles au membre (héritage compris), hors * ses propres dépôts. Distinct de « Documents » (perso/diffusion). */ collaborativeUnreadCountForMe: protectedProcedure.query(async ({ ctx }) => { const userId = ctx.sessionClient.user.id; const entries = await collaborativeEntriesForMember(ctx.prisma, userId); const groupIds = entries.map((e) => e.groupId); if (groupIds.length === 0) return 0; return ctx.prisma.memberDocument.count({ where: { groups: { some: { groupId: { in: groupIds } } }, receipts: { none: { userId, readAt: { not: null } } }, NOT: { publishedById: userId }, }, }); }), /** * LISTE des documents non lus des espaces collaboratifs accessibles * (héritage compris), hors ses propres dépôts — pour le menu de la cloche. */ collaborativeUnreadForMe: protectedProcedure.query(async ({ ctx }) => { const userId = ctx.sessionClient.user.id; const entries = await collaborativeEntriesForMember(ctx.prisma, userId); const groupIds = entries.map((e) => e.groupId); if (groupIds.length === 0) return []; const docs = await ctx.prisma.memberDocument.findMany({ where: { groups: { some: { groupId: { in: groupIds } } }, receipts: { none: { userId, readAt: { not: null } } }, NOT: { publishedById: userId }, }, select: { id: true, title: true, mediaAsset: { select: { originalFileName: true } }, groups: { select: { group: { select: { id: true, name: true } } } }, }, orderBy: { publishedAt: "desc" }, take: 20, }); return docs.map((d) => ({ id: d.id, title: d.title ?? d.mediaAsset?.originalFileName ?? "Document", groupName: d.groups.find((g) => groupIds.includes(g.group.id))?.group.name ?? d.groups[0]?.group.name ?? "", })); }), /** Non-lus ventilés : généraux (ALL_MEMBERS, bornés à l'année d'adhésion) et * persos (SPECIFIC pour ce membre, toujours signalés). Pour le badge de * l'item « Documents » de la navbar. */ unreadBreakdownForMe: protectedProcedure.query(async ({ ctx }) => { const userId = ctx.sessionClient.user.id; const me = await ctx.prisma.user.findUnique({ where: { id: userId }, select: { memberSince: true }, }); const yearStart = me?.memberSince ? new Date(Date.UTC(me.memberSince.getUTCFullYear(), 0, 1)) : null; const unread = { receipts: { none: { userId, readAt: { not: null } } } }; const generalBound = yearStart ? { publishedAt: { gte: yearStart } } : {}; const myGroups = await ctx.prisma.memberGroupMembership.findMany({ where: { userId }, select: { group: { select: { id: true, name: true } } }, }); const [general, perso, total, ...groupCounts] = await Promise.all([ ctx.prisma.memberDocument.count({ where: { audience: "ALL_MEMBERS" as const, ...unread, ...generalBound }, }), ctx.prisma.memberDocument.count({ where: { audience: "SPECIFIC" as const, recipients: { some: { userId } }, ...unread, }, }), // Total DISTINCT (un doc atteignant le membre par plusieurs canaux compté // une seule fois) : sert au badge. ctx.prisma.memberDocument.count({ where: { ...unread, OR: [ { audience: "ALL_MEMBERS" as const, ...generalBound }, { audience: "SPECIFIC" as const, OR: [ { recipients: { some: { userId } } }, { groups: { some: { group: { memberships: { some: { userId } } } }, }, }, ], }, ], }, }), ...myGroups.map(({ group }) => ctx.prisma.memberDocument.count({ where: { audience: "SPECIFIC" as const, groups: { some: { groupId: group.id } }, ...unread, }, }), ), ]); const byGroup = myGroups .map(({ group }, i) => ({ groupId: group.id, name: group.name, count: groupCounts[i] ?? 0, })) .filter((g) => g.count > 0); return { general, perso, total, byGroup }; }), /** Marquer lu (à l'ouverture de l'aperçu). */ markRead: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const userId = ctx.sessionClient.user.id; const readable = await memberDocumentReadableBy(ctx.prisma, userId); const visible = await ctx.prisma.memberDocument.findFirst({ where: { AND: [{ id: input.id }, readable.where] }, select: { id: true }, }); if (!visible) { throw new TRPCError({ code: "NOT_FOUND", message: "Document introuvable." }); } await ctx.prisma.documentReceipt.upsert({ where: { memberDocumentId_userId: { memberDocumentId: input.id, userId } }, create: { memberDocumentId: input.id, userId, readAt: new Date() }, update: { readAt: new Date() }, }); return { success: true }; }), /** Re-marquer non lu (garde le signal de la cloche pour y revenir). */ markUnread: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const userId = ctx.sessionClient.user.id; const readable = await memberDocumentReadableBy(ctx.prisma, userId); const visible = await ctx.prisma.memberDocument.findFirst({ where: { AND: [{ id: input.id }, readable.where] }, select: { id: true }, }); if (!visible) { throw new TRPCError({ code: "NOT_FOUND", message: "Document introuvable." }); } await ctx.prisma.documentReceipt.upsert({ where: { memberDocumentId_userId: { memberDocumentId: input.id, userId } }, create: { memberDocumentId: input.id, userId, readAt: null }, update: { readAt: null }, }); return { success: true }; }), // ─── Admin ───────────────────────────────────────────────────────────── /** Met un fichier du finder à disposition des membres. */ publish: adminProcedure .input( z.object({ path: z.string(), title: z.string().trim().max(300).optional(), audience: z.enum(["ALL_MEMBERS", "SPECIFIC"]), recipientUserIds: z.array(z.string()).optional(), groupIds: z.array(z.string()).optional(), }), ) .mutation(async ({ ctx, input }) => { const recipientIds = input.audience === "SPECIFIC" ? (input.recipientUserIds ?? []) : []; const groupIds = input.audience === "SPECIFIC" ? (input.groupIds ?? []) : []; if ( input.audience === "SPECIFIC" && recipientIds.length === 0 && groupIds.length === 0 ) { throw new TRPCError({ code: "BAD_REQUEST", message: "Choisissez au moins un groupe ou un membre.", }); } // Le finder fournit un chemin, pas un id : la clé de stockage (`fullPath`, unique). const asset = await ctx.prisma.mediaAsset.findUnique({ where: { fullPath: input.path }, select: { id: true }, }); if (!asset) { throw new TRPCError({ code: "NOT_FOUND", message: "Fichier introuvable." }); } const existing = await ctx.prisma.memberDocument.findUnique({ where: { mediaAssetId: asset.id }, select: { id: true }, }); if (existing) { throw new TRPCError({ code: "CONFLICT", message: "Ce fichier est déjà mis à disposition des membres.", }); } // Destinataires et groupes vérifiés en base : un identifiant inconnu (un membre supprimé // pendant que la boîte de dialogue était ouverte) donne un message, pas une erreur interne. const [knownUsers, knownGroups] = await Promise.all([ ctx.prisma.user.count({ where: { id: { in: recipientIds } } }), ctx.prisma.memberGroup.count({ where: { id: { in: groupIds } } }), ]); if (knownUsers !== new Set(recipientIds).size || knownGroups !== new Set(groupIds).size) { throw new TRPCError({ code: "BAD_REQUEST", message: "Destinataire ou groupe inconnu : rechargez la page." }); } const doc = await ctx.prisma.memberDocument.create({ data: { mediaAssetId: asset.id, title: input.title, audience: input.audience, publishedById: ctx.sessionClient.user.id, recipients: { create: [...new Set(recipientIds)].map((id) => ({ userId: id })) }, groups: { create: [...new Set(groupIds)].map((id) => ({ groupId: id })) }, }, select: { id: true }, }); return { id: doc.id }; }), /** * Dépôt d'un fichier dans l'espace d'un groupe collaboratif par un * ÉDITEUR (ou admin) : publie le MediaAsset comme MemberDocument ciblé * sur CE groupe, pour que tout le système documents (reçus, badges, * cloche) s'applique. Idempotent doux (ne re-publie pas un asset déjà * mis à disposition). */ depositToGroupSpace: protectedProcedure .input( z.object({ path: z.string(), groupId: z.string(), title: z.string().trim().max(300).optional(), }), ) .mutation(async ({ ctx, input }) => { await assertCanWriteGroupSpace({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, groupId: input.groupId, }); // Le fichier doit se trouver DANS l'espace de ce groupe : être éditeur d'un groupe ne // permet pas d'y diffuser un fichier pris ailleurs (document personnel d'un autre // membre, espace d'un autre groupe…), que ses membres pourraient alors ouvrir. if (input.path.includes("..") || groupIdFromLogicalPath(input.path) !== input.groupId) { throw new TRPCError({ code: "FORBIDDEN", message: "Ce fichier n'est pas dans l'espace de ce groupe.", }); } const asset = await ctx.prisma.mediaAsset.findUnique({ where: { fullPath: input.path }, select: { id: true }, }); if (!asset) { throw new TRPCError({ code: "NOT_FOUND", message: "Fichier introuvable." }); } const existing = await ctx.prisma.memberDocument.findUnique({ where: { mediaAssetId: asset.id }, select: { id: true }, }); if (existing) { return { id: existing.id, alreadyPublished: true }; } const doc = await ctx.prisma.memberDocument.create({ data: { mediaAssetId: asset.id, title: input.title, audience: "SPECIFIC", publishedById: ctx.sessionClient.user.id, groups: { create: [{ groupId: input.groupId }] }, }, select: { id: true }, }); return { id: doc.id, alreadyPublished: false }; }), /** Retire la mise à disposition (cascade reçus + destinataires). */ unpublish: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { await ctx.prisma.memberDocument.delete({ where: { id: input.id } }); return { success: true }; }), /** Liste admin de tous les documents mis à disposition. */ listAdmin: adminProcedure.query(async ({ ctx }) => { return ctx.prisma.memberDocument.findMany({ orderBy: { publishedAt: "desc" }, select: { id: true, title: true, audience: true, publishedAt: true, mediaAsset: { select: { displayName: true, originalFileName: true } }, _count: { select: { recipients: true, receipts: true } }, }, }); }), /** Membres, pour choisir les destinataires d'une diffusion restreinte. */ listMembers: adminProcedure.query(async ({ ctx }) => { const users = await ctx.prisma.user.findMany({ orderBy: [{ lastName: "asc" }, { firstName: "asc" }, { email: "asc" }], select: { id: true, firstName: true, lastName: true, email: true }, }); return users.map((u) => ({ id: u.id, name: [u.firstName, u.lastName].filter(Boolean).join(" ").trim() || u.email, email: u.email, isDemo: isDemoEmail(u.email), })); }), /** État de mise à disposition d'un fichier (pour le menu du finder). */ publicationForPath: adminProcedure .input(z.object({ path: z.string() })) .query(async ({ ctx, input }) => { const asset = await ctx.prisma.mediaAsset.findUnique({ where: { fullPath: input.path }, select: { id: true }, }); if (!asset) return null; return ctx.prisma.memberDocument.findUnique({ where: { mediaAssetId: asset.id }, select: { id: true, audience: true, _count: { select: { recipients: true } }, }, }); }),});Une garde ne fait pas confiance à deux informations séparées qui devraient concorder : elle déduit l’une de l’autre, ou elle vérifie qu’elles concordent. Le droit porte sur le groupe ; le groupe se lit dans le chemin.
Le finder des membres#
Un membre retrouve ses espaces sur la page « Mes espaces ». La page réutilise le même composant Finder que le tableau de bord, avec deux différences :
readOnly retire du finder les actions de gestion (sélection multiple, changement de statut, suppression). Les gardes refuseraient de toute façon ces opérations à qui n’en a pas le droit : l’interface ne les propose simplement pas.
memberFinderAdapter fournit au finder une racine virtuelle, « Mes espaces ». Un membre peut appartenir à plusieurs groupes sans lien entre eux, comme le bureau et la commission des stages : ses espaces n’ont pas de dossier commun dans le stockage. La racine virtuelle les réunit. Ses enfants sont les espaces de plus haut niveau auxquels le membre a accès, et chaque espace montre son contenu suivi de ses sous-groupes.
La liste de ces espaces vient du serveur :
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, }),Un administrateur voit tous les espaces, en éditeur. Un membre, ceux que donne la descente de l’arbre depuis ses appartenances (chapitre 4) — le même calcul que la cloche et que la condition d’ouverture des documents. Chaque espace porte son chemin, résolu par la même fonction que le dépôt.
Le dépôt passe par une barre au-dessus du finder, qui n’apparaît que dans un espace où le membre est éditeur :
L’espace courant se déduit du dossier ouvert : le premier espace dont le chemin est le dossier lui-même ou l’un de ses ancêtres. Le / de s.path + "/" évite qu’un espace bureau réponde aussi pour bureau-elargi.
Récapitulatif#
Question | Réponse du projet |
|---|---|
Qu’est-ce qu’un espace collaboratif ? | Le dossier collaborative-group-spaces/<nom>-<id> d’un groupe marqué isCollaborative, un seul pour toute sa vie. |
Que se passe-t-il quand le groupe est renommé ? | Rien ne bouge dans le stockage : le nom affiché change, le chemin reste. |
Comment une garde trouve-t-elle le groupe ? | Dans le chemin visé, par position, avec l’identifiant qui termine le dossier de l’espace. |
Qui peut déposer ? | Un administrateur, ou un éditeur du groupe (héritage compris) ; le serveur calcule le dossier. |
Que vérifie la publication d’un dépôt ? | L’accès éditeur, et que le fichier est bien dans l’espace du groupe visé ; un fichier déjà publié ne l’est pas deux fois. |
Comment un membre parcourt-il ses espaces ? | Avec le finder du tableau de bord, en lecture seule, sur une racine virtuelle qui réunit ses espaces. |
Showing entries1to6entries
À suivre#
Les membres reçoivent les documents du club et partagent leurs fichiers entre eux. Il leur reste à pouvoir s’adresser au club : poser une question, signaler un problème, demander une attestation, et obtenir une réponse suivie.
Chapitre suivant : les demandes. Un numéro, un responsable, un délai : le système de demandes, les réponses envoyées depuis le site, et la relève des réponses par e-mail.