Commit 3506520b authored by Kourser's avatar Kourser
Browse files

Animation : comcheck, RETEX structuré, réseaux joueurs, mur, « voir comme »

Cinq briques, issues du comparatif avec OpenAEV 3.26.

Comcheck — vérification des accès avant lancement. Adapté au bac à sable :
on ne teste pas des coordonnées réelles mais la validité des liens joueurs et
la présence de la cellule. Trois états actionnables (confirmé / lien jamais
ouvert / en attente), tableau temps réel côté animation.

RETEX structuré — questionnaires réutilisables au niveau de l'organisation
(modèles → rubriques → questions), instanciés par exercice, répondus par les
joueurs (0-5 + commentaire), agrégés dans le rapport. Deux modèles fournis.
Restitution anonyme par construction : l'identité ne sert qu'à empêcher les
doublons et ne ressort jamais de l'agrégation. Éditeur de modèles complet.

Réseaux sociaux — les joueurs publient et répondent. Fils de réponses sur un
seul niveau (parentId). Publier en son nom est libre ; le compte officiel de
l'organisation est réservé aux personnages désignés (Character.canPostAsOrg),
refus serveur sinon. L'animation peut répondre à toute publication, voit celles
de la cellule (badge) et peut les modérer.

Mur de crise — page pour un écran de salle : presse et réseaux en colonnes dont
la part d'écran s'adapte au volume de contenu, bandeau SIMULATION permanent,
horloge T+ vivante. Deux accès : second écran du poste d'animation, ou lien
d'affichage à jeton pour un PC de salle dédié (session en lecture seule, 24 h,
régénérable et révocable). Structuré pour accueillir le flux TV.

« Voir comme » — un animateur ouvre l'espace d'un joueur en lecture stricte.
Un point de contrôle unique dans PlayerGuard couvre les 25 routes joueur en
écriture. Ne laisse aucune trace dans l'exercice : pas de marquage en lu, pas
de confirmation à la place du joueur. Session de 30 min portée par un en-tête
et cantonnée à un onglet — un cookie aurait détourné la console de l'animateur.
OBSERVER exclu, chaque ouverture auditée.

Trois migrations à appliquer. Détail des invariants de sécurité dans
SECURITY.md, documentation utilisateur dans docs-site.

Ce lot n'a été ni compilé ni exécuté : l'environnement de développement utilisé
n'avait pas de toolchain Node. Les migrations SQL sont écrites à la main.
À passer par pnpm lint/typecheck/test/build et une migration sur base vide
avant toute revue de fond.

Co-Authored-By: Claude (RCA)
parent 02cf74aa
Loading
Loading
Loading
Loading
+35 −1
Original line number Diff line number Diff line
@@ -22,6 +22,33 @@ d'un autre tenant. Cet invariant est couvert par des tests unitaires.
  session en cookie **httpOnly** (JWT), `JWT_SECRET` obligatoire en production.
- Joueurs : **lien d'accès signé** (jeton à forte entropie, seule l'empreinte SHA-256 est stockée),
  sans compte ni mot de passe — friction minimale et donnée personnelle minimisée.
