Commit 41d9fedf authored by Kourser's avatar Kourser
Browse files

Personnalisation, cellule de crise et accès joueurs : six points

CE QUE L'ORGANISATION SAIT, L'EXERCICE NE LE REPRENAIT PAS. Le parcours
pré-remplissait le secteur avec celui du KIT, codait l'habillage en dur
sur « DEFAULT » et laissait le nom de l'organisation vide. On réglait
donc son organisation une fois pour rien, et on resaisissait tout à
chaque exercice.

Le secteur du kit devient un DERNIER repli : il passait pour une saisie
explicite, si bien que celui de l'organisation n'était jamais retenu —
un kit en porte toujours un.

`{{organisation}}` retombe sur le nom de l'organisation qui joue. Le
moteur de substitution ne lui donne volontairement aucun repli fixe
parce qu'il en existe un meilleur, celui-là ; encore fallait-il le
poser. Sans lui, le jeton restait brut devant les joueurs.

LES VARIABLES PROPRES À UN KIT ÉTAIENT JETÉES EN SILENCE. Le tuyau de
validation retire toute propriété non déclarée, et le DTO ignorait
`intranet` et `portail_rh` — pourtant proposés par le parcours.
L'animateur les saisissait, elles disparaissaient sans un mot. Un sac de
variables libres traverse maintenant la chaîne, et l'étape Contexte
demande celles que le kit cite, connues ou non.

CELLULE DE CRISE : une adresse de CONTACT, réelle, à ne pas confondre
avec `simEmail`, l'adresse fictive jouée dans l'exercice. Elle suit le
personnage jusqu'à l'exercice et sert à lui transmettre son lien.
Chargement par CSV qui AJOUTE sans remplacer : une cellule se complète
au fil des exercices, et un import qui écraserait tout ferait perdre un
travail de saisie sur une fausse manœuvre.

La colonne `email` d'un annuaire garde son sens HISTORIQUE — l'adresse
jouée. Le détourner aurait retourné tous les annuaires déjà écrits sans
prévenir ; l'adresse de contact a sa propre colonne.

PERSONNAGES & ACCÈS JOUEURS : colonnes triables, colonne d'adresse, et
un bouton d'envoi à côté de « Générer le lien ». L'envoi RÉGÉNÈRE le
jeton — le clair n'existe qu'à son émission, seule l'empreinte est
conservée — donc un lien déjà transmis cesse de marcher, et l'infobulle
le dit avant le clic. L'échec remonte, contrairement aux avis de la
plateforme : ici le courriel EST l'action.

MISE EN PAGE DES ONGLETS : une seule épaisseur de cadre. Deux blocs gris
étaient posés dans le panneau blanc qui les entoure déjà — dont une
carte au gabarit de la page de connexion, plafonnée à 420 px — puis
venaient des sections nues. Toutes partagent maintenant le même en-tête.

Une adresse posée par erreur pouvait être corrigée mais pas EFFACÉE :
`@IsEmail` refusait la chaîne vide. La dérogation est explicite.

