Fabriquer une procédure

Ce qui fait d’une fonction une procédure : la forme canonique, l’ordre de construction (accès, entrée, intention, contexte, délégation), query ou mutation, et trois exemples réels du projet — la connexion, la présentation d’un instructeur, la création d’une discipline.

Précédemment. Une procédure porte l’intention, une garde sécurise l’accès, un service porte la logique partagée, Prisma exécute. tRPC orchestre ; il ne remplace pas le backend.

Jusqu’ici, nous avons compris le système de l’extérieur :

  • la fracture créée par le JSON
  • la proximité réelle offerte par App Router
  • le rôle du contexte
  • la distinction entre intention et environnement
  • la place du router, des services, des guards et de Prisma

Il manque maintenant une pièce très concrète :

👉 comment fabriquons-nous réellement une procédure tRPC ?

Pas une procédure abstraite.
Pas une “fonction qui ressemble à tRPC”.

Une vraie procédure, pensée correctement dans un backend réel.


Une procédure n’est pas juste une fonction#

C’est le premier réflexe à corriger.

Nous pourrions croire qu’une procédure, c’est simplement :

Vision trop simpliste

ts
Anti-pattern
const createUser = (input) => {  return prisma.user.create({ data: input })}

Ce n’est pas complètement faux.

Mais ce n’est pas encore une procédure tRPC.

Une procédure tRPC, dans ce projet, est en réalité une composition de couches :

  • un niveau d’accès
  • une validation d’entrée
  • une forme d’intention (query ou mutation)
  • un contexte déjà injecté
  • une délégation vers le métier

💡 Autrement dit, nous ne “déclarons” pas juste une procédure.

👉 nous la construisons.

Et cette construction suit un ordre. Pas un ordre arbitraire de style d’écriture, mais un ordre logique qui reflète la hiérarchie des responsabilités dans le système.


La forme canonique d’une procédure#

Si nous réduisons la structure au squelette essentiel :

Squelette d’une procédure tRPC

ts
Exemple pédagogique
protectedProcedure  .input(z.object({    email: z.string().email(),    password: z.string().min(12),  }))  .mutation(async ({ ctx, input }) => {    return userService.createUser(ctx, input)  })

🧠 Ce squelette dit déjà presque tout :

  • protectedProcedure → qui a le droit d’entrer ?
  • .input(...) → qu’a-t-on le droit de fournir ?
  • .mutation(...) → quelle intention exécute-t-on ?
  • ctx → dans quel environnement cela s’exécute-t-il ?
  • userService.createUser(...) → où vit réellement la logique métier ?

Chaque partie répond à une question distincte. Et chaque question doit être répondue avant la suivante. C’est cette logique qui dicte l’ordre d’écriture.


Une procédure se construit dans un ordre précis#

Ordre de construction d’une procédure

Étape

Question traitée

Couche concernée

1
Qui peut entrer ?
publicProcedure / protectedProcedure
2
Quelle entrée est acceptée ?
.input(zodSchema)
3
Que veut-on faire ?
.query(...) ou .mutation(...)
4
Dans quel environnement ?
ctx injecté par tRPC
5
Où vit la logique ?
service métier

👉 Cet ordre matérialise une hiérarchie de responsabilités.

Et cette hiérarchie a une propriété importante : elle va du moins permissif au plus permissif. Nous commençons par refuser tout le monde (choix du niveau d’accès), puis nous refusons les mauvaises entrées (validation), puis nous acceptons l’intention, puis nous exécutons. À chaque étape, nous pouvons encore dire non. Une fois dans le service, nous disons oui.


Query vs Mutation — une distinction qui compte#

Avant d’entrer dans les exemples, il faut clarifier le choix entre .query(...) et .mutation(...).

La différence semble minime, mais elle porte une sémantique réelle :

  • .query(...) → lecture, idempotente, sans effet de bord. Peut être mise en cache.
  • .mutation(...) → écriture, non idempotente, change l’état du système. Ne doit jamais être mise en cache.

Côté HTTP, cela se traduit aussi : les queries passent en GET, les mutations en POST. Mais ce n’est qu’une conséquence. La distinction importante est sémantique : est-ce que cette procédure modifie quelque chose ?


Premier exemple : une procédure publique#