- Écrans de salle (mur de crise) : **lien d'affichage** au même format, mais ouvrant une session
  **strictement en lecture**. Elle n'expose qu'une route de contenu (`GET /api/display/wall`) et
  aucune écriture ; en temps réel elle rejoint la salle de l'exercice sans jamais recevoir de boîte
  privée. Un seul lien par exercice, régénérable (l'ancien devient aussitôt invalide) et révocable.
  Ce jeton vit sur un poste partagé : le traiter comme un lien joueur, et le révoquer après
  l'exercice si le poste n'est pas maîtrisé.

## Observation d'un espace joueur (« voir comme »)

Un animateur peut ouvrir l'espace d'un participant pour y voir ce que ce dernier voit.
Cet accès est encadré :

- **Lecture seule, imposée côté serveur.** Le jeton de session porte l'identifiant de
  l'animateur (`imp`) ; le garde joueur (`apps/api/src/engine/player.guard.ts`) refuse alors
  toute méthode autre que `GET`/`HEAD`. C'est un point de contrôle **unique** : il couvre les
  25 routes joueur en écriture existantes et toutes celles qui seront ajoutées ensuite.
- **Aucune trace laissée dans l'exercice** : les messages ne sont pas marqués comme lus, aucune
  confirmation n'est faite au nom du joueur — l'observation ne doit pas altérer ce que le
  débriefing mesure.
- **Moindre privilège** : réservé aux rôles `TENANT_ADMIN`, `DESIGNER`, `ANIMATOR`. `OBSERVER`
  en est exclu. La recherche du participant est cloisonnée au tenant de l'appelant.
- **Session courte** : 30 minutes, contre 8 h pour une session ordinaire.
- **Jamais en cookie** : le jeton transite par l'en-tête `x-play-session` et n'est conservé que
  dans l'onglet qui observe (`sessionStorage`). Un cookie aurait aussi détourné la console de
  l'animateur, la passerelle temps réel lisant le cookie joueur avant le cookie animateur.
- **Audité** : chaque ouverture est consignée (`participant.impersonate` — qui a observé qui,
  jamais le jeton).

## RGPD

@@ -31,7 +58,14 @@ d'un autre tenant. Cet invariant est couvert par des tests unitaires.
  (désactivée par défaut ; à configurer selon votre politique). Purge manuelle possible par un
  administrateur d'organisation.
- **Droit à l'effacement** : la suppression d'un exercice ou d'un tenant supprime en cascade
  toutes les données associées (messages, journaux, documents, participants…).
  toutes les données associées (messages, journaux, documents, participants…), y compris les
  vérifications d'accès et les réponses au questionnaire de RETEX.
- **Questionnaire de RETEX** : les réponses portent un jugement, ce qui les rend sensibles pour
  les répondants. L'identité (participant ou utilisateur) est conservée **uniquement** pour
  empêcher les doublons et permettre à chacun de corriger ses réponses ; la restitution
  (console et rapport) n'expose que des **moyennes et des commentaires sans auteur**. Ce choix
  est structurel, pas un réglage : voir `apps/api/src/engine/retex-aggregate.ts`, couvert par un
  test qui vérifie qu'aucun identifiant ne ressort de l'agrégation.
- **Journal d'audit** : les actions sensibles (connexions, création/suppression d'utilisateurs,
  suppression d'exercices, purges) sont tracées sur la sortie standard, **sans secret ni donnée
  personnelle superflue**.
+197 −0
Original line number Diff line number Diff line
-- Comcheck : verification des acces joueurs avant lancement.
-- RETEX structure : questionnaires reutilisables (modeles) + instanciation par
-- exercice + reponses (note 0-5 + commentaire).

-- CreateEnum
CREATE TYPE "ComcheckStatus" AS ENUM ('RUNNING', 'FINISHED', 'EXPIRED');

-- AlterTable
ALTER TABLE "Exercise" ADD COLUMN "retexOpen" BOOLEAN NOT NULL DEFAULT false;

-- CreateTable
CREATE TABLE "Comcheck" (
    "id" TEXT NOT NULL,
    "tenantId" TEXT NOT NULL,
    "exerciseId" TEXT NOT NULL,
    "label" TEXT NOT NULL,
    "message" TEXT NOT NULL,
    "status" "ComcheckStatus" NOT NULL DEFAULT 'RUNNING',
    "expiresAt" TIMESTAMP(3),
    "createdByUserId" TEXT,
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" TIMESTAMP(3) NOT NULL,

    CONSTRAINT "Comcheck_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "ComcheckEntry" (
    "id" TEXT NOT NULL,
    "tenantId" TEXT NOT NULL,
    "comcheckId" TEXT NOT NULL,
    "participantId" TEXT NOT NULL,
    "confirmedAt" TIMESTAMP(3),
    "note" TEXT,

    CONSTRAINT "ComcheckEntry_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "RetexTemplate" (
    "id" TEXT NOT NULL,
    "tenantId" TEXT NOT NULL,
    "builtinId" TEXT,
    "name" TEXT NOT NULL,
    "description" TEXT,
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" TIMESTAMP(3) NOT NULL,

    CONSTRAINT "RetexTemplate_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "RetexTemplateCategory" (
    "id" TEXT NOT NULL,
    "templateId" TEXT NOT NULL,
    "order" INTEGER NOT NULL DEFAULT 0,
    "name" TEXT NOT NULL,

    CONSTRAINT "RetexTemplateCategory_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "RetexTemplateQuestion" (
    "id" TEXT NOT NULL,
    "categoryId" TEXT NOT NULL,
    "order" INTEGER NOT NULL DEFAULT 0,
    "text" TEXT NOT NULL,

    CONSTRAINT "RetexTemplateQuestion_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "RetexCategory" (
    "id" TEXT NOT NULL,
    "tenantId" TEXT NOT NULL,
    "exerciseId" TEXT NOT NULL,
    "order" INTEGER NOT NULL DEFAULT 0,
    "name" TEXT NOT NULL,

    CONSTRAINT "RetexCategory_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "RetexQuestion" (
    "id" TEXT NOT NULL,
    "tenantId" TEXT NOT NULL,
    "categoryId" TEXT NOT NULL,
    "order" INTEGER NOT NULL DEFAULT 0,
    "text" TEXT NOT NULL,

    CONSTRAINT "RetexQuestion_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "RetexAnswer" (
    "id" TEXT NOT NULL,
    "tenantId" TEXT NOT NULL,
    "questionId" TEXT NOT NULL,
    "participantId" TEXT,
    "userId" TEXT,
    "score" INTEGER NOT NULL,
    "comment" TEXT,
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" TIMESTAMP(3) NOT NULL,

    CONSTRAINT "RetexAnswer_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "Comcheck_tenantId_idx" ON "Comcheck"("tenantId");

-- CreateIndex
CREATE INDEX "Comcheck_exerciseId_status_idx" ON "Comcheck"("exerciseId", "status");

-- CreateIndex
CREATE UNIQUE INDEX "ComcheckEntry_comcheckId_participantId_key" ON "ComcheckEntry"("comcheckId", "participantId");

-- CreateIndex
CREATE INDEX "ComcheckEntry_tenantId_idx" ON "ComcheckEntry"("tenantId");

-- CreateIndex
CREATE INDEX "ComcheckEntry_participantId_idx" ON "ComcheckEntry"("participantId");

-- CreateIndex
CREATE INDEX "RetexTemplate_tenantId_idx" ON "RetexTemplate"("tenantId");

-- CreateIndex
CREATE INDEX "RetexTemplateCategory_templateId_idx" ON "RetexTemplateCategory"("templateId");

-- CreateIndex
CREATE INDEX "RetexTemplateQuestion_categoryId_idx" ON "RetexTemplateQuestion"("categoryId");

-- CreateIndex
CREATE INDEX "RetexCategory_tenantId_idx" ON "RetexCategory"("tenantId");

-- CreateIndex
CREATE INDEX "RetexCategory_exerciseId_idx" ON "RetexCategory"("exerciseId");

-- CreateIndex
CREATE INDEX "RetexQuestion_tenantId_idx" ON "RetexQuestion"("tenantId");

-- CreateIndex
CREATE INDEX "RetexQuestion_categoryId_idx" ON "RetexQuestion"("categoryId");

-- CreateIndex
CREATE UNIQUE INDEX "RetexAnswer_questionId_participantId_key" ON "RetexAnswer"("questionId", "participantId");

-- CreateIndex
CREATE UNIQUE INDEX "RetexAnswer_questionId_userId_key" ON "RetexAnswer"("questionId", "userId");

-- CreateIndex
CREATE INDEX "RetexAnswer_tenantId_idx" ON "RetexAnswer"("tenantId");

-- CreateIndex
CREATE INDEX "RetexAnswer_questionId_idx" ON "RetexAnswer"("questionId");

-- AddForeignKey
ALTER TABLE "Comcheck" ADD CONSTRAINT "Comcheck_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Comcheck" ADD CONSTRAINT "Comcheck_exerciseId_fkey" FOREIGN KEY ("exerciseId") REFERENCES "Exercise"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ComcheckEntry" ADD CONSTRAINT "ComcheckEntry_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ComcheckEntry" ADD CONSTRAINT "ComcheckEntry_comcheckId_fkey" FOREIGN KEY ("comcheckId") REFERENCES "Comcheck"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ComcheckEntry" ADD CONSTRAINT "ComcheckEntry_participantId_fkey" FOREIGN KEY ("participantId") REFERENCES "Participant"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "RetexTemplate" ADD CONSTRAINT "RetexTemplate_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "RetexTemplateCategory" ADD CONSTRAINT "RetexTemplateCategory_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "RetexTemplate"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "RetexTemplateQuestion" ADD CONSTRAINT "RetexTemplateQuestion_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "RetexTemplateCategory"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "RetexCategory" ADD CONSTRAINT "RetexCategory_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "RetexCategory" ADD CONSTRAINT "RetexCategory_exerciseId_fkey" FOREIGN KEY ("exerciseId") REFERENCES "Exercise"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "RetexQuestion" ADD CONSTRAINT "RetexQuestion_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "RetexQuestion" ADD CONSTRAINT "RetexQuestion_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "RetexCategory"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "RetexAnswer" ADD CONSTRAINT "RetexAnswer_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "RetexAnswer" ADD CONSTRAINT "RetexAnswer_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "RetexQuestion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+20 −0
Original line number Diff line number Diff line
-- Publication par les joueurs sur les reseaux sociaux simules :
-- fils de reponses (parentId, un seul niveau), auteur joueur, compte officiel de
-- l'organisation porte par l'exercice, et autorisation par personnage.

-- AlterTable
ALTER TABLE "Character" ADD COLUMN "canPostAsOrg" BOOLEAN NOT NULL DEFAULT false;

-- AlterTable
ALTER TABLE "Exercise" ADD COLUMN "socialAccountName" TEXT;
ALTER TABLE "Exercise" ADD COLUMN "socialAccountHandle" TEXT;

-- AlterTable
ALTER TABLE "SocialPost" ADD COLUMN "parentId" TEXT;
ALTER TABLE "SocialPost" ADD COLUMN "authorParticipantId" TEXT;

-- CreateIndex
CREATE INDEX "SocialPost_parentId_idx" ON "SocialPost"("parentId");

-- AddForeignKey
ALTER TABLE "SocialPost" ADD CONSTRAINT "SocialPost_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "SocialPost"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+32 −0
Original line number Diff line number Diff line
-- Lien d'affichage du mur de crise : jeton en lecture seule pour un ecran de
-- salle dedie, sans compte ni session animateur. Un seul lien par exercice.

-- CreateTable
CREATE TABLE "WallDisplay" (
    "id" TEXT NOT NULL,
    "tenantId" TEXT NOT NULL,
    "exerciseId" TEXT NOT NULL,
    "accessTokenHash" TEXT NOT NULL,
    "tokenExpiresAt" TIMESTAMP(3),
    "lastSeenAt" TIMESTAMP(3),
    "createdByUserId" TEXT,
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" TIMESTAMP(3) NOT NULL,

    CONSTRAINT "WallDisplay_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "WallDisplay_exerciseId_key" ON "WallDisplay"("exerciseId");

-- CreateIndex
CREATE UNIQUE INDEX "WallDisplay_accessTokenHash_key" ON "WallDisplay"("accessTokenHash");

-- CreateIndex
CREATE INDEX "WallDisplay_tenantId_idx" ON "WallDisplay"("tenantId");

-- AddForeignKey
ALTER TABLE "WallDisplay" ADD CONSTRAINT "WallDisplay_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "WallDisplay" ADD CONSTRAINT "WallDisplay_exerciseId_fkey" FOREIGN KEY ("exerciseId") REFERENCES "Exercise"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+195 −1
Original line number Diff line number Diff line
@@ -35,6 +35,13 @@ model Tenant {
  observerNotes     ObserverNote[]
  events            EventLog[]
  kits              Kit[]
  comchecks         Comcheck[]
  comcheckEntries   ComcheckEntry[]
  wallDisplays      WallDisplay[]
  retexTemplates    RetexTemplate[]
  retexCategories   RetexCategory[]
  retexQuestions    RetexQuestion[]
  retexAnswers      RetexAnswer[]
  createdAt         DateTime           @default(now())
  updatedAt         DateTime           @updatedAt
}
@@ -101,6 +108,14 @@ model Exercise {
  clockAnchorWall    DateTime?
  clockAnchorSeconds Int       @default(0)

  /// Questionnaire de RETEX ouvert aux reponses des joueurs.
  retexOpen Boolean @default(false)

  /// Compte officiel de l'organisation sur les reseaux sociaux simules.
  /// Null = derive du contexte de personnalisation, puis du nom de l'exercice.
  socialAccountName   String?
  socialAccountHandle String?

  characters   Character[]
  participants Participant[]
  injects      Inject[]
@@ -113,6 +128,9 @@ model Exercise {
  documents     Document[]
  observerNotes ObserverNote[]
  events        EventLog[]
  comchecks       Comcheck[]
  retexCategories RetexCategory[]
  wallDisplay     WallDisplay?

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
@@ -139,6 +157,9 @@ model Character {
  kind        CharacterKind @default(PLAYER)
  simEmail    String?
  avatarColor String?
  /// Autorise a publier sous le compte officiel de l'organisation sur les reseaux
  /// sociaux simules. Publier en son nom propre ne demande aucune autorisation.
  canPostAsOrg Boolean      @default(false)
  participant Participant?
  messageRecipients MessageRecipient[]

@@ -164,6 +185,7 @@ model Participant {
  lastSeenAt      DateTime?
  receivedMessages MessageRecipient[]
  channelMemberships ChannelMember[]
  comcheckEntries    ComcheckEntry[]

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
@@ -384,7 +406,7 @@ enum SocialNetwork {
  INSTAGRAM
}

/// Une publication sur un reseau social simule (publiee par l'animation).
/// Une publication sur un reseau social simule (animation ou joueur).
model SocialPost {
  id            String        @id @default(cuid())
  tenantId      String
@@ -395,14 +417,23 @@ model SocialPost {
  authorHandle  String
  authorName    String
  body          String
  /// Engagement factice, pilote par l'animation pour materialiser la viralite.
  likes         Int           @default(0)
  shares        Int           @default(0)
  comments      Int           @default(0)
  /// Fil de discussion, sur un seul niveau : null = publication racine.
  parentId      String?
  parent        SocialPost?   @relation("SocialThread", fields: [parentId], references: [id], onDelete: Cascade)
  replies       SocialPost[]  @relation("SocialThread")
  /// Joueur auteur de la publication ; null = publiee par la cellule d'animation.
  /// Renseigne meme sous le compte officiel, pour savoir qui a engage l'organisation.
  authorParticipantId String?
  atExerciseSec Int
  createdAt     DateTime      @default(now())

  @@index([tenantId])
  @@index([exerciseId, createdAt])
  @@index([parentId])
}

/// Media simule : pilote l'habillage (masthead, couleurs, typo) de l'article cote joueur.
@@ -588,3 +619,166 @@ model KitInject {

  @@index([kitId])
}

/// Lien d'affichage du mur de crise : jeton a forte entropie permettant a un
/// ecran de salle dedie d'afficher le mur SANS compte ni session animateur.
///
/// Strictement en LECTURE : la session ouverte par ce jeton ne donne acces qu'au
/// contenu du mur (presse, reseaux sociaux, horloge) et a aucune ecriture.
/// Un seul lien par exercice ; il vaut pour autant d'ecrans que necessaire.
model WallDisplay {
  id              String    @id @default(cuid())
  tenantId        String
  tenant          Tenant    @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  exerciseId      String    @unique
  exercise        Exercise  @relation(fields: [exerciseId], references: [id], onDelete: Cascade)
  /// Seule l'empreinte SHA-256 est stockee (le jeton en clair n'existe qu'une fois).
  accessTokenHash String    @unique
  tokenExpiresAt  DateTime?
  lastSeenAt      DateTime?
  createdByUserId String?

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@index([tenantId])
}

/// Etat d'une campagne de verification des acces joueurs.
enum ComcheckStatus {
  RUNNING
  FINISHED
  EXPIRED
}

/// Campagne de verification avant lancement : chaque joueur confirme depuis son
/// espace que son lien fonctionne et qu'il est pret. Confine : rien n'est envoye
/// a l'exterieur, la sollicitation arrive dans l'environnement simule.
model Comcheck {
  id              String          @id @default(cuid())
  tenantId        String
  tenant          Tenant          @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  exerciseId      String
  exercise        Exercise        @relation(fields: [exerciseId], references: [id], onDelete: Cascade)
  label           String
  message         String
  status          ComcheckStatus  @default(RUNNING)
  expiresAt       DateTime?
  createdByUserId String?
  entries         ComcheckEntry[]

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@index([tenantId])
  @@index([exerciseId, status])
}

/// Reponse attendue d'un participant a une campagne de verification.
model ComcheckEntry {
  id            String      @id @default(cuid())
  tenantId      String
  tenant        Tenant      @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  comcheckId    String
  comcheck      Comcheck    @relation(fields: [comcheckId], references: [id], onDelete: Cascade)
  participantId String
  participant   Participant @relation(fields: [participantId], references: [id], onDelete: Cascade)
  confirmedAt   DateTime?
  note          String?

  @@unique([comcheckId, participantId])
  @@index([tenantId])
  @@index([participantId])
}

/// Modele de questionnaire de RETEX reutilisable au niveau de l'organisation
/// (categories -> questions). Applique a un exercice au moment du debriefing.
model RetexTemplate {
  id          String                  @id @default(cuid())
  tenantId    String
  tenant      Tenant                  @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  /// Id du modele fourni d'origine (pour reimport) ; null si cree de zero.
  builtinId   String?
  name        String
  description String?
  categories  RetexTemplateCategory[]

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@index([tenantId])
}

model RetexTemplateCategory {
  id         String                  @id @default(cuid())
  templateId String
  template   RetexTemplate           @relation(fields: [templateId], references: [id], onDelete: Cascade)
  order      Int                     @default(0)
  name       String
  questions  RetexTemplateQuestion[]

  @@index([templateId])
}

model RetexTemplateQuestion {
  id         String                @id @default(cuid())
  categoryId String
  category   RetexTemplateCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
  order      Int                   @default(0)
  text       String

  @@index([categoryId])
}

/// Une rubrique du questionnaire de RETEX instancie sur un exercice.
model RetexCategory {
  id         String          @id @default(cuid())
  tenantId   String
  tenant     Tenant          @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  exerciseId String
  exercise   Exercise        @relation(fields: [exerciseId], references: [id], onDelete: Cascade)
  order      Int             @default(0)
  name       String
  questions  RetexQuestion[]

  @@index([tenantId])
  @@index([exerciseId])
}

model RetexQuestion {
  id         String        @id @default(cuid())
  tenantId   String
  tenant     Tenant        @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  categoryId String
  category   RetexCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
  order      Int           @default(0)
  text       String
  answers    RetexAnswer[]

  @@index([tenantId])
  @@index([categoryId])
}

/// Reponse a une question de RETEX. L'identite (participant ou membre de
/// l'animation) sert uniquement a empecher les doublons : la restitution est
/// volontairement anonyme, pour la franchise des reponses et la minimisation RGPD.
model RetexAnswer {
  id            String        @id @default(cuid())
  tenantId      String
  tenant        Tenant        @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  questionId    String
  question      RetexQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
  participantId String?
  userId        String?
  /// Note de 0 (tres insuffisant) a 5 (tres satisfaisant).
  score         Int
  comment       String?

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@unique([questionId, participantId])
  @@unique([questionId, userId])
  @@index([tenantId])
  @@index([questionId])
}
Loading