Un contenu écrit par un membre

Ce qui change quand l’auteur d’un contenu public n’est pas un administrateur : la présentation d’instructeur, ce que le recensement rend public, qui peut écrire, quels fichiers et quel avatar montrer, la lecture d’un chemin par position, le sélecteur ouvert sur son espace, l’ordre réservé au club et le rattrapage du démarrage.

Précédemment. Chaque enregistrement recense, dans sa transaction, les fichiers qu’un contenu affiche. La route publique sert un fichier validé et recensé ; la corbeille et le changement de statut refusent de retirer un fichier affiché.

Les parcours du chapitre#

Deux parcours traversent ce chapitre : l’instructeur qui rédige sa présentation sur son profil, et le visiteur qui la lit sur la page « Nos instructeurs ».

Acteur : Instructeur (membre non administrateur)

  1. 1

    Son profil demande s’il est titulaire, sa présentation et le dossier où ouvrir le sélecteur

    user.getMyInstructorStatemembre

    Lit : Folder, MediaAsset, 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).

    user.getMyInstructorState packages/backend/src/modules/users/router.ts:328
    • resolvePersoBaseFolder packages/backend/src/modules/media/services/resolvePersoBaseFolder.service.ts:25
      • prisma.folder.findFirst packages/backend/src/modules/media/services/resolvePersoBaseFolder.service.ts:38Folder lue
      • prisma.mediaAsset.findFirst packages/backend/src/modules/media/services/resolvePersoBaseFolder.service.ts:45MediaAsset lue
      • prisma.user.findUnique packages/backend/src/modules/media/services/resolvePersoBaseFolder.service.ts:53User lue
    • ctx.prisma.user.findUnique packages/backend/src/modules/users/router.ts:331User lue
    • isInstructorUser packages/backend/src/modules/users/instructorBio.service.ts:20
      • prisma.user.findUnique packages/backend/src/modules/users/instructorBio.service.ts:21User lue

  2. 2

    Le sélecteur d’avatar ne lui propose que le sien

    user.listAvatarCandidatesmembre

    Lit : 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).

    user.listAvatarCandidates packages/backend/src/modules/users/router.ts:552
    • ctx.prisma.user.findMany packages/backend/src/modules/users/router.ts:554User lue

  3. 3

    Il enregistre ; le serveur vérifie qu’il est titulaire, que chaque fichier vient de son espace, puis recense

    user.saveMyInstructorBiomembre

    • PageMediaReference créée
    • PageMediaReference supprimée
    • User modifiée : instructorBio, instructorOrder

    Lit : MediaAsset, PageMediaReference, 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).

    user.saveMyInstructorBio packages/backend/src/modules/users/router.ts:360
    • syncPageMediaReferences packages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:47
      • tx.pageMediaReference.createMany packages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:130PageMediaReference créée
      • tx.pageMediaReference.deleteMany packages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:81PageMediaReference supprimée
      • tx.pageMediaReference.deleteMany packages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:122PageMediaReference supprimée
    • tx.user.update packages/backend/src/modules/users/router.ts:386User modifiée

Un auteur qui n’est pas administrateur 🥋#

Tous les contenus vus jusqu’ici sont écrits par un administrateur, qui peut déjà tout faire : publier un fichier, modifier une page, ranger la bibliothèque. Ce qu’il pose dans une page, il aurait pu le publier autrement.

La présentation d’un instructeur est différente. Elle est rédigée par l’instructeur lui-même, sur son profil, et affichée sur une page publique. Or le chapitre précédent l’a montré : recenser un fichier, c’est le rendre servi à tout visiteur. Une présentation est donc un moyen de publier — et la question devient : publier quoi ?

Ce qu’un membre pourrait poser

Ce que cela publierait

Un fichier de l’espace d’un groupe
Une photo que les autres membres du groupe partageaient entre eux, sans avoir choisi de la rendre publique.
Un fichier de l’espace d’un autre membre
Ses fichiers personnels.
Un fichier validé quelconque, dont il connaît l’identifiant
N’importe quel fichier de la bibliothèque qu’aucune page ne montre encore.
L’avatar d’un autre membre
Le visage de quelqu’un d’autre, peut-être mineur, sur une page qui ne le concerne pas.

Avant ce chapitre, tout cela était possible, et même un membre qui n’était pas instructeur pouvait enregistrer une présentation : aucune page ne l’affichait, mais ses fichiers devenaient servis au public. Les règles qui suivent sont celles d’un contenu public écrit par un membre ; elles valent pour tout contenu de ce genre qu’on ajouterait.

Une procédure qui déclare des fichiers (un recensement, une galerie) est une procédure qui publie. Ses droits ne sont pas ceux de l’écriture d’un texte, mais ceux de la publication de chaque fichier qu’elle déclare.

Qui peut écrire#

Une présentation n’a de sens que pour un titulaire : un membre rattaché à une discipline, un cours ou un stage. Le critère est écrit une fois :

Être titulaire
TS
packages/backend/src/modules/users/instructorBio.service.ts
export async function isInstructorUser(prisma: PrismaClient, userId: string): Promise<boolean> {  const user = await prisma.user.findUnique({    where: { id: userId },    select: {      _count: {        select: {          disciplinesAsInstructor: true,          coursesAsInstructor: true,          seminarsAsPrimaryAnimator: true,          seminarsAsAnimator: true,        },      },    },  });  const c = user?._count;  return Boolean(    c && (c.disciplinesAsInstructor > 0 || c.coursesAsInstructor > 0 || c.seminarsAsPrimaryAnimator > 0 || c.seminarsAsAnimator > 0),  );}

