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. 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.create packages/backend/src/modules/memberGroups/router.ts:53
    • ensureGroupSpaceFolder packages/backend/src/modules/memberGroups/ensureGroupSpaceFolder.service.ts:18
      • prisma.folder.upsert packages/backend/src/modules/memberGroups/ensureGroupSpaceFolder.service.ts:28Folder crééeFolder modifiée
    • ctx.prisma.memberGroup.create packages/backend/src/modules/memberGroups/router.ts:75MemberGroup créée

  2. 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.setParentGroup packages/backend/src/modules/memberGroups/router.ts:141
    • ctx.prisma.memberGroup.update packages/backend/src/modules/memberGroups/router.ts:205MemberGroup modifiée

  3. 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.addMember packages/backend/src/modules/memberGroups/router.ts:237
    • ctx.prisma.memberGroupMembership.upsert packages/backend/src/modules/memberGroups/router.ts:246MemberGroupMembership crééeMemberGroupMembership modifiée

  4. 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.removeMember packages/backend/src/modules/memberGroups/router.ts:265
    • ctx.prisma.memberGroupMembership.deleteMany packages/backend/src/modules/memberGroups/router.ts:280MemberGroupMembership supprimée

  5. 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.delete packages/backend/src/modules/memberGroups/router.ts:124
    • deleteGroupWithSpace packages/backend/src/modules/memberGroups/deleteGroupWithSpace.service.ts:20
      • prisma.folder.deleteMany packages/backend/src/modules/memberGroups/deleteGroupWithSpace.service.ts:86Folder supprimée
      • prisma.memberGroup.delete packages/backend/src/modules/memberGroups/deleteGroupWithSpace.service.ts:90MemberGroup supprimée
        • onDelete: Cascade (MemberDocumentGroup → MemberGroup) prisma/schema.prisma:1861MemberDocumentGroup supprimée · en cascade
        • onDelete: SetNull (MemberGroup → MemberGroup) prisma/schema.prisma:1810MemberGroup modifiée
        • onDelete: Cascade (MemberGroupMembership → MemberGroup) prisma/schema.prisma:1834MemberGroupMembership supprimée · en cascade
      • prisma.memberGroup.updateMany packages/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).

MemberGroup
PRISMA
prisma/schema.prisma
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 :

MemberGroupMembership
PRISMA
prisma/schema.prisma
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.

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 : jamais soi-même des Administrateurs
TS
packages/backend/src/modules/memberGroups/router.ts
  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.

Du groupe visé vers la racine
TS
packages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts
guided extract: lines 15-43focus line 10
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 :

collaborativeEntriesForMember — descendre
TS
packages/backend/src/modules/memberGroups/collaborativeEntriesForMember.service.ts
  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 :

ensureAdminGroup
TS
packages/backend/src/modules/memberGroups/ensureAdminGroup.service.ts
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é :

À la création : sous Administrateurs par défaut
TS
packages/backend/src/modules/memberGroups/router.ts
      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.

La racine reste la racine
TS
packages/backend/src/modules/memberGroups/router.ts
guided extract: lines 141-210focus line 16
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.

Jamais la racine
TS
packages/backend/src/modules/memberGroups/deleteGroupWithSpace.service.ts
guided extract: lines 40-91focus line 1
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.

À 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.

Sur cette page