Douze essais ajoutés, dont l'ordre des deux refus d'envoi : dire « pas
de serveur de courriel » à qui n'a pas renseigné d'adresse l'envoie
corriger la mauvaise chose.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 5f28b764
Loading
Loading
Loading
Loading
+11 −0
Changes for apps/api/prisma/migrations/20260903100000_contact_email/migration.sql: 11 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- Adresse de contact REELLE d'un membre de la cellule et d'un personnage.
--
-- A NE PAS CONFONDRE AVEC `simEmail`, qui est l'adresse FICTIVE utilisee DANS
-- l'exercice (« paul@logipro.exercice ») et qui n'existe pas. Celle-ci est
-- l'adresse professionnelle de la personne qui jouera le role : elle sert a lui
-- transmettre son lien d'acces, et a rien d'autre.
--
-- C'est une donnee personnelle : elle suit la duree de vie de l'exercice et
-- disparait avec lui, en cascade, comme le reste.
ALTER TABLE "OrgMember" ADD COLUMN "contactEmail" TEXT;
ALTER TABLE "Character" ADD COLUMN "contactEmail" TEXT;
+12 −0
Changes for apps/api/prisma/schema.prisma: 12 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -500,6 +500,12 @@ model Character {
  orgUnit     String?
  kind        CharacterKind @default(PLAYER)
  simEmail    String?
  /// Adresse de contact REELLE de la personne qui jouera ce role.
  ///
  /// A ne pas confondre avec `simEmail`, l'adresse FICTIVE utilisee dans
  /// l'exercice. Celle-ci sert a transmettre le lien d'acces, et a rien d'autre.
  /// Donnee personnelle : elle disparait avec l'exercice, en cascade.
  contactEmail 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.
@@ -529,6 +535,12 @@ model OrgMember {
  orgUnit     String?
  kind        CharacterKind @default(PLAYER)
  simEmail    String?
  /// Adresse de contact REELLE de la personne qui jouera ce role.
  ///
  /// A ne pas confondre avec `simEmail`, l'adresse FICTIVE utilisee dans
  /// l'exercice. Celle-ci sert a transmettre le lien d'acces, et a rien d'autre.
  /// Donnee personnelle : elle disparait avec l'exercice, en cascade.
  contactEmail String?
  avatarColor String?
  /// Autorise a publier sous le compte officiel de l'organisation.
  canPostAsOrg Boolean      @default(false)
+23 −1
Changes for apps/api/src/common/personalization.dto.ts: 23 added lines, 1 removed line.
Original line number Diff line number Diff line
import { IsBoolean, IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
import {
  IsBoolean,
  IsEnum,
  IsObject,
  IsOptional,
  IsString,
  MaxLength,
} from 'class-validator';
import { PlayerSkin } from '@prisma/client';

/** Champs de personnalisation communs à la création d'exercice et à l'installation d'un kit. */
@@ -59,4 +66,19 @@ export class PersonalizationDto {
  @IsString()
  @MaxLength(40)
  startAt?: string;

  /**
   * Variables SUPPLEMENTAIRES, citees par le kit mais inconnues du produit.
   *
   * Sans ce champ, elles etaient jetees en SILENCE : le tuyau de validation
   * retire toute propriete non declaree (`whitelist: true`), et l'animateur
   * voyait ses saisies disparaitre sans un mot — `intranet` et `portail_rh`,
   * pourtant proposes par le parcours, n'arrivaient jamais.
   *
   * Un objet libre, parce que c'est le kit qui decide des noms : les borner
   * reviendrait a interdire d'ecrire un kit sans modifier le produit.
   */
  @IsOptional()
  @IsObject()
  variables?: Record<string, string>;
}
+24 −3
Changes for apps/api/src/common/personalization.ts: 24 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -25,6 +25,14 @@ export interface Personalization {
  startAt?: string;
  /** Reprendre la cellule de crise pre-configuree de l'organisation (defaut : oui). */
  useOrgMembers?: boolean;
  /**
   * Variables supplementaires citees par le kit et inconnues du produit.
   *
   * Elles rejoignent le contexte comme les autres. Sans elles, un kit ne pouvait
   * citer que les variables prevues ici : en ecrire une nouvelle demandait de
   * modifier le produit.
   */
  variables?: Record<string, string>;
}

export interface ExerciseContext {
@@ -87,6 +95,9 @@ const VARIABLE_DEFAULTS: Record<string, string> = {
  media: 'la presse locale',
};

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

/** Contexte (mise en situation) nettoyé, ou undefined si aucun champ renseigné. */
export function buildContext(p: Personalization | undefined): ExerciseContext | undefined {
  if (!p) return undefined;
@@ -95,12 +106,22 @@ export function buildContext(p: Personalization | undefined): ExerciseContext |
    const v = p[k];
    if (typeof v === 'string' && v.trim() !== '') ctx[k] = v.trim();
  }
  /*
   * Les variables libres viennent APRES les clefs connues : si un kit cite
   * `ville` par les deux chemins, la saisie explicite du champ dedie l'emporte
   * — c'est celle que l'animateur a vue nommee.
   *
   * Le nom de la variable est filtre sur le meme jeu que le moteur de
   * substitution : une clef qui ne peut pas s'ecrire `{{...}}` n'a aucun effet,
   * et l'accepter donnerait l'illusion d'un reglage.
   */
  for (const [k, v] of Object.entries(p.variables ?? {})) {
    if (!/^[a-zA-Z0-9_]+$/.test(k) || NOT_A_VARIABLE.has(k)) continue;
    if (typeof v === 'string' && v.trim() !== '' && !ctx[k]) ctx[k] = v.trim();
  }
  return Object.keys(ctx).length ? (ctx as ExerciseContext) : undefined;
}

/** 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.
 *
+25 −1
Changes for apps/api/src/exercises/annuaire-import.spec.ts: 25 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -3,7 +3,14 @@ import { lireAnnuaire } from './characters.service';
describe('lireAnnuaire', () => {
  it('garde l’ordre historique quand il n’y a pas d’en-tête', () => {
    expect(lireAnnuaire('Paul Rivière;PDG;Direction;joueur;paul@exemple.fr')).toEqual([
      { name: 'Paul Rivière', title: 'PDG', orgUnit: 'Direction', kind: 'PLAYER', simEmail: 'paul@exemple.fr' },
      {
        name: 'Paul Rivière',
        title: 'PDG',
        orgUnit: 'Direction',
        kind: 'PLAYER',
        simEmail: 'paul@exemple.fr',
        contactEmail: null,
      },
    ]);
  });

@@ -17,6 +24,7 @@ describe('lireAnnuaire', () => {
      orgUnit: 'DSI',
      kind: 'PLAYER',
      simEmail: null,
      contactEmail: null,
    });
  });

@@ -44,3 +52,19 @@ describe('lireAnnuaire, distinction en-tête / données', () => {
    expect(gens[0]).toMatchObject({ name: 'Paul', title: 'PDG' });
  });
});

describe('lireAnnuaire — adresse de contact', () => {
  it('« email » garde son sens historique : l’adresse JOUÉE dans l’exercice', () => {
    // Changer ce sens aurait retourné tous les annuaires déjà écrits, sans
    // prévenir : leurs adresses fictives seraient devenues des adresses réelles.
    const [c] = lireAnnuaire('nom;email\nPaul;paul@logipro.exercice');
    expect(c.simEmail).toBe('paul@logipro.exercice');
    expect(c.contactEmail).toBeNull();
  });

  it('l’adresse de contact a sa propre colonne', () => {
    const [c] = lireAnnuaire('nom;email;emailcontact\nPaul;paul@logipro.exercice;paul@vrai.fr');
    expect(c.simEmail).toBe('paul@logipro.exercice');
    expect(c.contactEmail).toBe('paul@vrai.fr');
  });
});
Loading