Commit fb9e10c6 authored by Kourser's avatar Kourser
Browse files

Documents : espace d'animation séparé, et suppression

Deux espaces documentaires par exercice, distingués par une colonne `audience` sur
Document plutôt que par deux modèles : « Joueurs » est ce que la cellule consulte,
« Animation » est le classeur privé de l'équipe (trame, notes, contacts).

PLAYERS est la valeur par défaut, donc tout document existant — kits compris — reste
visible des joueurs. Les kits ne renseignent pas l'audience et n'avaient pas à être
touchés.

Le cloisonnement ne repose PAS sur l'interface : les trois routes joueur (liste,
document, fichier) imposent PLAYERS côté serveur. Connaître l'identifiant d'un
document d'animation ne suffit donc pas à le lire. Et un document d'animation ne
déclenche aucune notification temps réel vers les joueurs.

Suppression : la route n'existait pas du tout, ce n'était pas un défaut d'interface.
Ajoutée côté animation avec confirmation, diffusion `document:deleted` pour que les
espaces ouverts se rafraîchissent, et retrait de la sélection courante si c'était le
document affiché.

Limite connue, antérieure à ce lot : supprimer un document ne supprime pas le
fichier téléversé sur le disque. La suppression d'un exercice ou d'un tenant a le
même comportement. Un balayage des fichiers orphelins mériterait d'être traité pour
de bon, y compris au regard du droit à l'effacement annoncé dans SECURITY.md.

Une migration à appliquer (enum + colonne + index).

