Commit 2d316ee9 authored by Kourser's avatar Kourser
Browse files

Espace joueur : un accès créé en cours de crise trouvait une boîte vide

La messagerie est le seul canal dont la remise est matérialisée à
l'envoi : un destinataire est créé pour chaque participant EXISTANT. Le
chat, les réseaux, la presse et la GED se lisent à la demande, si bien
qu'un joueur arrivé en route les voyait en entier — mais sa messagerie
restait vide quand ses collègues en avaient dix, à commencer par le
message de lancement, celui qui porte la mise en situation.

Le cas est courant : remplaçant, oubli, personnage ajouté en route.

Les courriels déjà partis qui lui étaient destinés lui sont donc remis à
la création de son accès : ceux diffusés à tous, et ceux qui visaient
nommément son personnage. Le ciblage est relu depuis l'inject d'origine,
ce qui laisse dehors les messages écrits par d'autres joueurs — une
correspondance privée ne s'ouvre pas rétroactivement.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent a8943b78
Loading
Loading
Loading
Loading
+57 −0
Changes for apps/api/src/exercises/participants.service.ts: 57 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -56,10 +56,67 @@ export class ParticipantsService {
      },
    });

    await this.rattraperMessages(tenantId, exerciseId, participant.id, dto.characterId);

    const base = await this.appUrl.base();
    return { participant: toPublic(participant), accessUrl: buildAccessUrl(base, token) };
  }

  /**
   * Remet a ce participant les courriels DEJA partis qui lui etaient destines.
   *
   * La messagerie est le seul canal dont la remise est materialisee au moment de
   * l'envoi : un destinataire est cree pour chaque participant existant. Le chat,
   * les reseaux, la presse et la GED, eux, se lisent a la demande — un joueur qui
   * arrive en cours de crise les voit donc en entier.
   *
   * Sans ce rattrapage, ce joueur-la trouvait une boite VIDE quand ses collegues
   * en avaient dix, a commencer par le message de lancement qui porte la mise en
   * situation. Cas courant : un acces cree apres le debut (remplacant, oubli,
   * personnage ajoute en route).
   *
   * On ne remet que ce qui lui etait adresse : les messages diffuses a tous, et
   * ceux qui visaient nommement son personnage. Le ciblage est relu depuis
   * l'inject d'origine — un message ecrit par un autre joueur n'en a pas, et ne
   * se rattrape donc pas, ce qui est voulu : une correspondance privee ne
   * s'ouvre pas retroactivement.
   */
  private async rattraperMessages(
    tenantId: string,
    exerciseId: string,
    participantId: string,
    characterId: string,
  ): Promise<void> {
    const db = this.prisma.forTenant(tenantId);
    // `Message.injectId` est une colonne simple, sans relation : la jointure se
    // fait ici, sur les seuls injects de cet exercice.
    const [messages, injects] = await Promise.all([
      db.message.findMany({ where: { exerciseId, injectId: { not: null } }, select: { id: true, injectId: true } }),
      db.inject.findMany({ where: { exerciseId }, select: { id: true, targeting: true } }),
    ]);
    const ciblages = new Map(injects.map((i) => [i.id, i.targeting]));

    const dus = messages.filter((m) => {
      const cible = (ciblages.get(m.injectId ?? '') ?? {}) as {
        mode?: string;
        characterIds?: unknown;
      };
      if (cible.mode !== 'CHARACTERS') return true;
      return Array.isArray(cible.characterIds) && cible.characterIds.includes(characterId);
    });
    if (dus.length === 0) return;

    await db.messageRecipient.createMany({
      data: dus.map((m) => ({
        tenantId,
        messageId: m.id,
        kind: 'PARTICIPANT' as const,
        participantId,
      })),
      skipDuplicates: true,
    });
  }

  /** Regenere le lien d'acces d'un participant : nouveau jeton, l'ancien devient invalide. */
  async regenerate(
    tenantId: string,