C’est le même critère que la page publique, qui ne liste que les titulaires :

La page publique : titulaire ET présenté
TSX
apps/web/src/app/(public)/about/instructeurs/page.tsx
empty code block

Les deux sont écrits différemment (un comptage d’un côté, un filtre some de l’autre), mais disent la même chose : rattaché à au moins une des quatre relations. Un membre qui cesse d’enseigner disparaît de la page, et ne peut plus modifier une présentation que personne ne voit.

Quels fichiers, quel avatar#

La règle est simple à énoncer : un membre ne montre que ce qui lui appartient. Ses fichiers, c’est son espace personnel ; son image, c’est son avatar.

splitBioMedia — ce qui est à lui, et le reste

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

Un avatar qui n’est pas le sien
TS
packages/backend/src/modules/users/instructorBio.service.ts
guided extract: lines 48-72focus lines 9-10
import type { PrismaClient } from "@prisma/client";import { TRPCError } from "@trpc/server";import { extractMediaIdsFromContent, type PageContentV1 } from "@contracts/page";import { personalSpaceOwnerId } from "@backend/modules/memberGroups/assertCanTrashPaths.service";/** * instructorBio.service.ts — les règles de la présentation d'instructeur. * * Une présentation est PUBLIQUE (page « Nos instructeurs ») et écrite par le membre lui-même. Les * fichiers qu'elle référence deviennent servis à tout visiteur (route publique : validé ET * référencé). D'où deux questions, posées à l'enregistrement (`users.saveMyInstructorBio`) et au * rattrapage du démarrage (`ensureInstructorBioMediaReferences`). *//** * Titulaire = rattaché à une discipline, un cours ou un stage. Le MÊME critère que * `getInstructors` / `listPublicInstructors` et la page publique des instructeurs. */export async function isInstructorUser(prisma: PrismaClient, userId: string): Promise<boolean> {  const user = await prisma.user.findUnique({    where: { id: userId },    select: {      _count: {        select: {          disciplinesAsInstructor: true,          coursesAsInstructor: true,          seminarsAsPrimaryAnimator: true,          seminarsAsAnimator: true,        },      },    },  });  const c = user?._count;  return Boolean(    c && (c.disciplinesAsInstructor > 0 || c.coursesAsInstructor > 0 || c.seminarsAsPrimaryAnimator > 0 || c.seminarsAsAnimator > 0),  );}/** * Ce qu'une présentation montre, rangé en deux : ce qui appartient à son auteur, et le reste. * * Un membre qui n'est pas administrateur ne peut y poser que : *   - des fichiers de SON espace personnel (pas ceux d'un groupe, que ses autres membres n'ont pas *     choisi de rendre publics, ni ceux d'un autre membre) ; *   - SON avatar (pas celui d'un autre membre, peut-être mineur). */export async function splitBioMedia(  prisma: PrismaClient,  userId: string,  bio: PageContentV1,): Promise<{ ownIds: string[]; foreignIds: string[]; foreignAvatar: boolean }> {  const foreignAvatar = bio.blocks.some(    (block) =>      (block.type === "media-text" || block.type === "float-text") &&      block.media?.kind === "avatar" &&      block.media.userId !== userId,  );  const ids = extractMediaIdsFromContent(bio);  if (ids.length === 0) return { ownIds: [], foreignIds: [], foreignAvatar };  const assets = await prisma.mediaAsset.findMany({    where: { id: { in: [...ids] } },    select: { id: true, fullPath: true },  });  const ownerOf = new Map(assets.map((a) => [a.id, personalSpaceOwnerId(a.fullPath)]));  return {    ownIds: ids.filter((id) => ownerOf.get(id) === userId),    foreignIds: ids.filter((id) => ownerOf.get(id) !== userId),    foreignAvatar,  };}/** Refuse une présentation qui montre autre chose que les fichiers et l'avatar de son auteur. */export async function assertBioMediaAllowed(  prisma: PrismaClient,  userId: string,  bio: PageContentV1,): Promise<void> {  const { foreignIds, foreignAvatar } = await splitBioMedia(prisma, userId, bio);  if (foreignAvatar) {    throw new TRPCError({ code: "FORBIDDEN", message: "Une présentation ne peut montrer que votre propre avatar." });  }  if (foreignIds.length > 0) {    throw new TRPCError({      code: "FORBIDDEN",      message: "Une présentation ne peut utiliser que des fichiers de votre espace personnel.",    });  }}

La fonction ne décide rien : elle classe. L’enregistrement refusera s’il y a un reste ; le rattrapage du démarrage, lui, ne déclarera que la première liste. Une seule définition de « ce qui est à lui », deux usages.

Un fichier de son espace doit encore être validé pour être recensé (chapitre 5), et seul un administrateur valide les fichiers d’un espace personnel. La présentation d’un instructeur passe donc par une modération : il dépose sa photo, un administrateur la valide, la présentation peut la montrer.

Lire un chemin par position#

« Le propriétaire de l’espace personnel qui contient ce chemin » doit être lu avec soin. Les espaces suivent une forme fixe — AKFC/personal-space/<prénom-nom>-<identifiant>/… — et l’identifiant du membre est la fin du troisième segment :

spaceOwnerId — un chemin lu par position

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

