Commit a8943b78 authored by Kourser's avatar Kourser
Browse files

Duplication d'un exercice : emporter le scénario, pas seulement le casting

Elle ne recopiait que les personnages. Mesuré sur un exercice installé
depuis un kit : 32 injects, 6 documents, 2 canaux, 3 phases, 6 fiches
réflexes et 19 critères d'observation disparaissaient, pour une copie
portant le même nom suivi de « (copie) ». L'animateur ne s'en apercevait
qu'en ouvrant le chronogramme — alors qu'il duplique justement pour
rejouer ce scénario avec un autre groupe.

La frontière est désormais celle-ci : la PRÉPARATION suit — personnages,
canaux, documents, phases, fiches, grille d'observation, questionnaire
de RETEX, chronogramme, et les réglages de mise en situation ; le
DÉROULÉ ne suit pas — participants, messages, chat, publications, main
courante, journal, comchecks, observations, réponses.

Les injects citent des identifiants : expéditeur, destinataires, canal
de chat, pièces jointes, phase. Chacun est réécrit vers son homologue de
la copie. Un identifiant sans homologue est écarté plutôt que recopié :
gardé, il aurait désigné les objets de l'original, et les supprimer
aurait cassé la copie à distance.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 96e8f3f6
Loading
Loading
Loading
Loading
+58 −0
Changes for apps/api/src/exercises/duplicate-refs.spec.ts: 58 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { reecrireCiblage, reecrireContenu } from './exercises.service';

describe('reecrireCiblage', () => {
  const persos = new Map([
    ['ancien-1', 'nouveau-1'],
    ['ancien-2', 'nouveau-2'],
  ]);

  it('traduit les destinataires vers ceux de la copie', () => {
    expect(reecrireCiblage({ mode: 'CHARACTERS', characterIds: ['ancien-2', 'ancien-1'] }, persos)).toEqual({
      mode: 'CHARACTERS',
      characterIds: ['nouveau-2', 'nouveau-1'],
    });
  });

  it('laisse « tous » inchangé', () => {
    expect(reecrireCiblage({ mode: 'ALL' }, persos)).toEqual({ mode: 'ALL' });
  });

  it('écarte un identifiant sans homologue', () => {
    // Le recopier tel quel enverrait le message à quelqu'un d'un autre exercice.
    expect(reecrireCiblage({ mode: 'CHARACTERS', characterIds: ['ancien-1', 'inconnu'] }, persos)).toEqual({
      mode: 'CHARACTERS',
      characterIds: ['nouveau-1'],
    });
  });

  it('retombe sur « tous » si plus personne ne reste', () => {
    expect(reecrireCiblage({ mode: 'CHARACTERS', characterIds: ['inconnu'] }, persos)).toEqual({ mode: 'ALL' });
  });

  it('supporte un ciblage absent ou malformé', () => {
    expect(reecrireCiblage(null, persos)).toEqual({ mode: 'ALL' });
    expect(reecrireCiblage({ mode: 'CHARACTERS' }, persos)).toEqual({ mode: 'ALL' });
  });
});

describe('reecrireContenu', () => {
  const canaux = new Map([['chan-a', 'chan-b']]);
  const docs = new Map([['doc-a', 'doc-b']]);

  it('traduit le canal de chat et les pièces jointes', () => {
    expect(
      reecrireContenu({ channelId: 'chan-a', attachmentDocIds: ['doc-a'], body: 'Texte' }, canaux, docs),
    ).toEqual({ channelId: 'chan-b', attachmentDocIds: ['doc-b'], body: 'Texte' });
  });

  it('recopie tel quel ce qui n’est pas une référence', () => {
    const contenu = { subject: 'Objet', body: 'Corps', network: 'X', likes: 12 };
    expect(reecrireContenu(contenu, canaux, docs)).toEqual(contenu);
  });

  it('retire une référence sans homologue au lieu de la garder', () => {
    // Gardée, elle pointerait le canal de l'original : le supprimer casserait la
    // copie à distance.
    expect(reecrireContenu({ channelId: 'inconnu', attachmentDocIds: ['inconnu'] }, canaux, docs)).toEqual({});
  });
});
+220 −6
Changes for apps/api/src/exercises/exercises.service.ts: 220 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -8,6 +8,57 @@ import { exerciseSnapshot } from './organisation-snapshot';
import type { CreateExerciseDto } from './dto/create-exercise.dto';
import type { UpdateExerciseDto } from './dto/update-exercise.dto';

