Un modèle qui s’ouvre
Comment le modèle a accueilli ce qui ne rentrait pas dans ses cases : l’origine et la famille devenues des entités, l’événement distinct du stage, la règle « au moins un des trois » vérifiée à la création et à la modification, le renommage de Stage en Seminar, des slugs stables, et des dossiers qui ne dépendent plus d’un nom affiché.
Précédemment. Six conventions relient les entités du domaine : les heures HHMM, la date pure d’une séance, le composite de contenu, le statut d’un fichier, la hiérarchie des instructeurs et la date de publication, que suivent tous les dépendants d’un brouillon.
Le besoin qui a tout déclenché#
Pendant un temps, le modèle initial a bien servi. Tant que le club ne proposait que des cours hebdomadaires et des stages dans ses disciplines enseignées, tout rentrait dans les cases : la discipline était l’art, le cours le créneau, le stage le rendez-vous intensif, et chaque entité était obligatoirement rattachée à une discipline du club.
Puis le club a voulu organiser un stage de calligraphie chinoise, avec un calligraphe invité. Et le modèle a résisté. Le stage exigeait une disciplineId non nulle — mais la calligraphie chinoise n’était pas une discipline du club. Le club ne l’enseignait pas en cours régulier : créer une pour l’occasion l’aurait fait apparaître dans le menu « Nos disciplines », dans les sélecteurs, dans l’accueil, comme une discipline fantôme.
Le problème s’est étendu avec d’autres besoins : des stages ponctuels par des intervenants extérieurs sur des pratiques que le club n’enseigne pas ; des événements culturels plus larges (un repas, une conférence sur le bouddhisme zen) qui ne se prêtaient à aucune des cases existantes.
Le modèle initial supposait qu’un rendez-vous est toujours rattaché à une discipline du club. Cette hypothèse est devenue fausse à mesure que le club s’est ouvert aux intervenants extérieurs et aux événements culturels. L’évolution du modèle lève cette contrainte, sans renoncer à ce que chaque rendez-vous ait un contexte.
Trois briques y répondent, dans l’ordre où nous les prenons : une origine qui devient une entité, un événement distinct du stage, et la règle « au moins un des trois ».
Une origine est une entité, pas un texte#
La première brique remplace l’ancien champ , un texte libre, par un modèle . Pourquoi cette transformation ?
Un texte libre laisse coexister des doublons orthographiques. « Chine », « chine », « China » et « CHINE » sont quatre chaînes distinctes, alors qu’elles désignent la même chose. Le jour où nous voulons regrouper « tout ce qui vient de Chine », nous sommes coincés : un lower(origin) = 'chine' ne capture même pas « China ».
Des origines en texte libre
SELECT DISTINCT origin FROM "Discipline";-- "Chine"-- "chine"-- "China"-- "Fujian" ← rattaché à la Chine, mais comment le savoir ?Une entité dédiée règle tout cela d’un coup :
model Origin { /// Identifiant auto-incrémenté. id Int @id @default(autoincrement()) /// Nom affiché, unique (ex. « Chine »). name String @unique /// Slug unique. Aucune page publique ne l'utilise aujourd'hui. slug String @unique /// Description culturelle, en texte simple. description String? /// Pays moderne. Peut différer de `name` quand celui-ci désigne une région /// (ex. name « Fujian », country « Chine »). country String? /// Région ou sous-région culturelle (ex. « Fujian »). region String? /// Drapeau : emoji (« 🇨🇳 ») ou code ISO 3166 alpha-2 (« CN »). Format libre. flag String? /// Période historique d'ancrage (ex. « Dynastie Qing »). historicalPeriod String? /// Rang d'affichage dans les listes, croissant (puis par nom). sortOrder Int @default(0) /// Création. createdAt DateTime @default(now()) /// Dernière modification (tenue à jour par Prisma). updatedAt DateTime @updatedAt /// Disciplines de cette origine (1–N, Discipline.origin). Détachées à sa /// suppression (SET NULL). disciplines Discipline[] /// Séminaires de cette origine (1–N, Seminar.origin), notamment ceux sans /// discipline du club. Détachés à sa suppression (SET NULL). seminars Seminar[] /// Évènements de cette origine (1–N, Event.origin). Détachés à sa /// suppression (SET NULL). events Event[] @@index([sortOrder]) /// Galeries associées (1–N, Gallery.origin). Détachées à sa suppression /// (SET NULL). galleries Gallery[]}L’unicité du nom et du slug élimine les doublons par construction : le formulaire propose un sélecteur, et on ne crée pas une origine en tapant son nom une deuxième fois. Les métadonnées culturelles (pays, région, drapeau, période historique) servent à l’affichage — le drapeau apparaît à côté de l’origine sur la fiche d’un stage. Le regroupement transverse devient possible : disciplines, stages et événements pointent vers la même , et l’API sait déjà répondre « tous les événements de cette origine » (event.getAllByOrigin).
La séparation du nom, du pays et de la région permet une nuance utile : « Fujian » peut être une origine à part entière (name: "Fujian", country: "Chine", region: "Fujian"), tout en restant géographiquement rattachée à la Chine. Le club pourra présenter les arts du sud de la Chine distinctement de ceux du nord.
La famille de disciplines a suivi le même chemin, pour la même raison : « Kung-fu », « Kung Fu » et « kungfu » auraient fait trois entrées de menu.
model DisciplineFamily { /// Identifiant auto-incrémenté. id Int @id @default(autoincrement()) /// Nom affiché, unique. name String @unique /// Slug unique. Aucune page publique de famille ne l'utilise aujourd'hui. slug String @unique /// Rang d'affichage, croissant (puis par nom). sortOrder Int @default(0) /// Création. createdAt DateTime @default(now()) /// Dernière modification (tenue à jour par Prisma). updatedAt DateTime @updatedAt /// Disciplines de la famille (1–N, Discipline.family). Détachées à sa /// suppression (SET NULL). disciplines Discipline[] @@index([sortOrder])}Supprimer une origine ou une famille détache ce qui y était rattaché (SetNull), sans rien supprimer d’autre ; les routeurs refusent pourtant la suppression tant qu’elles sont utilisées, pour que l’administrateur sache ce qu’il défait.
Passer d’un texte libre à une entité n’est pas cosmétique : c’est ce qui rend possibles les regroupements transverses et les métadonnées riches. Une fois l’origine devenue une entité, une page /origines/chine qui liste disciplines, stages et événements ne demande aucune gymnastique de requête.
Pourquoi un événement n’est pas un stage#
Face aux événements culturels, deux voies étaient possibles. La première : élargir le stage pour qu’il accepte tout — rendre la discipline facultative, ajouter des champs, et appeler « stage » un repas de fin d’année. La seconde : créer un modèle distinct.
Nous avons choisi la seconde, parce que le stage et l’événement ont des sens différents, même si leurs structures se ressemblent. Un stage reste centré sur la pratique : un animateur principal, des co-animateurs, un programme détaillé, des séances intensives. Un événement est centré sur la rencontre : un organisateur, une présentation, une ou plusieurs séances, sans programme au sens du stage.
Aspect | Stage (Seminar) | Événement (Event) |
|---|---|---|
Contenus | Deux : description et programme (plus un résumé) | Un : content (plus un résumé) |
Animation | Un animateur principal et des co-animateurs | Un organisateur |
Disciplines du club | Au plus une (disciplineId) | Autant que voulu (table EventDiscipline) |
Disciplines extérieures | Un nom (externalDisciplineLabel) | Une liste (externalDisciplineLabels) |
Fichiers déposés par les membres | Non | Oui (mediaAssets) |
Séances | SeminarSession | EventSession, de même structure |
Rattachement | Au moins un des trois | Au moins un des trois |
Showing entries1to7entries
L’option « un seul modèle » nous aurait donné un stage avec deux contenus dont l’un serait souvent vide, deux niveaux d’animation dont l’un serait rarement rempli, et une liste de disciplines qui n’a de sens que pour certains rendez-vous. Aucun gain de simplicité, et une perte de clarté : l’administrateur ne saurait plus quel champ remplir. Le prix de la séparation est faible — deux modèles de séances de même structure — et l’agenda public les réunit de toute façon dans une seule liste (chapitre 4).
La règle que nous appliquons : deux modèles distincts quand le sens métier diverge, même si la structure technique se ressemble. La duplication de structure coûte peu ; la confusion de sens coûte à chaque formulaire.
« Au moins un des trois »#
Pour que le stage de calligraphie existe, la discipline du stage devient facultative. Mais un stage sans aucun rattachement serait un contenant sans contexte : rien pour le classer, rien pour le présenter. Nous acceptons donc qu’il ne soit pas rattaché à une discipline du club, à condition d’avoir au moins un autre signal de contexte.
D’où la règle « au moins un des trois » : disciplineId, , originId. Si la pratique n’est pas enseignée au club mais que nous savons la nommer (« Calligraphie chinoise »), nous remplissons le nom extérieur. Si nous connaissons seulement sa culture (« stage culturel japonais »), l’origine. Et nous combinons quand cela a du sens.
Trois façons de rattacher un stage
// Stage d’armes de Tchoy Lee Fut : une discipline du club{ disciplineId: 5, externalDisciplineLabel: null, originId: null }// Calligraphie chinoise, par un calligraphe invité{ disciplineId: null, externalDisciplineLabel: "Calligraphie chinoise", originId: 3 }// Journée culturelle sur le Fujian{ disciplineId: null, externalDisciplineLabel: null, originId: 7 }La règle se vérifie à deux endroits, et la différence entre les deux est une leçon en soi. À la création, toutes les valeurs sont dans la requête : un refine du schéma Zod peut décider seul.
.refine( (data) => (data.disciplineId !== null && data.disciplineId !== undefined) || (data.externalDisciplineLabel !== null && data.externalDisciplineLabel !== undefined) || (data.originId !== null && data.originId !== undefined), { message: "Indiquez une discipline (du club ou extérieure) ou une origine.", path: ["disciplineId"], }, );À la modification, Zod ne voit que la requête. Une requête qui ne contient que originId: null (« retirer l’origine ») est valide en soi ; elle ne devient fautive que si le stage n’a ni discipline ni nom extérieur en base. Le routeur lit donc l’état actuel, calcule la combinaison qui serait enregistrée, et refuse si elle est vide :
const existing = await ctx.prisma.seminar.findUnique({ where: { id }, select: { disciplineId: true, externalDisciplineLabel: true, originId: true, primaryAnimatorId: true, animators: { select: { id: true } }, }, }); if (!existing) { throw new TRPCError({ code: "NOT_FOUND", message: "Stage introuvable.", }); } // Validation « au moins un des trois » après merge. // `rest.disciplineId !== undefined` signifie que le champ est dans // le payload — soit avec un id, soit avec `null` (détachement). const mergedDisciplineId = rest.disciplineId !== undefined ? rest.disciplineId : existing.disciplineId; const mergedExternalLabel = rest.externalDisciplineLabel !== undefined ? rest.externalDisciplineLabel : existing.externalDisciplineLabel; const mergedOriginId = rest.originId !== undefined ? rest.originId : existing.originId; const hasAtLeastOne = mergedDisciplineId !== null || (mergedExternalLabel !== null && mergedExternalLabel.length > 0) || mergedOriginId !== null; if (!hasAtLeastOne) { throw new TRPCError({ code: "BAD_REQUEST", message: "Un stage doit garder une discipline (du club ou extérieure) ou une origine.", }); }undefined et null n’y veulent pas dire la même chose : undefined signifie « champ absent de la requête, on garde la valeur en base » ; null signifie « détacher ». C’est ce qui permet au même update de ne toucher qu’au libellé, ou de retirer une origine.
L’événement applique la même règle à ses listes : au moins une discipline du club, au moins une discipline extérieure, ou une origine. Les listes se fusionnent de la même façon, dédoublonnées :
const disciplinesChanged = rest.disciplineIds !== undefined; const labelsChanged = rest.externalDisciplineLabels !== undefined; const mergedDisciplineIds = disciplinesChanged ? [...new Set(rest.disciplineIds ?? [])] : existing.disciplineLinks.map((l) => l.disciplineId); const mergedExternalLabels = labelsChanged ? [...new Set(rest.externalDisciplineLabels ?? [])] : existing.externalDisciplineLabels; const mergedOriginId = rest.originId !== undefined ? rest.originId : existing.originId; // Validation « au moins un des trois » après merge. const hasAtLeastOne = mergedDisciplineIds.length > 0 || mergedExternalLabels.length > 0 || mergedOriginId !== null; if (!hasAtLeastOne) { throw new TRPCError({ code: "BAD_REQUEST", message: "Un évènement doit garder au moins une discipline (du club ou extérieure) ou une origine.", }); }Ce n’est pas une particularité des stages : c’est la règle générale de toute validation qui porte sur plusieurs champs, et le chapitre précédent l’a appliquée aux horaires (« la fin après le début »). Une règle sur plusieurs champs se vérifie sur l’état qui sera enregistré, pas sur la requête seule.
« Au moins un des trois » est la règle de cohérence des rendez-vous qui ne sont plus obligatoirement rattachés à une discipline du club. Elle se vérifie dans le schéma à la création, et dans le routeur, après fusion avec l’état en base, à la modification.
Stage devient Seminar#
Le modèle s’est appelé Stage jusqu’en novembre 2026. Le projet nomme pourtant en anglais tout ce qui relève du code : modèles, procédures, URL, dossiers de stockage. « Stage » était l’exception, et une exception trompeuse : en anglais, stage veut dire « étape » ou « scène ». Il a été renommé .
Un renommage de modèle Prisma est le cas d’école du chapitre « Écrire et faire évoluer » : généré automatiquement, il devient « supprimer la table Stage, créer la table », et efface les données. La migration a donc été écrite à la main, en ne faisant que des renommages : tables, colonnes, séquences, contraintes et index.
-- Renommage Stage → Seminar (métadonnée pure ; validé en transaction au préalable).ALTER TABLE "Stage" RENAME TO "Seminar";ALTER TABLE "StageSession" RENAME TO "SeminarSession";ALTER TABLE "_StageAnimators" RENAME TO "_SeminarAnimators";ALTER TABLE "SeminarSession" RENAME COLUMN "stageId" TO "seminarId";ALTER TABLE "Gallery" RENAME COLUMN "stageId" TO "seminarId";ALTER SEQUENCE "Stage_id_seq" RENAME TO "Seminar_id_seq";ALTER SEQUENCE "StageSession_id_seq" RENAME TO "SeminarSession_id_seq";ALTER TABLE "Seminar" RENAME CONSTRAINT "Stage_disciplineId_fkey" TO "Seminar_disciplineId_fkey";ALTER TABLE "Seminar" RENAME CONSTRAINT "Stage_originId_fkey" TO "Seminar_originId_fkey";ALTER TABLE "Seminar" RENAME CONSTRAINT "Stage_primaryAnimatorId_fkey" TO "Seminar_primaryAnimatorId_fkey";ALTER TABLE "SeminarSession" RENAME CONSTRAINT "StageSession_stageId_fkey" TO "SeminarSession_seminarId_fkey";Une seconde migration a renommé les valeurs d’énumération (STAGE_DESCRIPTION → SEMINAR_DESCRIPTION dans PageReferencerKind). Aucune donnée n’a été modifiée. L’interface, elle, parle français et dit toujours « stage » ; les URL publiques sont /seminars/[slug].
Des adresses stables : le slug#
Une discipline, un stage et un événement ont chacun une page publique, à une adresse lisible : /disciplines/tchoy-lee-fut, /seminars/stage-d-armes-mars. Le segment final, le slug, est saisi par l’administrateur (le formulaire le pré-remplit à partir du nom), et validé par un schéma partagé :
import { z } from "zod";/** * slugSchema — validation d'un slug URL-safe : minuscules + chiffres + * tirets simples, sans tiret en tête/queue. Identique à la regex du * router Origin. Utilisé par les `createInput`/`updateInput` des routers. */export const slugSchema = z .string() .trim() .min(1) .max(80) .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { message: "Le slug ne contient que des minuscules sans accent, des chiffres et des tirets simples (pas de tiret au début ni à la fin).", });Le slug est stable : renommer une discipline ne le change pas, et les liens déjà partagés restent valides. Il est unique en base, ce qui donne à ces modèles une deuxième contrainte d’unicité (en plus du nom par catégorie, ou du libellé par discipline). Les routeurs distinguent donc les deux conflits, d’après la cible de l’erreur P2002, pour donner le bon message :
} 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.", }); }Une limite à connaître : la contrainte @@unique([disciplineId, label]) d’un stage ne protège rien quand disciplineId est nul. PostgreSQL considère deux NULL comme différents ; deux stages extérieurs peuvent donc porter le même libellé. Leur slug, lui, reste unique : leurs pages ne se confondent pas.
Le nom affiché n’est pas une identité#
La dernière évolution du modèle est la plus récente. Les fichiers d’une discipline sont rangés dans un dossier sous celui de sa catégorie : AKFC/courses/tchoy-lee-fut/. Jusqu’ici, ce nom de dossier était recalculé à chaque usage, à partir du nom de la discipline (et, pour la catégorie, de son libellé, avec une exception écrite à la main : « Cours » → courses).
C’est le même défaut que celui des espaces collaboratifs, corrigé plus tôt avec l’identifiant du groupe : un nom affiché peut changer, et tout ce qui est construit dessus se casse ou se dédouble. Renommer « Tchoy Lee Fut » en « Tchoy Li Fut » faisait trois choses à la fois :
- les nouveaux fichiers partaient dans un nouveau dossier,
tchoy-li-fut, et les anciens restaient danstchoy-lee-fut; - l’ancien dossier n’était plus protégé contre la suppression, puisque la protection recalculait le nom attendu à partir du nom actuel ;
- à la suppression de la discipline, la vérification « le dossier est-il vide ? » regardait le mauvais dossier, et laissait derrière elle des fichiers orphelins.
Pour une catégorie, c’était pire : renommer « Cours » déplaçait virtuellement tout son contenu, et course.create, qui reconnaissait la catégorie des cours à son libellé, refusait toute création de créneau.
La correction consiste à figer le segment de dossier à la création, dans une colonne storageSegment, pour les catégories et les disciplines. Le nom devient un simple libellé.
// 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,export async function newDisciplineSegment(db: Db, categoryId: number, name: string): Promise<string> { const base = slugify(name, SLUG_OPTIONS) || "discipline"; const taken = new Set( ( await db.discipline.findMany({ where: { categoryId }, select: { id: true, name: true, storageSegment: true }, }) ).map(disciplineSegment), ); return firstFree(base, taken);}Le suffixe règle un cas que l’ancien calcul ignorait : « Kung-fu » et « Kung Fu » donnent le même slug, et auraient partagé le même dossier. Toutes les fonctions qui construisent un chemin (envoi d’un fichier, protection des dossiers, suppression d’une discipline, dossiers racines) lisent désormais le segment par une seule fonction, disciplineSegment ou categorySegment, et la catégorie des cours se reconnaît à son segment (courses), non à son libellé.
Renommer ne change plus que ce que le finder affiche : un FolderLabel associe au dossier le nom actuel.
// Renommage : seul le libellé du dossier suit, le dossier lui-même ne bouge pas. if (rest.name !== undefined) { const category = await tx.category.findUniqueOrThrow({ where: { id: updated.categoryId }, select: { type: true, storageSegment: true }, }); await setFolderLabel(tx, disciplineFolderPath(ctx.appRoot, category, updated), updated.name); }Une migration qui ne déplace aucun fichier#
Les lignes existantes avaient un dossier calculé à partir de leur nom, avec la bibliothèque slugify. Écrire en SQL un équivalent exact de slugify (translittérations, caractères supprimés) aurait été un pari. La migration se contente donc d’ajouter les colonnes, vides :
-- Segments de stockage figés à la création (catégories, disciplines).-- Les lignes existantes restent à NULL ici : l'application les complète au démarrage-- (ensureStorageSegments) avec exactement le segment calculé jusqu'ici à partir du nom,-- ce qui garantit qu'aucun fichier ne change de dossier. Aucune donnée n'est modifiée.ALTER TABLE "Category" ADD COLUMN "storageSegment" TEXT;ALTER TABLE "Discipline" ADD COLUMN "storageSegment" TEXT;CREATE UNIQUE INDEX "Category_storageSegment_key" ON "Category"("storageSegment");CREATE UNIQUE INDEX "Discipline_categoryId_storageSegment_key" ON "Discipline"("categoryId", "storageSegment");C’est l’application qui les complète au démarrage, avec exactement le calcul d’origine, conservé pour cela (legacyDisciplineSegment) : chaque discipline reçoit le dossier qu’elle avait déjà.
export async function ensureStorageSegments(prisma: PrismaClient): Promise<{ filled: number; conflicts: string[] }> { let filled = 0; const conflicts: string[] = []; const categories = await prisma.category.findMany({ where: { storageSegment: null }, select: { id: true, type: true }, }); for (const c of categories) { const segment = legacyCategorySegment(c.type); const clash = await prisma.category.findFirst({ where: { storageSegment: segment }, select: { id: true } }); if (clash) { conflicts.push(`catégorie « ${c.type} » (${segment})`); continue; } await prisma.category.update({ where: { id: c.id }, data: { storageSegment: segment } }); filled++; } const disciplines = await prisma.discipline.findMany({ where: { storageSegment: null }, select: { id: true, name: true, categoryId: true }, orderBy: { id: "asc" }, }); for (const d of disciplines) { const segment = legacyDisciplineSegment(d); const clash = await prisma.discipline.findFirst({ where: { categoryId: d.categoryId, storageSegment: segment }, select: { id: true }, }); if (clash) { conflicts.push(`discipline « ${d.name} » (${segment})`); continue; } await prisma.discipline.update({ where: { id: d.id }, data: { storageSegment: segment } }); filled++; } return { filled, conflicts };}Entre la migration et ce premier démarrage, rien ne casse : tant que la colonne est vide, disciplineSegment retombe sur le calcul d’origine. Le seul cas qui demande une décision humaine est celui de deux disciplines d’une même catégorie dont les noms donnaient déjà le même slug : elles partageaient déjà un dossier. La seconde garde alors le calcul d’origine, et le journal du démarrage la signale.
Un nom affiché n’est jamais une identité de stockage. Ce qui sert à construire un chemin, une clé ou un lien durable se calcule une fois, se fige, et ne dépend plus du libellé. La même règle a déjà servi aux espaces collaboratifs ; elle sert ici aux catégories et aux disciplines.
Récapitulatif#
Évolution | Ce qu’elle permet | Garantie par |
|---|---|---|
Origin et DisciplineFamily, des entités | Pas de doublons, des métadonnées, des regroupements transverses | Unicité du nom et du slug ; sélecteurs |
Event distinct de Seminar | Des rendez-vous culturels sans programme ni animateurs ; plusieurs disciplines | Deux modèles, deux routeurs ; un agenda commun |
« Au moins un des trois » | Des stages et des événements sans discipline du club, mais jamais sans contexte | refine Zod à la création ; routeur après fusion à la modification |
Stage → Seminar | Un code nommé en anglais, sans exception trompeuse | Une migration de renommages seuls |
Slugs | Des adresses lisibles et stables | slugSchema ; unicité ; messages selon la cible du P2002 |
storageSegment | Renommer sans déplacer de fichiers, ni perdre la protection d’un dossier | Segment figé à la création ; complété au démarrage pour l’existant |
Showing entries1to6entries
À suivre#
Le modèle est en place, ses conventions aussi. Reste ce que le visiteur en voit : les fiches publiques, ce qu’elles montrent et ce qu’elles taisent, l’agenda qui réunit stages et événements, et le fichier qui les fait entrer dans son agenda personnel.
Chapitre suivant : ce que voit le visiteur. Les fiches publiques, l’agenda, le fichier .ics, et la vue d’ensemble de la partie.