Commit a54cab48 authored by Kourser's avatar Kourser
Browse files

Variables : page de configuration, substituées à la livraison

Arbitrage tranché : les variables sont résolues AU MOMENT DE LA LIVRAISON de
l'inject, et l'installation de kit cesse de figer le contenu. C'était la seule
option qui rende la page utile — figer à l'installation ferait qu'éditer une valeur
ensuite n'aurait aucun effet sur les injects déjà planifiés.

Le partage est explicite : les libellés STRUCTURELS lus dans la console (nom
d'exercice, personnages, canaux, documents, titre d'inject) restent résolus à
l'installation ; le CONTENU délivré aux joueurs garde ses jetons et n'est résolu
qu'à l'envoi. Sujet et corps d'e-mail, chat, réseaux sociaux, presse et consignes
d'appel passent tous par la résolution.

Le mode d'échec est délibéré : un jeton dont la variable n'est pas définie reste
visible tel quel. Mieux vaut un `{{intranet}}` repérable à l'écran qu'une
disparition silencieuse.

Le moteur accepte maintenant des clefs libres. Toute clef du contexte devient une
variable, donc l'animation peut en définir de nouvelles sans qu'on touche au code.
Les clefs historiques restent nommées parce qu'elles portent des valeurs par défaut.
Le stockage réutilise `Exercise.context` : aucun modèle supplémentaire, aucune
migration.

Page dans l'onglet Préparation : tableau clef/valeur, suggestions des clefs
attendues par les kits fournis, validation du format de clef, et refus d'écraser
`startAt` qui n'est pas une variable de texte.

Une spec couvre le moteur, qui n'en avait aucune alors que je viens d'en changer la
sémantique — dont l'idempotence et le maintien des jetons inconnus.

