<?php
namespace App\Controller;
use App\Entity\User;
use App\Entity\Workroom;
use App\Entity\WorkroomArena;
use App\Entity\WorkroomChatMessage;
use App\Repository\WorkroomArenaRepository;
use App\Repository\WorkroomChatMessageRepository;
use App\Repository\WorkroomRepository;
use App\Security\Voter\WorkroomVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Annotation\Route;
/**
* Chat workroom natif (Phase 23) — remplace l'ancien chat-block CKEditor
* Cloud Services par une implémentation Symfony + Mercure SSE + Doctrine.
*
* Sécurité : voter `WorkroomVoter::WORKROOM_VIEW` (= membre du workroom)
* gardes toutes les routes. Anti-IDOR par UUID forgé.
*
* Realtime : on publie sur le topic Mercure `/workroom/{uuid}/chat` à
* chaque envoi de message + chaque heartbeat presence. Le browser est en
* écoute SSE sur ce topic et merge les messages dans la liste DOM.
*
* Routes :
* - GET /workroom/{uuid}/chat/messages historique 50 derniers
* - POST /workroom/{uuid}/chat/messages envoie + publish Mercure
* - DELETE /workroom/chat/messages/{id} supprime un message (auteur uniquement)
* - POST /workroom/{uuid}/chat/presence heartbeat presence (publish ping)
*/
class WorkroomChatController extends AbstractController
{
/** Garde-fou anti-spam — un client ne peut envoyer plus de messages
* que ça par minute (FeatureRateLimiter au cas où, mais aussi ce cap
* applicatif léger en cas d'abus de l'API direct). */
private const MAX_MESSAGE_LENGTH = 4000;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly WorkroomChatMessageRepository $messages,
private readonly WorkroomRepository $workrooms,
private readonly WorkroomArenaRepository $arenas,
private readonly HubInterface $hub,
) {}
private function currentUser(): ?User
{
$u = $this->getUser();
return $u instanceof User ? $u : null;
}
/**
* Résout un UUID en Workroom OU WorkroomArena (chat polymorphique).
* Voter `WorkroomVoter::WORKROOM_VIEW` appliqué dans les deux cas — pour
* une arène, on vérifie le voter sur le workroom parent (être membre du
* workroom suffit pour voir les arènes qui en sont des forks).
*/
private function loadContainer(string $uuid): Workroom|WorkroomArena|null
{
$w = $this->workrooms->findOneBy(['uuid' => $uuid]);
if ($w) {
if (!$this->isGranted(WorkroomVoter::WORKROOM_VIEW, $w)) return null;
return $w;
}
$arena = $this->arenas->findOneBy(['uuid' => $uuid]);
if ($arena) {
$parent = $arena->getWorkroom();
if (!$parent || !$this->isGranted(WorkroomVoter::WORKROOM_VIEW, $parent)) return null;
return $arena;
}
return null;
}
/** @deprecated remplacé par loadContainer */
private function loadWorkroom(string $uuid): ?Workroom
{
$c = $this->loadContainer($uuid);
return $c instanceof Workroom ? $c : null;
}
/** Historique des 50 derniers messages (DESC). */
#[Route('/workroom/{uuid}/chat/messages', name: 'workroom_chat_list', methods: ['GET'], options: ['expose' => true])]
public function list(string $uuid): JsonResponse
{
$user = $this->currentUser();
if (!$user) return new JsonResponse(['error' => 'unauthorized'], 401);
$container = $this->loadContainer($uuid);
if (!$container) return new JsonResponse(['error' => 'forbidden'], 403);
$items = $container instanceof WorkroomArena
? $this->messages->findRecentForArena($container, 50)
: $this->messages->findRecentForWorkroom($container, 50);
return new JsonResponse([
'messages' => array_map(static fn (WorkroomChatMessage $m) => $m->toArray(), $items),
'topic' => $this->topicForContainer($container),
]);
}
/** Envoie un message + publie sur Mercure pour les autres clients. */
#[Route('/workroom/{uuid}/chat/messages', name: 'workroom_chat_send', methods: ['POST'], options: ['expose' => true])]
public function send(string $uuid, Request $request): JsonResponse
{
$user = $this->currentUser();
if (!$user) return new JsonResponse(['error' => 'unauthorized'], 401);
$container = $this->loadContainer($uuid);
if (!$container) return new JsonResponse(['error' => 'forbidden'], 403);
$body = json_decode($request->getContent(), true) ?? [];
$text = trim((string) ($body['text'] ?? ''));
if ($text === '') return new JsonResponse(['error' => 'text_required'], 400);
if (mb_strlen($text) > self::MAX_MESSAGE_LENGTH) {
return new JsonResponse(['error' => 'text_too_long', 'max' => self::MAX_MESSAGE_LENGTH], 400);
}
$msg = (new WorkroomChatMessage())
->setUser($user)
->setText($text);
if ($container instanceof WorkroomArena) {
$msg->setWorkroomArena($container);
} else {
$msg->setWorkroom($container);
}
$this->em->persist($msg);
$this->em->flush();
try {
$this->hub->publish(new Update(
$this->topicForContainer($container),
json_encode([
'type' => 'message',
'message' => $msg->toArray(),
], JSON_UNESCAPED_UNICODE),
));
} catch (\Throwable) {}
return new JsonResponse(['message' => $msg->toArray()], 201);
}
/** Suppression d'un message (auteur uniquement). */
#[Route('/workroom/chat/messages/{id}', name: 'workroom_chat_delete', methods: ['DELETE'], requirements: ['id' => '\d+'], options: ['expose' => true])]
public function delete(int $id): JsonResponse
{
$user = $this->currentUser();
if (!$user) return new JsonResponse(['error' => 'unauthorized'], 401);
$msg = $this->messages->find($id);
if (!$msg) return new JsonResponse(['error' => 'not_found'], 404);
if ($msg->getUser()?->getId() !== $user->getId()) {
return new JsonResponse(['error' => 'forbidden'], 403);
}
// Vérif permission selon le container du message (workroom direct OU
// arène — pour l'arène on remonte au workroom parent).
$workroom = $msg->getWorkroom();
$arena = $msg->getWorkroomArena();
$checkWorkroom = $workroom ?? $arena?->getWorkroom();
if ($checkWorkroom && !$this->isGranted(WorkroomVoter::WORKROOM_VIEW, $checkWorkroom)) {
return new JsonResponse(['error' => 'forbidden'], 403);
}
$messageId = $msg->getId();
$topic = $workroom ? $this->topicForContainer($workroom)
: ($arena ? $this->topicForContainer($arena) : null);
$this->em->remove($msg);
$this->em->flush();
if ($topic) {
try {
$this->hub->publish(new Update($topic, json_encode([
'type' => 'delete',
'messageId' => $messageId,
], JSON_UNESCAPED_UNICODE)));
} catch (\Throwable) {}
}
return new JsonResponse(['deleted' => true]);
}
/**
* Heartbeat presence — le client appelle cette route toutes les 15s
* tant que le panel chat est ouvert. Le serveur publish un ping sur
* Mercure ; les autres clients en déduisent qui est en ligne (timeout
* 45s côté browser = "user disparu de la presence list").
*
* On ne stocke RIEN en DB (presence = éphémère, pure Mercure).
*/
#[Route('/workroom/{uuid}/chat/presence', name: 'workroom_chat_presence', methods: ['POST'], options: ['expose' => true])]
public function presence(string $uuid, \Psr\Cache\CacheItemPoolInterface $cache): JsonResponse
{
$user = $this->currentUser();
if (!$user) return new JsonResponse(['error' => 'unauthorized'], 401);
$container = $this->loadContainer($uuid);
if (!$container) return new JsonResponse(['error' => 'forbidden'], 403);
// Persist en cache.app pour qu'un autre user puisse fetcher la liste live
// sans dépendre de SSE (filet de sécurité indépendant). Pattern PSR-6
// direct (getItem/set/save) pour pouvoir update en-place.
$cacheKey = self::presenceCacheKey($container);
try {
$now = time();
$item = $cache->getItem($cacheKey);
$map = $item->isHit() ? (array) $item->get() : [];
$map[$user->getId()] = [
'id' => $user->getId(),
'firstName' => $user->getFirstName(),
'lastName' => $user->getLastName(),
'at' => $now,
];
foreach ($map as $uid => $entry) {
if (($entry['at'] ?? 0) < $now - 20) unset($map[$uid]);
}
$item->set($map);
$item->expiresAfter(60);
$cache->save($item);
} catch (\Throwable) {}
try {
$this->hub->publish(new Update($this->topicForContainer($container), json_encode([
'type' => 'presence',
'user' => [
'id' => $user->getId(),
'firstName' => $user->getFirstName(),
'lastName' => $user->getLastName(),
],
'at' => time(),
], JSON_UNESCAPED_UNICODE)));
} catch (\Throwable) {}
return new JsonResponse(['ok' => true]);
}
/**
* GET /workroom/{uuid}/chat/presence — liste des users actuellement en
* ligne pour ce workroom/arena. Filet de sécurité indépendant de SSE :
* le frontend poll cet endpoint toutes les 5-10s pour repeupler la
* presence list si Mercure échoue à délivrer les events temps réel.
*/
#[Route('/workroom/{uuid}/chat/presence', name: 'workroom_chat_presence_list', methods: ['GET'], options: ['expose' => true])]
public function presenceList(string $uuid, \Psr\Cache\CacheItemPoolInterface $cache): JsonResponse
{
$user = $this->currentUser();
if (!$user) return new JsonResponse(['error' => 'unauthorized'], 401);
$container = $this->loadContainer($uuid);
if (!$container) return new JsonResponse(['error' => 'forbidden'], 403);
$now = time();
$users = [];
try {
$item = $cache->getItem(self::presenceCacheKey($container));
$map = $item->isHit() ? (array) $item->get() : [];
foreach ($map as $entry) {
if (($entry['at'] ?? 0) >= $now - 20) {
$users[] = [
'id' => $entry['id'],
'firstName' => $entry['firstName'] ?? '',
'lastName' => $entry['lastName'] ?? '',
];
}
}
} catch (\Throwable) {}
return new JsonResponse(['users' => $users]);
}
private static function presenceCacheKey(Workroom|WorkroomArena $c): string
{
return 'presence_state_'.$c->getUuid();
}
/**
* Topic Mercure du chat. Distinct entre workroom et arène pour ne pas
* mélanger les conversations (une arène = fork avec son propre fil).
*/
private function topicForContainer(Workroom|WorkroomArena $c): string
{
$prefix = $c instanceof WorkroomArena ? '/arena/' : '/workroom/';
return $prefix . $c->getUuid() . '/chat';
}
}