Commit d7aa0886 authored by Kourser's avatar Kourser
Browse files

Animation : UX du chronogramme, thème du champ X, logos et avatars

Console d'animation — refonte pour tenir à l'échelle. Le chronogramme n'avait ni
borne ni filtre : cinquante injects faisaient une page de plusieurs écrans, les
envoyés s'y accumulaient, et rien ne signalait un inject planifié dont le T+ était
dépassé. Il est désormais borné et défilant, groupé par urgence (en retard /
imminent / à venir / manuel / traités, ces derniers repliés), avec recherche et
filtre par canal. Un bandeau « prochain » et un compteur de retard répondent à la
seule question utile en séance : qu'est-ce qui part maintenant, et qu'ai-je raté.
Le formulaire de création passe en tête, dépliable, au lieu d'être enterré sous la
liste. Les trois flux de la colonne droite deviennent des onglets : le journal, le
plus verbeux, chassait les appels à passer hors de l'écran.

Correction — le champ de publication du skin X ignorait le mode clair/sombre : ses
couleurs étaient codées en dur alors que le skin expose une palette thème-consciente.
Le même défaut existait sur LinkedIn et Instagram, moins voyant : leur composer
héritait de la palette de l'application au lieu de celle du skin. Les trois sont
corrigés, ainsi que les surfaces adjacentes (sélecteur de compte, fil de réponses).

Avatars des comptes sur les réseaux simulés. Logo à deux niveaux : celui de
l'organisation (section Organisation, réservée à l'administrateur) vaut par défaut
pour tous ses exercices, celui d'un exercice le remplace. Une seule route de lecture
applique la précédence. Les autres comptes reçoivent une image d'une banque de 13
illustrations embarquées — jamais de photos de personnes, associer un visage réel à
des propos fabriqués poserait un problème de droit à l'image. L'attribution est
DÉTERMINISTE (empreinte du pseudo) et non tirée au sort à chaque rendu : un compte
qui change de visage au rechargement détruirait l'immersion.

Le stockage de fichiers est extrait en module partagé plutôt que dupliqué depuis la
GED, qui l'utilise désormais aussi.

Une migration à appliquer (deux colonnes nullables). Compromis de sécurité assumé et
documenté dans SECURITY.md : la route de lecture du logo est sans garde, cette image
étant référencée depuis quatre types de session.

Ce lot n'a été ni compilé ni exécuté (pas de toolchain Node dans l'environnement de
développement utilisé). La refonte visuelle et le rendu des avatars sont précisément
ce qu'une relecture ne valide pas : à regarder dans les deux thèmes et les trois
skins avant revue de fond.

Co-Authored-By: Claude (RCA)
parent 3506520b
Loading
Loading
Loading
Loading
+15 −0
Original line number Diff line number Diff line
@@ -29,6 +29,21 @@ d'un autre tenant. Cet invariant est couvert par des tests unitaires.
  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é.

## Avatars et logos

- Les avatars des comptes fictifs viennent d'une **banque embarquée** (`apps/web/public/avatars/`) :
  aucune image n'est chargée depuis un service tiers, le confinement reste entier. Ce sont des
  illustrations, jamais des photos de personnes — associer un visage réel à des propos fabriqués
  poserait un problème de droit à l'image.
- L'attribution est **déterministe** (empreinte du pseudo) : un compte garde son avatar d'un
  rechargement à l'autre.
- Les logos téléversés sont filtrés par **liste blanche de types** (PNG, JPEG, WebP, SVG) et
  plafonnés à 2 Mo. Le nom de fichier d'origine n'est jamais réutilisé sur le disque.