Pas de détour
TS
packages/backend/src/modules/memberGroups/assertCanTrashPaths.service.ts
guided extract: lines 13-32focus line 3
import { isAdminByGroup } from "@backend/modules/memberGroups/isAdminByGroup.service";import { TRPCError } from "@trpc/server";import type { PrismaClient } from "@prisma/client";import { resolveGroupAccessForUser } from "@backend/modules/memberGroups/resolveGroupAccessForUser.service";/** * Propriétaire d'un segment d'espace `<slug>-<id>` : l'identifiant (cuid, sans tiret) suit le * DERNIER tiret. Le chemin est lu PAR POSITION — `<racine>/<conteneur>/<slug>-<id>/…` — et jamais * en cherchant le nom du conteneur n'importe où : un dossier nommé « collaborative-group-spaces » * rangé ailleurs (sous `courses/`, par exemple) n'ouvre rien. Un chemin qui contient « . » ou « .. » * ne désigne pas ce qu'il semble désigner : aucune garde ne l'accepte. */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");}/** Membre dont l'espace personnel contient ce chemin (`<racine>/personal-space/<slug>-<userId>/…`). */export function personalSpaceOwnerId(path: string): string | null {  return spaceOwnerId(path, "personal-space");}/** * Garde de SUPPRESSION (mise en corbeille) : un ADMIN peut tout envoyer à la * corbeille ; un non-admin ne le peut QUE pour des cibles situées dans un * espace de groupe collaboratif dont il est membre EDITOR. Toute cible hors * d'un espace de groupe, ou dans un groupe où il n'est pas EDITOR, est refusée. */export async function assertCanTrashPaths(params: {  prisma: PrismaClient;  userId: string;  paths: readonly string[];}): Promise<void> {  const { prisma, userId, paths } = params;  if (await isAdminByGroup(prisma, userId)) return;  for (const path of paths) {    const groupId = groupIdFromLogicalPath(path);    if (!groupId) {      throw new TRPCError({        code: "FORBIDDEN",        message: "Suppression hors espace de groupe réservée aux admins.",      });    }    const access = await resolveGroupAccessForUser(prisma, userId, groupId);    if (access !== "EDITOR") {      throw new TRPCError({        code: "FORBIDDEN",        message: "Suppression réservée aux éditeurs de ce groupe.",      });    }  }}

La version précédente cherchait le nom du conteneur n’importe où dans le chemin (indexOf). Un administrateur qui rangeait un dossier nommé collaborative-group-spaces sous courses/ créait, sans le savoir, un faux espace de groupe que la garde reconnaissait. Lire par position, c’est lire le chemin comme l’application le fabrique, et rien d’autre.

Un chemin de stockage est une donnée structurée écrite comme une chaîne. Le lire par recherche de sous-chaîne (includes, indexOf, startsWith sans / final) revient à accepter toutes les chaînes qui ressemblent à la bonne.

L’enregistrement, garde par garde#

saveMyInstructorBio — trois gardes, puis la transaction

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

