Commit d39c2672 authored by Kourser's avatar Kourser
Browse files

Clôture : confirmation, blocage des écritures joueur, annonce et lien RETEX

Confirmation côté animation. Le bouton Clôturer ouvrait la clôture sans filet ;
il ouvre désormais une boîte de dialogue qui annonce ce que la clôture entraîne
et propose d'ouvrir la collecte du RETEX du même geste — c'est le seul moment où
les joueurs sont encore tous là pour y répondre.

Si la collecte est demandée alors qu'aucune question n'existe, l'appel échoue AVANT
de clôturer, avec un message qui dit quoi faire. Une clôture à moitié réussie dont
l'animateur ignorerait l'échec serait pire qu'un refus.

Blocage des écritures joueur. Le PlayerGuard refuse toute méthode non-GET quand
l'exercice est clôturé ou archivé — même point de contrôle unique que « voir
comme », donc valable pour les routes joueur existantes et futures. La requête de
statut ne porte que sur les écritures : les lectures ne paient rien.

Le questionnaire de RETEX est explicitement exempté. Le fermer avec le reste aurait
vidé de son sens la collecte qu'on vient de proposer à la clôture.

Annonce côté joueur. Modale à la clôture, avec accès direct au questionnaire si la
collecte est ouverte, et « Plus tard » sinon — un joueur peut vouloir relire son fil
avant de répondre. Un joueur qui ouvre son lien après la clôture voit l'annonce
immédiatement, sans dépendre de l'événement temps réel qu'il aurait manqué.

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

Co-Authored-By: Claude (RCA)
parent 0bbac224
Loading
Loading
Loading
Loading
+10 −3
Original line number Diff line number Diff line
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { IsIn, IsOptional, IsNumber, Min } from 'class-validator';
import { IsBoolean, IsIn, IsOptional, IsNumber, Min } from 'class-validator';
import { AuthGuard } from '../auth/auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
@@ -17,6 +17,13 @@ class ClockActionDto {
  speed?: number;
}

class CloseDto {
  /** Ouvre du meme geste la collecte du questionnaire de RETEX. */
  @IsOptional()
  @IsBoolean()
  openRetex?: boolean;
}