Pour bien sentir cette construction, nous commençons par une procédure publique.

Le bon candidat, c’est le router d’authentification. Il montre très bien qu’une procédure n’a pas besoin d’être protégée pour être structurée.

Une procédure publique bien construite
TS
packages/backend/src/modules/auth/router.ts
  login: publicProcedure    // Même schéma que le formulaire de connexion (packages/contracts) : une seule règle, un seul message.    .input(loginSchema)    .mutation(async ({ input }) => {      const { email, password } = input;      try {        await loginService(email, password);        return {          success: true as const,        };      } catch (error) {        // Un mauvais identifiant fait partie du fonctionnement normal du formulaire : c'est un        // résultat. Tout le reste (base injoignable, bug) est une vraie erreur : elle remonte,        // est journalisée par la route /api/trpc, et le formulaire affiche son message générique.        if (error instanceof TRPCError && error.message === "INVALID_CREDENTIALS") {          return {            success: false as const,            error: "INVALID_CREDENTIALS" as const,          };        }        // Trop d'échecs pour cette adresse : un résultat aussi, que le formulaire explique.        if (error instanceof TRPCError && error.message === "TOO_MANY_ATTEMPTS") {          return {            success: false as const,            error: "TOO_MANY_ATTEMPTS" as const,          };        }        throw error;      }    }),

Lecture guidée de login#

Anatomie d’une procédure publique

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