/**
 * Ciblage d'un inject, reecrit vers les personnages de la copie.
 *
 * Un identifiant sans homologue est ECARTE plutot que recopie tel quel : il
 * designerait un personnage d'un autre exercice, et le message partirait chez
 * quelqu'un qui n'a rien a voir avec celui-la. Si le ciblage se vide, il repasse
 * a « tous les joueurs », qui est le defaut du produit.
 */
export function reecrireCiblage(
  ciblage: unknown,
  persos: ReadonlyMap<string, string>,
): Prisma.InputJsonValue {
  const c = (ciblage ?? {}) as { mode?: string; characterIds?: unknown };
  if (c.mode !== 'CHARACTERS' || !Array.isArray(c.characterIds)) return { mode: 'ALL' };
  const ids = c.characterIds
    .filter((x): x is string => typeof x === 'string')
    .map((x) => persos.get(x))
    .filter((x): x is string => x !== undefined);
  return ids.length > 0 ? { mode: 'CHARACTERS', characterIds: ids } : { mode: 'ALL' };
}

/**
 * Contenu d'un inject, reecrit vers le canal et les documents de la copie.
 *
 * Les autres champs du contenu (objet, corps, reseau, auteur...) sont recopies
 * tels quels : ce sont des donnees, pas des references.
 */
export function reecrireContenu(
  contenu: unknown,
  canaux: ReadonlyMap<string, string>,
  docs: ReadonlyMap<string, string>,
): Prisma.InputJsonValue {
  const p = { ...((contenu ?? {}) as Record<string, unknown>) };
  if (typeof p.channelId === 'string') {
    const nouveau = canaux.get(p.channelId);
    // Un canal introuvable laisserait un inject de chat sans destination : on
    // retire la cle, l'inject se signale alors comme incomplet a l'edition.
    if (nouveau) p.channelId = nouveau;
    else delete p.channelId;
  }
  if (Array.isArray(p.attachmentDocIds)) {
    const pieces = p.attachmentDocIds
      .filter((x): x is string => typeof x === 'string')
      .map((x) => docs.get(x))
      .filter((x): x is string => x !== undefined);
    if (pieces.length > 0) p.attachmentDocIds = pieces;
    else delete p.attachmentDocIds;
  }
  return p as Prisma.InputJsonValue;
}

@Injectable()
export class ExercisesService {
  constructor(
@@ -180,12 +231,42 @@ export class ExercisesService {
    this.audit.record('exercise.delete', { tenantId, exerciseId: id });
  }

  /** Duplique un exercice (personnages inclus, participants exclus, statut remis a DRAFT). */
  /**
   * Duplique un exercice : tout ce qui a ete PREPARE, rien de ce qui a ete VECU.
   *
   * La version precedente ne recopiait que les personnages. Un exercice de trente
   * injects, six documents et trois phases donnait une coquille portant le meme
   * nom suivi de « (copie) » — et l'animateur ne s'en apercevait qu'en ouvrant le
   * chronogramme, alors qu'il venait de dupliquer justement pour rejouer ce
   * scenario avec un autre groupe. L'installation d'un kit, elle, montait deja
   * tout : c'est la duplication qui etait restee en arriere.
   *
   * La frontiere est celle-ci : la PREPARATION suit (personnages, canaux,
   * documents, phases, fiches reflexes, grille d'observation, questionnaire de
   * RETEX, chronogramme), le DEROULE ne suit pas (participants et leurs liens,
   * messages, chat, publications, presse, main courante, journal, comchecks,
   * observations, reponses de RETEX, images televersees). Un exercice duplique
   * repart a DRAFT, horloge a zero.
   *
   * Les injects citent des identifiants — expediteur, destinataires, canal de
   * chat, pieces jointes, phase. Chacun est reecrit vers son homologue de la
   * copie ; sans quoi la copie designerait les objets de l'original, et les
   * supprimer aurait casse la copie a distance.
   */
  async duplicate(tenantId: string, id: string) {
    const db = this.prisma.forTenant(tenantId);
    const source = await db.exercise.findFirst({
      where: { id },
      include: { characters: true },
      include: {
        characters: true,
        channels: true,
        documents: true,
        phases: { orderBy: { order: 'asc' } },
        reflexSheets: { orderBy: { position: 'asc' } },
        observationCriteria: { orderBy: { order: 'asc' } },
        retexCategories: { orderBy: { order: 'asc' }, include: { questions: true } },
        injects: { orderBy: [{ offsetSeconds: 'asc' }, { createdAt: 'asc' }] },
      },
    });
    if (!source) throw new NotFoundException('Exercice introuvable');

@@ -196,12 +277,31 @@ export class ExercisesService {
        description: source.description,
        objectives: source.objectives,
        sector: source.sector,
        // Reglages de mise en situation : sans eux, la copie ne ressemble pas a
        // l'original la ou ca se voit le plus (habillage, compte officiel).
        playerSkin: source.playerSkin,
        context: source.context === null ? Prisma.DbNull : (source.context as Prisma.InputJsonValue),
        socialAccountName: source.socialAccountName,
        socialAccountHandle: source.socialAccountHandle,
        socialAccountLogoPath: source.socialAccountLogoPath,
        phasesVisibleToPlayers: source.phasesVisibleToPlayers,
        voiceCallsEnabled: source.voiceCallsEnabled,
        autonomous: source.autonomous,
        pacingSeconds: source.pacingSeconds,
        autoCloseAfterSec: source.autoCloseAfterSec,
        autoOpenRetex: source.autoOpenRetex,
      },
    });

