Le modèle de session et son contrat
La table Session lue champ par champ, pourquoi aucun droit n’y est recopié, la chaîne Session → membre → groupe Administrateurs, et le contrat SessionClient qui décide de ce qui part vers le navigateur.
Précédemment. Toute authentification résout deux problèmes : identifier le membre, puis le reconnaître à chaque requête. Le projet combine bcrypt, un jeton signé et une session en base.
Le modèle de données : Session, User et les groupes#
Avant d’écrire la moindre ligne de code d’authentification, regardons ce que la base de données nous offre.
model Session { /// Identifiant CUID, embarqué dans le JWT du cookie de session. id String @id @default(cuid()) /// Membre connecté. userId String /// Ouverture de la session (connexion). createdAt DateTime @default(now()) /// Fin de validité : ouverture + 7 jours (SESSION_DURATION_MS). Une session /// expirée est refusée, et supprimée au moment où elle est présentée. expiresAt DateTime /// Membre (N–1, User.sessions). Session effacée avec le membre (CASCADE). user User @relation(fields: [userId], references: [id], onDelete: Cascade)}Lecture guidée du modèle Session#
Anatomie d’une session persistée
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
generator client { provider = "prisma-client-js" binaryTargets = ["native"] engineType = "binary" previewFeatures = ["relationJoins"]}datasource db { provider = "postgresql" url = env("DATABASE_URL")}// ============================================================================// USER & AUTH// ============================================================================/// Membre de l'association, et compte de connexion au site.////// Création : par un administrateur (`user.create`), avec un mot de passe généré/// côté serveur et envoyé par e-mail ; le membre est inscrit d'office dans un/// groupe (accès EDITOR). Être administrateur ne dépend d'aucun champ de ce/// modèle : c'est l'appartenance à un groupe marqué `MemberGroup.isAdminGroup`.////// Suppression d'un membre (actions réelles en base) :/// - BLOQUÉE (RESTRICT) tant qu'il est auteur de posts, commentaires, réactions/// ou votes, instructeur principal d'une discipline, animateur principal d'un/// séminaire, organisateur d'un évènement, uploader d'un média, ou qu'il/// possède une ligne Preferences ;/// - EFFACE avec lui (CASCADE) : sessions, jetons de réinitialisation, adhésions/// aux groupes, destinations et accusés de lecture de documents, liens de/// co-animation de séminaires ;/// - DÉTACHE (SET NULL) : créneaux dont il est l'instructeur attitré, documents/// dont il est l'éditeur, entrées du journal d'audit.model User { /// Identifiant CUID. id String @id @default(cuid()) /// Prénom. Facultatif : le nom affiché retombe sur `pseudo`, puis sur `email` /// (formatUserName). firstName String? /// Nom de famille. Facultatif (même repli que `firstName`). lastName String? /// Adresse e-mail, unique : c'est l'identifiant de connexion. PERSONNELLE : /// jamais montrée qu'aux administrateurs (et à l'intéressé). email String @unique /// Adresse du club attribuée au membre (ex. prenom@akfc.fr, alias de contact@), /// saisie par un administrateur. Celle des instructeurs est montrée aux membres /// connectés, à la place de leur adresse personnelle. null → aucune. clubEmail String? /// ⚠ Inutilisé : jamais lu, seulement écrit à `false` par les services de /// restauration de médias. Vestige probable d'une ancienne librairie /// d'authentification. Candidat à suppression. emailVerified Boolean @default(false) /// Empreinte bcrypt (coût 12) du mot de passe, jamais le mot de passe en clair. /// Exception : l'utilisateur technique « Legacy Import » (services de /// restauration) porte une valeur non bcrypt et ne se connecte pas. password String /// Date de naissance. Facultative pour un majeur ; obligatoire pour un mineur (preuve de /// la nécessité de l'accord parental, passage à la majorité). Saisie par le membre dans son /// profil, sauf une fois vérifiée par un administrateur (voir `birthDateVerified`). birthDate DateTime? /// Date de naissance saisie ou vérifiée par un administrateur (dossier MJC) : le membre ne /// peut plus la modifier. birthDateVerified Boolean @default(false) /// Majorité, renseignée par un administrateur à la création du compte (UNKNOWN pour les /// comptes antérieurs, jusqu'au tri). legalAge LegalAge @default(UNKNOWN) /// Mineur : nom du parent référent (représentant légal), saisi par un administrateur. /// Effacé avec l'adresse quand le membre devenu majeur a confirmé ses choix, ou 6 mois /// après ses 18 ans. guardianName String? /// Mineur : adresse e-mail du parent référent, saisie par un administrateur, de préférence /// recueillie auprès du parent lui-même. Sert UNIQUEMENT au droit à l'image (lien /// d'autorisation, récapitulatif). Jamais modifiable par le membre. guardianEmail String? /// Numéro de téléphone, saisi par le membre dans son profil. Facultatif. phone String? /// ⚠ Jamais écrit par le code : seulement lu en repli quand `avatar` est vide /// (portraitUrl). Vestige probable d'une ancienne librairie /// d'authentification ; le portrait réel est `avatar`. Candidat à suppression. image String? /// Date d'adhésion. Borne les documents « non lus » : seuls comptent ceux /// publiés depuis le 1er janvier de l'année d'adhésion. null → pas de borne. /// ⚠ Aucun écran ni code ne l'écrit : il ne se renseigne qu'en base. memberSince DateTime? /// Vrai tant que le membre n'a pas enregistré son profil : FirstLoginRedirect /// le ramène sur /profil/edit. Repasse à false à l'enregistrement du profil. isFirstLogin Boolean @default(true) /// Création du compte. createdAt DateTime @default(now()) /// Dernière modification de la ligne (tenue à jour par Prisma). updatedAt DateTime @default(now()) @updatedAt /// Dernière connexion (posée sans toucher updatedAt) : signale les comptes /// inactifs à examiner (durées de conservation, registre RGPD). lastLoginAt DateTime? /// Droit à l'image (photos et vidéos des activités) : autorisation recueillie /// par le club, saisie par un administrateur ; date de la dernière saisie. imageConsent ImageConsent @default(UNKNOWN) imageConsentAt DateTime? /// Présentation libre du profil, en texte brut. Distincte de `instructorBio`. aboutMe String? /// Nom d'usage, affiché quand prénom et nom manquent. pseudo String? /// Portrait : clé R2 de l'image recadrée. Servi publiquement par /// /api/media/public/avatar/[userId] : l'URL porte l'id du membre, jamais la /// clé. null → pas de portrait. avatar String? /// Présentation publique d'instructeur (page /about/instructeurs) : contenu de /// builder (PageContentV1, JSON), même format que les descriptions de /// discipline. Le membre n'apparaît sur la page que si ce champ est renseigné /// ET qu'il instruit une discipline ou un créneau, ou anime un séminaire. /// Les fichiers de la bibliothèque qu'elle utilise sont déclarés dans /// PageMediaReference (INSTRUCTOR_BIO), ce qui permet de les servir. instructorBio Json? /// Rang d'affichage sur /about/instructeurs, croissant. null → après les /// membres classés. Égalités départagées par nom puis prénom. instructorOrder Int? // ── Authentification ───────────────────────────────────────────────────── /// Préférences d'affichage (0..1, Preferences.user ; clé portée par /// Preferences.userId). Pas encore branchées. Une ligne existante bloque la /// suppression du membre (RESTRICT). preferences Preferences? /// Sessions de connexion ouvertes (1–N, Session.user). Effacées avec le membre. sessions Session[] /// Jetons de réinitialisation de mot de passe (1–N, PasswordResetToken.user). /// Effacés avec le membre. passwordResetTokens PasswordResetToken[] // ── Audit (⚠ modèle inutilisé) ──────────────────────────────────────────── /// Entrées dont il est l'auteur (1–N, AuditLog.actor). Détachées à sa suppression. auditLogsAsActor AuditLog[] @relation("AuditActor") /// Entrées dont il est la cible (1–N, AuditLog.targetUser). Détachées à sa /// suppression. auditLogsAsTargetUser AuditLog[] @relation("AuditTargetUser") // ── Vie sociale ────────────────────────────────────────────────────────── /// Posts rédigés (1–N, Post.author). Bloquent la suppression du membre. posts Post[] /// Commentaires rédigés (1–N, Comment.author). Bloquent la suppression du membre. comments Comment[] /// Réactions données (1–N, Reaction.user). Bloquent la suppression du membre. reactions Reaction[] /// Votes aux sondages (1–N, PollVote.user). Bloquent la suppression du membre. pollVotes PollVote[] // ── Enseignement et médias ─────────────────────────────────────────────── /// Disciplines dont il est l'instructeur principal (1–N, Discipline.instructor). /// Bloquent sa suppression. disciplinesAsInstructor Discipline[] @relation("DisciplineInstructor") /// Créneaux dont il est l'instructeur attitré (1–N, Course.instructor). À sa /// suppression, le créneau retombe sur l'instructeur principal de la discipline. coursesAsInstructor Course[] @relation("CourseInstructor") /// Séminaires dont il est l'animateur principal (1–N, Seminar.primaryAnimator). /// Bloquent sa suppression. seminarsAsPrimaryAnimator Seminar[] @relation("SeminarPrimaryAnimator") /// Séminaires qu'il co-anime (N–N, Seminar.animators, table _SeminarAnimators). /// Liens effacés avec lui. seminarsAsAnimator Seminar[] @relation("SeminarAnimators") /// Médias qu'il a téléversés (1–N, MediaAsset.uploader). Bloquent sa suppression. uploadedMediaAssets MediaAsset[] @relation("UserMediaUploads") /// Évènements qu'il organise (1–N, Event.organizer). Bloquent sa suppression. eventsAsOrganizer Event[] @relation("EventOrganizer") // ── Documents membres et groupes ───────────────────────────────────────── /// Documents qu'il a publiés (1–N, MemberDocument.publishedBy). À sa /// suppression, les documents restent, sans éditeur. memberDocumentsPublished MemberDocument[] @relation("MemberDocumentPublisher") /// Documents dont il est destinataire (1–N, MemberDocumentRecipient.user). /// Effacés avec lui. memberDocumentRecipients MemberDocumentRecipient[] @relation("MemberDocumentRecipient") /// Accusés de lecture de documents (1–N, DocumentReceipt.user). Effacés avec lui. documentReceipts DocumentReceipt[] @relation("DocumentReceiptUser") /// Formulaires de droit à l'image signés (1–N, ImageConsentForm.user). imageConsentForms ImageConsentForm[] @relation("ImageConsentFormUser") /// Formulaires de droit à l'image déposés par cet admin (1–N, ImageConsentForm.uploadedBy). imageConsentFormsUploaded ImageConsentForm[] @relation("ImageConsentFormUploader") /// Demande d'autorisation en attente (1–0..1, ImageConsentRequest.user). Effacée avec lui. imageConsentRequest ImageConsentRequest? /// Adhésions aux groupes de membres (1–N, MemberGroupMembership.user). /// Effacées avec lui. memberGroupMemberships MemberGroupMembership[] @relation("MemberGroupMembership") // ── Demandes (tickets) ─────────────────────────────────────────────────── /// Demandes qu'il a adressées au club (1–N, Ticket.requester). Conservées /// sans lien à sa suppression. ticketsRequested Ticket[] @relation("TicketRequester") /// Demandes dont il est responsable (1–N, Ticket.assignee). Remises « sans /// responsable » à sa suppression. ticketsAssigned Ticket[] @relation("TicketAssignee") /// Messages de demandes dont il est l'auteur (1–N, TicketMessage.author). ticketMessages TicketMessage[] @relation("TicketMessageAuthor") /// Évènements d'historique dont il est l'auteur (1–N, TicketEvent.actor). ticketEventsAsActor TicketEvent[] @relation("TicketEventActor") /// Transferts de demandes qu'il a reçus (1–N, TicketEvent.toUser). ticketEventsAsTarget TicketEvent[] @relation("TicketEventTarget")}/// Préférences d'affichage d'un membre (une ligne au plus par membre)./// Fonctionnalité PRÉVUE mais pas encore branchée : aucun code ne lit ni/// n'écrit ce modèle pour l'instant. D'autres préférences sont envisagées/// (par exemple : photo réelle ou avatar « persona »)./// ⚠ Au branchement : passer la relation `user` en onDelete: Cascade, sinon/// la ligne de préférences bloquera la suppression du membre (RESTRICT).model Preferences { /// Identifiant auto-incrémenté. id Int @id @default(autoincrement()) /// Montrer son adresse e-mail sur son profil. displayEmail Boolean @default(false) /// Montrer son numéro de téléphone sur son profil. displayPhone Boolean @default(false) /// Thème sombre de l'interface. darkMode Boolean @default(true) /// Membre concerné (unique : une ligne au plus par membre). userId String @unique /// Texte libre (usage à préciser). description String? /// Membre (1–1, User.preferences). RESTRICT : une ligne existante bloque la /// suppression du membre (voir l'avertissement du modèle). user User @relation(fields: [userId], references: [id])}/// Session de connexion, stockée en base. Le cookie ne porte qu'un JWT contenant/// l'`id` de cette ligne : supprimer la ligne déconnecte l'appareil à sa/// prochaine requête (déconnexion ; changement de mot de passe, qui supprime les/// sessions des autres appareils).model Session { /// Identifiant CUID, embarqué dans le JWT du cookie de session. id String @id @default(cuid()) /// Membre connecté. userId String /// Ouverture de la session (connexion). createdAt DateTime @default(now()) /// Fin de validité : ouverture + 7 jours (SESSION_DURATION_MS). Une session /// expirée est refusée, et supprimée au moment où elle est présentée. expiresAt DateTime /// Membre (N–1, User.sessions). Session effacée avec le membre (CASCADE). user User @relation(fields: [userId], references: [id], onDelete: Cascade)}/// Jeton de réinitialisation de mot de passe (« mot de passe oublié »)./// Le jeton en clair (32 octets aléatoires, 64 caractères hexadécimaux) n'existe/// que dans le lien envoyé par e-mail ; seule son empreinte SHA-256 est stockée./// Usage unique, valable 30 minutes.model PasswordResetToken { /// Identifiant CUID. id String @id @default(cuid()) /// Émission. Sert aussi d'anti-rafale : une nouvelle demande moins de 2 minutes /// après la précédente n'envoie rien (RESET_TOKEN_COOLDOWN_MS). createdAt DateTime @default(now()) /// Émission + 30 minutes (RESET_TOKEN_TTL_MS). Au-delà, le jeton est refusé. expiresAt DateTime /// Date d'utilisation. Non null → jeton consommé, refusé ensuite. usedAt DateTime? /// Empreinte SHA-256 (hexadécimale) du jeton, unique. Le lien est vérifié en /// hachant le jeton reçu. tokenHash String @unique /// Membre qui a demandé la réinitialisation. userId String /// Membre (N–1, User.passwordResetTokens). Jeton effacé avec le membre (CASCADE). user User @relation(fields: [userId], references: [id], onDelete: Cascade) /// Adresse IP à l'origine de la demande. Enregistrée, jamais lue par le code. requestIp String? /// Navigateur à l'origine de la demande. Enregistré, jamais lu par le code. userAgent String? // Recherche du dernier jeton d'un membre (anti-rafale). @@index([userId, createdAt]) // Aucune purge des jetons expirés n'existe : cet index ne sert pas encore. @@index([expiresAt])}/// Jeton de vérification générique (identifiant, valeur, expiration)./// ⚠ Inutilisé : aucun code ne lit ni n'écrit ce modèle. Vestige probable d'une/// ancienne librairie d'authentification. Candidat à suppression.model Verification { /// Identifiant, fourni par l'appelant (aucune valeur par défaut). id String @id /// Ce qui est vérifié (par exemple une adresse e-mail). identifier String /// Valeur secrète attendue. value String /// Fin de validité. expiresAt DateTime /// Création. createdAt DateTime @default(now()) /// Dernière modification (tenue à jour par Prisma). updatedAt DateTime @default(now()) @updatedAt}// ============================================================================// AUDIT// ============================================================================/// Journal d'audit des actions sensibles./// ⚠ Inutilisé : aucun code n'écrit ni ne lit ce journal. Candidat à suppression./// Les commentaires de champ décrivent l'intention apparente du modèle.model AuditLog { /// Identifiant CUID. id String @id @default(cuid()) /// Date de l'action. createdAt DateTime @default(now()) /// Membre auteur de l'action. null si inconnu ou compte supprimé (SET NULL). actorUserId String? /// Copie de l'e-mail de l'auteur, lisible même après suppression de son compte. actorEmail String? /// Nature de l'action. action AuditAction /// Membre visé par l'action. null si sans objet ou compte supprimé (SET NULL). targetUserId String? /// Détails libres de l'action (JSON). meta Json? /// Auteur (N–1, User.auditLogsAsActor). Détaché à la suppression du membre. actor User? @relation("AuditActor", fields: [actorUserId], references: [id], onDelete: SetNull) /// Cible (N–1, User.auditLogsAsTargetUser). Détachée à la suppression du membre. targetUser User? @relation("AuditTargetUser", fields: [targetUserId], references: [id], onDelete: SetNull) @@index([actorUserId]) @@index([targetUserId]) @@index([action, createdAt])}/// Actions consignées dans AuditLog. ⚠ Inutilisé (voir AuditLog)./// Droit à l'image d'un membre.enum ImageConsent { /// Autorisation pas encore recueillie. UNKNOWN /// Le membre (ou ses parents) autorise la publication. GRANTED /// Le membre (ou ses parents) refuse : ne pas publier d'image où il est reconnaissable. REFUSED}/// Usages de l'image qu'un formulaire signé autorise (une case « Oui » par usage)./// Majorité d'un membre.enum LegalAge { /// Pas encore renseignée (comptes créés avant la règle). UNKNOWN /// Majeur : il confirme lui-même son autorisation. ADULT /// Mineur : date de naissance et parent référent obligatoires ; l'autorisation vient du parent. MINOR}/// Voie par laquelle une autorisation de droit à l'image a été donnée.enum ImageConsentSource { /// Formulaire papier signé, déposé par un administrateur. PAPER /// Confirmée en ligne par le membre majeur, connecté (mot de passe redemandé). ONLINE_MEMBER /// Confirmée en ligne par le parent référent, via le lien envoyé à son adresse. ONLINE_GUARDIAN}enum ImageUsage { /// Galeries de l'espace membres. MEMBER_SPACE /// Site public. PUBLIC_SITE /// Réseaux sociaux du club. SOCIAL_NETWORKS /// Affiches, plaquettes, supports de la MJC. PRINT /// Presse locale. PRESS}enum AuditAction { /// Changement de rôle d'un membre. Notion caduque : User n'a plus de rôle, /// l'administration passe par le groupe « Administrateurs ». USER_ROLE_CHANGED}// ============================================================================// DOMAINE MÉTIER — Category → Discipline// ============================================================================/// Champ de pratique d'une discipline. Volontairement court : il distingue/// les grands champs, pas les écoles ni les styles (champs libres de/// Discipline). Affiché en administration (« Art martial », « Calligraphie »).enum DisciplineType { /// Art martial (ex. kung-fu, kali). MARTIAL_ART /// Calligraphie. CALLIGRAPHY}/// Public visé par un cours, un séminaire ou un évènement./// Règle du club, NON appliquée par le code : pas de passage de grade pour/// les adolescents.enum Audience { /// Enfants (typiquement moins de 12 ans). KIDS /// Adolescents (environ 12 à 17 ans). TEENAGERS /// Adultes (18 ans et plus). ADULTS /// Tous publics. ALL_AGES}/// Jour de la semaine d'un créneau récurrent (Course).enum Day { /// Lundi. MONDAY /// Mardi. TUESDAY /// Mercredi. WEDNESDAY /// Jeudi. THURSDAY /// Vendredi. FRIDAY /// Samedi. SATURDAY /// Dimanche. SUNDAY}// ============================================================================// ORIGINES CULTURELLES// ============================================================================/// Origine culturelle d'une discipline, d'un séminaire ou d'un évènement/// (ex. « Chine », « Philippines »). Entité dédiée plutôt que texte libre :/// pas de doublons orthographiques (nom et slug uniques), métadonnées/// culturelles, regroupement transverse. Gérée en administration ; pas de/// page publique d'origine.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[]}/// Catégorie d'enseignement, la plus grosse maille de classement (ex./// « Cours »). Regroupe des disciplines et range la médiathèque : le dossier/// physique d'une catégorie est {appRoot}/{segment}, où le segment vient de/// categoryStorageSegment (« Cours » → « courses », sinon le slug du/// libellé) ; son nom affiché est le libellé (FolderLabel).model Category { /// Identifiant auto-incrémenté. id Int @id @default(autoincrement()) /// Libellé de la catégorie, unique (ex. « Cours »). Nommé `type` pour des /// raisons historiques. Affichage seulement : le dossier suit `storageSegment`. type String @unique /// Segment du dossier de la catégorie (`${appRoot}/<segment>`, ex. `courses`), /// FIGÉ à la création : renommer la catégorie ne déplace aucun fichier. null /// seulement pour une ligne antérieure à la colonne, complétée au démarrage /// (ensureStorageSegments) avec le segment qu'elle avait. storageSegment String? @unique /// Disciplines de la catégorie (1–N, Discipline.category). Bloquent la /// suppression de la catégorie (RESTRICT). disciplines Discipline[] /// Fichiers classés dans la catégorie (1–N, MediaAsset.category). Détachés /// à sa suppression (SET NULL). mediaAssets MediaAsset[] /// Galeries associées (1–N, Gallery.category). Détachées à sa suppression /// (SET NULL). galleries Gallery[]}/// Famille de disciplines proches. Sert à regrouper les disciplines dans le/// menu public « Nos disciplines » : familles triées par `sortOrder`,/// familles vides masquées, disciplines sans famille listées à part.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])}/// Discipline enseignée par le club, au sens conceptuel (ex. Tchoy Lee Fut) :/// indépendante du temps et du lieu. Ses créneaux hebdomadaires sont des/// Course ; les séminaires (« stages » dans l'interface) et les évènements/// peuvent s'y rattacher./// Page publique : /disciplines/{slug}, une fois publiée./// Suppression : bloquée tant qu'il reste des créneaux (RESTRICT) ; les/// séminaires, fichiers et galeries sont détachés (SET NULL), les liens/// d'évènements effacés (CASCADE).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])}// ============================================================================// OCCURRENCES// ============================================================================/// Créneau hebdomadaire récurrent d'une discipline : un jour, une heure, un/// public, chaque semaine. Un cours = un créneau, pas une séance datée./// Ses horaires alimentent la colonne « Horaires » du pied de page./// L'unicité (discipline, jour, heure de début, public) définit un doublon.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])}/// Séminaire (« stage » dans l'interface) : manifestation ponctuelle et/// intensive (souvent un week-end, parfois avec un intervenant invité),/// composée d'une ou plusieurs séances. Les dates vivent dans ses séances/// (SeminarSession) : le séminaire lui-même n'est pas daté./// Deux composites : `description` (ce que c'est) et `program` (ce qu'on y fait).////// Rattachement : à une discipline du club (`disciplineId`), à une discipline/// extérieure nommée librement (`externalDisciplineLabel`) et/ou à une/// origine (`originId`). Le routeur exige au moins l'un des trois./// ⚠ L'unicité (disciplineId, label) ne joue que si `disciplineId` est non/// null (en SQL, NULL ≠ NULL) : sans discipline, rien n'empêche un doublon de/// libellé./// Page publique : /seminars/{slug}, une fois publié.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])}/// Séance d'un séminaire (ex. samedi matin). Porte la date et les horaires ;/// pas de contenu riche, qui vit dans le séminaire.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])}// ============================================================================// ÉVÉNEMENTS// ============================================================================/// Évènement ponctuel : repas, conférence, portes ouvertes, forum des/// associations, atelier culturel… Plus léger qu'un séminaire : un seul/// composite (`content`), un seul organisateur. Les dates vivent dans ses/// séances (EventSession)./// Rattachements facultatifs et cumulables : disciplines du club/// (EventDiscipline), disciplines extérieures nommées librement/// (`externalDisciplineLabels`), origine (`originId`)./// Page publique : /events/{slug}, une fois publié.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])}/// Séance d'un évènement : sa date et ses horaires. Un évènement d'un soir en/// a une ; un évènement sur plusieurs dates en a plusieurs.model EventSession { /// Identifiant auto-incrémenté. id Int @id @default(autoincrement()) /// Évènement parent. eventId 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. location String? /// Notes libres. notes String? /// Création. createdAt DateTime @default(now()) /// Dernière modification (tenue à jour par Prisma). updatedAt DateTime @updatedAt /// Évènement parent (N–1, Event.sessions). Séance effacée avec lui (CASCADE). event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) // Une séance au plus par évènement, jour et heure de début. @@unique([eventId, date, beginTime]) @@index([eventId]) @@index([date])}// ============================================================================// MEDIA// ============================================================================/// Fichier de la médiathèque (finder), stocké sur Cloudflare R2 (images,/// vidéos, audio, documents, archives). Vignettes et affiches de vidéo sont/// fabriquées par le serveur à la demande.////// `fullPath` est la clé universelle du fichier : la clé de l'objet R2,/// extension comprise./// Un nouvel upload arrive sur un chemin SANS segment de statut (ex./// AKFC/seminars/{slug}/…) : le statut vit dans `status`. Des fichiers plus/// anciens portent encore le statut dans leur chemin (…/pending/…,/// …/published/…) ; le finder fusionne ces emplacements en une seule vue.////// Servi aux visiteurs (routes /api/media/public/…) seulement s'il est/// `published` ET placé : référencé par un contenu (PageMediaReference) ou/// présent dans une galerie PUBLIC.model MediaAsset { /// Identifiant CUID. id String @id @default(cuid()) // ── Identité de stockage ───────────────────────────────────────────────── /// Clé universelle et unique du fichier (voir le modèle). Change quand le /// fichier est déplacé ou renommé. fullPath String @unique // ── Fichier ────────────────────────────────────────────────────────────── /// Type MIME. mimeType String /// Format ou extension (ex. jpg, pdf). format String? /// Nom du fichier au moment de l'upload. originalFileName String /// Nom affiché dans le finder et les listes. null → nom d'origine. displayName String? /// Description libre. description String? /// Taille en octets. bytes Int /// Largeur en pixels (images, vidéos). width Int? /// Hauteur en pixels (images, vidéos). height Int? /// Durée en secondes (audio, vidéo). duration Float? // ── Métier ─────────────────────────────────────────────────────────────── /// Racine applicative (variable APP_SHORT_NAME, ex. « AKFC ») : premier /// segment de tous les chemins. appRoot String /// Cycle de vie : "pending" (en attente, valeur d'un nouvel upload), /// "published" (publié) ou "bin" (à la corbeille). Texte libre en base : /// seul le code garantit ces valeurs. status String /// Catégorie d'enseignement du fichier. null → sans catégorie. categoryId Int? /// Discipline du fichier. null → sans discipline (ou discipline /// « proposée », voir `proposedDisciplineName`). disciplineId Int? /// Nom d'une discipline « proposée » à l'upload, pas encore créée ; le /// fichier serait rangé sous {catégorie}/new/{slug}. /// ⚠ Code mort : le serveur accepte cette option, mais aucun écran ne la /// propose. Seuls les services d'import le renseignent, d'après un ancien /// chemin …/new/…. Rien ne rattache ensuite le fichier à la discipline. proposedDisciplineName String? /// Date d'évènement saisie à l'upload. ⚠ Écrite, jamais relue par le code. eventDate DateTime? /// Évènement auquel le fichier est rattaché (upload vers un évènement). /// null → aucun, cas de la grande majorité des fichiers. eventId Int? // ── Provenance ─────────────────────────────────────────────────────────── /// Membre qui a téléversé le fichier. uploaderUserId String /// Date réelle d'upload chez le fournisseur (reprise de l'ancien fournisseur /// pour les fichiers migrés). C'est la date exposée par l'API. uploadedAt DateTime @default(now()) /// Création de la ligne. createdAt DateTime @default(now()) /// Dernière modification de la ligne (tenue à jour par Prisma). updatedAt DateTime @updatedAt /// Catégorie (N–1, Category.mediaAssets). Détachée si la catégorie est /// supprimée (SET NULL). category Category? @relation(fields: [categoryId], references: [id]) /// Discipline (N–1, Discipline.mediaAssets). Détachée si la discipline est /// supprimée (SET NULL). discipline Discipline? @relation(fields: [disciplineId], references: [id]) /// Évènement (N–1, Event.mediaAssets). Détaché si l'évènement est supprimé /// (SET NULL). event Event? @relation(fields: [eventId], references: [id]) /// Auteur de l'upload (N–1, User.uploadedMediaAssets). Bloque la suppression /// du membre (RESTRICT). uploader User @relation("UserMediaUploads", fields: [uploaderUserId], references: [id]) /// Contenus qui utilisent le fichier (1–N, PageMediaReference.mediaAsset). /// Tant qu'il en existe, le fichier ne peut pas être supprimé (RESTRICT). pageReferences PageMediaReference[] /// Présences dans des galeries (1–N, GalleryItem.mediaAsset). Effacées avec /// le fichier. galleryItems GalleryItem[] /// Publication aux membres comme document (0..1, MemberDocument.mediaAsset). /// Effacée avec le fichier. memberDocument MemberDocument? /// Ancien identifiant Cloudinary d'un fichier migré vers R2 (sortie de /// Cloudinary, 2026) : simple trace. null pour un fichier né sur R2. legacyCloudinaryPublicId String? @@index([status]) @@index([categoryId]) @@index([disciplineId]) @@index([eventId]) @@index([uploaderUserId]) @@index([uploadedAt]) @@index([appRoot, fullPath])}// ============================================================================// FOLDERS & TRASH// ============================================================================/// Registre des dossiers du finder./// Les stockages objet ne connaissent pas les dossiers vides : ce registre les/// matérialise pour qu'un dossier s'affiche même sans fichier. Alimenté au/// parcours de l'arborescence, aux déplacements et aux restaurations depuis la/// corbeille ; dossiers racines créés au démarrage (ensureRootFolders) ;/// dossiers vidés élagués (pruneEmptyFolders).model Folder { /// Identifiant CUID. id String @id @default(cuid()) /// Racine applicative (voir MediaAsset.appRoot). appRoot String /// Chemin complet du dossier, unique par racine. fullPath String /// Statut déduit du chemin (statusFromPath), à l'inscription et au /// renommage du dossier : vestige de l'époque où le statut était un /// segment du chemin. status FolderStatus /// Inscription du dossier. createdAt DateTime @default(now()) /// Dernière modification (tenue à jour par Prisma). updatedAt DateTime @updatedAt @@unique([appRoot, fullPath]) @@index([appRoot]) @@index([appRoot, status])}/// Statut d'un dossier du registre, déduit du chemin par statusFromPath.enum FolderStatus { /// Chemin sous …/pending/…, ou sans segment de statut reconnu. pending /// Chemin sous …/published/…. published /// Chemin sous …/bin/… (corbeille). bin}/// Élément (fichier ou dossier) placé dans la corbeille, avec sa provenance/// pour pouvoir le restaurer. Créé au passage en corbeille (trashToBin), AVANT/// le déplacement physique vers {appRoot}/bin/.trash/{id}/{displayName}./// Suppression définitive : depuis la vue corbeille, par identifiant/// (deleteForever), ou depuis le finder, par chemin (purge). Aucune purge/// automatique n'existe.model TrashEntry { /// Identifiant UUID, réservé avant le déplacement ; il figure dans le chemin /// de rangement. id String @id @default(uuid()) /// Racine applicative (voir MediaAsset.appRoot). appRoot String /// Fichier ou dossier. kind TrashEntryKind /// Dans la corbeille, restauré, ou supprimé définitivement. status TrashEntryStatus @default(IN_BIN) /// Nom de l'élément au moment de sa mise à la corbeille. displayName String /// Chemin d'origine, utilisé pour la restauration. previousPath String /// Emplacement de rangement : {appRoot}/bin/.trash/{id}/{displayName}. storageRoot String /// Mise à la corbeille. trashedAt DateTime @default(now()) /// Restauration. null tant que l'élément n'a pas été restauré. restoredAt DateTime? /// Chemin effectif de la restauration. restoredToPath String? /// Suppression définitive. null tant qu'elle n'a pas eu lieu. deletedAt DateTime? /// Taille en octets : celle du fichier, ou le total des fichiers contenus /// pour un dossier. sizeBytes BigInt? /// Date de création chez le fournisseur (R2) ; pour un /// dossier, la plus récente des fichiers contenus. providerCreatedAt DateTime? /// Nature du fichier : image, video, audio ou document, déduite de son type /// MIME (deriveMediaKind). null pour un dossier. Les entrées antérieures /// classent l'audio en « document ». mediaKind String? @@index([appRoot]) @@index([appRoot, status, trashedAt]) @@index([appRoot, previousPath]) @@index([appRoot, kind])}/// Nature d'un élément de corbeille.enum TrashEntryKind { /// Dossier, avec tout son contenu. folder /// Fichier seul. file}/// État d'un élément de corbeille.enum TrashEntryStatus { /// Dans la corbeille, restaurable. IN_BIN /// Restauré (voir restoredAt, restoredToPath). RESTORED /// Supprimé définitivement (voir deletedAt) : le fichier n'existe plus. DELETED}// ============================================================================// POSTS / COMMENTS / LIKES// ============================================================================/// Article du mur : actualité, compte rendu, annonce, réflexion. Commentable,/// avec réactions et, en option, un sondage. Indépendant de toute discipline./// Suppression : ses commentaires et son sondage suivent en base (CASCADE) ;/// ses réactions et celles de ses commentaires sont effacées par le routeur/// (elles n'ont pas de clé étrangère), ainsi que ses liens média.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])}/// Commentaire d'un post, en arborescence : `parentId` désigne le commentaire/// auquel il répond (null → commentaire racine)./// Suppression (par son auteur) : le routeur efface les réactions de toute la/// sous-arborescence, puis la base supprime les réponses en cascade.model Comment { /// Identifiant auto-incrémenté. id Int @id @default(autoincrement()) /// Texte, document ProseMirror (JSON). L'éditeur limite la mise en forme /// (gras, italique, liens ; ni titres, ni images, ni blocs). content Json /// Auteur. authorId String /// Post commenté. postId Int /// Commentaire parent. null → commentaire racine. parentId Int? /// Création. createdAt DateTime @default(now()) /// Dernière modification (tenue à jour par Prisma). updatedAt DateTime @updatedAt /// Auteur (N–1, User.comments). Le membre ne peut pas être supprimé tant /// qu'il en a (RESTRICT). author User @relation(fields: [authorId], references: [id]) /// Post (N–1, Post.comments). Commentaire effacé avec le post. post Post @relation(fields: [postId], references: [id], onDelete: Cascade) /// Commentaire parent (N–1, Comment.replies). Réponse effacée avec lui. parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade) /// Réponses directes (1–N, Comment.parent). replies Comment[] @relation("CommentReplies") @@index([postId]) @@index([parentId])}/// Nature de la cible d'une réaction (Reaction.targetType).enum ReactionTarget { /// Post : targetId = Post.id. POST /// Commentaire : targetId = Comment.id. COMMENT}/// Réaction emoji d'un membre sur un post ou un commentaire (le pouce levé/// n'est qu'un cas : emoji « 👍 »). Polymorphe par convention (`targetType` +/// `targetId`) : sans clé étrangère vers la cible, ce sont les routeurs qui/// effacent les réactions quand un post ou un commentaire est supprimé.model Reaction { /// Identifiant auto-incrémenté. id Int @id @default(autoincrement()) /// Emoji. L'interface en propose six ; le serveur accepte toute chaîne de 1 à /// 32 caractères. emoji String /// Membre qui réagit. userId String /// Nature de la cible (voir ReactionTarget). targetType ReactionTarget /// Identifiant de la cible (post ou commentaire), sans clé étrangère. targetId Int /// Date de la réaction. createdAt DateTime @default(now()) /// Membre (N–1, User.reactions). Le membre ne peut pas être supprimé tant /// qu'il en a (RESTRICT). user User @relation(fields: [userId], references: [id]) // Un même emoji au plus une fois par membre et par cible. @@unique([userId, targetType, targetId, emoji]) @@index([targetType, targetId])}/// Sondage d'un post (un au plus : `postId` unique) ; sa présence active le/// vote. Règles appliquées par le serveur : vote refusé après `closesAt` ; un/// seul choix si `multiple` est faux ; un nouveau vote remplace le précédent.model Poll { /// Identifiant auto-incrémenté. id Int @id @default(autoincrement()) /// Post porteur (unique). postId Int @unique /// Question posée. question String /// Vrai : plusieurs choix par votant ; faux : un seul. multiple Boolean @default(false) /// Clôture. null → pas de clôture ; une fois passée, les votes sont refusés. closesAt DateTime? /// Création. createdAt DateTime @default(now()) /// Post (1–1, Post.poll). Sondage effacé avec le post. post Post @relation(fields: [postId], references: [id], onDelete: Cascade) /// Options de réponse (1–N, PollOption.poll). Effacées avec le sondage. options PollOption[]}/// Option de réponse d'un sondage.model PollOption { /// Identifiant auto-incrémenté. id Int @id @default(autoincrement()) /// Sondage. pollId Int /// Libellé affiché. label String /// Rang d'affichage, croissant. sortOrder Int @default(0) /// Sondage (N–1, Poll.options). Option effacée avec lui. poll Poll @relation(fields: [pollId], references: [id], onDelete: Cascade) /// Votes pour cette option (1–N, PollVote.option). Effacés avec elle. votes PollVote[] @@index([pollId])}/// Vote d'un membre pour une option. `pollId` est une copie (sans clé/// étrangère) du sondage de l'option : elle permet d'appliquer la règle du/// choix unique et de lire les votes d'un sondage sans jointure.model PollVote { /// Identifiant auto-incrémenté. id Int @id @default(autoincrement()) /// Option choisie. optionId Int /// Sondage de l'option (copie, sans clé étrangère). pollId Int /// Votant. userId String /// Date du vote. createdAt DateTime @default(now()) /// Option (N–1, PollOption.votes). Vote effacé avec l'option. option PollOption @relation(fields: [optionId], references: [id], onDelete: Cascade) /// Votant (N–1, User.pollVotes). Le membre ne peut pas être supprimé tant /// qu'il a voté (RESTRICT). user User @relation(fields: [userId], references: [id]) // Un membre vote au plus une fois pour une option donnée. @@unique([optionId, userId]) @@index([pollId, userId])}/// Lien entre un contenu de builder et un fichier de la médiathèque qu'il/// utilise. Tenu à jour dans la même transaction que l'enregistrement du/// contenu (syncPageMediaReferences), par les routeurs des pages de site,/// disciplines, posts, évènements, cours, séminaires et bios d'instructeur ;/// supprimer le contenu supprime ses liens. Les liens des bios existantes/// sont rattrapés au démarrage (ensureInstructorBioMediaReferences).////// Deux rôles :/// - PROTECTION : tant qu'un lien existe, le fichier ne peut pas être supprimé/// (RESTRICT), et le faire sortir de « published » est refusé ;/// - DIFFUSION : un fichier publié et référencé peut être servi aux visiteurs.////// `pageId` n'a pas de clé étrangère : selon `pageType`, il désigne des/// enregistrements de modèles différents. L'intégrité de ce côté est tenue/// par l'application.model PageMediaReference { /// Identifiant CUID. id String @id @default(cuid()) /// Fichier référencé. mediaAssetId String /// Type de contenu qui utilise le fichier (voir PageReferencerKind). pageType PageReferencerKind /// Identifiant du contenu, en texte (les identifiants numériques sont /// convertis). pageId String /// Création du lien. createdAt DateTime @default(now()) /// Fichier (N–1, MediaAsset.pageReferences). Le lien BLOQUE la suppression /// du fichier (RESTRICT). mediaAsset MediaAsset @relation(fields: [mediaAssetId], references: [id], onDelete: Restrict) // Un lien au plus par fichier et par contenu. @@unique([mediaAssetId, pageType, pageId]) @@index([pageType, pageId])}/// Type de contenu référenceur dans PageMediaReference. Un contenu qui porte/// plusieurs composites de builder a une valeur par composite (sauf/// Discipline, dont les deux composites sont fusionnés).enum PageReferencerKind { /// Cours : Course.content. COURSE /// Séminaire : Seminar.description. SEMINAR_DESCRIPTION /// Séminaire : Seminar.program. SEMINAR_PROGRAM /// Post : Post.content. POST /// Discipline : Discipline.description ET Discipline.summary, fusionnés. DISCIPLINE /// Évènement : Event.content. EVENT /// Page de contenu éditoriale : SitePage.content (ex. « L'association »). SITE_PAGE /// Séminaire : Seminar.summary (présentation synthétique, carte d'agenda). SEMINAR_SUMMARY /// Évènement : Event.summary (présentation synthétique, carte d'agenda). EVENT_SUMMARY /// Bio d'instructeur : User.instructorBio (pageId = id du membre). Liens /// effacés avec le membre (users.delete). INSTRUCTOR_BIO}// ─────────────────────────────────────────────────────────────────────────// AJOUTS AU SCHÉMA — feature Galerie/Carousel// À coller dans prisma/schema.prisma (à la racine du monorepo).// ─────────────────────────────────────────────────────────────────────────/// Visibilité d'un contenu. Utilisée aujourd'hui par Gallery seulement.enum Visibility { /// Visible par tous, y compris les visiteurs anonymes. PUBLIC /// Réservé aux membres connectés ; un visiteur anonyme reçoit « introuvable ». MEMBERS}// 2) Nouveaux modèles./// Galerie d'images : une sélection ordonnée de fichiers de la médiathèque/// (GalleryItem), sans nouvel upload. Rattachements facultatifs et/// CUMULABLES (discipline, séminaire, évènement, catégorie, origine) : ce/// sont les facettes de filtrage de la vue publique./// La galerie de slug « home-carousel » alimente le carrousel de la page/// d'accueil ; elle est exclue des listes de galeries.model Gallery { /// Identifiant auto-incrémenté. id Int @id @default(autoincrement()) /// Segment d'URL, unique. « home-carousel » est réservé au carrousel d'accueil. slug String @unique /// Titre affiché. title String /// Qui peut voir la galerie (voir Visibility). Ses images ne sont servies /// aux visiteurs anonymes que si elle est PUBLIC. visibility Visibility @default(PUBLIC) /// Rang : ordre des listes d'administration, et départage à date égale dans /// la vue publique. sortOrder Int @default(0) /// Date de la galerie (jour du séminaire ou de l'évènement photographié). La /// vue publique trie par date décroissante, galeries sans date en dernier. date DateTime? /// Discipline associée. null → aucune. disciplineId Int? /// Discipline (N–1, Discipline.galleries). Détachée si la discipline est /// supprimée (SET NULL). discipline Discipline? @relation(fields: [disciplineId], references: [id]) /// Séminaire associé. null → aucun. seminarId Int? /// Séminaire (N–1, Seminar.galleries). Détaché si le séminaire est supprimé /// (SET NULL). seminar Seminar? @relation(fields: [seminarId], references: [id]) /// Évènement associé. null → aucun. eventId Int? /// Évènement (N–1, Event.galleries). Détaché si l'évènement est supprimé /// (SET NULL). event Event? @relation(fields: [eventId], references: [id]) /// Catégorie associée. null → aucune. categoryId Int? /// Catégorie (N–1, Category.galleries). Détachée si la catégorie est /// supprimée (SET NULL). category Category? @relation(fields: [categoryId], references: [id]) /// Origine associée. null → aucune. originId Int? /// Origine (N–1, Origin.galleries). Détachée si l'origine est supprimée /// (SET NULL). origin Origin? @relation(fields: [originId], references: [id]) /// Images de la galerie (1–N, GalleryItem.gallery). Effacées avec la galerie. items GalleryItem[] @@index([sortOrder]) @@index([date])}/// Présence d'un fichier de la médiathèque dans une galerie, à un rang donné./// Un fichier n'apparaît qu'une fois par galerie.model GalleryItem { /// Identifiant auto-incrémenté. id Int @id @default(autoincrement()) /// Galerie. galleryId Int /// Galerie (N–1, Gallery.items). Effacé avec la galerie. gallery Gallery @relation(fields: [galleryId], references: [id], onDelete: Cascade) /// Fichier affiché. mediaAssetId String /// Fichier (N–1, MediaAsset.galleryItems). Effacé avec le fichier (CASCADE) : /// contrairement aux contenus, une galerie ne protège pas ses fichiers. mediaAsset MediaAsset @relation(fields: [mediaAssetId], references: [id], onDelete: Cascade) /// Rang dans la galerie, croissant. sortOrder Int @default(0) // Un fichier au plus une fois par galerie. @@unique([galleryId, mediaAssetId]) @@index([galleryId, sortOrder])}// ═══════════════════════════════════════════════════════════════════════// BREAKING NEWS — la « voix du club »// ═══════════════════════════════════════════════════════════════════════/// Actualité courte, diffusée par le ruban défilant et le panneau latéral/// escamotable du site public. À sens unique, contrairement à Post : pas/// d'auteur affiché (c'est le club qui parle), ni commentaires ni réactions.model BreakingNews { /// Identifiant auto-incrémenté. id Int @id @default(autoincrement()) /// Titre. title String /// Corps court, en texte brut : le ruban défile mal du texte riche. body String /// Lien facultatif, interne (ex. « /#post-12 ») ou externe : bouton d'action /// de la fiche dans le panneau latéral, PAS la cible du clic sur le ruban /// (qui ouvre le panneau). href String? /// Date de publication. null → brouillon ; une date future programme la /// publication. publicationDate DateTime? /// Échéance : passée, l'actualité quitte le ruban et le panneau. null → sans /// échéance. expiresAt DateTime? /// Création. createdAt DateTime @default(now()) /// Dernière modification (tenue à jour par Prisma). updatedAt DateTime @updatedAt @@index([publicationDate])}/// Discipline du club présentée lors d'un évènement (ex. forum des/// associations, démonstration). Jointure explicite plutôt qu'implicite, pour/// maîtriser la table et pouvoir y ajouter des informations plus tard.model EventDiscipline { /// Évènement. eventId Int /// Discipline présentée. disciplineId Int /// Évènement (N–1, Event.disciplineLinks). Lien effacé avec l'évènement. event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) /// Discipline (N–1, Discipline.eventLinks). Lien effacé avec la discipline. discipline Discipline @relation(fields: [disciplineId], references: [id], onDelete: Cascade) @@id([eventId, disciplineId]) @@index([disciplineId])}/// Page de contenu éditoriale : un titre, un composite PageBuilder, une URL/// stable. Sert « L'association » (/about) et les pages d'information/// (/infos/{slug} : mentions légales…)./// Clé primaire = slug : ces pages sont désignées par leur nom, dans les URL/// comme dans le code.model SitePage { /// Clé stable, également segment d'URL (ex. « association »). slug String @id /// Titre affiché en tête de page. title String /// Contenu, édité au PageBuilder (PageContentV1). content Json @default("{\"version\":1,\"blocks\":[]}") /// Dernière modification (tenue à jour par Prisma). updatedAt DateTime @updatedAt}/// Réglages typographiques du rendu des contenus du builder, pour tout le site/// (ligne unique, id 1). Source de vérité : cette ligne ; les valeurs de/// globals.css ne servent que de repli tant qu'aucun réglage n'est enregistré.model SiteStyle { /// Identifiant fixe : 1. id Int @id @default(1) /// Variables CSS `--akfc-*` (ex. { "--akfc-leading": "1.65" }). Un JSON /// plutôt qu'une colonne par variable : ajouter un curseur au laboratoire /// ne demande pas de migration. variables Json /// Longueur maximale d'une présentation synthétique de discipline, en /// caractères de texte brut. Colonne à part : ce n'est pas une propriété /// CSS. summaryMaxChars Int @default(600) /// Hauteur repliée des cartes de l'accueil, en pixels : une hauteur (et non /// un nombre de caractères) rend les cartes égales. cardCollapsedHeight Int @default(220) /// Dernière modification (tenue à jour par Prisma). updatedAt DateTime @updatedAt}/// Diffusion d'un MemberDocument.enum DocumentAudience { /// Tous les membres, y compris ceux qui adhèrent après la publication. /// Les groupes et destinataires éventuellement présents sont ignorés. ALL_MEMBERS /// Diffusion ciblée : membres DIRECTS des groupes visés (MemberDocumentGroup) /// et/ou membres désignés nommément (MemberDocumentRecipient). SPECIFIC}/// Document du club mis à disposition des membres (compte rendu d'AG, carte/// d'affiliation…), avec un statut de lecture par membre./// Surcouche posée sur un fichier du finder : référence le MediaAsset sans/// dupliquer ni son chemin ni son URL. Un fichier ne se publie qu'une fois/// (mediaAssetId unique).model MemberDocument { /// Identifiant CUID. id String @id @default(cuid()) /// Fichier publié (unique). mediaAssetId String @unique /// Fichier (1–1, MediaAsset.memberDocument). Supprimer le fichier supprime /// la publication (CASCADE). mediaAsset MediaAsset @relation(fields: [mediaAssetId], references: [id], onDelete: Cascade) /// Titre affiché. À défaut : nom affiché du fichier, puis son nom d'origine. title String? /// Diffusion : à tous, ou ciblée (voir DocumentAudience). audience DocumentAudience @default(ALL_MEMBERS) /// Date de publication. Ordonne les listes (plus récent d'abord) et borne les /// documents « non lus » d'un membre (voir User.memberSince). publishedAt DateTime @default(now()) /// Membre qui a publié. null si son compte a été supprimé. publishedById String? /// Éditeur (N–1, User.memberDocumentsPublished). Détaché à la suppression du /// membre (SET NULL). publishedBy User? @relation("MemberDocumentPublisher", fields: [publishedById], references: [id], onDelete: SetNull) /// Membres désignés nommément (1–N, MemberDocumentRecipient.memberDocument). /// Pris en compte en SPECIFIC uniquement. recipients MemberDocumentRecipient[] /// Groupes visés (1–N, MemberDocumentGroup.memberDocument). Pris en compte en /// SPECIFIC uniquement. groups MemberDocumentGroup[] /// Statuts de lecture par membre (1–N, DocumentReceipt.memberDocument). receipts DocumentReceipt[] @@index([audience])}/// Membre désigné nommément comme destinataire d'un document ciblé (SPECIFIC).model MemberDocumentRecipient { /// Identifiant CUID. id String @id @default(cuid()) /// Document. memberDocumentId String /// Document (N–1, MemberDocument.recipients). Lien effacé avec le document. memberDocument MemberDocument @relation(fields: [memberDocumentId], references: [id], onDelete: Cascade) /// Destinataire. userId String /// Destinataire (N–1, User.memberDocumentRecipients). Lien effacé avec le membre. user User @relation("MemberDocumentRecipient", fields: [userId], references: [id], onDelete: Cascade) // Un destinataire au plus une fois par document. @@unique([memberDocumentId, userId]) @@index([userId])}/// Statut de lecture d'un document par un membre./// Pas de ligne, ou `readAt` null → non lu : le document est signalé par la/// cloche des documents (s'il est publié depuis l'année d'adhésion du membre).model DocumentReceipt { /// Identifiant CUID. id String @id @default(cuid()) /// Document. memberDocumentId String /// Document (N–1, MemberDocument.receipts). Statut effacé avec le document. memberDocument MemberDocument @relation(fields: [memberDocumentId], references: [id], onDelete: Cascade) /// Membre lecteur. userId String /// Membre (N–1, User.documentReceipts). Statut effacé avec le membre. user User @relation("DocumentReceiptUser", fields: [userId], references: [id], onDelete: Cascade) /// Date de lecture : posée à l'ouverture de l'aperçu (markRead), remise à /// null quand le membre re-marque le document non lu (markUnread). readAt DateTime? // Un statut par membre et par document. @@unique([memberDocumentId, userId]) @@index([userId])}/// Droit d'un membre sur l'espace d'un groupe COLLABORATIF/// (MemberGroupMembership.access). Sans effet sur la diffusion des documents.enum MemberGroupAccess { /// Consultation seule de l'espace (assertCanReadPath). VIEWER /// Consultation, écriture et mise à la corbeille dans l'espace /// (assertCanWriteGroupSpace, assertCanTrashPaths). EDITOR}/// Groupe de membres (ex. « Bureau »), géré dans le panneau de contrôle.////// Deux usages, cumulables :/// - DIFFUSION de documents : un MemberDocument ciblé peut viser le groupe./// L'appartenance est résolue à la lecture : un membre ajouté reçoit aussi/// les documents déjà publiés. Seuls les membres DIRECTS sont concernés (la/// hiérarchie n'intervient pas)./// - ESPACE COLLABORATIF (`isCollaborative`) : un dossier du finder réservé au/// groupe, sous collaborative-group-spaces/{slug}-{id}, avec un droit par/// membre (MemberGroupMembership.access).////// Hiérarchie (`parentGroup`) : un membre d'un groupe obtient, sur les espaces/// de TOUS ses groupes descendants, le même niveau d'accès (le plus élevé/// l'emporte : EDITOR > VIEWER). Les administrateurs passent outre ces droits.////// Suppression (deleteGroupWithSpace) : refusée pour le groupe Administrateurs ;/// exige un espace vide ; les sous-groupes sont d'abord rattachés au parent du/// groupe supprimé. Adhésions et ciblages de documents disparaissent avec lui.model MemberGroup { /// Identifiant CUID. Sert aussi de suffixe au dossier de l'espace (stable /// quand le groupe est renommé). id String @id @default(cuid()) /// Nom affiché. name String /// Description libre, affichée dans le panneau de contrôle. description String? /// Vrai : le groupe dispose d'un espace collaboratif dans le finder. /// Faux (défaut) : simple liste de diffusion de documents. isCollaborative Boolean @default(false) /// Groupe « Administrateurs » : ses membres sont les administrateurs du site. /// Créé au démarrage s'il manque (ensureAdminGroup), toujours collaboratif, /// non supprimable. Unique par convention : aucune contrainte en base /// n'empêche un second groupe marqué ; c'est ensureAdminGroup qui s'en charge. isAdminGroup Boolean @default(false) /// Création du groupe. createdAt DateTime @default(now()) /// Groupe parent ; null = groupe racine. Un parent ne peut pas être un /// descendant du groupe (cycle refusé par memberGroup.setParentGroup). parentGroupId String? /// Parent (N–1, MemberGroup.childGroups). Filet de sécurité : si le parent /// disparaît sans passer par deleteGroupWithSpace, le groupe devient racine /// (SET NULL). parentGroup MemberGroup? @relation("GroupHierarchy", fields: [parentGroupId], references: [id], onDelete: SetNull) /// Sous-groupes (1–N, MemberGroup.parentGroup). childGroups MemberGroup[] @relation("GroupHierarchy") /// Adhésions (1–N, MemberGroupMembership.group). Effacées avec le groupe. memberships MemberGroupMembership[] /// Documents qui ciblent ce groupe (1–N, MemberDocumentGroup.group). Les liens /// sont effacés avec le groupe ; les documents restent. documents MemberDocumentGroup[] /// Catégories de demandes dont ce groupe est responsable (1–N, /// TicketCategory.group). Empêchent sa suppression. ticketCategories TicketCategory[] @@index([parentGroupId])}/// Appartenance d'un membre à un groupe. Un membre peut appartenir à plusieurs/// groupes, une seule fois par groupe.model MemberGroupMembership { /// Identifiant CUID. id String @id @default(cuid()) /// Groupe. groupId String /// Groupe (N–1, MemberGroup.memberships). Adhésion effacée avec le groupe. group MemberGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) /// Membre. userId String /// Membre (N–1, User.memberGroupMemberships). Adhésion effacée avec le membre. user User @relation("MemberGroupMembership", fields: [userId], references: [id], onDelete: Cascade) /// Droit du membre sur l'espace du groupe, si celui-ci est COLLABORATIF (voir /// MemberGroupAccess). Sans effet sur la diffusion des documents. access MemberGroupAccess @default(EDITOR) // Un membre au plus une fois par groupe. @@unique([groupId, userId]) @@index([userId])}/// Groupe visé par un document ciblé (SPECIFIC). Un document peut viser/// plusieurs groupes, chacun une seule fois. Seuls les membres DIRECTS du/// groupe reçoivent le document : la hiérarchie des groupes n'intervient pas.model MemberDocumentGroup { /// Identifiant CUID. id String @id @default(cuid()) /// Document. memberDocumentId String /// Document (N–1, MemberDocument.groups). Lien effacé avec le document. memberDocument MemberDocument @relation(fields: [memberDocumentId], references: [id], onDelete: Cascade) /// Groupe visé. groupId String /// Groupe (N–1, MemberGroup.documents). Lien effacé avec le groupe. group MemberGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) // Un groupe au plus une fois par document. @@unique([memberDocumentId, groupId]) @@index([groupId])}/// Publication d'une page éditoriale (mode « En construction »). Clés : celles/// du registre des pages (pageRegistry : « home », « association »,/// « contacts »). Pas de ligne, ou `published` faux → la page affiche « Page/// en construction » aux non-administrateurs (isEditorialPageGated).model PageVisibility { /// Clé de la page (voir pageRegistry). key String @id /// Vrai : page visible de tous. published Boolean @default(false) /// Dernière modification (tenue à jour par Prisma). updatedAt DateTime @updatedAt}/// Introduction de la page d'accueil (ligne unique, id « home ») : le titre/// et le texte de la section d'accueil. Le reste de la page est structuré par/// le code. La publication de l'accueil passe par PageVisibility (clé « home »).model HomeHero { /// Identifiant fixe : « home ». id String @id @default("home") /// Titre de la section d'accueil. title String /// Texte de la section d'accueil. body String}/// Identité éditoriale du site (ligne unique, id « site ») : ce qu'un/// administrateur change sans toucher au code, dans Réglages du site./// Distincte de APP_SHORT_NAME (racine technique du stockage) : `shortTitle`/// n'est qu'un libellé d'affichage.model SiteSettings { /// Identifiant fixe : « site ». id String @id @default("site") /// Libellé court : en-tête, onglet, pied de page (copyright), texte /// alternatif du logo. shortTitle String @default("AKFC") /// Libellé long, lu par le layout racine. longTitle String @default("Association de Kung Fu de Chambéry") /// Accroche facultative : pied de page, et description du site (layout racine). tagline String? /// E-mail de contact facultatif, cité dans l'e-mail de bienvenue. supportEmail String? /// ⚠ Sans effet : aucun code ne le lit (la langue de <html> est fixée à /// « fr ») et la page Réglages ne l'expose plus. Candidat à suppression. defaultLocale String @default("fr") /// ⚠ Vestige de l'ancien logo, choisi au picker (MediaAsset). Plus renseigné : /// la page Réglages ne l'envoie plus, et chaque enregistrement le remet à /// null. Encore lu en repli par siteSettings.get et par la route publique /// des images. Candidat à suppression. logoAssetId String? /// Clé R2 RÉSERVÉE du logo (« system/logo.svg »), hors finder : le logo n'est /// pas un MediaAsset. Posée par siteSettings.uploadLogo, effacée par /// clearLogo ; servie par /api/media/site-logo. null → logo embarqué. logoKey String? /// Dernière modification (tenue à jour par Prisma). Sert aussi de version /// du logo (?v=…) pour contourner les caches. updatedAt DateTime @updatedAt /// Délai de PREMIÈRE réponse promis aux demandeurs, en jours (7 par défaut). /// Fixe l'échéance de chaque nouvelle demande (Ticket.dueAt). ticketResponseDays Int @default(7) /// Coordonnées PUBLIQUES (page « Nous contacter », Réglages du site) : e-mail du club. contactEmail String? @default("contact@akfc.fr") /// Téléphones publics : tableau JSON de { label, number, call, whatsapp } /// (voir @contracts/siteContact/siteContact.schema). contactPhones Json @default("[]") /// Adresse affichée, une ligne par élément (texte multi-lignes). contactAddress String? /// ── Informations légales (Réglages du site → Informations légales) ────────── /// Alimentent les pages « Mentions légales » et « Politique de confidentialité » /// (générées) et le registre des traitements. Vide = non renseigné. /// Dénomination exacte, telle que dans les statuts. legalName String? /// Numéro RNA (W + 9 chiffres). legalRna String? /// SIRET, si l'association en a un. legalSiret String? /// Coordonnées LÉGALES de l'association (siège, téléphone, e-mail), seules utilisées par les /// documents à valeur légale (mentions légales, politique de confidentialité, registre). /// Distinctes des coordonnées publiques (page « Nous contacter »), qui peuvent différer. /// Siège social, une ligne par élément. legalAddress String? legalPhone String? legalEmail String? /// Directeur de la publication (président ou présidente) et sa qualité. publicationDirector String? publicationDirectorTitle String? /// Hébergeur du site (mentions légales). hostName String? hostAddress String? hostPhone String? hostUrl String? /// Région du stockage Cloudflare R2 : "EU" | "GLOBAL" (voir @contracts/legal). storageRegion String? /// Mention « Conception et réalisation » (facultative). credits String? /// Durées de conservation annoncées (et appliquées par la purge) : compte après la /// fin de la dernière adhésion, demande close après son dernier échange. accountRetentionMonths Int @default(12) ticketRetentionMonths Int @default(36) /// Conservation des sauvegardes, en jours (null : non renseignée). backupRetentionDays Int? /// Dernière modification des informations légales (« Dernière mise à jour »). legalUpdatedAt DateTime?}/// Nom AFFICHÉ d'un dossier du finder, découplé de son chemin physique./// Les dossiers portent des noms physiques en anglais (courses, seminars,/// events…) ; ce libellé leur donne leur nom affiché (ex. « Cours »), et/// permet de renommer un dossier, y compris un dossier rattaché à une entité,/// sans toucher au stockage.model FolderLabel { /// Chemin complet du dossier, identité du libellé (ex. « AKFC/courses », /// « AKFC/collaborative-group-spaces/{slug}-{id} »). path String @id /// Nom affiché. displayName String /// Dernière modification (tenue à jour par Prisma). updatedAt DateTime @updatedAt}// ═══════════════════════════════════════════════════════════════════════// DEMANDES (TICKETS) — suivi des messages adressés au club// ═══════════════════════════════════════════════════════════════════════/// Étape d'une demande. Le délai promis est un délai de RÉPONSE (première/// réponse), pas de résolution.enum TicketStatus { /// Personne ne s'en occupe encore. WAITING /// Un responsable s'en occupe (ou le demandeur a relancé après une réponse). IN_PROGRESS /// Le club a répondu ; en attente d'un éventuel retour du demandeur. ANSWERED /// Traitée. Un nouveau message du demandeur la rouvre. CLOSED}/// Sens d'un message d'une demande.enum TicketMessageDirection { /// Du demandeur vers le club (formulaire ou e-mail). IN /// Du club vers le demandeur (réponse depuis le site). OUT}/// Nature d'un évènement de l'historique d'une demande.enum TicketEventType { /// Création de la demande. CREATED /// Prise en charge (« je m'en occupe »). TAKEN /// Passage de relais à une autre personne, avec une note. TRANSFERRED /// Message reçu du demandeur. MESSAGE_IN /// Réponse envoyée par le club. MESSAGE_OUT /// Clôture. CLOSED /// Réouverture (nouveau message après clôture). REOPENED}/// Catégorie de demande : décide du GROUPE responsable et de l'adresse de/// réponse (alias de contact@). Créées au démarrage par ensureTicketCategories/// (« gestion » → groupe Administrateurs, « support » → groupe Support).model TicketCategory { /// Identifiant CUID. id String @id @default(cuid()) /// Clé technique stable (« gestion », « support »). key String @unique /// Nom affiché (« Gestion du club », « Support du site »). label String /// Adresse de réponse des messages envoyés pour cette catégorie (Reply-To), /// alias de la boîte contact@ (« admin@akfc.fr », « support@akfc.fr »). replyTo String /// Groupe responsable : ses membres traitent les demandes et reçoivent le /// récapitulatif. groupId String /// Groupe responsable (N–1, MemberGroup.ticketCategories). Le groupe ne peut /// pas être supprimé tant qu'une catégorie le désigne. group MemberGroup @relation(fields: [groupId], references: [id], onDelete: Restrict) /// Ordre d'affichage dans le formulaire. position Int @default(0) /// Proposée dans le formulaire de contact. isActive Boolean @default(true) /// Demandes de cette catégorie (1–N, Ticket.category). tickets Ticket[]}/// Demande adressée au club, numérotée (référence affichée « AKFC-0042 »).model Ticket { /// Identifiant CUID. id String @id @default(cuid()) /// Numéro séquentiel, base de la référence affichée (AKFC-0042). Jamais /// modifié, même en cas de transfert. number Int @unique @default(autoincrement()) /// Objet de la demande. subject String /// Étape de la demande. status TicketStatus @default(WAITING) /// Catégorie. categoryId String /// Catégorie (N–1, TicketCategory.tickets). Ne peut pas être supprimée tant /// qu'elle a des demandes. category TicketCategory @relation(fields: [categoryId], references: [id], onDelete: Restrict) /// Membre demandeur, s'il était connecté (null : e-mail d'une personne /// extérieure, ou compte supprimé depuis). requesterId String? /// Membre demandeur (N–1, User.ticketsRequested). Demande conservée, sans /// lien, à la suppression du compte. requester User? @relation("TicketRequester", fields: [requesterId], references: [id], onDelete: SetNull) /// Adresse du demandeur : destinataire des réponses. Toujours renseignée. requesterEmail String /// Nom du demandeur, tel qu'affiché. requesterName String? /// Responsable actuel (null : personne). assigneeId String? /// Responsable actuel (N–1, User.ticketsAssigned). Demande remise « sans /// responsable » à la suppression du compte. assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) /// Échéance de la PREMIÈRE réponse : création + délai réglé dans SiteSettings. /// Fixée à la création, jamais repoussée (ni par un transfert, ni par une relance). dueAt DateTime /// Date de la première réponse du club (null : pas encore de réponse). firstResponseAt DateTime? /// Date de clôture (null : ouverte). closedAt DateTime? /// Création. createdAt DateTime @default(now()) /// Dernière modification (tenue à jour par Prisma). updatedAt DateTime @updatedAt /// Messages échangés (1–N, TicketMessage.ticket). messages TicketMessage[] /// Historique (1–N, TicketEvent.ticket). events TicketEvent[] @@index([status]) @@index([categoryId, status]) @@index([assigneeId])}/// Message d'une demande, entrant (demandeur) ou sortant (club).model TicketMessage { /// Identifiant CUID. id String @id @default(cuid()) /// Demande. ticketId String /// Demande (N–1, Ticket.messages). Messages effacés avec elle. ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) /// Sens du message. direction TicketMessageDirection /// Auteur, si c'est un membre (réponse du club ou demandeur connecté). authorId String? /// Auteur (N–1, User.ticketMessages). Message conservé, sans lien, à la /// suppression du compte. author User? @relation("TicketMessageAuthor", fields: [authorId], references: [id], onDelete: SetNull) /// Nom de l'auteur tel qu'affiché (conservé même si le compte disparaît). authorName String? /// Adresse d'origine d'un message reçu par e-mail. fromEmail String? /// Texte du message (brut, sans HTML). body String /// Identifiant « Message-ID » de l'e-mail d'origine : empêche d'importer deux /// fois le même message lors de la relève de la boîte. emailMessageId String? @unique /// Date du message. createdAt DateTime @default(now()) @@index([ticketId, createdAt])}/// Évènement de l'historique d'une demande (qui, quoi, quand).model TicketEvent { /// Identifiant CUID. id String @id @default(cuid()) /// Demande. ticketId String /// Demande (N–1, Ticket.events). Historique effacé avec elle. ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) /// Nature de l'évènement. type TicketEventType /// Membre à l'origine de l'évènement (null : le système, ou un demandeur extérieur). actorId String? /// Membre à l'origine (N–1, User.ticketEventsAsActor). Conservé sans lien à /// la suppression du compte. actor User? @relation("TicketEventActor", fields: [actorId], references: [id], onDelete: SetNull) /// Destinataire d'un transfert (TRANSFERRED uniquement). toUserId String? /// Destinataire d'un transfert (N–1, User.ticketEventsAsTarget). toUser User? @relation("TicketEventTarget", fields: [toUserId], references: [id], onDelete: SetNull) /// Note d'un transfert (obligatoire pour TRANSFERRED). note String? /// Date de l'évènement. createdAt DateTime @default(now()) @@index([ticketId, createdAt])}// ═══════════════════════════════════════════════════════════════════════// PARTENAIRES (MJC, FÉDÉRATION…)// ═══════════════════════════════════════════════════════════════════════/// Rôle d'un partenaire du club ; décide où et comment il est présenté.enum SitePartnerRole { /// Structure qui ACCUEILLE le club : cours et stages dans ses locaux, /// inscriptions et paiements à son accueil (la MJC de Chambéry). HOST /// Fédération à laquelle le club est affilié (licences). FEDERATION /// Tout autre partenaire. OTHER}/// Partenaire du club, présenté sur le site (pied de page, page d'inscription)/// avec son logo et un lien vers son site. Géré dans Réglages du site.model SitePartner { /// Identifiant (cuid). id String @id @default(cuid()) /// Nom complet (ex. « MJC de Chambéry ») : texte alternatif du logo et titre. name String /// Rôle (accueil, fédération, autre). role SitePartnerRole @default(OTHER) /// Phrase courte qui dit ce que le partenaire apporte au club (facultative). description String? /// Site du partenaire (facultatif : sans lui, le logo n'est pas un lien). websiteUrl String? /// Liens utiles supplémentaires, ex. la page d'inscription ou des tarifs : /// tableau JSON de { label, url }, affichés sur la page d'inscription. links Json @default("[]") /// Fichier du logo (SVG, PNG, WebP ou JPEG, ≤ 512 Ko), stocké en base : /// quelques logos légers, servis par /api/site-partners/{id}/logo. logoData Bytes? /// Type MIME de logoData. logoMime String? /// Date du dernier logo : version de l'URL (?v=…) pour contourner les caches. logoUpdatedAt DateTime? /// Rang d'affichage (croissant). position Int @default(0) /// Affiché sur le site. Faux : conservé mais masqué. isActive Boolean @default(true) /// Création. createdAt DateTime @default(now()) /// Dernière modification (tenue à jour par Prisma). updatedAt DateTime @updatedAt @@index([isActive, position])}/// Autorisation de droit à l'image : une VERSION par ligne, quelle que soit sa voie (`source`) —/// formulaire papier signé déposé par un admin, confirmation en ligne du membre majeur, ou du parent/// référent d'un mineur (le fichier est alors le récapitulatif PDF généré). Preuve du consentement/// (art. 7.1 RGPD) : on garde l'historique, la plus récente fait foi et fixe User.imageConsent. Le fichier vit dans R2 sous `_private/image-consent/`,/// hors de la bibliothèque ; il n'est servi qu'au membre concerné et aux admins/// (/api/media/image-consent/[id]). À la suppression du compte, le formulaire est détaché et gardé/// 5 ans (preuve), puis effacé par la purge nocturne.model ImageConsentForm { /// Identifiant CUID. id String @id @default(cuid()) /// Membre concerné. null une fois son compte supprimé (voir detachedAt). userId String? /// Membre (N–1, User.imageConsentForms). Détaché à la suppression du compte (SET NULL). user User? @relation("ImageConsentFormUser", fields: [userId], references: [id], onDelete: SetNull) /// Nom du membre au dépôt : identifie le formulaire une fois le compte supprimé. memberName String /// Suppression du compte : le formulaire est effacé 5 ans après. detachedAt DateTime? /// Clé R2 du fichier (PDF ou image). storageKey String @unique /// Type du fichier (application/pdf, image/jpeg, image/png, image/webp). mimeType String /// Nom du fichier envoyé, pour le téléchargement. fileName String /// Taille en octets. size Int /// Usages cochés « Oui ». Vide → refus de tout usage. usages ImageUsage[] /// Choix « après mon départ » (version du formulaire qui le propose) : true → le club peut /// continuer à utiliser les images déjà prises ; false → retrait sous un an ; null → non précisé. keepAfterLeave Boolean? /// Date de signature portée sur le formulaire. signedAt DateTime? /// Voie de l'autorisation (papier, en ligne par le membre, en ligne par le parent). source ImageConsentSource @default(PAPER) /// Version du texte d'autorisation accepté (en ligne). null : formulaire papier antérieur. textVersion String? /// Qui a signé : le membre, ou son représentant légal (nom saisi par le parent en ligne). signerName String? /// Qualité du signataire mineur (« mère », « père », « tuteur »…). null : le membre lui-même. signerQuality String? /// Adresse vérifiée par le lien (ONLINE_GUARDIAN) : celle à laquelle le lien a été envoyé. signerEmail String? /// Date de naissance du membre au moment de l'autorisation (justifie l'accord parental). memberBirthDate DateTime? /// Admin qui a déposé le formulaire papier. null en ligne, ou si son compte a été supprimé. uploadedById String? /// Admin (N–1, User.imageConsentFormsUploaded). Détaché à sa suppression (SET NULL). uploadedBy User? @relation("ImageConsentFormUploader", fields: [uploadedById], references: [id], onDelete: SetNull) /// Dépôt. createdAt DateTime @default(now()) @@index([userId, createdAt]) @@index([detachedAt])}/// Demande d'autorisation de droit à l'image EN ATTENTE pour un membre mineur : ses souhaits (sans/// effet) et le lien envoyé à son parent référent. Une seule par membre. Seule la confirmation du/// parent, sur la page du lien (bouton, jamais la simple ouverture), crée une version valide/// (ImageConsentForm, source ONLINE_GUARDIAN) ; la demande est alors supprimée.model ImageConsentRequest { /// Identifiant CUID. id String @id @default(cuid()) /// Membre mineur concerné (unique : une demande en attente au plus). userId String @unique /// Membre (1–1, User.imageConsentRequest). Supprimée avec son compte (CASCADE). user User @relation(fields: [userId], references: [id], onDelete: Cascade) /// Souhaits du membre, pré-cochés pour le parent. Sans effet tant que le parent n'a pas confirmé. usages ImageUsage[] /// Souhait « après mon départ » (voir ImageConsentForm.keepAfterLeave). keepAfterLeave Boolean? /// Empreinte SHA-256 du jeton du lien (le jeton lui-même n'est jamais stocké). null : pas encore /// envoyé, ou lien expiré remis à zéro. tokenHash String? @unique /// Adresse à laquelle le lien a été envoyé (celle du parent référent à l'envoi). sentTo String? /// Envoi du dernier lien. sentAt DateTime? /// Fin de validité du lien (7 jours après l'envoi). Usage unique. expiresAt DateTime? /// Création. createdAt DateTime @default(now()) /// Dernière modification (souhaits, renvoi). updatedAt DateTime @updatedAt}🔭 Pour aller plus loin — pourquoi aucun droit dans Session ?#
Une décision du projet mérite d’être regardée pour elle-même : la table n’embarque aucune information sur les droits. Elle ne fait que pointer vers le .
Ce n’a pas toujours été le cas. Au temps des rôles, la session portait un champ role, recopié depuis le membre à la connexion. Une migration l’a retiré ; la première version de ce tutoriel la montrait (l’historique des migrations a depuis été consolidé dans 0_init).
Pourquoi cette suppression ?
Parce que recopier un droit dans la session crée un problème de cohérence. Si un administrateur retire un membre du groupe Administrateurs, toutes les sessions ouvertes de ce membre continueraient à porter l’ancien droit jusqu’à leur expiration — sept jours. Le changement n’aurait aucun effet immédiat, et c’est précisément le genre de changement qu’on veut immédiat.
Sans copie, le droit est relu à chaque requête, à partir des relations du membre. C’est une jointure de plus, mais c’est cohérent : un changement de groupe est appliqué tout de suite, sur toutes les sessions ouvertes.
C’est exactement le principe vu dans la partie « Données » : l’autorité du système réside dans la base, pas dans des copies dispersées.
La chaîne Session → User → groupe Administrateurs#
pointe vers . Ce qui nous intéresse vraiment, c’est ce que permet de retrouver, en cascade :
Scroller horizontalement si nécessaire
À chaque requête authentifiée, le projet remonte cette chaîne, en une seule requête SQL, pour obtenir tout ce dont le backend a besoin : qui est le membre, et s’il est administrateur. Les droits plus fins — l’accès à l’espace d’un groupe — se calculent à la demande, quand une procédure en a besoin (chapitre 9).
C’est cette chaîne qui rend possible l’écriture, dans n’importe quelle procédure, de :
L’aboutissement de la chaîne, dans une procédure
ctx.sessionClient.user.isAdminMais avant d’arriver là, nous devons d’abord décider de ce que cette structure laissera sortir vers le navigateur.
👉 Où nous en sommes : la base est posée, normalisée, cohérente. La session pointe vers le membre, et le membre, par ses adhésions, dit s’il est administrateur. Reste à décider ce que cette structure laissera sortir vers le client — c’est le rôle du contrat que nous allons ouvrir.
Le contrat partagé : SessionClient#
Avant de manipuler la session, posons une frontière : que recevra exactement le navigateur ?
Le projet définit cette frontière dans packages/contracts, partagé entre frontend et backend.
export type SessionClient = { expiresAt: Date; user: { id: string; email: string; firstName: string | null; lastName: string | null; pseudo: string | null; avatar: string | null; isFirstLogin: boolean; isAdmin: boolean; } | null;};Lecture guidée du contrat#
Ce qui sort du backend, et ce qui n’en sort jamais
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
export type SessionClient = { expiresAt: Date; user: { id: string; email: string; firstName: string | null; lastName: string | null; pseudo: string | null; avatar: string | null; isFirstLogin: boolean; isAdmin: boolean; } | null;};SessionClient est un contrat, pas une projection.
Il décrit ce que le système promet d’exposer, et garantit qu’aucune information sensible ne fuit accidentellement.
Cette définition gouverne tout ce qui suit. Quand nous lirons getSessionFromRequest (chapitre 4), la construction du SessionClient sera vérifiée par TypeScript contre ce contrat.
Si demain quelqu’un ajoute un champ sensible à dans Prisma, ce champ ne sortira pas tant qu’il n’aura pas été explicitement ajouté au contrat et à la sélection qui lit la session. C’est une protection passive, qui ne demande aucune vigilance répétée, parce qu’elle est portée par les types.
👉 Où nous en sommes : la frontière de sortie est définie, le contrat est clair. Nous pouvons maintenant nous placer à l’autre extrémité du flux — l’entrée — et regarder ce qui arrive du navigateur quand le membre soumet le formulaire de connexion.
À suivre#
Le modèle est posé, la sortie est bornée. Il reste à suivre ce qui entre : une adresse et un mot de passe, tapés dans un formulaire.
Chapitre suivant : se connecter. La validation des identifiants, le service de connexion — le même message, et le même temps, pour deux erreurs —, et la création de la session avec son cookie.