- `GET /api/exercises/:id/logo` est **volontairement sans garde** : cette image est référencée
  depuis quatre types de session (animateur, joueur, « voir comme », écran d'affichage). Le
  compromis est assumé pour un logo d'entreprise derrière un identifiant non devinable ; cette
  route ne doit pas être élargie à du contenu d'exercice.

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

Un animateur peut ouvrir l'espace d'un participant pour y voir ce que ce dernier voit.
+10 −0
Original line number Diff line number Diff line
-- Avatars des comptes sur les reseaux simules :
-- logo de l'organisation (defaut, niveau Tenant) et logo propre a un exercice
-- (prioritaire). Les avatars des comptes fictifs viennent d'une banque embarquee
-- cote interface, attribuee de facon deterministe : rien a stocker en base.

-- AlterTable
ALTER TABLE "Tenant" ADD COLUMN "logoPath" TEXT;

-- AlterTable
ALTER TABLE "Exercise" ADD COLUMN "socialAccountLogoPath" TEXT;
+6 −0
Original line number Diff line number Diff line
@@ -18,6 +18,9 @@ model Tenant {
  id           String           @id @default(cuid())
  name         String
  slug         String           @unique
  /// Logo de l'organisation (cle de fichier televerse). Avatar par defaut du
  /// compte officiel sur les reseaux simules ; un exercice peut le remplacer.
  logoPath     String?
  users        User[]
  exercises    Exercise[]
  characters   Character[]
@@ -115,6 +118,9 @@ model Exercise {
  /// Null = derive du contexte de personnalisation, puis du nom de l'exercice.
  socialAccountName   String?
  socialAccountHandle String?
  /// Logo propre a cet exercice (cle de fichier televerse). Prioritaire sur
  /// celui de l'organisation : un exercice peut incarner une entite fictive.
  socialAccountLogoPath String?

  characters   Character[]
  participants Participant[]
+43 −0
Original line number Diff line number Diff line
import { existsSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { randomUUID } from 'node:crypto';

/**
 * Stockage des fichiers televerses par les utilisateurs (persistant, cf. volume Docker).
 * Source unique : documents de la GED et logos d'organisation partagent ce repertoire.
 */
export const UPLOADS_DIR = process.env.UPLOADS_DIR ?? join(process.cwd(), 'data', 'uploads');

/** Prefixe distinguant un fichier televerse d'un asset embarque dans un kit. */
export const UPLOAD_PREFIX = 'uploads/';

export interface UploadedFile {
  originalname: string;
  mimetype: string;
  buffer: Buffer;
  size: number;
}

/**
 * Ecrit un fichier televerse sous un nom aleatoire et renvoie sa cle prefixee.
 * Le nom d'origine ne sert JAMAIS de nom de fichier : il vient du client.
 */
export async function saveUpload(file: UploadedFile): Promise<string> {
  await mkdir(UPLOADS_DIR, { recursive: true });
  const ext = extname(file.originalname).slice(0, 12).replace(/[^.a-zA-Z0-9]/g, '');
  const key = randomUUID() + ext;
  await writeFile(join(UPLOADS_DIR, key), file.buffer);
  return UPLOAD_PREFIX + key;
}

/**
 * Resout le chemin absolu d'un fichier televerse, avec garde anti-traversal.
 * Renvoie null si la cle sort du repertoire ou si le fichier a disparu.
 */
export function resolveUpload(assetPath: string): string | null {
  if (!assetPath.startsWith(UPLOAD_PREFIX)) return null;
  const absPath = normalize(join(UPLOADS_DIR, assetPath.slice(UPLOAD_PREFIX.length)));
  if (!absPath.startsWith(normalize(UPLOADS_DIR)) || !existsSync(absPath)) return null;
  return absPath;
}
+83 −0
Original line number Diff line number Diff line
import {
  AVATAR_BANK_SIZE,
  avatarForAuthor,
  bankAvatar,
  DEFAULT_ORG_AVATAR,
  fnv1a,
} from './avatar-bank';

describe('fnv1a', () => {
  it('est deterministe et sensible a la moindre difference', () => {
    expect(fnv1a('abc')).toBe(fnv1a('abc'));
    expect(fnv1a('abc')).not.toBe(fnv1a('abd'));
  });

  it('reste dans les entiers non signes 32 bits', () => {
    for (const s of ['', 'a', 'villedemeridien', 'é'.repeat(50)]) {
      const h = fnv1a(s);
      expect(Number.isInteger(h)).toBe(true);
      expect(h).toBeGreaterThanOrEqual(0);
      expect(h).toBeLessThanOrEqual(0xffffffff);
    }
  });
});

describe('bankAvatar', () => {
  it('renvoie toujours un fichier de la banque', () => {
    for (const seed of ['a', 'journaliste', 'x'.repeat(40), '']) {
      expect(bankAvatar(seed)).toMatch(/^\/avatars\/a(0[1-9]|1[0-2])\.svg$/);
    }
  });

  it('est stable : le meme pseudo donne toujours la meme image', () => {
    expect(bankAvatar('journaliste')).toBe(bankAvatar('journaliste'));
  });

  it('ignore la casse', () => {
    expect(bankAvatar('Journaliste')).toBe(bankAvatar('journaliste'));
  });

  it('repartit sur toute la banque', () => {
    const seen = new Set<string>();
    for (let i = 0; i < 400; i++) seen.add(bankAvatar(`compte${i}`));
    expect(seen.size).toBe(AVATAR_BANK_SIZE);
  });
});

describe('avatarForAuthor', () => {
  const official = 'villedemeridien';

  it('donne le logo au compte officiel', () => {
    expect(
      avatarForAuthor({ handle: official, officialHandle: official, logoUrl: '/api/x/logo' }),
    ).toBe('/api/x/logo');
  });

  it('reconnait le compte officiel malgre l’arobase et la casse', () => {
    expect(
      avatarForAuthor({ handle: '@VilleDeMeridien', officialHandle: official, logoUrl: '/l' }),
    ).toBe('/l');
  });

  it('se replie sur l’avatar organisation quand il n’y a pas de logo', () => {
    expect(avatarForAuthor({ handle: official, officialHandle: official, logoUrl: null })).toBe(
      DEFAULT_ORG_AVATAR,
    );
  });

  it('donne une image de banque a tout autre compte', () => {
    const url = avatarForAuthor({
      handle: 'journaliste',
      officialHandle: official,
      logoUrl: '/api/x/logo',
    });
    expect(url).toBe(bankAvatar('journaliste'));
    expect(url).not.toBe('/api/x/logo');
  });

  it('n’attribue jamais le logo par accident a un pseudo voisin', () => {
    expect(
      avatarForAuthor({ handle: 'villedemeridien2', officialHandle: official, logoUrl: '/l' }),
    ).not.toBe('/l');
  });
});
Loading