RESTE À FAIRE, signalé plutôt que masqué : les kits ANSSI contiennent encore des
marqueurs littéraux entre crochets ([votre entité] ×21, [société partenaire] ×30,
[intranet] ×12 et une dizaine d'autres) qui ne sont pas des jetons `{{...}}` et ne
seront donc pas substitués. Les convertir est un travail de données sur 12 fichiers
JSON, à faire dans un lot dédié.

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

Co-Authored-By: Claude (RCA)
parent a33d353e
Loading
Loading
Loading
Loading
+82 −0
Original line number Diff line number Diff line
import { applyVars, buildContext, buildVars } from './personalization';

describe('buildVars', () => {
  it('expose toute clef du contexte comme variable', () => {
    const vars = buildVars(null, { organisation: 'Ville de Méridien', intranet: 'MonPortail' });
    expect(vars.organisation).toBe('Ville de Méridien');
    // Clef libre definie par l'animation : c'est le coeur de la page des variables.
    expect(vars.intranet).toBe('MonPortail');
  });

  it('rogne les valeurs et ignore celles qui sont vides', () => {
    const vars = buildVars(null, { organisation: '  Hélios  ', ville: '   ' });
    expect(vars.organisation).toBe('Hélios');
    expect(vars.ville).toBeUndefined();
  });

  it('n’expose pas startAt, qui est une date et non un texte', () => {
    const vars = buildVars(null, { startAt: '2026-08-18T09:00:00.000Z' });
    expect(vars.startAt).toBeUndefined();
  });

  it('ajoute « secteur » depuis le champ dedie de l’exercice', () => {
    expect(buildVars('Collectivité', undefined).secteur).toBe('Collectivité');
    expect(buildVars('   ', undefined).secteur).toBeUndefined();
  });

  it('resout toujours les variables non-cyber, saisies ou par defaut', () => {
    const defaults = buildVars(null, undefined);
    expect(defaults.cours_deau).toBe('la rivière');
    expect(defaults.prefecture).toBe('la préfecture');
    expect(defaults.gestionnaire_reseau).toBe('le gestionnaire du réseau');
    expect(buildVars(null, { cours_deau: 'la Loire' }).cours_deau).toBe('la Loire');
  });

  it('accepte un contexte absent', () => {
    expect(() => buildVars(null, undefined)).not.toThrow();
  });
});

describe('applyVars', () => {
  const vars = { organisation: 'Ville de Méridien', partenaire: 'Sotrex' };

  it('substitue les jetons connus', () => {
    expect(applyVars('Panne chez {{organisation}}', vars)).toBe('Panne chez Ville de Méridien');
  });

  it('tolere les espaces dans le jeton', () => {
    expect(applyVars('{{ partenaire }}', vars)).toBe('Sotrex');
  });

  it('laisse INTACT un jeton inconnu : un oubli doit se voir', () => {
    expect(applyVars('Contact {{inconnu}}', vars)).toBe('Contact {{inconnu}}');
  });

  it('substitue plusieurs occurrences', () => {
    expect(applyVars('{{organisation}} et {{organisation}}', vars)).toBe(
      'Ville de Méridien et Ville de Méridien',
    );
  });

  it('laisse passer null et undefined sans erreur', () => {
    expect(applyVars(null, vars)).toBeNull();
    expect(applyVars(undefined, vars)).toBeUndefined();
  });

  it('est idempotent : un texte deja resolu ne change plus', () => {
    const once = applyVars('Chez {{organisation}}', vars);
    expect(applyVars(once, vars)).toBe(once);
  });
});

describe('buildContext', () => {
  it('renvoie undefined quand rien n’est renseigne', () => {
    expect(buildContext(undefined)).toBeUndefined();
    expect(buildContext({})).toBeUndefined();
  });

  it('ne garde que les champs connus, rognes', () => {
    const ctx = buildContext({ organisation: '  Hélios ', ville: '', name: 'ignore' });
    expect(ctx).toEqual({ organisation: 'Hélios' });
  });
});
+17 −6
Original line number Diff line number Diff line
@@ -24,6 +24,8 @@ export interface Personalization {
}

export interface ExerciseContext {
  /** Variables libres definies par l'animation, en plus des clefs connues. */
  [key: string]: string | undefined;
  organisation?: string;
  ville?: string;
  edr?: string;
@@ -65,18 +67,27 @@ export function buildContext(p: Personalization | undefined): ExerciseContext |
  return Object.keys(ctx).length ? (ctx as ExerciseContext) : undefined;
}

/** Table des variables {{...}} disponibles pour la substitution. */
/** Clefs du contexte qui ne sont pas des variables substituables. */
const NOT_A_VARIABLE = new Set<string>(['startAt']);

/**
 * Table des variables {{...}} disponibles pour la substitution.
 *
 * Toute clef du contexte devient une variable : l'animateur peut en definir de
 * nouvelles depuis la page des variables sans qu'on touche a ce fichier. Les
 * clefs historiques restent nommees ci-dessous parce qu'elles portent des
 * valeurs par defaut ou un alias.
 */
export function buildVars(
  sector: string | null | undefined,
  ctx: ExerciseContext | undefined,
): Record<string, string> {
  const vars: Record<string, string> = {};
  if (ctx?.organisation) vars.organisation = ctx.organisation;
  if (ctx?.ville) vars.ville = ctx.ville;
  for (const [key, value] of Object.entries((ctx ?? {}) as Record<string, unknown>)) {
    if (NOT_A_VARIABLE.has(key)) continue;
    if (typeof value === 'string' && value.trim() !== '') vars[key] = value.trim();
  }
  if (sector && sector.trim()) vars.secteur = sector.trim();
  if (ctx?.edr) vars.edr = ctx.edr;
  if (ctx?.partenaire) vars.partenaire = ctx.partenaire;
  if (ctx?.media) vars.media = ctx.media;
  // Variables non-cyber : toujours résolues (valeur saisie ou défaut) pour éviter
  // qu'un jeton {{...}} reste visible côté joueur dans les kits inondation/black-out/canicule.
  vars.cours_deau = ctx?.cours_deau?.trim() || NON_CYBER_DEFAULTS.cours_deau;
+33 −14
Original line number Diff line number Diff line
@@ -10,6 +10,11 @@ import { ChatService } from './chat.service';
import { SocialService } from './social.service';
import { NewsService } from './news.service';
import { computeElapsedSeconds } from './clock';
import {
  applyVars,
  buildVars,
  type ExerciseContext,
} from '../common/personalization';
import {
  RT,
  type CallInjectPayload,
@@ -249,17 +254,29 @@ export class InjectsService {
      },
    });

    /*
     * Variables resolues MAINTENANT, et non figees a l'installation du kit : c'est
     * ce qui permet a la page des variables d'agir sur des injects deja planifies.
     * Un jeton dont la variable n'est pas definie reste visible tel quel, pour que
     * l'oubli se remarque plutot que de disparaitre silencieusement.
     */
    const vars = buildVars(
      inject.exercise.sector,
      (inject.exercise.context ?? undefined) as ExerciseContext,
    );
    const sub = (text: string): string => applyVars(text, vars);

    // Diffusion propre au canal (confinement : aucune sortie externe).
    if (inject.channel === 'CHAT') {
      const p = inject.payload as unknown as ChatInjectPayload;
      await this.chat.postAnimation(tenantId, inject.exerciseId, p.channelId, p.body, senderName);
      await this.chat.postAnimation(tenantId, inject.exerciseId, p.channelId, sub(p.body), senderName);
    } else if (inject.channel === 'SOCIAL') {
      const p = inject.payload as unknown as SocialInjectPayload;
      await this.social.create(tenantId, inject.exerciseId, {
        network: p.network,
        authorName: p.authorName,
        authorName: sub(p.authorName),
        authorHandle: p.authorHandle,
        body: p.body,
        body: sub(p.body),
        likes: p.likes,
        shares: p.shares,
        comments: p.comments,
@@ -268,24 +285,24 @@ export class InjectsService {
      const p = inject.payload as unknown as NewsInjectPayload;
      await this.news.create(tenantId, inject.exerciseId, {
        outlet: p.outlet,
        headline: p.headline,
        source: p.source,
        headline: sub(p.headline),
        source: sub(p.source),
        category: p.category,
        body: p.body,
        body: sub(p.body),
      });
    } else if (inject.channel === 'CALL') {
      // Rappel cote animateur : aucune diffusion aux joueurs.
      const p = inject.payload as unknown as CallInjectPayload;
      this.rt.emitToExercise(inject.exerciseId, RT.callReminder, {
        injectId,
        title: inject.title,
        to: p.to ?? null,
        body: p.body,
        title: sub(inject.title),
        to: p.to ? sub(p.to) : null,
        body: sub(p.body),
        atExerciseSec: atSec,
        at: new Date().toISOString(),
      });
    } else {
      await this.deliverEmail(db, tenantId, inject, injectId, atSec, senderName);
      await this.deliverEmail(db, tenantId, inject, injectId, atSec, senderName, sub);
    }

    // Journal + notification console (commun a tous les canaux).
@@ -383,6 +400,8 @@ export class InjectsService {
    injectId: string,
    atSec: number,
    senderName: string,
    /** Resolution des variables, appliquee au sujet et au corps. */
    sub: (text: string) => string,
  ): Promise<void> {
    const payload = inject.payload as unknown as EmailPayload;
    const targeting = inject.targeting as unknown as InjectTargeting;
@@ -406,8 +425,8 @@ export class InjectsService {
      data: {
        tenantId,
        exerciseId: inject.exerciseId,
        subject: payload.subject,
        body: payload.body,
        subject: sub(payload.subject),
        body: sub(payload.body),
        origin: 'INJECT',
        authorCharId: inject.senderCharId,
        authorLabel: senderName,
@@ -432,8 +451,8 @@ export class InjectsService {
        recipientId: r.id,
        messageId: created.id,
        from: senderName,
        subject: payload.subject,
        body: payload.body,
        subject: sub(payload.subject),
        body: sub(payload.body),
        origin: 'INJECT',
        attachments,
        sentAtExerciseSec: atSec,
+13 −0
Original line number Diff line number Diff line
import { IsObject } from 'class-validator';

/**
 * Remplacement complet des variables d'un exercice.
 *
 * La forme est volontairement libre : l'animation definit ses propres clefs. Le
 * service filtre les clefs utilisables dans un jeton `{{...}}` et ignore le reste,
 * plutot que d'imposer ici une liste fermee qu'il faudrait maintenir.
 */
export class SetVariablesDto {
  @IsObject()
  variables!: Record<string, string>;
}
+18 −0
Original line number Diff line number Diff line
@@ -8,6 +8,7 @@ import {
  Param,
  Patch,
  Post,
  Put,
  UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard';
@@ -18,6 +19,7 @@ import type { AuthUser } from '../auth/auth.types';
import { ExercisesService } from './exercises.service';
import { CreateExerciseDto } from './dto/create-exercise.dto';
import { UpdateExerciseDto } from './dto/update-exercise.dto';
import { SetVariablesDto } from './dto/set-variables.dto';

@Controller('exercises')
@UseGuards(AuthGuard, RolesGuard)
@@ -46,6 +48,22 @@ export class ExercisesController {
    return this.exercises.update(me.tenantId, id, dto);
  }

  @Get(':id/variables')
  variables(@CurrentUser() me: AuthUser, @Param('id') id: string) {
    return this.exercises.getVariables(me.tenantId, id);
  }

  /** Remplace l'ensemble des variables de mise en situation. */
  @Put(':id/variables')
  @Roles('TENANT_ADMIN', 'DESIGNER', 'ANIMATOR')
  setVariables(
    @CurrentUser() me: AuthUser,
    @Param('id') id: string,
    @Body() dto: SetVariablesDto,
  ) {
    return this.exercises.setVariables(me.tenantId, id, dto.variables);
  }

  @Post(':id/duplicate')
  @Roles('TENANT_ADMIN', 'DESIGNER', 'ANIMATOR')
  duplicate(@CurrentUser() me: AuthUser, @Param('id') id: string) {
Loading