publicProcedure : un choix explicite, pas un défaut
TS
packages/backend/src/modules/auth/router.ts
guided extract: lines 28-59focus line 1
import { router, publicProcedure, protectedProcedure } from "@backend/trpc/core";import { deleteSessionFromCookie } from "@backend/lib/session/session.server";import { loginService } from "@backend/modules/auth/services/auth.service";import {  createPasswordResetToken,  hashResetToken,  RESET_TOKEN_COOLDOWN_MS,  RESET_TOKEN_TTL_MS,} from "@backend/modules/auth/services/passwordReset.service";import { sendPasswordResetEmail } from "@backend/email/services/sendPasswordResetEmail";import { TRPCError } from "@trpc/server";import { hashPassword, verifyPassword } from "@backend/lib/auth/password";import { getToken, verifyJwt } from "@backend/lib/session/session.server";import {  changePasswordSchema,  loginSchema,  requestPasswordResetSchema,  resetPasswordSchema,} from "@contracts/auth/auth.schema";import { assertPasswordOfConnectedUser } from "@backend/lib/security/passwordCheck";/** * Connexion, déconnexion, session courante, mot de passe (changement, oubli, réinitialisation). * `getSession` est appelée en permanence par le front : elle répond à « qui suis-je, maintenant ? ». */export const authRouter = router({  login: publicProcedure    // Même schéma que le formulaire de connexion (packages/contracts) : une seule règle, un seul message.    .input(loginSchema)    .mutation(async ({ input }) => {      const { email, password } = input;      try {        await loginService(email, password);        return {          success: true as const,        };      } catch (error) {        // Un mauvais identifiant fait partie du fonctionnement normal du formulaire : c'est un        // résultat. Tout le reste (base injoignable, bug) est une vraie erreur : elle remonte,        // est journalisée par la route /api/trpc, et le formulaire affiche son message générique.        if (error instanceof TRPCError && error.message === "INVALID_CREDENTIALS") {          return {            success: false as const,            error: "INVALID_CREDENTIALS" as const,          };        }        // Trop d'échecs pour cette adresse : un résultat aussi, que le formulaire explique.        if (error instanceof TRPCError && error.message === "TOO_MANY_ATTEMPTS") {          return {            success: false as const,            error: "TOO_MANY_ATTEMPTS" as const,          };        }        throw error;      }    }),  logout: protectedProcedure.mutation(async () => {    await deleteSessionFromCookie();    return {      success: true,    };  }),  /**   * Changement de mot de passe (utilisateur connecté).   * Vérifie le mot de passe actuel, refuse un nouveau identique, re-hash, et   * DÉCONNECTE LES AUTRES APPAREILS (supprime leurs sessions ; garde la   * courante via le sessionId du cookie).   */  changePassword: protectedProcedure    .input(changePasswordSchema)    .mutation(async ({ ctx, input }) => {      const userId = ctx.sessionClient.user.id;      const user = await ctx.prisma.user.findUnique({        where: { id: userId },        select: { password: true },      });      if (!user) {        throw new TRPCError({ code: "NOT_FOUND", message: "Membre introuvable." });      }      // BAD_REQUEST, pas UNAUTHORIZED : le membre EST connecté ; c'est la saisie qui est fausse.      // UNAUTHORIZED signifie « aucune session », et un client pourrait le traiter comme une déconnexion.      // Essais limités : une session volée ne permet pas d'essayer des mots de passe sans fin.      await assertPasswordOfConnectedUser({        userId,        password: input.currentPassword,        hash: user.password,        wrongMessage: "Mot de passe actuel incorrect.",      });      const same = await verifyPassword(input.newPassword, user.password);      if (same) {        throw new TRPCError({          code: "BAD_REQUEST",          message: "Le nouveau mot de passe doit être différent de l'actuel.",        });      }      const hashed = await hashPassword(input.newPassword);      const payload = verifyJwt(await getToken());      const currentSessionId = payload?.sessionId ?? null;      await ctx.prisma.$transaction(async (tx) => {        await tx.user.update({          where: { id: userId },          data: { password: hashed },        });        await tx.session.deleteMany({          where: {            userId,            ...(currentSessionId ? { NOT: { id: currentSessionId } } : {}),          },        });      });      return { success: true };    }),  getSession: publicProcedure.query(async ({ ctx }) => {    return ctx.sessionClient;  }),  requestPasswordReset: publicProcedure    .input(requestPasswordResetSchema)    .mutation(async ({ input, ctx }) => {      // Déjà nettoyée et mise en minuscules par emailSchema, comme toutes les adresses enregistrées.      const user = await ctx.prisma.user.findUnique({        where: { email: input.email },        select: { id: true, email: true },      });      if (!user) {        return { ok: true as const };      }      const lastToken = await ctx.prisma.passwordResetToken.findFirst({        where: { userId: user.id },        orderBy: { createdAt: "desc" },        select: { createdAt: true },      });      if (lastToken) {        const elapsed = Date.now() - lastToken.createdAt.getTime();        if (elapsed < RESET_TOKEN_COOLDOWN_MS) {          return { ok: true as const };        }      }      const { token, tokenHash } = createPasswordResetToken();      const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS);      await ctx.prisma.passwordResetToken.create({        data: {          userId: user.id,          tokenHash,          expiresAt,          requestIp: ctx.requestIp ?? null,          userAgent: ctx.userAgent ?? null,        },      });      // L'envoi n'est PAS attendu : il prend plusieurs secondes, et une réponse lente pour une      // adresse connue, rapide pour une inconnue, trahirait qui a un compte. Jamais le jeton dans      // les journaux ; un échec d'envoi est journalisé, la réponse reste la même.      void sendPasswordResetEmail({ toEmail: user.email, token }).catch((err: unknown) => {        console.error(          "[auth] e-mail de réinitialisation non envoyé :",          err instanceof Error ? err.message : err,        );      });      return { ok: true as const };    }),  resetPassword: publicProcedure    .input(resetPasswordSchema)    .mutation(async ({ ctx, input }) => {      const token = input.token.trim();      const tokenHash = hashResetToken(token);      const entry = await ctx.prisma.passwordResetToken.findUnique({        where: { tokenHash },        select: {          id: true,          userId: true,          expiresAt: true,          usedAt: true,        },      });      if (!entry) {        throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien n'est pas valide. Demandez-en un nouveau avec « Mot de passe oublié »." });      }      if (entry.usedAt) {        throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien a déjà servi. Connectez-vous, ou demandez-en un nouveau avec « Mot de passe oublié »." });      }      if (entry.expiresAt.getTime() < Date.now()) {        throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien a expiré. Demandez-en un nouveau avec « Mot de passe oublié »." });      }      const hashedPassword = await hashPassword(input.newPassword);      await ctx.prisma.$transaction(async (tx) => {        await tx.user.update({          where: { id: entry.userId },          // `isFirstLogin` n'est pas touché : choisir un mot de passe ne complète pas le profil.          // Un nouveau membre qui passe par « Mot de passe oublié » y reste invité.          data: { password: hashedPassword },        });        // Consommation atomique : la ligne n'est marquée que si elle ne l'était pas encore. Deux        // envois simultanés du même lien passent tous deux les vérifications ci-dessus ; un seul        // obtient ici count === 1, l'autre annule sa transaction.        const consumed = await tx.passwordResetToken.updateMany({          where: { id: entry.id, usedAt: null },          data: { usedAt: new Date() },        });        if (consumed.count !== 1) {          throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien a déjà servi. Connectez-vous, ou demandez-en un nouveau avec « Mot de passe oublié »." });        }        await tx.session.deleteMany({          where: { userId: entry.userId },        });      });      return { ok: true as const };    }),});

Une procédure bien construite ne commence jamais par Prisma.

Elle commence par un contrat d’accès, un contrat d’entrée, puis une intention.


Deuxième exemple : une procédure protégée#

Maintenant, nous ajoutons une contrainte supplémentaire.

La procédure ne doit plus seulement être valide. Elle doit aussi être réservée à un utilisateur authentifié.

C’est ici que protectedProcedure change vraiment de dimension.

Le bon candidat est la procédure par laquelle un instructeur enregistre sa présentation, celle qui s’affiche sur la page publique des instructeurs. (La modification du profil, elle, passe par une server action : la partie « Formulaires » l’a étudiée.)

Une procédure protégée : saveMyInstructorBio
TS
packages/backend/src/modules/users/router.ts
  /**   * 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 };    }),

Lecture guidée de saveMyInstructorBio#

Une procédure où le contexte est réellement exploité

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

protectedProcedure garantit un membre authentifié
TS
packages/backend/src/modules/users/router.ts
guided extract: lines 350-404focus line 11
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" }],    });  }),});

Ce que protectedProcedure change réellement#

Le gain n’est pas seulement :

“il faut être connecté”

Le gain est plus profond.

👉 La procédure est construite sur un contexte requalifié.
👉 Le backend n’a plus à se demander à chaque ligne si l’utilisateur existe.

Cela change immédiatement la lecture du code.

Nous passons de :

  • logique défensive (if (!ctx.user) throw...)
  • vérifications répétées
  • accès dispersés

à :

  • hypothèses plus fortes
  • code plus lisible
  • responsabilité mieux répartie

Authentification, administrateur, accès à un espace : trois niveaux différents#

Il faut être très net sur ces termes. Ils sont souvent confondus, et cette confusion mène à des backends fragiles.

Les niveaux à ne pas confondre

Niveau

Question posée

Où cela pèse sur la procédure ?

Authentification
Qui es-tu ?
protectedProcedure (session requise)
Administrateur
Appartiens-tu au groupe Administrateurs ?
.use(isAdmin), dans la chaîne
Accès à un espace
Quel est ton niveau sur cet espace (lecteur, éditeur) ?
une garde dans la procédure, qui a besoin de l’entrée (le chemin visé)
Procédure
Quelle intention veux-tu exécuter ?
query / mutation

🧠 Si nous mélangeons ces niveaux, le backend devient vite confus. Si nous les séparons, la procédure devient au contraire très lisible :

  • d’abord nous savons qui agit (authentification)
  • ensuite nous savons s’il a le droit (administrateur, ou niveau d’accès à l’espace visé)
  • enfin nous exécutons ce qu’il veut faire (intention)

Le troisième niveau ne peut pas vivre dans la chaîne : pour savoir si un membre peut écrire dans un dossier, il faut connaître le dossier, c’est-à-dire l’entrée. Les procédures du finder commencent donc par une garde qui reçoit le chemin (assertCanReadPath, assertCanTrashPaths…) ; la partie « Stockage et médias » les détaille.


Troisième exemple : une écriture complète#

Les deux exemples précédents sont courts. Une écriture réelle accumule plus d’étapes ; discipline.create, qu’un administrateur appelle depuis le tableau de bord, les montre toutes.

1. L’entrée est validée par Zod. Le schéma décrit chaque champ, et tRPC refuse la requête avant même d’exécuter la procédure si l’entrée ne le respecte pas. Le type TypeScript de l’entrée est déduit du schéma : une seule déclaration sert de validation et de type.

Le schéma d’entrée de discipline.create
TS
packages/backend/src/modules/disciplines/router.ts
/* -------------------------------------------------------------------------- *//*                           SHARED VALIDATION SCHEMAS                        *//* -------------------------------------------------------------------------- */const disciplineTypeEnum = z.enum(["MARTIAL_ART", "CALLIGRAPHY"]);const createInput = z.object({  name: z.string().trim().min(1).max(120),  slug: slugSchema,  type: disciplineTypeEnum,  // ID de la famille de disciplines (relation vers le modèle  // DisciplineFamily). Nullable : une discipline peut ne pas être  // rattachée à une famille (création progressive).  familyId: z.number().int().positive().nullable().optional(),  school: z.string().trim().min(1).max(120).nullable().optional(),  classification: z.string().trim().min(1).max(120).nullable().optional(),  // ID de l'origine culturelle (relation vers le modèle Origin).  // Nullable : une discipline peut ne pas avoir d'origine renseignée  // (création progressive — on lie l'origine plus tard).  originId: z.number().int().positive().nullable().optional(),  // Composite Json du PageBuilder pour la page de présentation de la  // discipline. Requis au create — le frontend envoie au minimum  // `emptyPageContentV1()` si l'admin n'a rien rédigé.  description: pageContentSchemaV1,  // Présentation synthétique pour l'accueil. Optionnelle : la colonne a un  // composite vide par défaut, et un formulaire qui ne la renseigne pas doit  // continuer de fonctionner.  summary: pageContentSchemaV1.optional(),  // Image de la carte d'accueil. Nullable : une discipline peut ne pas en  // avoir, et la carte se rend alors sans illustration.

2. Les références sont vérifiées. Une catégorie, un instructeur, une famille ou une origine inexistants produisent une erreur BAD_REQUEST explicite, plutôt qu’une violation de clé étrangère incompréhensible. C’est le service assertDisciplineReferences du chapitre précédent.

3. L’écriture et ses conséquences forment une transaction. Créer une discipline, c’est aussi enregistrer les médias que sa description utilise, pour que la route publique accepte de les servir et que personne ne puisse les supprimer tant qu’ils sont affichés :

Écrire la discipline et ses médias, ou rien
TS
packages/backend/src/modules/disciplines/router.ts
        familyId: input.familyId,        originId: input.originId,      });      // ─── Transaction : create discipline + sync references ─────────────      return await ctx.prisma.$transaction(async (tx) => {        // Segment du dossier, figé ici une fois pour toutes (voir storageSegments.ts).        const storageSegment = await newDisciplineSegment(tx, input.categoryId, input.name);        let created;        try {          created = await tx.discipline.create({            data: {              name: input.name,              slug: input.slug,              storageSegment,              type: input.type,              familyId: input.familyId ?? null,              school: input.school ?? null,              classification: input.classification ?? null,              originId: input.originId ?? null,              description: input.description as Prisma.InputJsonValue,              summary:                input.summary === undefined                  ? undefined                  : (input.summary as Prisma.InputJsonValue),              summaryMediaId: input.summaryMediaId ?? null,              categoryId: input.categoryId,              instructorId: input.instructorId,              publicationDate: input.publicationDate ?? null,            },          });        } catch (err) {          if (            err instanceof Prisma.PrismaClientKnownRequestError &&            err.code === "P2002"          ) {            const t = err.meta?.target;            const onSlug = Array.isArray(t)              ? t.includes("slug")              : String(t ?? "").includes("slug");            throw new TRPCError({              code: "CONFLICT",              message: onSlug                ? "Ce slug est déjà utilisé. Choisissez-en un autre."                : "Une discipline de ce nom existe déjà dans cette catégorie.",            });          }          throw err;        }        // UNION des deux composites. `syncPageMediaReferences` recalcule        // l'ensemble COMPLET des références de la page : ne lui passer que la        // description ferait passer les images du résumé pour orphelines.        await syncPageMediaReferences(tx, {          pageType: "DISCIPLINE",          pageId: String(created.id),          newContent: {            version: 1,            blocks: [              ...input.description.blocks,              ...(input.summary?.blocks ?? []),

Si l’enregistrement des médias échoue (par exemple parce que la description utilise un média encore en attente de validation), la création est annulée : il n’existe jamais de discipline enregistrée dont les médias ne le seraient pas.

4. Les erreurs de la base deviennent des erreurs lisibles. L’unicité du slug et celle du nom dans une catégorie sont garanties par la base. Plutôt que de vérifier avant d’écrire, ce qui laisserait une fenêtre où deux requêtes simultanées passeraient toutes les deux, la procédure écrit, et traduit l’erreur Prisma P2002 en CONFLICT en précisant quelle contrainte a sauté. Le chapitre 8 reviendra sur ce choix, que le projet n’avait pas fait partout.

Quatre étapes, et pourtant la procédure reste lisible : chacune occupe un bloc, dans l’ordre où une requête les traverse.


Le vrai but : alléger la procédure, pas la gonfler#

Nous pourrions être tentés d’empiler toujours plus dans la procédure : validation, sécurité, logique métier, Prisma, gestion d’erreur, mapping.

Mais une bonne procédure ne doit pas tout absorber.

👉 Elle doit surtout orchestrer.

C’est pour cela que, dans un backend propre :

  • la validation est décrite à l’entrée (Zod)
  • l’accès est centralisé par middleware / guard
  • la logique métier est déplacée dans un service
  • Prisma reste le dernier maillon

Fabriquer une procédure, ce n’est pas écrire toute la logique au même endroit.

C’est composer proprement : 👉 accès → validation → intention → contexte → délégation métier


Le modèle final d’une bonne procédure#

À ce stade, nous pouvons résumer la forme canonique d’une procédure tRPC dans ce projet :

Scroller horizontalement si nécessaire

Niveau d’accès
→
Validation Zod
→
Intention (query/mutation)
→
ctx injecté
→
service métier
→
Prisma

Et cette fois, la lecture devient très stable.

Une bonne procédure :

  • ne recrée pas le contexte
  • ne disperse pas la sécurité
  • ne fait pas porter toute la logique au router
  • ne commence pas par Prisma

👉 elle reçoit un cadre, affirme une intention, puis délègue correctement.


Ce que la suite doit maintenant approfondir#

Nous savons désormais fabriquer une procédure.

La suite logique consiste à descendre plus profondément dans chacune des couches qui la composent :

  • protéger une procédure (session, groupe Administrateurs, règles métier)
  • valider l’entrée avec Zod sans bruit inutile
  • gérer les erreurs sans casser la lisibilité du flux
  • mapper correctement les sorties vers des DTO stables

🚀 Autrement dit : nous avons le squelette.

La suite va montrer comment le rendre vraiment robuste.


👉 Où nous en sommes : nous savons désormais composer une procédure — intention d’abord, validation, contexte, sécurité, délégation au service, retour. Le squelette tient. Reste à durcir chaque articulation, une par une, pour qu’il résiste à la vie réelle d’un backend en production.


Ce que nous venons de construire dans le Mouvement 2#

Nous avons pris les pièces séparées du Mouvement 1 et nous les avons articulées. Trois idées en sont ressorties :

  • Le router orchestre, mais ne fait pas. Une procédure nomme une intention, vérifie son cadre, et délègue. Si nous voyons de la logique métier lourde à l’intérieur d’une procédure, c’est qu’elle est au mauvais endroit.
  • Les services portent la logique, isolément de tRPC. Ils ne connaissent ni les routes, ni les cookies, ni le protocole — ils travaillent sur un ctx et des données déjà validées. C’est cette ignorance qui les rend testables, réutilisables, et stables dans le temps.
  • Une procédure se compose dans un ordre précis. Guard, validation, règle métier, service, réponse — chaque étape apparaît une fois, à sa place, et peut être lue indépendamment des autres. C’est cet ordre qui transforme une procédure de « bloc de code » en « document lisible ».

À suivre#

Le squelette tient, et le Mouvement 2 s’achève. Mais un squelette n’est pas encore un backend robuste : chaque articulation doit maintenant être durcie, une par une, en commençant par la plus sensible.

Chapitre suivant : protéger une procédure. Authentification et autorisation, la garde isAdmin, une sécurité que le projet avait dispersée, l’histoire des brouillons visibles par tous, et des règles métier qui ne vivaient que dans l’interface.

Sur cette page