Ce que le club modélise
Le vocabulaire du domaine : ce que sont vraiment une discipline, un cours, un stage, un événement et une actualité, ce qui les distingue, et les personnes qui les animent.
Précédemment. Les actualités et les pages se construisent avec des blocs, et chaque contenu tient à jour la liste des médias qu’il affiche. L’identité du site et ses coordonnées se règlent dans une ligne unique, depuis le tableau de bord. Les contenus les plus importants du site, pourtant, ne sont pas des pages libres : ce sont les disciplines, les cours, les stages et les événements du club.
Les parcours du chapitre#
Trois cas d’utilisation traversent ce chapitre : ouvrir une discipline et ses cours, programmer un stage, et ce qu’en voit un visiteur. Chaque onglet en déroule les étapes et ce qu’elles lisent ou écrivent en base, relevé dans le code ; chaque étape mène à son explication plus bas, ou à la carte des entités, rejouée à cette étape.
Acteur : Administrateur
- 1
Il crée la discipline dans une catégorie, avec sa famille, son origine et son instructeur principal
discipline.createadmin- Discipline créée : categoryId, classification, description, familyId, instructorId, name, originId, publicationDate, school, slug, storageSegment, summary, summaryMediaId, type
- FolderLabel créée : displayName, path
- FolderLabel modifiée : displayName, path
- PageMediaReference créée
- PageMediaReference supprimée
Lit : Category, Discipline, DisciplineFamily, MediaAsset, Origin, PageMediaReference, User
Le chemin dans le code
Chaque ligne est un appel, suivi de l'endroit où le lire (à ouvrir dans l'éditeur, par exemple avec la recherche de fichier).
discipline.createpackages/backend/src/modules/disciplines/router.ts:250tx.discipline.createpackages/backend/src/modules/disciplines/router.ts:271Discipline crééesetFolderLabelpackages/backend/src/modules/storage/storageSegments.ts:151db.folderLabel.upsertpackages/backend/src/modules/storage/storageSegments.ts:152FolderLabel crééeFolderLabel modifiée
syncPageMediaReferencespackages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:47tx.pageMediaReference.createManypackages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:130PageMediaReference crééetx.pageMediaReference.deleteManypackages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:81PageMediaReference suppriméetx.pageMediaReference.deleteManypackages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:122PageMediaReference supprimée
- 2
Il ajoute un créneau hebdomadaire, pour un public
course.createadmin- Course créée : audience, beginTime, content, day, disciplineId, endTime, instructorId, requisites
- PageMediaReference créée
- PageMediaReference supprimée
Lit : Category, Discipline, MediaAsset, PageMediaReference, User
Le chemin dans le code
Chaque ligne est un appel, suivi de l'endroit où le lire (à ouvrir dans l'éditeur, par exemple avec la recherche de fichier).
course.createpackages/backend/src/modules/courses/router.ts:177tx.course.createpackages/backend/src/modules/courses/router.ts:228Course crééesyncPageMediaReferencespackages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:47tx.pageMediaReference.createManypackages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:130PageMediaReference crééetx.pageMediaReference.deleteManypackages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:81PageMediaReference suppriméetx.pageMediaReference.deleteManypackages/backend/src/modules/media/services/syncPageMediaReferences.service.ts:122PageMediaReference supprimée
Pourquoi commencer par le vocabulaire#
Avant de construire des pages d’administration, des formulaires, des sélecteurs et des composants visuels, nous avons besoin de savoir exactement ce que nous modélisons. Sans ce socle, chaque décision technique flotte : quel champ exposer dans le formulaire ? quelle relation filtrer ? quelle contrainte poser à la validation ? Toutes ces questions trouvent leur réponse dans le sens des choses, pas dans l’inspection du schéma Prisma seul.
« Le cours de Tchoy Lee Fut » peut désigner l’art martial lui-même, le créneau du mardi soir, ou la séance d’hier. Chacun de ces sens correspond à un modèle différent, avec des règles différentes. Confondre deux d’entre eux mène à des formulaires qui demandent des informations absurdes (une date pour un créneau hebdomadaire) ou qui n’en demandent pas assez (aucune date pour un stage).
Ce chapitre construit donc une compréhension conceptuelle des entités du club : non pas une description des tables, mais une lecture de ce qu’elles représentent dans la vie réelle d’un club d’arts martiaux et de pratiques culturelles asiatiques. Le modèle distingue ce qui est enseigné (les disciplines, rangées par catégorie, famille et origine), quand on le pratique régulièrement (les cours) et ce qui se passe ponctuellement (les stages et les événements, avec leurs séances datées). À côté, l’actualité vit sa propre vie.
Modèle | Dans l’interface | Nature | Exemple |
|---|---|---|---|
Category | Catégorie | La plus grosse maille de classement ; elle range aussi la bibliothèque de fichiers. | Cours |
Discipline | Discipline | Un art enseigné par le club, indépendant du temps et du lieu. | Tchoy Lee Fut |
DisciplineFamily | Famille | Un regroupement de disciplines pour le menu. | Kung-fu |
Origin | Origine | Les racines culturelles d’une discipline, d’un stage, d’un événement. | Chine |
Course | Cours | Un créneau HEBDOMADAIRE : jour, horaire, public. Sans date. | Le mardi, 18h30–20h, adultes |
Seminar | Stage | Un rendez-vous ponctuel, avec une présentation, un programme et des séances datées. | Stage d’armes, deux week-ends de mars |
Event | Événement | Un rendez-vous plus libre, avec plusieurs disciplines ou aucune. | Forum des associations |
SeminarSession, EventSession | Séance | Une date et un horaire précis d’un stage ou d’un événement. | Samedi 14 mars, 9h–12h |
Post | Actualité | Un article éditorial, rattaché à rien d’autre du domaine. | Compte rendu de la démonstration |
Showing entries1to9entries
Dans le code, un stage s’appelle . Le modèle s’appelait Stage ; il a été renommé pour suivre la convention du projet, qui nomme en anglais le code, les URL et les dossiers de stockage (courses, seminars, events). L’interface, elle, parle français et dit « stage ». La page publique d’un stage est donc /seminars/[slug], et la migration rename_stage_to_seminar ne fait que renommer tables, colonnes et contraintes, sans toucher aux données. Le chapitre 3 revient sur ce renommage.
Une discipline n’est pas un cours : c’est un art#
Quand nous commençons à modéliser un club d’arts martiaux, la tentation est de partir des cours visibles sur le site — « Tchoy Lee Fut adultes, mardi 18h30 », « Kali enfants, mercredi 17h » — et d’en faire des entités. Cette approche capture ce qui se passe concrètement chaque semaine, mais elle confond deux niveaux d’abstraction qu’il faut distinguer.
Une discipline est un art enseigné, au sens conceptuel. Le Tchoy Lee Fut existe en tant que tel — il a une histoire, des techniques, une école, une lignée de maîtres — indépendamment du fait qu’il soit enseigné le mardi à 18h30 ou le samedi à 10h. Si les cours s’arrêtent l’été et qu’aucun créneau n’a lieu pendant deux mois, la discipline existe toujours.
Un cours, lui, est un créneau récurrent dans lequel une discipline est enseignée à un public donné : discipline, plus jour, plus heure, plus public. C’est concret, planifié, placé dans la semaine.
Cette distinction guide tout le reste du modèle. La porte les informations conceptuelles : son nom, son école, sa classification, son origine culturelle, sa page de présentation, son instructeur principal. Le porte les informations pratiques du créneau : son jour, ses horaires, son public, ses prérequis, son instructeur éventuel.
model Discipline { /// Identifiant auto-incrémenté. id Int @id @default(autoincrement()) /// Nom affiché. Unique au sein de sa catégorie. name String /// Champ de pratique (voir DisciplineType). type DisciplineType /// Segment d'URL de la page publique, unique. Obligatoire à la création /// (routeur) ; nul seulement pour d'anciennes lignes, qui n'ont alors ni /// page publique ni entrée dans le sitemap. slug String? @unique /// Date de publication. null → brouillon, invisible du public. Une date /// future programme la publication (les pages publiques filtrent sur /// « date passée »). publicationDate DateTime? /// Famille, pour le regroupement dans le menu public. null → sans famille. familyId Int? /// Famille (N–1, DisciplineFamily.disciplines). Détachée si la famille est /// supprimée (SET NULL). family DisciplineFamily? @relation(fields: [familyId], references: [id]) /// École (ex. « Shaolin du Sud »). Texte libre. school String? /// Classification (ex. « Art martial traditionnel »). Texte libre. classification String? /// Origine culturelle. null → non renseignée. originId Int? /// Page complète de la discipline, éditée au PageBuilder (PageContentV1). description Json @default("{\"version\":1,\"blocks\":[]}") /// Présentation SYNTHÉTIQUE, éditée au PageBuilder restreint (bloc « texte /// enrobant une image » uniquement). Alimente la carte de la page /// d'accueil, qui renvoie vers la page complète. Même format que /// `description`. Composite VIDE → pas de carte sur l'accueil. summary Json @default("{\"version\":1,\"blocks\":[]}") /// Image de la carte d'accueil : identifiant de MediaAsset en texte simple /// (sans clé étrangère), comme dans les blocs du PageBuilder. Déclarée dans /// PageMediaReference avec les composites (type DISCIPLINE), donc protégée /// et servie comme eux. summaryMediaId String? /// Catégorie (obligatoire). categoryId Int /// Segment du dossier de la discipline, sous celui de sa catégorie /// (`${appRoot}/courses/<segment>`), FIGÉ à la création : renommer la /// discipline ne déplace aucun fichier. Unique dans la catégorie. null /// seulement pour une ligne antérieure à la colonne, complétée au démarrage. storageSegment String? /// Instructeur principal, responsable pédagogique de la discipline /// (obligatoire). Un créneau peut avoir son propre instructeur /// (Course.instructorId). instructorId String /// Création. createdAt DateTime @default(now()) /// Dernière modification (tenue à jour par Prisma). updatedAt DateTime @updatedAt /// Catégorie (N–1, Category.disciplines). La catégorie ne peut pas être /// supprimée tant qu'elle a des disciplines (RESTRICT). category Category @relation(fields: [categoryId], references: [id]) /// Origine (N–1, Origin.disciplines). Détachée si l'origine est supprimée /// (SET NULL). origin Origin? @relation(fields: [originId], references: [id]) /// Instructeur principal (N–1, User.disciplinesAsInstructor). Le membre ne /// peut pas être supprimé tant qu'il l'est (RESTRICT). instructor User @relation("DisciplineInstructor", fields: [instructorId], references: [id]) /// Créneaux hebdomadaires (1–N, Course.discipline). Bloquent la /// suppression de la discipline (RESTRICT). courses Course[] /// Séminaires rattachés (1–N, Seminar.discipline). Détachés à la /// suppression de la discipline (SET NULL). seminars Seminar[] /// Fichiers de la médiathèque classés dans la discipline (1–N, /// MediaAsset.discipline). Détachés à sa suppression (SET NULL). mediaAssets MediaAsset[] /// Galeries associées (1–N, Gallery.discipline). Détachées à sa /// suppression (SET NULL). galleries Gallery[] /// Évènements où la discipline est présentée (1–N, /// EventDiscipline.discipline). Liens effacés avec elle (CASCADE). eventLinks EventDiscipline[] @@unique([categoryId, name]) @@unique([categoryId, storageSegment]) @@index([categoryId]) @@index([instructorId]) @@index([originId]) @@index([type]) @@index([familyId])}Plusieurs décisions sont visibles dans ce modèle, et chacune répond à une question concrète.
Un nom unique par catégorie, un slug unique tout court. Deux catégories peuvent avoir une discipline du même nom (@@unique([categoryId, name])) ; l’adresse publique, elle, doit être unique. Le slug est saisi par l’administrateur, pré-rempli à partir du nom, et ne change pas quand on renomme la discipline : les liens déjà partagés restent valides. Il est facultatif en base pour d’anciennes lignes seulement ; le routeur l’exige à la création.
Famille et origine sont des entités, pas du texte libre. Un champ texte « Famille » aurait vite contenu « Kung-fu », « Kung Fu » et « kungfu » : trois entrées de menu pour la même famille. Une table, avec un sélecteur dans le formulaire, rend les doublons impossibles. Le chapitre 3 raconte comment l’origine est passée du texte à l’entité, et ce que cela permet.
Deux contenus. est la page complète de la discipline, construite avec les blocs du constructeur de pages. et forment la carte de la page d’accueil : une image, puis un court texte, avec « Lire la suite ». Une discipline au résumé vide n’a pas de carte. Le chapitre 2 montre comment ces deux contenus et l’image partagent une seule liste de références de médias.
La catégorie ne change pas après la création. discipline.update n’accepte pas : le dossier des fichiers d’une discipline est rangé sous celui de sa catégorie (AKFC/courses/tchoy-lee-fut/). Déplacer la discipline couperait le lien avec ses fichiers.
Une date de publication. Sans date, la discipline est un brouillon ; avec une date future, sa publication est programmée. Le filtre est appliqué par toutes les lectures de l’API, et par tout ce qui dépend d’elle — ses créneaux compris (chapitre 2).
Un instructeur principal obligatoire. C’est le responsable pédagogique de la discipline. Les créneaux peuvent avoir le leur ; à défaut, c’est lui qui s’applique (chapitre 2, « Les deux instructeurs d’un créneau »).
La discipline est l’art au sens conceptuel ; le cours est le créneau dans lequel cet art est enseigné. Une discipline peut n’avoir aucun cours actif et exister quand même, par exemple si le club arrête temporairement de l’enseigner. Un cours, à l’inverse, ne peut pas exister sans sa discipline : sa suppression est d’ailleurs bloquée tant qu’elle a des créneaux, des stages ou des événements.
Le cours : un créneau, pas une date#
model Course { /// Identifiant auto-incrémenté. id Int @id @default(autoincrement()) /// Discipline enseignée (obligatoire). disciplineId Int /// Public visé (voir Audience). audience Audience /// Jour de la semaine. day Day /// Heure de début au format HHMM (1830 = 18h30, 905 = 9h05), heure locale /// du club. beginTime Int /// Heure de fin au format HHMM, strictement après `beginTime` (vérifié par /// le routeur et le formulaire). endTime Int /// Instructeur attitré du créneau. null → l'instructeur principal de la /// discipline s'applique. instructorId String? /// Prérequis libres (ex. « 14 ans révolus »). Affichés en administration /// seulement. requisites String[] /// Présentation du cours (programme, photos…), éditée au PageBuilder /// (PageContentV1) : tout le contenu riche vit ici. content Json /// Création. createdAt DateTime @default(now()) /// Dernière modification (tenue à jour par Prisma). updatedAt DateTime @updatedAt /// Discipline (N–1, Discipline.courses). Le créneau empêche la suppression /// de la discipline (RESTRICT). discipline Discipline @relation(fields: [disciplineId], references: [id]) /// Instructeur attitré (N–1, User.coursesAsInstructor). À sa suppression, le /// créneau retombe sur l'instructeur principal (SET NULL). instructor User? @relation("CourseInstructor", fields: [instructorId], references: [id]) @@unique([disciplineId, day, beginTime, audience]) @@index([disciplineId]) @@index([instructorId])}Un cours n’a pas de date : c’est « le mardi à 18h30, pour les adultes ». Les vacances et les annulations ponctuelles ne font pas partie du modèle, et c’est voulu : elles relèvent d’une actualité ou du bandeau d’annonces, sans toucher à la grille hebdomadaire. Le jour va du lundi au dimanche (l’énumération Day a sept valeurs, et la colonne « Horaires » du pied de page les trie dans cet ordre).
Les horaires sont des entiers au format HHMM (1830 pour 18h30), et non des DateTime. Une heure de cours n’a ni jour ni fuseau ; un DateTime aurait obligé à inventer une date arbitraire et à gérer le changement d’heure. Le chapitre suivant détaille cette convention, ses validations, et ce qu’elle permet.
La contrainte d’unicité empêche de saisir deux fois le même créneau pour le même public : « Tchoy Lee Fut, adultes, mercredi 18h » et « Tchoy Lee Fut, ados, mercredi 18h » sont deux cours légitimes ; deux fois le premier, non. Elle ne porte que sur l’heure de début : deux créneaux qui se chevauchent sans commencer à la même heure restent possibles, et c’est à l’administrateur d’y veiller.
La discipline ne change pas après la création. Changer la discipline d’un cours reviendrait à en créer un autre ; course.update n’accepte donc pas disciplineId.
Le cours doit appartenir à la catégorie des cours. course.create vérifie que la discipline choisie est rangée dans la catégorie dont le dossier est courses : une discipline de calligraphie présentée seulement en stage n’a pas de grille hebdomadaire. La vérification lit le segment de stockage de la catégorie, figé, et non son libellé, que l’on peut renommer (chapitre 3).
requisites est une liste de prérequis libres (« 14 ans révolus », « certificat médical ») et content la présentation complète du cours, au constructeur de pages.
Le stage : un contenant de séances#
À première vue, un stage ressemble à un cours plus long. Si nous nous arrêtions là, nous modéliserions le stage comme une variante de , avec quelques champs de plus pour la durée. Ce serait une erreur.
Un stage est un rendez-vous ponctuel, qui se déroule sur quelques jours et qui rassemble une communauté autour d’un thème précis. Il a une narration propre : qui anime, dans quel cadre, avec quels intervenants, sur quelles techniques. Cette narration n’est pas celle d’un cours hebdomadaire, qui par nature se répète et se contente d’une fiche stable.
Conséquence : le stage porte deux contenus séparés, et program. La description raconte ce qu’est le stage et pourquoi il existe ; le programme détaille ce qui va s’y passer, séance par séance. Un cours, plus uniforme, n’en a qu’un.
model Seminar { /// Identifiant auto-incrémenté. id Int @id @default(autoincrement()) /// Discipline du club concernée. null → séminaire sans discipline du club. disciplineId Int? /// Nom libre d'une discipline que le club n'enseigne pas (ex. « Calligraphie /// chinoise »), typiquement avec un intervenant extérieur. externalDisciplineLabel String? /// Origine culturelle, pour le regroupement quand il n'y a pas de discipline. originId Int? /// Nom du séminaire. Unique par discipline quand `disciplineId` est non null. label String /// Public visé (voir Audience). audience Audience /// Présentation : contexte, intervenants, photos (PageContentV1). description Json /// Programme : séance par séance, techniques abordées, supports (PageContentV1). program Json /// Présentation SYNTHÉTIQUE, éditée au builder restreint (texte seul). /// Alimente la carte de la page « Agenda », qui renvoie ici. Composite VIDE /// → pas de carte. summary Json @default("{\"version\":1,\"blocks\":[]}") /// Image de la carte d'agenda : identifiant de MediaAsset en texte simple /// (sans clé étrangère), comme dans les blocs du PageBuilder. Déclarée dans /// PageMediaReference avec le composite `summary` (SEMINAR_SUMMARY). summaryMediaId String? /// ⚠ Malgré son nom, ce sont des PRÉREQUIS (un par ligne), affichés sous /// « Prérequis » sur la page publique : l'équivalent de Course.requisites. preRegistered String[] /// Animateur principal (obligatoire). primaryAnimatorId String /// Date de publication. null → brouillon, invisible du public. Une date /// future programme la publication. publicationDate DateTime? /// Création. createdAt DateTime @default(now()) /// Dernière modification (tenue à jour par Prisma). updatedAt DateTime @updatedAt /// Discipline (N–1, Discipline.seminars). Détachée si la discipline est /// supprimée (SET NULL). discipline Discipline? @relation(fields: [disciplineId], references: [id]) /// Origine (N–1, Origin.seminars). Détachée si l'origine est supprimée /// (SET NULL). origin Origin? @relation(fields: [originId], references: [id]) /// Animateur principal (N–1, User.seminarsAsPrimaryAnimator). Le membre ne /// peut pas être supprimé tant qu'il l'est (RESTRICT). primaryAnimator User @relation("SeminarPrimaryAnimator", fields: [primaryAnimatorId], references: [id]) /// Co-animateurs (N–N, User.seminarsAsAnimator, table _SeminarAnimators). animators User[] @relation("SeminarAnimators") /// Séances (1–N, SeminarSession.seminar). Effacées avec le séminaire. sessions SeminarSession[] /// Segment d'URL de la page publique, unique. Obligatoire à la création /// (routeur) ; nul seulement pour d'anciennes lignes, alors sans page /// publique ni entrée dans le sitemap. slug String? @unique /// Galeries associées (1–N, Gallery.seminar). Détachées à sa suppression /// (SET NULL). galleries Gallery[] @@unique([disciplineId, label]) @@index([disciplineId]) @@index([originId]) @@index([primaryAnimatorId]) @@index([publicationDate])}Surtout, un stage n’a pas de date directe. Il a des séances (), chacune avec sa date, ses horaires, un lieu et des remarques. Un stage de week-end en a quatre : samedi matin, samedi après-midi, dimanche matin, dimanche après-midi. Le stage est un contenant temporel, pas un point dans le temps.
model SeminarSession { /// Identifiant auto-incrémenté. id Int @id @default(autoincrement()) /// Séminaire parent. seminarId Int /// Jour de la séance. date DateTime /// Heure de début au format HHMM. beginTime Int /// Heure de fin au format HHMM, strictement après `beginTime` (vérifié par /// le routeur et le formulaire). endTime Int /// Lieu, en texte libre (ex. « Dojo principal »). location String? /// Notes libres (ex. « apporter sa tenue »). notes String? /// Création. createdAt DateTime @default(now()) /// Dernière modification (tenue à jour par Prisma). updatedAt DateTime @updatedAt /// Séminaire parent (N–1, Seminar.sessions). Séance effacée avec lui /// (CASCADE). seminar Seminar @relation(fields: [seminarId], references: [id], onDelete: Cascade) // Une séance au plus par séminaire, jour et heure de début. @@unique([seminarId, date, beginTime]) @@index([seminarId]) @@index([date])}Les séances sont supprimées avec leur stage (onDelete: Cascade) : une séance sans stage n’a aucun sens. Leur ne change pas après la création ; pour déplacer une séance vers un autre stage, on la supprime et on la recrée.
Trois détails du modèle méritent l’attention.
L’animation. Un animateur principal obligatoire (), et des co-animateurs. La liste animators contient l’animateur principal : le routeur reçoit et coAnimatorIds, refuse qu’une même personne soit les deux ou qu’un co-animateur figure deux fois, puis compose la liste complète.
Le rattachement. Un stage peut porter sur une discipline du club (disciplineId), sur une discipline que le club n’enseigne pas (, en texte), ou sur une culture () — mais il doit avoir au moins un de ces trois rattachements. Le chapitre 3 raconte pourquoi, et comment la règle se vérifie à la création et à la modification.
preRegistered. Malgré son nom, ce champ contient des prérequis, un par ligne, affichés sous « Prérequis » sur la page publique : c’est l’équivalent de . Le nom est un reste d’une intention abandonnée (une liste d’inscrits) ; le commentaire du schéma le signale, en attendant un renommage.
Le stage est un contenant temporel qui regroupe une ou plusieurs séances concrètes. Il a sa propre narration en deux temps — sa description (ce qu’il est) et son programme (ce qu’on y fera). Les dates effectives vivent dans les séances, pas dans le stage.
L’événement : l’entité la plus libre#
Si nous nous arrêtions au trio discipline, cours, stage, nous ne pourrions pas modéliser proprement le repas de fin d’année, la conférence sur le bouddhisme zen, la journée portes ouvertes ou le forum des associations. Tous ont leur place dans la vie du club, mais ils ne sont ni des cours ni des stages.
model Event { /// Identifiant auto-incrémenté. id Int @id @default(autoincrement()) /// Nom de l'évènement (ex. « Repas de fin d'année »). label String /// Présentation complète (contexte, intervenants, programme, photos), /// éditée au PageBuilder (PageContentV1). content Json @default("{\"version\":1,\"blocks\":[]}") /// Présentation SYNTHÉTIQUE, éditée au builder restreint (texte seul). /// Alimente la carte de la page « Agenda », qui renvoie ici. Composite VIDE /// → pas de carte. summary Json @default("{\"version\":1,\"blocks\":[]}") /// Image de la carte d'agenda : identifiant de MediaAsset en texte simple /// (sans clé étrangère), comme dans les blocs du PageBuilder. Déclarée dans /// PageMediaReference avec le composite `summary` (EVENT_SUMMARY). summaryMediaId String? /// Public visé (obligatoire ; ALL_AGES pour un évènement tout public). audience Audience /// Noms libres des disciplines que le club n'enseigne pas, présentées lors /// de l'évènement (ex. « Calligraphie chinoise »). externalDisciplineLabels String[] @default([]) /// Origine culturelle. null → aucune. originId Int? /// Organisateur responsable (obligatoire). organizerId String /// Date de publication. null → brouillon, invisible du public. Une date /// future programme la publication. publicationDate DateTime? /// Création. createdAt DateTime @default(now()) /// Dernière modification (tenue à jour par Prisma). updatedAt DateTime @updatedAt /// Origine (N–1, Origin.events). Détachée si l'origine est supprimée /// (SET NULL). origin Origin? @relation(fields: [originId], references: [id]) /// Organisateur (N–1, User.eventsAsOrganizer). Le membre ne peut pas être /// supprimé tant qu'il l'est (RESTRICT). organizer User @relation("EventOrganizer", fields: [organizerId], references: [id]) /// Séances (1–N, EventSession.event), au moins une en pratique. Effacées /// avec l'évènement. sessions EventSession[] /// Segment d'URL de la page publique, unique. Obligatoire à la création /// (routeur) ; nul seulement pour d'anciennes lignes, alors sans page /// publique ni entrée dans le sitemap. slug String? @unique /// Galeries associées : une sélection montée par les administrateurs /// (1–N, Gallery.event). Détachées à sa suppression (SET NULL). galleries Gallery[] /// Disciplines du club présentées (1–N, EventDiscipline.event). Liens /// effacés avec l'évènement. disciplineLinks EventDiscipline[] /// Fichiers déposés POUR l'évènement (1–N, MediaAsset.event) : le contenu /// brut, distinct des galeries. Détachés à sa suppression (SET NULL). mediaAssets MediaAsset[] @@index([originId]) @@index([organizerId]) @@index([publicationDate])}L’événement est volontairement plus libre que le stage :
- il présente plusieurs disciplines du club, ou aucune : la table de liaison relie un événement à autant de disciplines que nécessaire (un forum en présente quatre), et liste autant de disciplines extérieures que voulu ;
- il porte un seul contenu (
content), pas la paire description et programme : sa narration est tournée vers l’annonce et la présentation ; - il a un organisateur (), pas un animateur principal entouré de co-animateurs : la logique d’animation collective n’a pas de sens à ce niveau ;
- il peut recevoir des fichiers déposés par les membres (
mediaAssets) : les photos d’un repas ou d’une démonstration, distinctes des galeries montées par les administrateurs.
Comme le stage, l’événement est un contenant temporel : ses dates vivent dans des , de même structure que les séances de stage. Un événement d’un soir en a une ; une conférence en deux soirées, deux.
L’événement est l’entité la plus libre du domaine : pas obligatoirement martial, pas obligatoirement rattaché à une discipline du club, une structure narrative simple. Il porte la dimension culturelle large du club, celle qui ne rentre pas dans le cadre des disciplines enseignées.
L’actualité : un article à part#
model Post { /// Identifiant auto-incrémenté. id Int @id @default(autoincrement()) /// Titre. title String /// Corps de l'article, édité au PageBuilder (PageContentV1). content Json /// Auteur (obligatoire). authorId String /// Création. createdAt DateTime @default(now()) /// Dernière modification (tenue à jour par Prisma). updatedAt DateTime @updatedAt /// Date de publication. null → brouillon, invisible du public. Une date /// future programme la publication. publicationDate DateTime? /// Auteur (N–1, User.posts). Le membre ne peut pas être supprimé tant qu'il /// en a (RESTRICT). author User @relation(fields: [authorId], references: [id]) /// Commentaires (1–N, Comment.post). Effacés avec le post. comments Comment[] /// Sondage facultatif (0..1, Poll.post) : sa présence active le vote sur le /// post. Effacé avec le post. poll Poll? @@index([publicationDate])}L’actualité () est volontairement à part. Ce n’est ni un cours, ni un stage, ni un événement, ni une discipline : c’est un article éditorial. Une annonce, un compte rendu, une réflexion. Il a un titre, un contenu au constructeur de pages, un auteur et une date de publication.
Il n’est rattaché à rien d’autre du domaine : ni disciplineId, ni , ni . Une actualité peut parler de la vie associative, d’un événement qui n’est même pas enregistré dans la base, de ce que l’on veut. Cette indépendance est volontaire : si le club voulait un jour classer ses actualités en rubriques, il ajouterait une catégorisation ; aujourd’hui, tout vit en un seul fil.
L’actualité porte en revanche une dimension communautaire que les autres entités n’ont pas : des commentaires (Comment, avec réponses), des réactions (Reaction) et, facultativement, un sondage (Poll). C’est le point d’entrée social du modèle.
L’actualité est l’outil éditorial transversal du club : indépendante des disciplines, libre dans son contenu, seule à porter une dimension sociale. Sa convention de publication — publicationDate nulle pour un brouillon, future pour une publication programmée, passée pour un article en ligne — s’est généralisée ensuite aux disciplines, aux stages et aux événements.
Les personnes#
Les instructeurs, animateurs et organisateurs ne sont pas des entités à part : ce sont des membres (User), reliés par des relations nommées (DisciplineInstructor, CourseInstructor, SeminarPrimaryAnimator, SeminarAnimators, EventOrganizer). Un instructeur se connecte avec son compte de membre, et a son profil, sa photo, sa biographie.
Il n’existe d’ailleurs pas de rôle « instructeur » en base : l’instructeur se définit par ce qu’il anime. Le sélecteur des formulaires d’administration (user.getAssignableInstructors) propose les membres du groupe Administrateurs, plus tous ceux qui ont déjà une responsabilité — instructeur d’une discipline ou d’un créneau, animateur d’un stage, organisateur d’un événement. Cette dernière condition manquait : un organisateur qui n’était ni administrateur ni instructeur disparaissait du sélecteur, et le formulaire d’édition de son événement affichait un choix vide.
Ce que devient une responsabilité quand son titulaire part dépend de la relation :
Relation | À la suppression du membre | Pourquoi |
|---|---|---|
Instructeur principal d’une discipline | Refusée (Restrict) | Une discipline sans responsable n’a pas de sens : il faut d’abord la confier à quelqu’un d’autre. |
Animateur principal d’un stage | Refusée (Restrict) | Même raison : le stage perdrait son responsable. |
Organisateur d’un événement | Refusée (Restrict) | Même raison. |
Instructeur attitré d’un créneau | Le créneau retombe sur l’instructeur de la discipline (SetNull) | Le créneau a une valeur par défaut : rien n’est perdu. |
Co-animateur d’un stage | Retiré de la liste | Le stage garde son animateur principal. |
Auteur d’une actualité | Refusée (Restrict) | L’article perdrait sa signature. |
Showing entries1to6entries
Supprimer le compte d’un instructeur n’efface donc jamais une discipline par accident.
Récapitulatif#
Entité | Nature | Datée ? | Contenus au constructeur | Rattachement aux disciplines |
|---|---|---|---|---|
Discipline | L’art enseigné | Non | description, summary | C’est elle |
Course | Créneau hebdomadaire | Non : jour et horaires | content | Une, obligatoire, figée |
Seminar (stage) | Rendez-vous ponctuel | Par ses séances | description, program, summary | Une du club, OU une extérieure, OU une origine (au moins un) |
Event (événement) | Rendez-vous libre | Par ses séances | content, summary | 0..N du club, 0..N extérieures, une origine (au moins un) |
Post (actualité) | Article éditorial | Non | content | Aucun |
Showing entries1to5entries
Trois idées à garder de ce chapitre :
- La discipline est conceptuelle, le cours est le créneau : une distinction qui structure tout le reste.
- Le stage et l’événement sont des contenants : leurs dates vivent dans des séances.
- Les personnes sont des membres : leurs responsabilités sont des relations, et la suppression d’un membre responsable est refusée tant qu’il l’est.
À suivre#
Ces entités partagent des conventions qui ne se voient pas en lisant un modèle à la fois : le format des heures, la date d’une séance, le composite de contenu et ses références de médias, la hiérarchie des instructeurs, la date de publication et tout ce qui en dépend.
Chapitre suivant : les conventions du domaine. Ce qui relie les entités entre elles, et les règles qu’un formulaire, un routeur et une page publique doivent appliquer de la même façon.