Commit bdef3c32 authored by Kourser's avatar Kourser
Browse files

Animation : appels en attente de confirmation, réponse aux messages joueurs

Inject APPEL. Un appel n'est pas « envoyé » par la plateforme : c'est un humain
qui le passe. Son T+ atteint le met désormais en attente (nouveau statut AWAITING)
au lieu de le marquer envoyé, et seule la confirmation explicite de l'animation le
fait passer à SENT. Le chronogramme le range dans un groupe placé avant « en
retard » — rien n'est plus urgent qu'un geste humain attendu — et l'arrivée déclenche
un signal sonore avec bascule sur l'onglet Appels.

`sentAt` reste vide tant que l'appel n'est pas passé : ce champ marque la remise
effective, pas le déclenchement.

`cancel` accepte maintenant AWAITING en plus de PENDING. Sans ça, un appel que
l'animation renonce à passer restait bloqué dans la file, sans issue.

Réponse de l'animation. La boîte d'animation listait les messages des joueurs sans
permettre d'y répondre — le joueur écrivait dans le vide. La réponse part sous
l'identité du personnage auquel il avait écrit, résolue côté serveur : répondre
« Animation » à quelqu'un qui a écrit au support informatique casserait la fiction.
Un message de joueur déclenche aussi signal sonore et bascule d'onglet.

Le bip est synthétisé en WebAudio (aucun fichier, aucune requête sortante) et échoue
en silence si le navigateur refuse : le signal visuel, lui, est toujours présent.

