Maîtriser la sortie
Ce que le backend renvoie vraiment : une empreinte de mot de passe qui partait vers le navigateur, select et omit, le DTO comme contrat partagé, satisfies qui vérifie sans élargir, et où vit le mapping.
Précédemment. Une erreur est levée avec un code, à l’endroit qui sait pourquoi elle se produit ; seuls les messages écrits pour l’utilisateur sortent tels quels.
Nous avons construit quelque chose de solide.
Entrée validée, contexte fiable, sécurité centralisée, erreurs structurées.
Mais une dernière question reste ouverte.
🧠 Que renvoyons-nous réellement au client ?#
Sans réflexion, le réflexe naturel est de faire ceci :
Retour direct du modèle Prisma (anti-pattern)
return ctx.prisma.user.findMany()Cela fonctionne.
Mais quelque chose pose un problème silencieux.
Nous exposons directement :
- tous les champs du modèle — y compris
password - des relations parfois non voulues
- une structure intimement liée à la base
💥 Le client devient dépendant de la structure interne du backend.
Et le jour où Prisma évolue — un champ renommé, une relation ajoutée — le frontend peut casser sans que le backend ait "changé" du point de vue métier.
Plus grave : des données sensibles peuvent fuir. Le hash du mot de passe, des métadonnées d’audit, des identifiants internes — tout ce qui vit dans le modèle Prisma traverse silencieusement la frontière.
Ce n’est pas un risque théorique. La suppression d’un membre (user.delete) se terminait ainsi :
Avant : la ligne complète renvoyée
return tx.user.delete({ where: { id: input.id },});delete renvoie la ligne supprimée, et la procédure la renvoyait au navigateur : à chaque suppression, l’empreinte bcrypt du mot de passe du membre partait vers l’écran de l’administrateur, visible dans l’onglet Réseau des outils du navigateur. Aucune page ne l’affichait ; l’API la donnait. L’écran, lui, n’utilisait même pas la réponse.
La correction tient en une ligne : ne demander à Prisma que ce qui doit sortir.
Après : seul l’identifiant sort
return tx.user.delete({ where: { id: input.id }, select: { id: true },});Une empreinte n’est pas un mot de passe, mais elle permet de le chercher hors ligne, sans limite d’essais. Elle n’a rien à faire hors du serveur, même chez un administrateur.
🔥 Le principe fondamental#
Le backend ne doit jamais exposer directement ses modèles internes.
Il doit exposer :
👉 une forme contrôlée, stable, pensée pour le client
1) La première forme de contrôle : select#
Avant même de parler de DTO explicite, il existe une façon très directe de contrôler la sortie : ne demander à Prisma que ce dont nous avons besoin.
Le profil d’un membre est lu par deux procédures : le membre lui-même (getCurrentUserProfile), et un administrateur qui consulte une fiche (getProfileById). Les deux décrivaient la même liste de champs, recopiée ; elle est désormais nommée une fois :
/** 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, 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); }),password, les données légales (majorité, parent référent), l’adresse du club, les dates de création — rien de tout cela ne sort.
Prisma ne les charge même pas.
👉 Ce n’est pas encore un DTO formel.
👉 Mais c’est déjà une décision d’exposition.
Et cette décision a un double bénéfice : sécurité (les champs sensibles ne quittent jamais la base) et performance (Prisma génère des requêtes SQL plus ciblées, transférant moins de données).
Le projet utilise aussi l’approche inverse, omit, par exemple dans la liste des membres de l’administration :
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.Les deux ne protègent pas de la même chose. omit retire ce qu’on sait dangereux : tout le reste sort, y compris le champ qu’on ajoutera au modèle l’an prochain. select n’autorise que ce qu’on a listé : un nouveau champ ne sort pas tant que personne ne l’a ajouté. Pour une donnée exposée largement, select est le choix prudent ; omit convient à un écran d’administration qui a réellement besoin de presque tout.
2) Le DTO : une forme nommée, partageable#
Pour les données qui doivent être stables dans le temps — et partagées avec le frontend — nous allons plus loin.
Dans ce projet, UserProfile est défini dans packages/contracts :
export interface UserProfile { id: string | null; firstName: string | null; lastName: string | null; pseudo: string | null; email: string | null; avatar: string | null; aboutMe: string | null; phone: string | null; birthDate: string | null; isFirstLogin: boolean;}Ce type ne ressemble pas au modèle Prisma.
birthDate est une string | null, pas une Date : le formulaire de profil travaille avec la valeur d’un champ de date (« AAAA-MM-JJ »).
password n’existe pas.
Les données légales (majorité, parent référent) n’existent pas non plus : elles ne concernent pas le profil.
👉 Ce n’est pas un modèle de données.
👉 C’est une représentation métier pensée pour le consommateur.
Il n’est pas parfait pour autant : id et email y sont déclarés string | null, alors qu’un membre a toujours les deux. Le contrat est plus large que la réalité. Nous allons voir que cela compte, et comment le projet s’en accommode.
🔭 Pour aller plus loin — le sens du sigle DTO#
DTO signifie Data Transfer Object. Le terme est ancien — il vient des architectures Java d’entreprise — mais l’idée est intemporelle.
Un DTO est un objet conçu uniquement pour le transport. Il ne contient pas de logique. Il ne persiste pas. Il sert d’intermédiaire entre deux couches.
Dans notre cas, le DTO est la forme que prend la donnée quand elle sort du backend vers le frontend. Il est distinct du modèle de base (ce que Prisma manipule) et de la vue (ce que React affiche). Il est la forme du contrat.
Cette distinction porte un nom en architecture : le pattern Boundary. La donnée change de forme aux frontières du système, et chaque forme est adaptée à son contexte.
3) Le satisfies : relier le service au contrat#
Voyons maintenant comment ce contrat est utilisé. La projection vit à côté du routeur, dans une fonction partagée par les deux lectures :
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;Lecture guidée — anatomie d'un mapping contrôlé#
De la requête Prisma au DTO partagé
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
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" }], }); }),});satisfies ne ment pas au compilateur.
Il lui demande de vérifier que ce que nous produisons respecte ce que nous avons promis.
C’est la différence entre un cast aveugle et un contrat honnête.
Le projet a vérifié cette différence à ses dépens, au moment même où cette fonction a été écrite. Sa première version annotait le type de retour : function toUserProfile(…): UserProfile. Tout compilait côté serveur. Mais deux écrans du front (la page de profil et la fiche d’un membre) ont aussitôt cessé de compiler : id y était devenu string | null, parce que le contrat UserProfile, plus large que la réalité, admet un identifiant nul. L’annotation avait élargi le type ; satisfies le vérifie sans y toucher, et le front a retrouvé id: string.
🔭 Pour aller plus loin — satisfies vs as vs type annotation#
TypeScript offre trois façons différentes de déclarer le type d’une valeur, et satisfies est le plus récent (TS 4.9).
const x = value as Type→ cast aveugle. TypeScript fait confiance, ne vérifie pas. Dangereux.const x: Type = value→ annotation. TypeScript vérifie quevaluerespecteType, mais perd le type concret devalue:xaura le typeType, pas la forme réelle devalue.const x = value satisfies Type→ vérification avec préservation. TypeScript vérifie la conformité, et garde le type concret devalue.
Dans le cas du DTO, satisfies est le bon choix. Nous voulons vérifier que la projection respecte UserProfile, mais nous voulons aussi garder le type exact pour l’inférence côté client (tRPC remonte le type concret jusqu’au frontend).
4) Où vit le mapping ?#
Dans le projet :
Responsabilité du mapping selon le contexte
Approche | Où vit le mapping | Quand l’utiliser |
|---|---|---|
select (ou omit) Prisma | dans la requête | filtrage simple, sans transformation |
transformation dans la procédure | dans la procédure | transformation légère, à un seul endroit (users.getAll ajoute isDemo et les groupes triés) |
fonction de projection | à côté du routeur (toUserProfile) | dès que deux procédures renvoient la même forme |
DTO partagé | dans packages/contracts | quand le front a besoin de nommer le type (UserProfile) |
Showing entries1to4entries
C’est la même règle que pour les services : une forme utilisée à un seul endroit reste sur place ; dès qu’elle sert deux fois, elle reçoit un nom.
🔥 Le vrai bénéfice#
Avant :
- le frontend lit directement la structure de la base
- chaque évolution du schéma Prisma peut casser l’interface
- des données sensibles peuvent fuir sans que personne le remarque
Après :
- le frontend consomme un contrat stable
- Prisma peut évoluer sans impacter le client si le DTO reste compatible
- la sortie est maîtrisée dès la procédure
🔁 Le flux complet#
Scroller horizontalement si nécessaire
Le backend ne renvoie pas "la donnée".
Il renvoie une version maîtrisée de la donnée — filtrée, transformée, validée par un contrat.
Et ce contrat peut être partagé avec le frontend.
🚀 Conclusion#
Nous ne renvoyons plus les modèles internes.
Nous exposons des contrats stables, pensés pour le consommateur.
Nous avons maintenant un système complet :
- entrée validée (Zod, inline ou partagé)
- contexte fiable (createTRPCContext)
- intention claire (procédure)
- sécurité dans la chaîne (
protectedProcedure,isAdmin) et règles métier dans la procédure - logique métier isolée (service)
- erreurs structurées (TRPCError)
- sortie maîtrisée (select, DTO, satisfies)
👉 Le flux est désormais cohérent de bout en bout.
Pas seulement dans sa mécanique — dans sa lisibilité.
Chaque couche dit ce qu’elle fait.
Chaque erreur dit ce qui a échoué.
Chaque réponse dit exactement ce qu’elle promet.
🎯 C’est ce qui transforme un backend fonctionnel en backend compréhensible.
👉 Où nous en sommes : le DTO ferme le flux — la sortie est filtrée, structurée, ancrée à un contrat que le frontend peut consommer sans trembler. Chaque couche du backend a désormais sa propre façon d’être stricte. Reste à prendre du recul et à regarder le système dans son ensemble — parce qu’un backend ne se comprend vraiment qu’une fois que toutes ses couches fonctionnent ensemble.
Ce que nous venons de construire dans le Mouvement 3#
Nous avons durci chaque articulation du flux. Quatre idées en sont ressorties :
- La sécurité est centralisée, pas dispersée.
protectedProcedureetisAdmin: les règles d’accès vivent dans des middlewares composables, déclarés dans la chaîne de chaque procédure. Les règles métier qui dépendent de l’entrée vivent dans la procédure, et jamais seulement dans l’interface. Le reste du code peut faire confiance aux types, parce que les vérifications ont déjà eu lieu — et TypeScript requalifie le contexte pour le prouver. - Zod est la frontière du backend. Toute donnée qui entre passe par un schéma explicite, dont la portée dépend de l’usage : local à la procédure, ou hissé dans
packages/contractspour être partagé avec le frontend. Entre la requête et la logique métier, il y a toujours ce seuil — et le service peut travailler sans défiance. TRPCErrorest un signal, pas un incident à cacher. Chaque couche lève ce qu’elle sait lever, avec le bon code, et laisse tRPC transformer ce signal en réponse HTTP cohérente. Le happy path et le chemin d’erreur coexistent sans que la lisibilité du code en souffre.- Le DTO contrôle ce qui sort.
selectfiltre à la source, le mapping transforme,satisfiesrelie le service à un contrat vérifiable. La sortie n’est plus le reflet brut du modèle Prisma — c’est une forme choisie, stable, et partageable.
Le dernier chapitre va maintenant reprendre tout ce que nous avons construit — du premier appel client jusqu’à la réponse typée — pour le lire d’un seul trait.
À suivre#
Chaque couche a désormais sa façon d’être stricte, et le Mouvement 3 s’achève. Reste à prendre du recul, et à suivre une requête d’un bout à l’autre.
Chapitre suivant : le circuit complet. Une requête, du clic à la réponse, en onze étapes ; puis le navigateur : les deux clients, le regroupement des appels, le cache et l’invalidation.