    if (source.characters.length > 0) {
      await db.character.createMany({
        data: source.characters.map((c) => ({
    /** Ancien identifiant -> nouveau, par nature d'objet. */
    const persos = new Map<string, string>();
    const canaux = new Map<string, string>();
    const docs = new Map<string, string>();
    const phases = new Map<string, string>();

    for (const c of source.characters) {
      const cree = await db.character.create({
        data: {
          tenantId,
          exerciseId: copy.id,
          name: c.name,
@@ -210,9 +310,123 @@ export class ExercisesService {
          kind: c.kind,
          simEmail: c.simEmail,
          avatarColor: c.avatarColor,
        })),
          canPostAsOrg: c.canPostAsOrg,
        },
      });
      persos.set(c.id, cree.id);
    }

    for (const ch of source.channels) {
      const cree = await db.channel.create({
        data: { tenantId, exerciseId: copy.id, name: ch.name, kind: ch.kind },
      });
      canaux.set(ch.id, cree.id);
    }

    for (const d of source.documents) {
      const cree = await db.document.create({
        data: {
          tenantId,
          exerciseId: copy.id,
          folder: d.folder,
          audience: d.audience,
          name: d.name,
          description: d.description,
          body: d.body,
          fileName: d.fileName,
          mimeType: d.mimeType,
          // Le fichier televerse est PARTAGE, pas recopie : deux exercices
          // pointent le meme octet sur disque, qui n'est jamais modifie en place.
          assetPath: d.assetPath,
          atExerciseSec: d.atExerciseSec,
        },
      });
      docs.set(d.id, cree.id);
    }

    for (const ph of source.phases) {
      const cree = await db.phase.create({
        data: {
          tenantId,
          exerciseId: copy.id,
          name: ph.name,
          description: ph.description,
          startsAtSec: ph.startsAtSec,
          order: ph.order,
          // startedAtSec appartient au deroule : la copie n'a rien vecu.
        },
      });
      phases.set(ph.id, cree.id);
    }

    for (const r of source.reflexSheets) {
      await db.reflexSheet.create({
        data: {
          tenantId,
          exerciseId: copy.id,
          characterId: r.characterId ? (persos.get(r.characterId) ?? null) : null,
          role: r.role,
          mission: r.mission,
          risks: r.risks,
          reflexes: r.reflexes,
          position: r.position,
        },
      });
    }

    for (const o of source.observationCriteria) {
      await db.observationCriterion.create({
        data: {
          tenantId,
          exerciseId: copy.id,
          phaseId: o.phaseId ? (phases.get(o.phaseId) ?? null) : null,
          theme: o.theme,
          label: o.label,
          order: o.order,
        },
      });
    }

    for (const cat of source.retexCategories) {
      await db.retexCategory.create({
        data: {
          tenantId,
          exerciseId: copy.id,
          name: cat.name,
          order: cat.order,
          questions: {
            create: cat.questions.map((q) => ({ tenantId, text: q.text, order: q.order })),
          },
        },
      });
    }

    for (const inj of source.injects) {
      await db.inject.create({
        data: {
          tenantId,
          exerciseId: copy.id,
          channel: inj.channel,
          trigger: inj.trigger,
          // Statut remis a zero : un inject deja parti dans l'original n'est pas
          // parti dans la copie, qui n'a pas encore commence.
          status: 'PENDING',
          title: inj.title,
          offsetSeconds: inj.offsetSeconds,
          phaseId: inj.phaseId ? (phases.get(inj.phaseId) ?? null) : null,
          senderCharId: inj.senderCharId ? (persos.get(inj.senderCharId) ?? null) : null,
          targeting: reecrireCiblage(inj.targeting, persos),
          payload: reecrireContenu(inj.payload, canaux, docs),
        },
      });
    }

    this.audit.record('exercise.duplicate', {
      tenantId,
      sourceId: id,
      exerciseId: copy.id,
      injects: source.injects.length,
    });
    return this.get(tenantId, copy.id);
  }
}