Toujours soi
TS
packages/backend/src/modules/users/router.ts
guided extract: lines 360-404focus line 12
import { Prisma } from "@prisma/client";import { router, protectedProcedure, publicProcedure } from "@backend/trpc/core";import { isAdmin } from "@backend/trpc/middleware";import { z } from "zod";import generateStrongPassword from "@backend/lib/security/generatePassword";import { hashPassword } from "@backend/lib/auth/password";import { sendWelcomeEmail, sendWelcomeNotice } from "@backend/email/templates/welcomeEmailWithPassword";import { isDemoEmail } from "@backend/lib/demoAccounts";import { TRPCError } from "@trpc/server";import type { UserProfile } from "@contracts/users/user-profile.types";import type { ImageConsentProofValue } from "@contracts/legal/imageConsent";import { legalInfoInput, legalInfoData } from "@backend/modules/users/legalInfo";import { emailSchema } from "@contracts/auth/auth.schema";import { syncPageMediaReferences } from "@backend/modules/media/services/syncPageMediaReferences.service";import { pageContentSchemaV1 } from "@contracts/page";import { assertBioMediaAllowed, isInstructorUser } from "@backend/modules/users/instructorBio.service";import { removeAvatarFile } from "@backend/modules/avatar/avatar.service";import { resolvePersoBaseFolder } from "@backend/modules/media/services/resolvePersoBaseFolder.service";import { assertOwnAvatars } from "@backend/modules/media/services/assertOwnAvatars.service";import { byEffectiveDesc } from "@backend/modules/imageConsent/formOrder";/** Les champs du profil, et eux seuls : ni l'empreinte du mot de passe, ni les données légales. */const userProfileSelect = {  id: true,  email: true,  firstName: true,  lastName: true,  pseudo: true,  avatar: true,  aboutMe: true,  phone: true,  birthDate: true,  isFirstLogin: true,} satisfies Prisma.UserSelect;type UserProfileRow = Prisma.UserGetPayload<{ select: typeof userProfileSelect }>;/** * Du membre lu en base au contrat partagé avec le front : la date devient « AAAA-MM-JJ ». * `satisfies` vérifie la conformité à UserProfile sans élargir le type : le front garde * `id: string` (sûr), là où le contrat, plus large, admettrait `null`. */function toUserProfile(user: UserProfileRow) {  return {    ...user,    birthDate: user.birthDate ? user.birthDate.toISOString().split("T")[0] : null,  } satisfies UserProfile;}export const userRouter = router({  getAll: protectedProcedure    .use(isAdmin)    .query(async ({ ctx }) => {      // Jamais l'empreinte du mot de passe vers le navigateur.      const users = await ctx.prisma.user.findMany({        orderBy: { id: "asc" },        omit: { password: true },        include: {          memberGroupMemberships: {            select: { group: { select: { id: true, name: true, isAdminGroup: true } } },          },          // Droit à l'image : voie de la version la plus récente (par date des droits), et demande en          // attente du parent.          imageConsentForms: { select: { source: true, signedAt: true, createdAt: true } },          imageConsentRequest: { select: { expiresAt: true } },        },      });      const now = new Date();      return users.map(({ memberGroupMemberships, imageConsentForms, imageConsentRequest, ...u }) => ({        ...u,        isDemo: isDemoEmail(u.email),        consentProof: (imageConsentForms.sort(byEffectiveDesc)[0]?.source ??          (imageConsentRequest?.expiresAt && imageConsentRequest.expiresAt > now            ? "PENDING_GUARDIAN"            : "NONE")) satisfies ImageConsentProofValue,        // Groupes du membre : Administrateurs d'abord, puis ordre alphabétique.        groups: memberGroupMemberships          .map((m) => m.group)          .sort((a, b) => Number(b.isAdminGroup) - Number(a.isAdminGroup) || a.name.localeCompare(b.name, "fr")),      }));    }),  getById: protectedProcedure    .use(isAdmin)    .input(z.object({ id: z.string() }))    .query(async ({ ctx, input }) => {      const user = await ctx.prisma.user.findUnique({        where: { id: input.id },        omit: { password: true },      });      if (!user) {        throw new TRPCError({          code: "NOT_FOUND",          message: "Membre introuvable.",        });      }      return user;    }),  getByEmail: protectedProcedure    .use(isAdmin)    .input(z.object({ email: emailSchema }))    .query(async ({ ctx, input }) => {      const user = await ctx.prisma.user.findUnique({        where: { email: input.email },        omit: { password: true },      });      if (!user) {        throw new TRPCError({          code: "NOT_FOUND",          message: "Membre introuvable.",        });      }      return user;    }),  create: protectedProcedure    .use(isAdmin)    .input(      z.object({        // Même règle que la connexion : nettoyée, en minuscules (sinon le membre ne retrouverait        // pas son compte en tapant son adresse autrement).        email: emailSchema,        /** Un ou plusieurs groupes (au moins un). */        groupIds: z.array(z.string().min(1)).min(1, "Choisissez au moins un groupe."),        /** Majorité et, pour un mineur, date de naissance et parent référent (tous obligatoires). */        legal: legalInfoInput,      }),    )    .mutation(async ({ ctx, input }) => {      const groupIds = [...new Set(input.groupIds)];      const found = await ctx.prisma.memberGroup.count({ where: { id: { in: groupIds } } });      if (found !== groupIds.length) {        throw new TRPCError({ code: "BAD_REQUEST", message: "Groupe inconnu : rechargez la page." });      }      // Mot de passe généré côté serveur (jamais saisi par l'admin), robuste par      // construction, puis envoyé au SEUL membre (voir welcomeEmailWithPassword.ts).      // isFirstLogin=true (défaut Prisma) : le site lui propose de compléter son profil.      const password = generateStrongPassword();      const hash = await hashPassword(password);      if (input.legal.legalAge === "UNKNOWN") {        throw new TRPCError({ code: "BAD_REQUEST", message: "Indiquez si le membre est majeur ou mineur." });      }      const legal = legalInfoData(input.legal, input.email, { requireComplete: true });      // L'adresse est unique en base (@unique). Plutôt que de vérifier avant d'écrire (deux      // créations simultanées passeraient toutes les deux la vérification), on écrit, et on      // traduit le refus de la base.      let user;      try {        user = await ctx.prisma.user.create({          data: {            email: input.email,            password: hash,            ...legal,            memberGroupMemberships: {              create: groupIds.map((id) => ({                group: { connect: { id } },                access: "EDITOR" as const,              })),            },          },          omit: { password: true },        });      } catch (err) {        if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {          throw new TRPCError({ code: "CONFLICT", message: "Un membre utilise déjà cette adresse e-mail." });        }        throw err;      }      // E-mail de bienvenue au membre, puis avis SANS mot de passe à la boîte du club.      // Un échec d'envoi n'annule pas la création : il est journalisé et signalé au club.      // Compte de démonstration : son adresse ne peut rien recevoir, pas d'e-mail.      if (!isDemoEmail(input.email)) {        let sendError: string | null = null;        try {          await sendWelcomeEmail({ to: input.email, password });        } catch (err) {          sendError = err instanceof Error ? err.message : String(err);          console.error("[user.create] e-mail de bienvenue non envoyé :", sendError);        }        const [groups, admin] = await Promise.all([          ctx.prisma.memberGroup.findMany({ where: { id: { in: groupIds } }, select: { name: true } }),          ctx.prisma.user.findUnique({            where: { id: ctx.sessionClient.user.id },            select: { firstName: true, lastName: true, email: true },          }),        ]);        const adminName = admin          ? [admin.firstName, admin.lastName].filter(Boolean).join(" ").trim() || admin.email          : null;        await sendWelcomeNotice({          kind: sendError ? "send-failed" : "sent",          memberEmail: input.email,          groupNames: groups.map((g) => g.name),          createdBy: adminName,          detail: sendError,        }).catch((err) =>          console.error("[user.create] avis au club non envoyé :", err instanceof Error ? err.message : err),        );      }      return {        success: true,        user,      };    }),  /**   * Majorité, date de naissance et parent référent (admins). Un mineur peut être enregistré avant   * que tout soit connu (tri des comptes existants) : l'envoi au parent reste bloqué tant qu'il   * manque quelque chose. Majeur : les coordonnées du parent sont effacées.   */  setLegalInfo: protectedProcedure    .use(isAdmin)    .input(z.object({ userId: z.string().min(1), legal: legalInfoInput }))    .mutation(async ({ ctx, input }) => {      const u = await ctx.prisma.user.findUnique({ where: { id: input.userId }, select: { email: true } });      if (!u) throw new TRPCError({ code: "NOT_FOUND", message: "Compte introuvable." });      await ctx.prisma.user.update({        where: { id: input.userId },        data: legalInfoData(input.legal, u.email, { requireComplete: false }),        select: { id: true },      });      return { ok: true };    }),  /** Droit à l'image (admins) : autorisation recueillie par le club pour ce membre. */  setImageConsent: protectedProcedure    .use(isAdmin)    .input(z.object({ userId: z.string().min(1), consent: z.enum(["UNKNOWN", "GRANTED", "REFUSED"]) }))    .mutation(async ({ ctx, input }) => {      await ctx.prisma.user.update({        where: { id: input.userId },        data: { imageConsent: input.consent, imageConsentAt: new Date() },        select: { id: true },      });      return { ok: true };    }),  delete: protectedProcedure    .use(isAdmin)    .input(z.object({ id: z.string() }))    .mutation(async ({ ctx, input }) => {      // Règle métier, pas question de droit : l'administrateur a le droit de supprimer un compte,      // mais pas le sien (il perdrait l'accès, et le club pourrait se retrouver sans administrateur).      // L'interface désactive déjà le bouton ; c'est ici que la règle est garantie.      if (input.id === ctx.sessionClient.user.id) {        throw new TRPCError({ code: "FORBIDDEN", message: "Vous ne pouvez pas supprimer votre propre compte." });      }      const avatar = (await ctx.prisma.user.findUnique({ where: { id: input.id }, select: { avatar: true } }))?.avatar;      const deleted = await ctx.prisma.$transaction(async (tx) => {        // Formulaires de droit à l'image signés : gardés 5 ans comme preuve (purge nocturne).        await tx.imageConsentForm.updateMany({ where: { userId: input.id }, data: { detachedAt: new Date() } });        // `pageId` n'a pas de clé étrangère : sans ce nettoyage, les liens de        // la bio bloqueraient pour toujours la suppression de ses images.        await syncPageMediaReferences(tx, {          pageType: "INSTRUCTOR_BIO",          pageId: input.id,          newContent: null,        });        // Seul l'identifiant repart vers le navigateur : la ligne complète contient l'empreinte        // du mot de passe.        return tx.user.delete({          where: { id: input.id },          select: { id: true },        });      });      // Son portrait part avec le compte : un avatar n'est rattaché qu'à lui (aucune ligne      // MediaAsset ne le retient), il resterait sinon dans le stockage sans que rien ne l'atteigne.      // Après la transaction : un fichier ne se restaure pas si la base annule.      if (avatar) await removeAvatarFile(avatar);      return deleted;    }),  getCurrentUserProfile: protectedProcedure.query(async ({ ctx }) => {    const userId = ctx.sessionClient.user.id;    const user = await ctx.prisma.user.findUnique({      where: { id: userId },      select: userProfileSelect,    });    if (!user) {      throw new TRPCError({        code: "NOT_FOUND",        message: "Membre introuvable.",      });    }    return toUserProfile(user);  }),  getProfileById: protectedProcedure    .use(isAdmin)    .input(z.object({ id: z.string() }))    .query(async ({ ctx, input }) => {      const user = await ctx.prisma.user.findUnique({        where: { id: input.id },        select: userProfileSelect,      });      if (!user) {        throw new TRPCError({          code: "NOT_FOUND",          message: "Membre introuvable.",        });      }      return toUserProfile(user);    }),  /**   * L'utilisateur courant est-il titulaire, et quelle est sa bio ?   *   * Alimente l'éditeur du profil : la section n'apparaît que pour un   * titulaire, et se pré-remplit avec ce qu'il a déjà écrit. Le critère de   * titulaire est le MEME OR que `getInstructors` / `listPublicInstructors`.   */  getMyInstructorState: protectedProcedure.query(async ({ ctx }) => {    const userId = ctx.sessionClient.user.id;    const [user, isInstructor] = await Promise.all([      ctx.prisma.user.findUnique({        where: { id: userId },        select: { instructorBio: true, instructorOrder: true },      }),      isInstructorUser(ctx.prisma, userId),    ]);    return {      isInstructor,      bio: user?.instructorBio ?? null,      order: user?.instructorOrder ?? null,      // Où le sélecteur de médias s'ouvre : toute la bibliothèque pour un administrateur, son      // espace personnel pour un autre membre (le seul dont il puisse poser les fichiers).      mediaRoot: ctx.sessionClient.user.isAdmin        ? ctx.appRoot        : await resolvePersoBaseFolder({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId }),    };  }),  /**   * Un instructeur enregistre SA PROPRE présentation.   *   * La cible est toujours `ctx.sessionClient.user.id`, jamais un id reçu en   * entrée : la garde « c'est mon profil » est structurelle. Aucune   * permission d'administration n'entre en jeu — on n'édite que soi.   *   * `bio` est un document du constructeur de pages (validé par son schéma), ou `null`   * pour retirer sa présentation. L'ordre est optionnel.   */  saveMyInstructorBio: protectedProcedure    .input(      z.object({        // Le même contrat que tout contenu du constructeur de pages : un document invalide est        // refusé à l'entrée, au lieu d'être enregistré tel quel.        bio: pageContentSchemaV1.nullable(),        order: z.number().int().nullable().optional(),      }),    )    .mutation(async ({ ctx, input }) => {      assertOwnAvatars(ctx.sessionClient.user.id, input.bio);      const userId = ctx.sessionClient.user.id;      // Seul un titulaire a une présentation publique. Un autre membre pourrait sinon référencer des      // fichiers, et les rendre servis au public, sans qu'aucune page ne les montre.      if (!(await isInstructorUser(ctx.prisma, userId))) {        throw new TRPCError({ code: "FORBIDDEN", message: "La présentation est réservée aux instructeurs." });      }      if (!ctx.sessionClient.user.isAdmin) {        // L'ordre de la page publique est une décision du club : un instructeur ne s'y place pas        // lui-même en tête.        if (input.order !== undefined) {          throw new TRPCError({ code: "FORBIDDEN", message: "L'ordre d'affichage est fixé par un administrateur." });        }        if (input.bio !== null) await assertBioMediaAllowed(ctx.prisma, userId, input.bio);      }      await ctx.prisma.$transaction(async (tx) => {        await tx.user.update({          where: { id: userId },          data: {            instructorBio: input.bio === null ? Prisma.DbNull : (input.bio as Prisma.InputJsonValue),            ...(input.order !== undefined              ? { instructorOrder: input.order }              : {}),          },        });        // Déclare les fichiers de la bibliothèque utilisés par la bio : sans        // ce lien, ils ne sont ni protégés, ni servis aux visiteurs.        await syncPageMediaReferences(tx, {          pageType: "INSTRUCTOR_BIO",          pageId: userId,          newContent: input.bio,        });      });      return { success: true };    }),  /**   * La page publique des instructeurs titulaires.   *   * PUBLIQUE : elle alimente une page ouverte à tous. Deux filtres —   *   1. titulaire : le MEME critere que `getInstructors` (rattachement a   *      une discipline, un cours ou un stage). Reutilise, pas redefini.   *   2. presente : `instructorBio` non nul, sinon on afficherait une carte   *      vide pour un titulaire qui ne s'est pas encore decrit.   *   * Tri par `instructorOrder` (les nuls en dernier), puis par nom.   */  listPublicInstructors: publicProcedure.query(async ({ ctx }) => {    const rows = await ctx.prisma.user.findMany({      where: {        AND: [          { instructorBio: { not: Prisma.DbNull } },          {            OR: [              { disciplinesAsInstructor: { some: {} } },              { coursesAsInstructor: { some: {} } },              { seminarsAsPrimaryAnimator: { some: {} } },              { seminarsAsAnimator: { some: {} } },            ],          },        ],      },      select: {        id: true,        firstName: true,        lastName: true,        pseudo: true,        avatar: true,        instructorBio: true,        instructorOrder: true,      },      orderBy: [        { instructorOrder: { sort: "asc", nulls: "last" } },        { lastName: "asc" },        { firstName: "asc" },      ],    });    return rows;  }),  /**   * Vivier d'AFFECTATION du select instructeur (≠ « titulaires » réels).   * Résout l'œuf-et-la-poule : membres du groupe ADMIN (le staff) OU déjà   * rattachés (pour ne perdre aucun instructeur existant, même non-admin).   * `getInstructors` / `listPublicInstructors` restent la vérité publique.   */  getAssignableInstructors: protectedProcedure.use(isAdmin).query(async ({ ctx }) => {    return ctx.prisma.user.findMany({      where: {        OR: [          { memberGroupMemberships: { some: { group: { isAdminGroup: true } } } },          { disciplinesAsInstructor: { some: {} } },          { coursesAsInstructor: { some: {} } },          { seminarsAsPrimaryAnimator: { some: {} } },          { seminarsAsAnimator: { some: {} } },          // Organisateur d'un évènement : sans cette ligne, un organisateur qui n'est ni admin ni          // instructeur disparaît du sélecteur, et le formulaire d'édition affiche « — Choisir — ».          { eventsAsOrganizer: { some: {} } },        ],      },      select: {        id: true,        firstName: true,        lastName: true,        pseudo: true,        email: true,      },      orderBy: [{ lastName: "asc" }, { firstName: "asc" }],    });  }),  /**   * Liste tous les users qui sont **factuellement** instructeurs — c'est-à-dire   * qui ont au moins une relation d'animation dans le club : ils animent   * une discipline, un cours, ou un stage (en tant qu'animateur principal   * ou secondaire).   *   * Le club n'a pas de rôle "instructor" nominal en schéma ; cette   * définition factuelle permet de peupler les sélecteurs des forms admin   * (Course, Stage, etc.) sans dépendre d'un signal qui n'existe pas.   *   * Si un jour tu veux désigner un nouvel instructeur qui n'anime encore   * rien, il faudra introduire un autre signal (rôle dédié ou flag   * `isInstructor` sur User) — auquel cas cette query devra évoluer.   *   * Sélection minimale (id + nom) suffisante pour l'affichage en dropdown.   * Tri par lastName puis firstName pour l'UX du select.   */  getInstructors: protectedProcedure.use(isAdmin).query(async ({ ctx }) => {    return ctx.prisma.user.findMany({      where: {        OR: [          { disciplinesAsInstructor: { some: {} } },          { coursesAsInstructor: { some: {} } },          { seminarsAsPrimaryAnimator: { some: {} } },          { seminarsAsAnimator: { some: {} } },        ],      },      select: {        id: true,        firstName: true,        lastName: true,        pseudo: true,        email: true,      },      orderBy: [{ lastName: "asc" }, { firstName: "asc" }],    });  }),  /**   * Adresse du club d'un membre (alias @<domaine du club>), saisie par un administrateur.   * Le domaine est celui de l'adresse de contact des Réglages du site (contact@akfc.fr par   * défaut) : on n'attribue pas d'adresse d'un autre domaine. Chaîne vide → adresse retirée.   */  setClubEmail: protectedProcedure    .use(isAdmin)    .input(z.object({ id: z.string().min(1), clubEmail: z.string().trim().max(254) }))    .mutation(async ({ ctx, input }) => {      const value = input.clubEmail.toLowerCase();      if (value !== "") {        if (!z.string().email().safeParse(value).success) {          throw new TRPCError({ code: "BAD_REQUEST", message: "Adresse e-mail invalide." });        }        const settings = await ctx.prisma.siteSettings.findUnique({ where: { id: "site" }, select: { contactEmail: true } });        const domain = (settings?.contactEmail ?? "contact@akfc.fr").split("@")[1]?.toLowerCase() ?? "akfc.fr";        if (!value.endsWith(`@${domain}`)) {          throw new TRPCError({ code: "BAD_REQUEST", message: `L'adresse du club doit se terminer par @${domain}.` });        }      }      await ctx.prisma.user.update({ where: { id: input.id }, data: { clubEmail: value || null } });      return { clubEmail: value || null };    }),  /**   * Les avatars qu'un bloc media-text ou float-text peut montrer (« utiliser l'avatar de X »).   *   * Un membre ne pose que SON avatar, administrateurs compris (`assertOwnAvatars`, vérifié par   * chaque enregistrement de contenu) : la liste ne contient donc que lui. Elle reste une liste   * pour que le sélecteur n'ait rien à changer. Inclut un membre SANS avatar (avatar `null`) :   * l'UI affiche un placeholder. `avatar` est la clé de stockage brute ; l'URL d'affichage est construite côté   * client comme pour l'avatar du header.   */  listAvatarCandidates: protectedProcedure.query(async ({ ctx }) => {    const self = ctx.sessionClient.user.id;    return ctx.prisma.user.findMany({      where: { id: self },      select: {        id: true,        firstName: true,        lastName: true,        pseudo: true,        avatar: true,      },      orderBy: [{ lastName: "asc" }, { firstName: "asc" }],    });  }),});

Les gardes sont faites avant la transaction : elles ne font que lire, et un refus n’a rien à annuler. L’ordre compte aussi pour les messages : un membre qui n’enseigne pas apprend qu’il n’enseigne pas, pas que sa photo vient du mauvais dossier.

Le sélecteur, ouvert sur son espace#

Refuser à l’enregistrement ne suffit pas à rendre l’outil utilisable : il faut que le sélecteur de fichiers propose ce qui sera accepté. Le serveur dit au profil où l’ouvrir :

getMyInstructorState
TS
packages/backend/src/modules/users/router.ts
  getMyInstructorState: protectedProcedure.query(async ({ ctx }) => {    const userId = ctx.sessionClient.user.id;    const [user, isInstructor] = await Promise.all([      ctx.prisma.user.findUnique({        where: { id: userId },        select: { instructorBio: true, instructorOrder: true },      }),      isInstructorUser(ctx.prisma, userId),    ]);    return {      isInstructor,      bio: user?.instructorBio ?? null,      order: user?.instructorOrder ?? null,      // Où le sélecteur de médias s'ouvre : toute la bibliothèque pour un administrateur, son      // espace personnel pour un autre membre (le seul dont il puisse poser les fichiers).      mediaRoot: ctx.sessionClient.user.isAdmin        ? ctx.appRoot        : await resolvePersoBaseFolder({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId }),    };  }),

resolvePersoBaseFolder est la fonction qui fabrique le chemin de l’espace personnel à l’envoi d’un fichier (partie « Stockage et médias ») : le sélecteur s’ouvre exactement là où le membre dépose ses photos. Le profil le passe au constructeur comme mediaRoot (chapitre 3).

Pour que le sélecteur puisse lire ce dossier, la garde de lecture devait l’accepter. Elle ne connaissait que les espaces de groupe :

La garde de lecture : son espace personnel
TS
packages/backend/src/modules/memberGroups/assertCanReadPath.service.ts
  if (await isAdminByGroup(prisma, userId)) return;  // Son propre espace personnel (même règle que la livraison des fichiers, canReadStorageKey).  if (personalSpaceOwnerId(path) === userId) return;

C’est la même règle que la livraison des fichiers (canReadStorageKey) : on lit son propre espace, par la même fonction de lecture par position. La traduction d’un chemin choisi en identifiant (media.resolveByPaths) et l’aperçu (media.resolveByIds, qui ne résout pour un membre que les fichiers qu’il peut lire) suivent la même règle.

Son avatar, et seulement le sien#

Un avatar représente son titulaire : il s’affiche à côté de son nom quand il commente, publie ou réagit, et cela reste visible d’autrui. Le poser dans un contenu est autre chose : c’est publier le visage de quelqu’un sur une page qu’il n’a pas écrite. La règle du club est sans exception : on ne pose que son propre avatar, administrateurs compris. Elle ne concerne donc pas que la présentation d’instructeur, mais tout contenu du constructeur :

assertOwnAvatars
TS
packages/backend/src/modules/media/services/assertOwnAvatars.service.ts
export function assertOwnAvatars(  userId: string,  ...contents: readonly (PageContentV1 | null | undefined)[]): void {  const foreign = contents.some(    (content) => content != null && avatarUserIdsFromContent(content).some((id) => id !== userId),  );  if (foreign) {    throw new TRPCError({      code: "FORBIDDEN",      message:        "Un contenu ne peut montrer que votre propre avatar. Remplacez l'avatar d'un autre membre par une photo de la bibliothèque.",    });  }

Chaque procédure qui enregistre un contenu l’appelle en première ligne, sur les contenus reçus — pas sur ceux qu’elle relit en base pour recalculer un recensement. Une page plus ancienne qui montre l’avatar d’un autre (« l’avatar du président », que le sélecteur proposait aux administrateurs) est refusée au prochain enregistrement, avec un message qui dit quoi faire : remplacer l’avatar par une photo de la bibliothèque, validée et publiée comme toute photo.

Le sélecteur d’avatar suit la règle : il ne propose que soi.

Les avatars proposés
TS
packages/backend/src/modules/users/router.ts
  listAvatarCandidates: protectedProcedure.query(async ({ ctx }) => {    const self = ctx.sessionClient.user.id;    return ctx.prisma.user.findMany({      where: { id: self },

Le sélecteur et la garde disent la même chose : personne ne se voit proposer un choix que le serveur refusera.

L’ordre reste au club#

saveMyInstructorBio accepte aussi un ordre d’affichage, , qui trie la page « Nos instructeurs ». Aucun écran ne l’envoie, mais une requête le pouvait : un instructeur pouvait se placer en tête de la page. L’ordre de présentation des enseignants est une décision du club ; pour un membre qui n’est pas administrateur, la procédure le refuse désormais.

C’est un cas d’école : un champ sans écran n’est pas un champ sans risque. Ce qui protège, c’est ce que la procédure accepte, pas ce que l’interface envoie.

Les présentations d’avant#

Des présentations ont été enregistrées avant ces règles, et un rattrapage recalcule leur recensement à chaque démarrage (chapitre 5). Recalculer sans les règles reviendrait à republier, au démarrage, ce que l’enregistrement refuse désormais. Le rattrapage les applique donc :

Le rattrapage applique les mêmes règles
TS
packages/backend/src/modules/media/services/ensureInstructorBioMediaReferences.service.ts
      const bio = parsePageContentV1(u.instructorBio);      let newContent = bio;      let extraMediaIds: string[] = [];      if (!(await isAdminByGroup(prisma, u.id))) {        const { ownIds, foreignIds, foreignAvatar } = await splitBioMedia(prisma, u.id, bio);        if (foreignIds.length > 0 || foreignAvatar) {          console.warn(            `[ensureInstructorBioMediaReferences] la bio du membre ${u.id} montre ${foreignIds.length} fichier(s) hors de son espace${foreignAvatar ? " et l'avatar d'un autre membre" : ""} : non déclarés, à corriger dans la bio.`,          );          // Seuls ses propres fichiers sont déclarés : un contenu vide, et leurs identifiants à part.          newContent = { version: 1, blocks: [] };          extraMediaIds = ownIds;        }      }

Pour un membre qui n’est pas administrateur, seuls ses propres fichiers sont recensés : un contenu vide, et leurs identifiants passés à part (extraMediaIds). La différence calculée retire les lignes des autres fichiers : ils cessent d’être servis au public. L’écart est écrit dans les journaux du serveur, avec l’identifiant du membre, pour que la présentation soit corrigée.

Sur la page, une telle image apparaît alors cassée — la route publique la refuse —, ce qui se voit et se corrige ; un avatar d’un autre membre resterait visible, puisque la route des avatars ne vérifie rien (chapitre 4). C’est pourquoi le journal le nomme aussi.

Récapitulatif#

Question

Réponse du projet

Pourquoi un contenu écrit par un membre est-il différent ?
Recenser un fichier le rend public : écrire un contenu public, c’est publier chaque fichier qu’il montre.
Qui peut écrire une présentation ?
Soi seul (la cible est l’utilisateur de session), et seulement s’il est titulaire.
Quels fichiers peut-elle montrer ?
Pour un membre non administrateur : ceux de son espace personnel, validés par un administrateur.
Quel avatar ?
Le sien ; le sélecteur ne propose que lui, l’enregistrement refuse les autres.
Comment sait-on à qui appartient un fichier ?
Par la position dans son chemin (conteneur en deuxième segment, identifiant après le dernier tiret), jamais par recherche de sous-chaîne.
Où s’ouvre le sélecteur ?
Sur son espace personnel (mediaRoot), que la garde de lecture lui ouvre.
Et l’ordre de la page ?
Réservé aux administrateurs.
Et les présentations plus anciennes ?
Le rattrapage du démarrage ne recense que les fichiers de leur auteur et journalise l’écart.

À suivre#

Les contenus sont faits, rendus, recensés, et bornés à ce que leur auteur a le droit de publier. Il reste des informations qui n’appartiennent à aucun contenu, mais apparaissent sur toutes les pages : le nom du club, son logo, ses coordonnées, ses mentions légales.

Chapitre suivant : les réglages du site. Une ligne unique en base que les administrateurs modifient eux-mêmes, la frontière avec les variables d’environnement, une valeur de repli pour chaque lecture, et un logo servi hors de la bibliothèque.

Sur cette page
Documentation · AKFC