Non compilé ni exécuté (pas de toolchain Node dans l'environnement utilisé).

Co-Authored-By: Claude (RCA)
parent d39c2672
Loading
Loading
Loading
Loading
+11 −0
Original line number Diff line number Diff line
-- Deux espaces documentaires : celui des joueurs et celui de l'animation.
-- PLAYERS par defaut, pour que tout document existant reste visible des joueurs.

-- CreateEnum
CREATE TYPE "DocumentAudience" AS ENUM ('PLAYERS', 'ANIMATION');

-- AlterTable
ALTER TABLE "Document" ADD COLUMN "audience" "DocumentAudience" NOT NULL DEFAULT 'PLAYERS';

-- CreateIndex
CREATE INDEX "Document_exerciseId_audience_idx" ON "Document"("exerciseId", "audience");
+9 −0
Original line number Diff line number Diff line
@@ -473,6 +473,12 @@ model NewsArticle {
  @@index([exerciseId, createdAt])
}

/// Qui voit un document : les joueurs, ou la seule cellule d'animation.
enum DocumentAudience {
  PLAYERS
  ANIMATION
}

/// Un document de l'espace documentaire simule (consultable, injectable).
model Document {
  id            String   @id @default(cuid())
@@ -481,6 +487,8 @@ model Document {
  exerciseId    String
  exercise      Exercise @relation(fields: [exerciseId], references: [id], onDelete: Cascade)
  folder        String   @default("Documents")
  /// PLAYERS par defaut : tout ce qui existait avant reste visible des joueurs.
  audience      DocumentAudience @default(PLAYERS)
  name          String
  description   String?
  body          String   @default("")
@@ -493,6 +501,7 @@ model Document {

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

/// Note d'evaluation d'un observateur (privee a l'animation, alimente le RETEX).
+36 −6
Original line number Diff line number Diff line
@@ -3,9 +3,13 @@ import {
  BadRequestException,
  Body,
  Controller,
  Delete,
  Get,
  HttpCode,
  HttpStatus,
  Param,
  Post,
  Query,
  Res,
  UploadedFile,
  UseGuards,
@@ -38,12 +42,13 @@ export class DocumentsController {

  @Get()
  list(@CurrentPlayer() player: PlayerContext) {
    return this.documents.list(player.tenantId, player.exerciseId);
    // PLAYERS impose : l'espace d'animation n'existe pas pour un joueur.
    return this.documents.list(player.tenantId, player.exerciseId, 'PLAYERS');
  }

  @Get(':id')
  get(@CurrentPlayer() player: PlayerContext, @Param('id') id: string) {
    return this.documents.get(player.tenantId, player.exerciseId, id);
    return this.documents.get(player.tenantId, player.exerciseId, id, 'PLAYERS');
  }

  @Get(':id/file')
@@ -52,7 +57,7 @@ export class DocumentsController {
    @Param('id') id: string,
    @Res() res: Response,
  ): Promise<void> {
    streamFile(res, await this.documents.getFile(player.tenantId, player.exerciseId, id));
    streamFile(res, await this.documents.getFile(player.tenantId, player.exerciseId, id, 'PLAYERS'));
  }
}

@@ -62,9 +67,26 @@ export class DocumentsController {
export class StaffDocumentsController {
  constructor(private readonly documents: DocumentsService) {}

  /** `audience` absent = les deux espaces confondus. */
  @Get()
  list(@CurrentUser() me: AuthUser, @Param('exerciseId') exerciseId: string) {
    return this.documents.list(me.tenantId, exerciseId);
  list(
    @CurrentUser() me: AuthUser,
    @Param('exerciseId') exerciseId: string,
    @Query('audience') audience?: string,
  ) {
    const scope = audience === 'PLAYERS' || audience === 'ANIMATION' ? audience : undefined;
    return this.documents.list(me.tenantId, exerciseId, scope);
  }

  @Delete(':id')
  @Roles('TENANT_ADMIN', 'DESIGNER', 'ANIMATOR')
  @HttpCode(HttpStatus.NO_CONTENT)
  remove(
    @CurrentUser() me: AuthUser,
    @Param('exerciseId') exerciseId: string,
    @Param('id') id: string,
  ) {
    return this.documents.remove(me.tenantId, exerciseId, id);
  }

  @Get(':id/file')
@@ -96,8 +118,16 @@ export class StaffDocumentsController {
    @UploadedFile() file: UploadedFileData | undefined,
    @Body('folder') folder?: string,
    @Body('name') name?: string,
    @Body('audience') audience?: string,
  ) {
    if (!file) throw new BadRequestException('Aucun fichier');
    return this.documents.upload(me.tenantId, exerciseId, file, folder, name);
    return this.documents.upload(
      me.tenantId,
      exerciseId,
      file,
      folder,
      name,
      audience === 'ANIMATION' ? 'ANIMATION' : 'PLAYERS',
    );
  }
}
+47 −8
Original line number Diff line number Diff line
import { existsSync } from 'node:fs';
import { join, normalize } from 'node:path';
import { Injectable, NotFoundException } from '@nestjs/common';
import type { DocumentAudience } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { RealtimeGateway } from './realtime.gateway';
import { computeElapsedSeconds } from './clock';
@@ -24,24 +25,37 @@ export class DocumentsService {
    private readonly rt: RealtimeGateway,
  ) {}

  list(tenantId: string, exerciseId: string) {
  /**
   * Documents d'un exercice. `audience` restreint le champ : les routes joueur
   * imposent PLAYERS, la console peut demander l'un ou l'autre espace.
   */
  list(tenantId: string, exerciseId: string, audience?: DocumentAudience) {
    return this.prisma.forTenant(tenantId).document.findMany({
      where: { exerciseId },
      where: { exerciseId, ...(audience ? { audience } : {}) },
      orderBy: [{ folder: 'asc' }, { createdAt: 'asc' }],
    });
  }

  async get(tenantId: string, exerciseId: string, id: string) {
  /**
   * Un document donne. `audience` est le garde-fou des routes joueur : sans lui,
   * connaitre un identifiant suffirait a lire un document reserve a l'animation.
   */
  async get(tenantId: string, exerciseId: string, id: string, audience?: DocumentAudience) {
    const doc = await this.prisma.forTenant(tenantId).document.findFirst({
      where: { id, exerciseId },
      where: { id, exerciseId, ...(audience ? { audience } : {}) },
    });
    if (!doc) throw new NotFoundException('Document introuvable');
    return doc;
  }

  /** Resout le fichier binaire d'un document (asset de kit OU fichier televerse), anti-traversal. */
  async getFile(tenantId: string, exerciseId: string, id: string): Promise<DocFile> {
    const doc = await this.get(tenantId, exerciseId, id);
  async getFile(
    tenantId: string,
    exerciseId: string,
    id: string,
    audience?: DocumentAudience,
  ): Promise<DocFile> {
    const doc = await this.get(tenantId, exerciseId, id, audience);
    if (!doc.assetPath) throw new NotFoundException("Ce document n'a pas de fichier");
    const isUpload = doc.assetPath.startsWith(UPLOAD_PREFIX);
    const base = isUpload ? UPLOADS_DIR : KIT_FILES_DIR;
@@ -64,6 +78,7 @@ export class DocumentsService {
    file: UploadedFile,
    folder?: string,
    name?: string,
    audience: DocumentAudience = 'PLAYERS',
  ) {
    const ex = await this.prisma.forTenant(tenantId).exercise.findFirst({ where: { id: exerciseId } });
    if (!ex) throw new NotFoundException('Exercice introuvable');
@@ -73,6 +88,7 @@ export class DocumentsService {
        tenantId,
        exerciseId,
        folder: folder || 'Documents',
        audience,
        name: name || file.originalname,
        fileName: file.originalname,
        mimeType: file.mimetype,
@@ -80,7 +96,13 @@ export class DocumentsService {
        atExerciseSec: computeElapsedSeconds(ex, Date.now()),
      },
    });
    this.rt.emitToExercise(exerciseId, RT.documentNew, { id: doc.id, name: doc.name, folder: doc.folder });
    if (doc.audience === 'PLAYERS') {
      this.rt.emitToExercise(exerciseId, RT.documentNew, {
        id: doc.id,
        name: doc.name,
        folder: doc.folder,
      });
    }
    return doc;
  }

@@ -92,13 +114,30 @@ export class DocumentsService {
        tenantId,
        exerciseId,
        folder: dto.folder ?? 'Documents',
        audience: dto.audience ?? 'PLAYERS',
        name: dto.name,
        description: dto.description ?? null,
        body: dto.body,
        atExerciseSec: computeElapsedSeconds(ex, Date.now()),
      },
    });
    this.rt.emitToExercise(exerciseId, RT.documentNew, { id: doc.id, name: doc.name, folder: doc.folder });
    // Les joueurs ne sont prevenus que de ce qui les concerne.
    if (doc.audience === 'PLAYERS') {
      this.rt.emitToExercise(exerciseId, RT.documentNew, {
        id: doc.id,
        name: doc.name,
        folder: doc.folder,
      });
    }
    return doc;
  }

  /** Supprime un document (et l'oublie : le fichier televerse reste sur le disque). */
  async remove(tenantId: string, exerciseId: string, id: string): Promise<void> {
    const res = await this.prisma
      .forTenant(tenantId)
      .document.deleteMany({ where: { id, exerciseId } });
    if (res.count === 0) throw new NotFoundException('Document introuvable');
    this.rt.emitToExercise(exerciseId, RT.documentDeleted, { id });
  }
}
+6 −1
Original line number Diff line number Diff line
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';

export class CreateDocumentDto {
  @IsOptional()
@@ -19,4 +19,9 @@ export class CreateDocumentDto {
  @IsString()
  @MinLength(1)
  body!: string;

  /** PLAYERS par defaut : un document se destine aux joueurs sauf mention contraire. */
  @IsOptional()
  @IsIn(['PLAYERS', 'ANIMATION'])
  audience?: 'PLAYERS' | 'ANIMATION';
}
Loading