Une migration à appliquer (valeur d'enum ajoutée).

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

Co-Authored-By: Claude (RCA)
parent dea295bb
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
-- Statut AWAITING : un inject APPEL dont le T+ est atteint attend que l'animation
-- passe l'appel puis confirme. Il ne doit jamais basculer a SENT tout seul.
-- Postgres exige que l'ajout de valeur d'enum soit hors transaction implicite ;
-- Prisma execute chaque instruction separement, ce ALTER passe donc tel quel.
ALTER TYPE "InjectStatus" ADD VALUE IF NOT EXISTS 'AWAITING' AFTER 'PENDING';
+3 −0
Original line number Diff line number Diff line
@@ -219,6 +219,9 @@ enum InjectTrigger {
enum InjectStatus {
  DRAFT
  PENDING
  /// Appel dont le T+ est atteint : l'animation est prevenue et doit le passer
  /// elle-meme, puis confirmer. Aucun appel ne part tout seul.
  AWAITING
  SENT
  SKIPPED
  CANCELLED
+7 −0
Original line number Diff line number Diff line
@@ -78,6 +78,13 @@ export class InjectsController {
    return this.injects.reschedule(me.tenantId, id, dto.offsetSeconds);
  }

  /** L'animation confirme avoir passe un appel : c'est ce qui le marque « fait ». */
  @Post('injects/:id/confirm-call')
  @Roles('TENANT_ADMIN', 'DESIGNER', 'ANIMATOR')
  confirmCall(@CurrentUser() me: AuthUser, @Param('id') id: string) {
    return this.injects.confirmCall(me.tenantId, id, me.userId);
  }

  @Post('injects/:id/cancel')
  @Roles('TENANT_ADMIN', 'DESIGNER', 'ANIMATOR')
  cancel(@CurrentUser() me: AuthUser, @Param('id') id: string) {
+71 −4
Original line number Diff line number Diff line
@@ -164,9 +164,13 @@ export class InjectsService {
    return this.prisma.forTenant(tenantId).inject.findFirst({ where: { id } });
  }

  /**
   * Annule un inject non remis. AWAITING est inclus : un appel dont l'animation
   * renonce a le passer doit pouvoir sortir de la file, sinon il y reste bloque.
   */
  async cancel(tenantId: string, id: string) {
    const res = await this.prisma.forTenant(tenantId).inject.updateMany({
      where: { id, status: 'PENDING' },
      where: { id, status: { in: ['PENDING', 'AWAITING'] } },
      data: { status: 'CANCELLED' },
    });
    if (res.count === 0) throw new NotFoundException('Inject introuvable ou deja envoye');
@@ -231,9 +235,18 @@ export class InjectsService {
      if (c) senderName = c.name;
    }

    // Un APPEL n'est pas « envoye » par la plateforme : c'est un humain qui le
    // passe. Son T+ atteint le met en attente de confirmation, jamais a SENT.
    const isCall = inject.channel === 'CALL';
    const reachedStatus = isCall ? 'AWAITING' : 'SENT';
    await db.inject.updateMany({
      where: { id: injectId },
      data: { status: 'SENT', sentAt: new Date(), sentByUserId: userId ?? null },
      data: {
        status: reachedStatus,
        // sentAt marque la remise effective : un appel non encore passe n'en a pas.
        ...(isCall ? {} : { sentAt: new Date() }),
        sentByUserId: userId ?? null,
      },
    });

    // Diffusion propre au canal (confinement : aucune sortie externe).
@@ -294,8 +307,8 @@ export class InjectsService {
    const sentAtIso = new Date().toISOString();
    this.rt.emitToExercise(inject.exerciseId, RT.injectUpdated, {
      injectId,
      status: 'SENT',
      sentAt: sentAtIso,
      status: reachedStatus,
      sentAt: isCall ? null : sentAtIso,
    });
    this.rt.emitToExercise(inject.exerciseId, RT.eventNew, {
      type: 'INJECT_SENT',
@@ -308,6 +321,60 @@ export class InjectsService {
    return db.inject.findFirst({ where: { id: injectId } });
  }

  /**
   * L'animation confirme avoir passe un appel. Seul un APPEL en attente peut etre
   * confirme : c'est la seule voie par laquelle un appel devient « fait ».
   */
  async confirmCall(tenantId: string, injectId: string, userId: string) {
    const db = this.prisma.forTenant(tenantId);
    const inject = await db.inject.findFirst({ where: { id: injectId } });
    if (!inject) throw new NotFoundException('Inject introuvable');
    if (inject.channel !== 'CALL') {
      throw new BadRequestException('Seul un inject « appel » se confirme ainsi');
    }
    if (inject.status !== 'AWAITING') {
      throw new BadRequestException("Cet appel n'est pas en attente de confirmation");
    }

    const sentAt = new Date();
    await db.inject.updateMany({
      where: { id: injectId },
      data: { status: 'SENT', sentAt, sentByUserId: userId },
    });

    const exercise = await db.exercise.findFirst({ where: { id: inject.exerciseId } });
    const atSec = exercise ? computeElapsedSeconds(exercise, sentAt.getTime()) : 0;
    const user = await db.user.findFirst({ where: { id: userId } });
    const actor = user?.displayName ?? 'Animation';
    const message = `☎ Appel passé : ${inject.title}`;

    await db.eventLog.create({
      data: {
        tenantId,
        exerciseId: inject.exerciseId,
        type: 'INJECT_SENT',
        atExerciseSec: atSec,
        actorLabel: actor,
        message,
        metadata: { injectId, channel: 'CALL' },
      },
    });
    this.rt.emitToExercise(inject.exerciseId, RT.injectUpdated, {
      injectId,
      status: 'SENT',
      sentAt: sentAt.toISOString(),
    });
    this.rt.emitToExercise(inject.exerciseId, RT.eventNew, {
      type: 'INJECT_SENT',
      atExerciseSec: atSec,
      actorLabel: actor,
      message,
      at: sentAt.toISOString(),
    });

    return db.inject.findFirst({ where: { id: injectId } });
  }

  /** Livraison d'un inject email : message dans la messagerie unifiee + notification joueurs. */
  private async deliverEmail(
    db: ReturnType<PrismaService['forTenant']>,
+80 −0
Original line number Diff line number Diff line
@@ -283,6 +283,86 @@ export class MailboxService {
    }
  }

  /**
   * Reponse de l'animation a un message qui lui etait adresse.
   *
   * L'animation repond SOUS L'IDENTITE du personnage que le joueur avait ecrit :
   * repondre « Animation » a quelqu'un qui a ecrit au support informatique
   * casserait la fiction. Si le message visait explicitement « Animation », on
   * repond sous ce libelle, faute de personnage a incarner.
   */
  async replyFromAnimation(
    tenantId: string,
    exerciseId: string,
    recipientId: string,
    body: string,
  ): Promise<void> {
    const db = this.prisma.forTenant(tenantId);

    const target = await db.messageRecipient.findFirst({
      where: { id: recipientId, message: { exerciseId } },
      include: {
        message: true,
        character: { select: { id: true, name: true } },
      },
    });
    if (!target) throw new NotFoundException('Message introuvable');

    const author = target.message.authorParticipantId;
    if (!author) {
      throw new BadRequestException("Ce message ne vient pas d'un joueur : rien a repondre");
    }
    const player = await db.participant.findFirst({
      where: { id: author, exerciseId },
      include: { character: { select: { id: true } } },
    });
    if (!player) throw new NotFoundException('Joueur introuvable');

    const exercise = await db.exercise.findFirst({ where: { id: exerciseId } });
    const atSec = exercise ? computeElapsedSeconds(exercise, Date.now()) : 0;
    const authorLabel = target.character?.name ?? 'Animation';
    const subject = target.message.subject.startsWith('Re:')
      ? target.message.subject
      : `Re: ${target.message.subject}`;

    const created = await db.message.create({
      data: {
        tenantId,
        exerciseId,
        subject,
        body,
        origin: 'INJECT',
        authorCharId: target.character?.id ?? null,
        authorLabel,
        inReplyToId: target.messageId,
        sentAtExerciseSec: atSec,
        recipients: {
          create: [
            {
              tenantId,
              kind: 'PARTICIPANT' as const,
              characterId: player.character.id,
              participantId: player.id,
            },
          ],
        },
      },
      include: { recipients: true },
    });

    const r = created.recipients[0];
    this.rt.emitToParticipant(exerciseId, player.id, RT.mailNew, {
      recipientId: r.id,
      messageId: created.id,
      from: authorLabel,
      subject,
      body,
      origin: 'INJECT',
      sentAtExerciseSec: atSec,
      at: new Date().toISOString(),
    });
  }

  async markRead(player: PlayerContext, recipientId: string): Promise<void> {
    const me = await this.prisma
      .forTenant(player.tenantId)
Loading