Les groupes
Ce qu’est un groupe : une liste de diffusion, un niveau d’accès par appartenance, une place dans un arbre dont « Administrateurs » est la racine immobile ; comment les droits descendent l’arbre, comment on les calcule dans les deux sens, et comment l’arbre reste un arbre.
Précédemment. Le club diffuse des documents à tous, à des personnes ou à des groupes ; ce qui concerne un membre et ce qu’il peut ouvrir sont deux conditions posées à chaque lecture.
Les parcours du chapitre#
Un parcours traverse ce chapitre : l’administrateur qui organise les groupes du club, de la création à la suppression.
Acteur : Administrateur
- 1
Il crée un groupe ; sans parent choisi, il est rangé sous Administrateurs
memberGroup.createadmin- Folder créée : appRoot, fullPath, status
- Folder modifiée : appRoot, fullPath, status
- MemberGroup créée : description, isCollaborative, name, parentGroupId
Lit : Folder, MemberGroup
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).
memberGroup.createpackages/backend/src/modules/memberGroups/router.ts:53ensureGroupSpaceFolderpackages/backend/src/modules/memberGroups/ensureGroupSpaceFolder.service.ts:18prisma.folder.upsertpackages/backend/src/modules/memberGroups/ensureGroupSpaceFolder.service.ts:28Folder crééeFolder modifiée
ctx.prisma.memberGroup.createpackages/backend/src/modules/memberGroups/router.ts:75MemberGroup créée
- 2
Il le range sous un autre groupe ; un cycle est refusé
memberGroup.setParentGroupadmin- MemberGroup modifiée : parentGroupId
Lit : MemberGroup
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).
memberGroup.setParentGrouppackages/backend/src/modules/memberGroups/router.ts:141ctx.prisma.memberGroup.updatepackages/backend/src/modules/memberGroups/router.ts:205MemberGroup modifiée
- 3
Il y ajoute un membre, lecteur ou éditeur
memberGroup.addMemberadmin- MemberGroupMembership créée : access, groupId, userId
- MemberGroupMembership modifiée : access, groupId, userId
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).
memberGroup.addMemberpackages/backend/src/modules/memberGroups/router.ts:237ctx.prisma.memberGroupMembership.upsertpackages/backend/src/modules/memberGroups/router.ts:246MemberGroupMembership crééeMemberGroupMembership modifiée
- 4
Il retire un membre ; jamais lui-même des Administrateurs
memberGroup.removeMemberadmin- MemberGroupMembership supprimée
Lit : MemberGroup
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).
memberGroup.removeMemberpackages/backend/src/modules/memberGroups/router.ts:265ctx.prisma.memberGroupMembership.deleteManypackages/backend/src/modules/memberGroups/router.ts:280MemberGroupMembership supprimée
- 5
Il supprime un groupe dont l'espace est vide ; ses sous-groupes remontent d'un cran
memberGroup.deleteadmin- Folder supprimée
- MemberDocumentGroup supprimée · en cascade
- MemberGroup supprimée
- MemberGroup modifiée : parentGroupId
- MemberGroupMembership supprimée · en cascade
Lit : Folder, MemberGroup
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).
memberGroup.deletepackages/backend/src/modules/memberGroups/router.ts:124deleteGroupWithSpacepackages/backend/src/modules/memberGroups/deleteGroupWithSpace.service.ts:20prisma.folder.deleteManypackages/backend/src/modules/memberGroups/deleteGroupWithSpace.service.ts:86Folder suppriméeprisma.memberGroup.deletepackages/backend/src/modules/memberGroups/deleteGroupWithSpace.service.ts:90MemberGroup suppriméeonDelete: Cascade (MemberDocumentGroup → MemberGroup)prisma/schema.prisma:1861MemberDocumentGroup supprimée · en cascadeonDelete: SetNull (MemberGroup → MemberGroup)prisma/schema.prisma:1810MemberGroup modifiéeonDelete: Cascade (MemberGroupMembership → MemberGroup)prisma/schema.prisma:1834MemberGroupMembership supprimée · en cascade
prisma.memberGroup.updateManypackages/backend/src/modules/memberGroups/deleteGroupWithSpace.service.ts:81MemberGroup modifiée
Deux sortes de groupes 👥#
Un groupe () sert d’abord de liste de diffusion : on lui adresse des documents, et ses membres les reçoivent (chapitre 3). Un groupe marqué reçoit en plus un espace de fichiers, un dossier du stockage qui lui est propre (chapitre 5).
model MemberGroup { /// Identifiant CUID. Sert aussi de suffixe au dossier de l'espace (stable /// quand le groupe est renommé). id String @id @default(cuid()) /// Nom affiché. name String /// Description libre, affichée dans le panneau de contrôle. description String? /// Vrai : le groupe dispose d'un espace collaboratif dans le finder. /// Faux (défaut) : simple liste de diffusion de documents. isCollaborative Boolean @default(false) /// Groupe « Administrateurs » : ses membres sont les administrateurs du site. /// Créé au démarrage s'il manque (ensureAdminGroup), toujours collaboratif, /// non supprimable. Unique par convention : aucune contrainte en base /// n'empêche un second groupe marqué ; c'est ensureAdminGroup qui s'en charge. isAdminGroup Boolean @default(false) /// Création du groupe. createdAt DateTime @default(now()) /// Groupe parent ; null = groupe racine. Un parent ne peut pas être un /// descendant du groupe (cycle refusé par memberGroup.setParentGroup). parentGroupId String? /// Parent (N–1, MemberGroup.childGroups). Filet de sécurité : si le parent /// disparaît sans passer par deleteGroupWithSpace, le groupe devient racine /// (SET NULL). parentGroup MemberGroup? @relation("GroupHierarchy", fields: [parentGroupId], references: [id], onDelete: SetNull) /// Sous-groupes (1–N, MemberGroup.parentGroup). childGroups MemberGroup[] @relation("GroupHierarchy") /// Adhésions (1–N, MemberGroupMembership.group). Effacées avec le groupe. memberships MemberGroupMembership[] /// Documents qui ciblent ce groupe (1–N, MemberDocumentGroup.group). Les liens /// sont effacés avec le groupe ; les documents restent. documents MemberDocumentGroup[] /// Catégories de demandes dont ce groupe est responsable (1–N, /// TicketCategory.group). Empêchent sa suppression. ticketCategories TicketCategory[] @@index([parentGroupId])}Trois champs comptent ici. ouvre l’espace. marque le groupe des administrateurs : c’est l’appartenance à ce groupe, et rien d’autre, qui fait d’un membre un administrateur du site (partie « Authentification et droits »). range le groupe dans un arbre.
Lecteur ou éditeur#
L’appartenance à un groupe porte un niveau d’accès à son espace :
model MemberGroupMembership { /// Identifiant CUID. id String @id @default(cuid()) /// Groupe. groupId String /// Groupe (N–1, MemberGroup.memberships). Adhésion effacée avec le groupe. group MemberGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) /// Membre. userId String /// Membre (N–1, User.memberGroupMemberships). Adhésion effacée avec le membre. user User @relation("MemberGroupMembership", fields: [userId], references: [id], onDelete: Cascade) /// Droit du membre sur l'espace du groupe, si celui-ci est COLLABORATIF (voir /// MemberGroupAccess). Sans effet sur la diffusion des documents. access MemberGroupAccess @default(EDITOR) // Un membre au plus une fois par groupe. @@unique([groupId, userId]) @@index([userId])}Accès | Ce qu’il permet dans l’espace du groupe |
|---|---|
VIEWER | Consulter les dossiers et ouvrir les fichiers. |
EDITOR | Consulter, déposer des fichiers, les valider pour le groupe, et les mettre à la corbeille. |
Showing entries1to2entries
Ce niveau ne concerne que l’espace : il ne donne aucun droit ailleurs sur le site, et ne fait pas d’un membre un administrateur. Un compte est créé avec ses groupes en EDITOR (chapitre 1) ; l’administrateur abaisse l’accès d’un membre, groupe par groupe, s’il le faut.
Le retrait d’un membre porte une règle :
removeMember: adminProcedure .input(z.object({ groupId: z.string(), userId: z.string() })) .mutation(async ({ ctx, input }) => { if (input.userId === ctx.sessionClient.user.id) { const group = await ctx.prisma.memberGroup.findUnique({ where: { id: input.groupId }, select: { isAdminGroup: true }, }); if (group?.isAdminGroup) { throw new TRPCError({ code: "FORBIDDEN", message: "Vous ne pouvez pas vous retirer vous-même du groupe Administrateurs.", }); } } await ctx.prisma.memberGroupMembership.deleteMany({ where: { groupId: input.groupId, userId: input.userId }, }); return { success: true }; }),Un administrateur qui se retirerait du groupe Administrateurs perdrait aussitôt l’accès au panneau, et le club pourrait se retrouver sans administrateur. Un autre administrateur peut le faire ; lui, non.
Une hiérarchie qui transmet ses droits vers le bas#
Les groupes forment un arbre : chacun a au plus un parent. Les droits descendent dans cet arbre : un membre d’un groupe a accès aux espaces de tous les groupes placés sous lui, au même niveau. Un membre du bureau, éditeur de son groupe, est aussi éditeur de l’espace d’un sous-groupe « Commission des stages ». L’inverse n’est pas vrai : un membre de la commission ne voit pas l’espace du bureau.
Cet héritage se calcule dans les deux sens, selon la question posée.
« Quel accès a ce membre à CET espace ? » — la question d’une garde. On part du groupe de l’espace et on remonte vers la racine, en gardant le niveau le plus élevé rencontré :
resolveGroupAccessForUser — remonter
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import type { PrismaClient } from "@prisma/client";export type GroupAccessLevel = "NONE" | "VIEWER" | "EDITOR";/** * Accès EFFECTIF d'un utilisateur à l'espace d'un groupe, HÉRITAGE compris. * * On remonte la chaîne des ancêtres (parentGroupId) depuis le groupe visé et on * prend le niveau MAXIMUM parmi l'appartenance directe et celles aux groupes * ancêtres (EDITOR > VIEWER > NONE). Un membre d'un groupe parent hérite ainsi * de l'accès (même niveau) aux espaces des groupes descendants. * * NB : ne traite PAS l'admin (les gardes court-circuitent l'admin en amont). */export async function resolveGroupAccessForUser( prisma: PrismaClient, userId: string, groupId: string,): Promise<GroupAccessLevel> { let best: GroupAccessLevel = "NONE"; let cursor: string | null = groupId; const seen = new Set<string>(); while (cursor && !seen.has(cursor)) { seen.add(cursor); const membership = await prisma.memberGroupMembership.findUnique({ where: { groupId_userId: { groupId: cursor, userId } }, select: { access: true }, }); if (membership?.access === "EDITOR") return "EDITOR"; if (membership?.access === "VIEWER") best = "VIEWER"; const group: { parentGroupId: string | null } | null = await prisma.memberGroup.findUnique({ where: { id: cursor }, select: { parentGroupId: true }, }); cursor = group?.parentGroupId ?? null; } return best;}« À quels espaces ce membre a-t-il accès ? » — la question du finder des membres et de la cloche. On part de ses appartenances et on descend l’arbre, en propageant le niveau :
const effective = new Map<string, Access>(); const queue: { id: string; access: Access }[] = memberships.map((m) => ({ id: m.groupId, access: m.access, })); while (queue.length > 0) { const item = queue.shift(); if (!item) break; const prev = effective.get(item.id); const level: Access = item.access === "EDITOR" || prev === "EDITOR" ? "EDITOR" : "VIEWER"; if (prev === level) continue; effective.set(item.id, level); for (const childId of childrenOf.get(item.id) ?? []) { queue.push({ id: childId, access: level }); }Un parcours en largeur : une file de groupes à visiter, chacun avec le niveau qui lui arrive. Un groupe déjà atteint avec le même niveau n’est pas revisité (prev === level), ce qui borne le parcours même si l’arbre avait une boucle. Le résultat ne garde que les groupes collaboratifs : ce sont les seuls qui ont un espace.
Les deux fonctions doivent dire la même chose, lue dans deux sens. Quand elles divergent, ce que la cloche signale ne correspond plus à ce que les gardes permettent — c’est exactement le défaut du chapitre 3, dont la cloche ne se vidait pas.
Un arbre dont la racine ne bouge pas#
Le groupe « Administrateurs » est la racine de l’arbre. Il est garanti au démarrage du serveur, unique et collaboratif :
export async function ensureAdminGroup( prisma: PrismaClient, appRoot: string,): Promise<{ groupId: string }> { let group = await prisma.memberGroup.findFirst({ where: { isAdminGroup: true }, select: { id: true }, }); if (!group) { group = await prisma.memberGroup.create({ data: { name: ADMIN_GROUP_NAME, isCollaborative: true, isAdminGroup: true, }, select: { id: true }, }); } await ensureGroupSpaceFolder({ prisma, appRoot, groupId: group.id }); return { groupId: group.id };}Un groupe créé sans parent lui est rattaché :
const parentGroupId = input.parentGroupId ?? ( await ctx.prisma.memberGroup.findFirst({ where: { isAdminGroup: true }, select: { id: true }, }) )?.id ?? null;Comme il est l’ancêtre de tous les groupes, ses membres héritent de l’accès éditeur à tous les espaces. Les gardes vérifient d’ailleurs d’abord l’appartenance à ce groupe (isAdminByGroup), sans remonter l’arbre.
Cette position donne au groupe un pouvoir qui se transmet : quiconque serait placé au-dessus de lui hériterait de tous les espaces du club. L’arbre interdisait les cycles, mais rien n’empêchait de détacher un groupe (« aucun parent »), puis de le choisir comme parent d’Administrateurs. Ses membres seraient devenus éditeurs de tous les espaces.
Un arbre sans cycle#
setParentGroup — garder un arbre
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 { ensureGroupSpaceFolder } from "@backend/modules/memberGroups/ensureGroupSpaceFolder.service";import { deleteGroupWithSpace } from "@backend/modules/memberGroups/deleteGroupWithSpace.service";const adminProcedure = protectedProcedure.use(isAdmin);/** Nom affichable d'un membre (prénom/nom, sinon email). */function memberName(u: { firstName: string | null; lastName: string | null; email: string;}): string { return [u.firstName, u.lastName].filter(Boolean).join(" ").trim() || u.email;}/** * Groupes de membres (ex. « Bureau »), gérés dans le panneau de contrôle. * Servent de listes de diffusion : l'appartenance est dynamique (utilisée à la * volée pour la visibilité des documents, cf. router memberDocument). */export const memberGroupRouter = router({ /** Groupes + nombre de membres. */ list: adminProcedure.query(async ({ ctx }) => { const groups = await ctx.prisma.memberGroup.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true, description: true, isCollaborative: true, isAdminGroup: true, parentGroupId: true, _count: { select: { memberships: true } }, }, }); return groups.map((g) => ({ id: g.id, name: g.name, description: g.description, isCollaborative: g.isCollaborative, isAdminGroup: g.isAdminGroup, parentGroupId: g.parentGroupId, memberCount: g._count.memberships, })); }), /** Crée un groupe. */ create: adminProcedure .input( z.object({ name: z.string().trim().min(1).max(120), description: z.string().trim().max(500).optional(), isCollaborative: z.boolean().optional(), parentGroupId: z.string().optional(), }), ) .mutation(async ({ ctx, input }) => { // Règle « Administrateurs ancêtre de tout » : sans parent explicite, // le nouveau groupe est rattaché au groupe Administrateurs. const parentGroupId = input.parentGroupId ?? ( await ctx.prisma.memberGroup.findFirst({ where: { isAdminGroup: true }, select: { id: true }, }) )?.id ?? null; const group = await ctx.prisma.memberGroup.create({ data: { name: input.name, description: input.description, isCollaborative: input.isCollaborative ?? false, parentGroupId, }, select: { id: true }, }); if (input.isCollaborative) { await ensureGroupSpaceFolder({ prisma: ctx.prisma, appRoot: ctx.appRoot, groupId: group.id, }); } return { id: group.id }; }), /** Renomme / met à jour un groupe. */ update: adminProcedure .input( z.object({ id: z.string(), name: z.string().trim().min(1).max(120), description: z.string().trim().max(500).optional(), isCollaborative: z.boolean().optional(), }), ) .mutation(async ({ ctx, input }) => { await ctx.prisma.memberGroup.update({ where: { id: input.id }, data: { name: input.name, description: input.description ?? null, isCollaborative: input.isCollaborative, }, }); if (input.isCollaborative === true) { await ensureGroupSpaceFolder({ prisma: ctx.prisma, appRoot: ctx.appRoot, groupId: input.id, }); } return { success: true }; }), /** Supprime un groupe (cascade appartenances + liens documents). */ delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { await deleteGroupWithSpace({ prisma: ctx.prisma, appRoot: ctx.appRoot, groupId: input.id, }); return { success: true }; }), /** * Définit le groupe PARENT d'un groupe — l'inclusion/hiérarchie (arbre). * `parentGroupId=null` le rattache au groupe Administrateurs (la racine). * Garde anti-cycle : le nouveau parent ne peut être ni le groupe lui-même ni * l'un de ses descendants. Le groupe Administrateurs, lui, n'a jamais de parent. */ setParentGroup: adminProcedure .input( z.object({ groupId: z.string(), parentGroupId: z.string().nullable(), }), ) .mutation(async ({ ctx, input }) => { const { groupId } = input; const adminGroup = await ctx.prisma.memberGroup.findFirst({ where: { isAdminGroup: true }, select: { id: true }, }); // Administrateurs est la RACINE : ses membres héritent de l'accès à tous les espaces. Lui // donner un parent ferait hériter les membres de ce parent de tous les espaces du club. if (groupId === adminGroup?.id) { throw new TRPCError({ code: "FORBIDDEN", message: "Le groupe Administrateurs reste à la racine : il ne peut pas avoir de parent.", }); } // « Sans parent » = sous Administrateurs, comme à la création : la règle « Administrateurs // ancêtre de tout » ne se défait pas en détachant un groupe. const parentGroupId = input.parentGroupId ?? adminGroup?.id ?? null; if (parentGroupId === groupId) { throw new TRPCError({ code: "BAD_REQUEST", message: "Un groupe ne peut pas être son propre parent.", }); } if (parentGroupId) { const exists = await ctx.prisma.memberGroup.findUnique({ where: { id: parentGroupId }, select: { id: true }, }); if (!exists) { throw new TRPCError({ code: "NOT_FOUND", message: "Groupe parent introuvable." }); } // Le parent ne doit pas être un DESCENDANT du groupe (→ cycle) : // on remonte la chaîne des ancêtres du parent ; si on croise le // groupe, c'est qu'il est déjà un ancêtre du parent. let cursor: string | null = parentGroupId; const seen = new Set<string>(); while (cursor) { if (cursor === groupId) { throw new TRPCError({ code: "BAD_REQUEST", message: "Cycle interdit : le parent choisi est un descendant du groupe.", }); } if (seen.has(cursor)) break; seen.add(cursor); const parent: { parentGroupId: string | null } | null = await ctx.prisma.memberGroup.findUnique({ where: { id: cursor }, select: { parentGroupId: true }, }); cursor = parent?.parentGroupId ?? null; } } await ctx.prisma.memberGroup.update({ where: { id: groupId }, data: { parentGroupId }, }); return { success: true }; }), /** Membres d'un groupe. */ members: adminProcedure .input(z.object({ groupId: z.string() })) .query(async ({ ctx, input }) => { const memberships = await ctx.prisma.memberGroupMembership.findMany({ where: { groupId: input.groupId }, select: { access: true, user: { select: { id: true, firstName: true, lastName: true, email: true }, }, }, }); return memberships .map((m) => ({ id: m.user.id, name: memberName(m.user), email: m.user.email, isDemo: isDemoEmail(m.user.email), access: m.access, })) .sort((a, b) => a.name.localeCompare(b.name, "fr")); }), /** Ajoute un membre à un groupe (idempotent). */ addMember: adminProcedure .input( z.object({ groupId: z.string(), userId: z.string(), access: z.enum(["VIEWER", "EDITOR"]).optional(), }), ) .mutation(async ({ ctx, input }) => { await ctx.prisma.memberGroupMembership.upsert({ where: { groupId_userId: { groupId: input.groupId, userId: input.userId }, }, create: { groupId: input.groupId, userId: input.userId, access: input.access ?? "EDITOR", }, update: input.access ? { access: input.access } : {}, }); return { success: true }; }), /** * Retire un membre d'un groupe. Un administrateur ne peut pas se retirer lui-même du groupe * Administrateurs : il perdrait l'accès au panneau, et le club pourrait n'avoir plus aucun * administrateur. Un autre administrateur peut le faire. */ removeMember: adminProcedure .input(z.object({ groupId: z.string(), userId: z.string() })) .mutation(async ({ ctx, input }) => { if (input.userId === ctx.sessionClient.user.id) { const group = await ctx.prisma.memberGroup.findUnique({ where: { id: input.groupId }, select: { isAdminGroup: true }, }); if (group?.isAdminGroup) { throw new TRPCError({ code: "FORBIDDEN", message: "Vous ne pouvez pas vous retirer vous-même du groupe Administrateurs.", }); } } await ctx.prisma.memberGroupMembership.deleteMany({ where: { groupId: input.groupId, userId: input.userId }, }); return { success: true }; }), /** Change le droit d'un membre dans un groupe collaboratif (VIEWER/EDITOR). */ setMemberAccess: adminProcedure .input( z.object({ groupId: z.string(), userId: z.string(), access: z.enum(["VIEWER", "EDITOR"]), }), ) .mutation(async ({ ctx, input }) => { await ctx.prisma.memberGroupMembership.update({ where: { groupId_userId: { groupId: input.groupId, userId: input.userId }, }, data: { access: input.access }, }); return { success: true }; }),});Un droit qui se transmet (par héritage, par appartenance, par délégation) doit être protégé à sa source, pas seulement là où il s’exerce. Ici, la source est la place d’un groupe dans l’arbre : c’est elle que la procédure verrouille.
Supprimer un groupe#
Supprimer un groupe touche à trois choses : son espace, ses sous-groupes, ses appartenances.
deleteGroupWithSpace — la suppression sûre
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import type { PrismaClient } from "@prisma/client";import { TRPCError } from "@trpc/server";import { resolveGroupBaseFolder } from "@backend/modules/media/services/resolveGroupBaseFolder.service";import { isSpaceEmpty } from "@backend/modules/storage/isSpaceEmpty.service";import { groupIdFromLogicalPath } from "@backend/modules/memberGroups/assertCanTrashPaths.service";/** * Supprime un groupe et son espace, en cascade SÛRE (décisions A→E) : * - refuse le groupe Administrateurs (isAdminGroup) ; * - l'espace doit être VIDE — contrôle PHYSIQUE (isSpaceEmpty), aligné sur ce * que montre le finder (les lignes MediaAsset orphelines ne faussent rien) ; * layout plat → les espaces enfants (dossiers frères) ne comptent pas ; * - les groupes enfants sont RE-PARENTÉS vers le parent du groupe supprimé * (au pire Administrateurs) AVANT le delete, pour éviter le SetNull racine. * * Chemin d'espace résolu par le SUFFIXE `-{groupId}` du registre (stable au * renommage), repli sur le chemin canonique. */export async function deleteGroupWithSpace(params: { prisma: PrismaClient; appRoot: string; groupId: string;}): Promise<void> { const { prisma, appRoot, groupId } = params; const group = await prisma.memberGroup.findUnique({ where: { id: groupId }, select: { id: true, name: true, isAdminGroup: true, isCollaborative: true, parentGroupId: true, }, }); if (!group) { throw new TRPCError({ code: "NOT_FOUND", message: "Groupe introuvable." }); } if (group.isAdminGroup) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le groupe Administrateurs ne peut pas être supprimé.", }); } // Chemin(s) de l'espace : suffixe -{groupId} (robuste au renommage). const registered = await prisma.folder.findMany({ where: { appRoot, fullPath: { endsWith: `-${groupId}` } }, select: { fullPath: true }, }); const spacePaths = new Set<string>( // L'espace lui-même, lu par position : `<racine>/collaborative-group-spaces/<slug>-<groupId>` // (trois segments). Un dossier homonyme rangé ailleurs, ou un sous-dossier, n'en est pas un. registered .map((r) => r.fullPath) .filter((fp) => groupIdFromLogicalPath(fp) === groupId && fp.split("/").filter(Boolean).length === 3), ); if (spacePaths.size === 0 && group.isCollaborative) { try { spacePaths.add(await resolveGroupBaseFolder({ prisma, appRoot, groupId })); } catch { // non collaboratif / espace jamais matérialisé → rien à vérifier } } // Vérifie que chaque espace est VIDE (contrôle physique). for (const sp of spacePaths) { if (!(await isSpaceEmpty({ prisma, appRoot, path: sp }))) { throw new TRPCError({ code: "BAD_REQUEST", message: `L'espace du groupe « ${group.name} » n'est pas vide. Videz-le (fichiers et sous-dossiers) avant de supprimer le groupe.`, }); } } // Les trois écritures ensemble : des enfants re-parentés vers un groupe qui n'aurait finalement // pas été supprimé, ou un groupe supprimé avec ses enfants rattachés à rien, ne doivent pas exister. await prisma.$transaction([ // Re-parente les enfants vers le parent (AVANT le delete). prisma.memberGroup.updateMany({ where: { parentGroupId: groupId }, data: { parentGroupId: group.parentGroupId }, }), // Supprime les lignes de registre de l'espace (vide). prisma.folder.deleteMany({ where: { appRoot, fullPath: { in: [...spacePaths] } }, }), // Supprime le groupe (cascade DB : memberships + liens documents). prisma.memberGroup.delete({ where: { id: groupId } }), ]);}Récapitulatif#
Question | Réponse du projet |
|---|---|
Qu’est-ce qu’un groupe ? | Une liste de diffusion ; avec isCollaborative, un espace de fichiers ; avec isAdminGroup, le groupe qui fait les administrateurs. |
Quels droits porte une appartenance ? | VIEWER (consulter) ou EDITOR (déposer, valider, jeter), dans l’espace du groupe seulement. |
Comment les droits se transmettent-ils ? | Vers le bas : un membre d’un groupe a accès aux espaces des groupes placés sous lui, au même niveau. |
Comment les calcule-t-on ? | En remontant depuis un espace (gardes), en descendant depuis un membre (finder, cloche) ; les deux doivent concorder. |
Qu’est-ce qui protège la racine ? | Administrateurs n’a jamais de parent, et « sans parent » veut dire « sous Administrateurs ». |
Comment l’arbre reste-t-il un arbre ? | Pas son propre parent, pas un descendant comme parent ; les parcours se protègent quand même d’une boucle. |
Que fait la suppression d’un groupe ? | Refusée pour Administrateurs ou un espace non vide ; sinon sous-groupes re-parentés, registre et groupe supprimés, dans une transaction. |
Showing entries1to7entries
À suivre#
Les groupes sont en place, avec leurs droits et leur arbre. Il reste à voir ce que ces droits ouvrent : un espace de fichiers par groupe collaboratif, où ses membres déposent, rangent et partagent.
Chapitre suivant : les espaces collaboratifs. Un dossier par groupe pour toute sa vie, des gardes qui déduisent tout du chemin, un dépôt qui devient document du groupe, et un finder réservé aux membres.