@Controller('exercises/:id')
@UseGuards(AuthGuard, RolesGuard)
export class ClockController {
@@ -41,7 +48,7 @@ export class ClockController {

  @Post('close')
  @Roles('TENANT_ADMIN', 'DESIGNER', 'ANIMATOR')
  close(@CurrentUser() me: AuthUser, @Param('id') id: string) {
    return this.clock.closeExercise(me.tenantId, id);
  close(@CurrentUser() me: AuthUser, @Param('id') id: string, @Body() dto: CloseDto) {
    return this.clock.closeExercise(me.tenantId, id, dto.openRetex ?? false);
  }
}
+34 −4
Original line number Diff line number Diff line
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import type { ExerciseStatus } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { RealtimeGateway } from './realtime.gateway';
import { RT } from './engine.types';
import { RetexService } from './retex.service';
import {
  computeElapsedSeconds,
  pauseClock,
@@ -23,6 +24,7 @@ export class ClockService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly rt: RealtimeGateway,
    private readonly retex: RetexService,
  ) {}

  private async load(tenantId: string, exerciseId: string) {
@@ -86,14 +88,42 @@ export class ClockService {
    return view;
  }

  /** Cloture l'exercice et met l'horloge en pause. */
  async closeExercise(tenantId: string, exerciseId: string): Promise<ClockView> {
  /**
   * Cloture l'exercice et met l'horloge en pause.
   *
   * `openRetex` ouvre du meme geste la collecte du questionnaire : c'est le moment
   * ou les joueurs sont encore la. Si la collecte est demandee alors qu'aucune
   * question n'existe, on echoue AVANT de cloturer : mieux vaut un refus explicite
   * qu'une cloture a moitie faite dont l'animateur ne saurait pas qu'elle a rate.
   */
  async closeExercise(
    tenantId: string,
    exerciseId: string,
    openRetex = false,
  ): Promise<ClockView> {
    const ex = await this.load(tenantId, exerciseId);

    if (openRetex) {
      const questions = await this.prisma
        .forTenant(tenantId)
        .retexQuestion.count({ where: { category: { exerciseId } } });
      if (questions === 0) {
        throw new BadRequestException(
          'Aucune question de RETEX : appliquez un modèle de questionnaire, ou clôturez sans ouvrir la collecte',
        );
      }
    }

    const view = await this.apply(tenantId, exerciseId, {
      status: 'CLOSED',
      ...pauseClock(ex, Date.now()),
    });
    this.rt.emitToExercise(exerciseId, RT.exerciseStatus, { status: 'CLOSED' });
    if (openRetex) await this.retex.setOpen(tenantId, exerciseId, true);

    this.rt.emitToExercise(exerciseId, RT.exerciseStatus, {
      status: 'CLOSED',
      retexOpen: openRetex,
    });
    return view;
  }
}
+28 −3
Original line number Diff line number Diff line
@@ -7,6 +7,7 @@ import {
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { Request } from 'express';
import { PrismaService } from '../prisma/prisma.service';
import {
  PLAY_SESSION_HEADER,
  SESSION_PLAY_COOKIE,
@@ -14,9 +15,16 @@ import {
  type PlayerJwtPayload,
} from '../auth/auth.types';

/** Les seules methodes tolerees en session « voir comme ». */
/** Les seules methodes tolerees en session « voir comme » ou apres cloture. */
const READ_METHODS = new Set(['GET', 'HEAD']);

/**
 * Ecritures encore permises apres la cloture de l'exercice.
 * Le questionnaire de RETEX se remplit APRES coup : le fermer avec le reste
 * viderait de son sens la collecte proposee au moment de la cloture.
 */
const ALLOWED_AFTER_CLOSE = ['/play/retex/answers'];

/**
 * Exige une session joueur valide et attache `req.player`.
 *
@@ -32,7 +40,10 @@ const READ_METHODS = new Set(['GET', 'HEAD']);
 */
@Injectable()
export class PlayerGuard implements CanActivate {
  constructor(private readonly jwt: JwtService) {}
  constructor(
    private readonly jwt: JwtService,
    private readonly prisma: PrismaService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const req = context.switchToHttp().getRequest<Request & { player?: PlayerContext }>();
@@ -49,12 +60,26 @@ export class PlayerGuard implements CanActivate {
      throw new UnauthorizedException('Session joueur invalide ou expiree');
    }

    if (p.imp && !READ_METHODS.has(req.method.toUpperCase())) {
    const method = req.method.toUpperCase();
    const isRead = READ_METHODS.has(method);

    if (p.imp && !isRead) {
      throw new ForbiddenException(
        'Session « voir comme » : lecture seule, aucune action au nom du joueur',
      );
    }

    // Exercice cloture : plus aucune ecriture joueur, hors RETEX. La verification
    // ne porte que sur les ecritures — les lectures ne paient pas de requete.
    if (!isRead && !ALLOWED_AFTER_CLOSE.some((suffix) => req.path.endsWith(suffix))) {
      const exercise = await this.prisma
        .forTenant(p.tid)
        .exercise.findFirst({ where: { id: p.eid }, select: { status: true } });
      if (exercise?.status === 'CLOSED' || exercise?.status === 'ARCHIVED') {
        throw new ForbiddenException("L'exercice est clôturé : la saisie est terminée");
      }
    }

    req.player = {
      participantId: p.pid,
      exerciseId: p.eid,
+77 −1
Original line number Diff line number Diff line
@@ -83,6 +83,7 @@ export function Console({
  const [showHandled, setShowHandled] = useState(false);
  const [showForm, setShowForm] = useState(false);
  const [feedTab, setFeedTab] = useState<'calls' | 'mail' | 'activity'>('mail');
  const [closing, setClosing] = useState(false);
  const socketRef = useRef<ReturnType<typeof connectSocket> | null>(null);

  const reload = useCallback(async () => {
@@ -237,7 +238,7 @@ export function Console({
              </button>
            ))}
          {status !== 'CLOSED' && (
            <button className="btn ghost danger" onClick={() => void control(() => api.closeExercise(exerciseId))}>
            <button className="btn ghost danger" onClick={() => setClosing(true)}>
              {t('Clôturer')}
            </button>
          )}
@@ -465,6 +466,81 @@ export function Console({
          )}
        </div>
      </div>

      {closing && (
        <CloseExerciseDialog
          exerciseId={exerciseId}
          onCancel={() => setClosing(false)}
          onClosed={(v) => {
            setClosing(false);
            setClock({ view: v, at: Date.now() });
          }}
        />
      )}
    </div>
  );
}

/**
 * Confirmation de clôture. Une clôture est irréversible pour les joueurs : elle
 * mérite une confirmation explicite, et c'est le seul moment où ils sont encore
 * tous là pour remplir le RETEX — d'où la proposition d'ouvrir la collecte.
 */
function CloseExerciseDialog({
  exerciseId,
  onCancel,
  onClosed,
}: {
  exerciseId: string;
  onCancel: () => void;
  onClosed: (view: ClockView) => void;
}): JSX.Element {
  const { t } = useT();
  const [openRetex, setOpenRetex] = useState(true);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function confirm(): Promise<void> {
    setBusy(true);
    setError(null);
    try {
      onClosed(await api.closeExercise(exerciseId, openRetex));
    } catch (err) {
      setError(err instanceof ApiError ? err.message : t('Clôture impossible'));
      setBusy(false);
    }
  }

  return (
    <div className="modal-overlay" role="dialog" aria-modal="true">
      <div className="modal">
        <h3>{t("Clôturer l'exercice ?")}</h3>
        <p className="muted">
          {t(
            'La saisie des joueurs sera bloquée : plus de messages, de chat, de publications ni de main courante. L’horloge s’arrête.',
          )}
        </p>
        <label className="chip-check">
          <input
            type="checkbox"
            checked={openRetex}
            onChange={(e) => setOpenRetex(e.target.checked)}
          />
          {t('Ouvrir la collecte du RETEX aux joueurs')}
        </label>
        <p className="muted small">
          {t('Les joueurs pourront répondre au questionnaire après la clôture.')}
        </p>
        {error && <p className="err">{error}</p>}
        <div className="row">
          <button className="btn danger" disabled={busy} onClick={() => void confirm()}>
            {busy ? '' : t("Clôturer l'exercice")}
          </button>
          <button className="btn ghost" disabled={busy} onClick={onCancel}>
            {t('Annuler')}
          </button>
        </div>
      </div>
    </div>
  );
}
+51 −0
Original line number Diff line number Diff line
@@ -54,6 +54,8 @@ export function Player({
  const [retexOpen, setRetexOpen] = useState(false);
  // Comptes sous lesquels ce joueur peut publier (le sien, plus l'officiel s'il y est autorise).
  const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
  /** Annonce de cloture : null tant que l'exercice tourne. */
  const [closed, setClosed] = useState<{ retexOpen: boolean } | null>(null);
  const [openDoc, setOpenDoc] = useState<{
    name: string;
    body: string;
@@ -82,6 +84,8 @@ export function Player({
        const session = token ? await api.playResolve(token) : await api.playMe();
        if (cancelled) return;
        setState({ kind: 'ready', session });
        // Lien ouvert apres la cloture : l'annonce doit s'afficher tout de suite.
        if (session.exercise.status === 'CLOSED') setClosed({ retexOpen: false });
        setInbox(await api.playMailbox('inbox'));
        setContacts(await api.playContacts());
        setSocialAccounts(await api.playSocialAccounts());
@@ -116,6 +120,13 @@ export function Player({
          );
        });
        socket.on(RT.retexOpen, (e: { open: boolean }) => setRetexOpen(e.open));
        socket.on(RT.exerciseStatus, (e: { status: string; retexOpen?: boolean }) => {
          if (e.status !== 'CLOSED') return;
          // La collecte peut avoir ete ouverte du meme geste : on l'annonce ici,
          // sans attendre l'evenement retex:open qui arrive separement.
          if (e.retexOpen) setRetexOpen(true);
          setClosed({ retexOpen: e.retexOpen ?? false });
        });

        // Etat initial du questionnaire de RETEX (peut deja etre ouvert).
        try {
@@ -300,6 +311,46 @@ export function Player({

      <ComcheckBanner readOnly={readOnly} />

      {closed && (
        <div className="modal-overlay" role="dialog" aria-modal="true">
          <div className="modal">
            <h3>{t('Exercice terminé')}</h3>
            <p>
              {t(
                'L’animation a clôturé l’exercice. Vous ne pouvez plus envoyer de messages ni écrire dans la main courante.',
              )}
            </p>
            {retexOpen ? (
              <>
                <p className="muted">
                  {t('Votre retour compte : prenez quelques minutes pour le questionnaire.')}
                </p>
                <div className="row">
                  <button
                    className="btn primary"
                    onClick={() => {
                      setView('retex');
                      setClosed(null);
                    }}
                  >
                    {t('Répondre au questionnaire de RETEX')}
                  </button>
                  <button className="btn ghost" onClick={() => setClosed(null)}>
                    {t('Plus tard')}
                  </button>
                </div>
              </>
            ) : (
              <div className="row">
                <button className="btn primary" onClick={() => setClosed(null)}>
                  {t('Fermer')}
                </button>
              </div>
            )}
          </div>
        </div>
      )}

      <nav className="player-nav">
        <button className={view === 'mail' ? 'active' : ''} onClick={() => setView('mail')}>
          {t('Messagerie')}
Loading