15 KiB
Task Notifications Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Recréer un producteur de notifications en notifiant le nouvel assigné d'une tâche et les collaborateurs ajoutés, via un listener Doctrine couvrant tous les chemins d'écriture.
Architecture: Un unique TaskNotificationListener Doctrine écoute onFlush (collecte les destinataires à partir des changesets d'assignation et des ajouts de collaborateurs) et postFlush (persiste les Notification puis re-flush). L'acteur courant est lu via Security; on ne se notifie jamais soi-même, et sans acteur authentifié aucune notification n'est créée.
Tech Stack: PHP 8.4, Symfony 8, Doctrine ORM 3.6, PHPUnit (KernelTestCase).
Référence spec
docs/superpowers/specs/2026-06-15-task-notifications-design.md
File Structure
- Create
src/EventListener/TaskNotificationListener.php— listener Doctrine, seul producteur de notifications de tâche. Responsabilité unique : traduire les changements d'assignation/collaboration en entitésNotification. - Create
tests/Functional/EventListener/TaskNotificationListenerTest.php— tests fonctionnels (KernelTestCase) couvrant tous les cas de la spec. - Aucune migration, aucun changement d'entité, aucun changement frontend.
Détails de plateforme vérifiés
- Doctrine ORM 3.6 : le mapping d'une
PersistentCollections'obtient via$collection->getMapping()->fieldName(objetAssociationMapping, pas un tableau). Tasknon-nullables :number(int),title(string),project(relation).assigneeest nullable,collaboratorsest uneCollection.- En test, on réutilise un
Projectexistant (chargé par les fixtures) et on crée desUserfrais (isolation paruniqid). - L'acteur courant :
Security::getUser()lit le token storage. En test, on pose un token viaTokenStorageInterface::setToken().
Task 1: Listener + notifications d'assignation
Files:
-
Create:
src/EventListener/TaskNotificationListener.php -
Test:
tests/Functional/EventListener/TaskNotificationListenerTest.php -
Step 1: Écrire les tests d'assignation (échouent)
Créer tests/Functional/EventListener/TaskNotificationListenerTest.php :
<?php
declare(strict_types=1);
namespace App\Tests\Functional\EventListener;
use App\Entity\Notification;
use App\Entity\Project;
use App\Entity\Task;
use App\Entity\User;
use App\Repository\NotificationRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
/**
* @internal
*/
class TaskNotificationListenerTest extends KernelTestCase
{
private EntityManagerInterface $em;
private NotificationRepository $notifications;
private TokenStorageInterface $tokenStorage;
private Project $project;
private User $actor;
private User $alice;
private User $bob;
protected function setUp(): void
{
self::bootKernel();
$c = self::getContainer();
$this->em = $c->get(EntityManagerInterface::class);
$this->notifications = $c->get(NotificationRepository::class);
$this->tokenStorage = $c->get(TokenStorageInterface::class);
$project = $this->em->getRepository(Project::class)->findOneBy([]);
self::assertNotNull($project, 'Les fixtures doivent fournir au moins un projet.');
$this->project = $project;
$this->actor = $this->makeUser('actor');
$this->alice = $this->makeUser('alice');
$this->bob = $this->makeUser('bob');
$this->em->flush();
}
public function testAssignmentToOtherUserCreatesNotification(): void
{
$this->loginAs($this->actor);
$task = $this->makeTask();
$task->setAssignee($this->alice);
$this->em->persist($task);
$this->em->flush();
$rows = $this->notifications->findBy(['user' => $this->alice]);
self::assertCount(1, $rows);
self::assertSame('task_assigned', $rows[0]->getType());
self::assertStringContainsString((string) $task->getTitle(), (string) $rows[0]->getMessage());
}
public function testSelfAssignmentCreatesNoNotification(): void
{
$this->loginAs($this->actor);
$task = $this->makeTask();
$task->setAssignee($this->actor);
$this->em->persist($task);
$this->em->flush();
self::assertCount(0, $this->notifications->findBy(['user' => $this->actor]));
}
public function testReassignmentNotifiesOnlyNewAssignee(): void
{
$this->loginAs($this->actor);
$task = $this->makeTask();
$task->setAssignee($this->alice);
$this->em->persist($task);
$this->em->flush();
$task->setAssignee($this->bob);
$this->em->flush();
self::assertCount(1, $this->notifications->findBy(['user' => $this->alice]));
self::assertCount(1, $this->notifications->findBy(['user' => $this->bob]));
}
public function testAssigneeSetToNullCreatesNoNotificationForNull(): void
{
$this->loginAs($this->actor);
$task = $this->makeTask();
$task->setAssignee($this->alice);
$this->em->persist($task);
$this->em->flush();
$task->setAssignee(null);
$this->em->flush();
// alice a reçu la 1re notif, mais le passage à null n'en crée aucune autre.
self::assertCount(1, $this->notifications->findBy(['user' => $this->alice]));
}
public function testNoActorCreatesNoNotification(): void
{
$this->tokenStorage->setToken(null);
$task = $this->makeTask();
$task->setAssignee($this->alice);
$this->em->persist($task);
$this->em->flush();
self::assertCount(0, $this->notifications->findBy(['user' => $this->alice]));
}
private function makeUser(string $prefix): User
{
$user = new User();
$user->setUsername($prefix.'-'.uniqid());
$user->setPassword('x');
$user->setRoles(['ROLE_USER']);
$this->em->persist($user);
return $user;
}
private function makeTask(): Task
{
$task = new Task();
$task->setNumber(random_int(100000, 999999));
$task->setTitle('Tâche de test '.uniqid());
$task->setProject($this->project);
return $task;
}
private function loginAs(User $user): void
{
$this->tokenStorage->setToken(
new UsernamePasswordToken($user, 'main', $user->getRoles()),
);
}
}
- Step 2: Lancer les tests pour vérifier qu'ils échouent
Run: docker exec php-lesstime-fpm php bin/phpunit tests/Functional/EventListener/TaskNotificationListenerTest.php
Expected: FAIL — aucune Notification créée (le listener n'existe pas encore), assertCount(1, ...) échoue.
- Step 3: Créer le listener
Créer src/EventListener/TaskNotificationListener.php :
<?php
declare(strict_types=1);
namespace App\EventListener;
use App\Entity\Notification;
use App\Entity\Task;
use App\Entity\User;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
#[AsDoctrineListener(event: Events::onFlush)]
#[AsDoctrineListener(event: Events::postFlush)]
final class TaskNotificationListener
{
/** @var list<array{user: User, type: string, task: Task}> */
private array $pending = [];
public function __construct(private readonly Security $security)
{
}
public function onFlush(OnFlushEventArgs $args): void
{
$actor = $this->security->getUser();
if (!$actor instanceof User) {
return;
}
$uow = $args->getObjectManager()->getUnitOfWork();
// Assignation sur une tâche nouvellement créée.
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Task) {
continue;
}
$assignee = $entity->getAssignee();
if ($assignee instanceof User && $assignee !== $actor) {
$this->pending[] = ['user' => $assignee, 'type' => 'task_assigned', 'task' => $entity];
}
}
// Changement d'assignation sur une tâche existante.
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Task) {
continue;
}
$changeSet = $uow->getEntityChangeSet($entity);
if (!isset($changeSet['assignee'])) {
continue;
}
$new = $changeSet['assignee'][1];
if ($new instanceof User && $new !== $actor) {
$this->pending[] = ['user' => $new, 'type' => 'task_assigned', 'task' => $entity];
}
}
}
public function postFlush(PostFlushEventArgs $args): void
{
if ([] === $this->pending) {
return;
}
$pending = $this->pending;
$this->pending = [];
$em = $args->getObjectManager();
foreach ($pending as $item) {
$em->persist($this->buildNotification($item['user'], $item['type'], $item['task']));
}
$em->flush();
}
private function buildNotification(User $user, string $type, Task $task): Notification
{
[$title, $message] = $this->render($type, $task);
$notification = new Notification();
$notification->setUser($user);
$notification->setType($type);
$notification->setTitle($title);
$notification->setMessage($message);
$notification->setCreatedAt(new DateTimeImmutable());
return $notification;
}
/**
* @return array{0: string, 1: string}
*/
private function render(string $type, Task $task): array
{
$projectName = $task->getProject()?->getName() ?? '';
$suffix = '' !== $projectName ? sprintf(' — %s', $projectName) : '';
$context = sprintf('« %s »%s', (string) $task->getTitle(), $suffix);
return match ($type) {
'task_assigned' => ['Nouvelle tâche assignée', $context],
'task_collaborator_added' => ['Ajout à une tâche', $context],
default => ['Notification', $context],
};
}
}
- Step 4: Lancer les tests pour vérifier qu'ils passent
Run: docker exec php-lesstime-fpm php bin/phpunit tests/Functional/EventListener/TaskNotificationListenerTest.php
Expected: PASS (5 tests).
- Step 5: Commit
git add src/EventListener/TaskNotificationListener.php tests/Functional/EventListener/TaskNotificationListenerTest.php
git commit -m "feat(notification) : notifier le nouvel assigné d'une tâche"
Task 2: Notifications d'ajout de collaborateur
Files:
-
Modify:
src/EventListener/TaskNotificationListener.php(méthodeonFlush) -
Test:
tests/Functional/EventListener/TaskNotificationListenerTest.php(ajout de tests) -
Step 1: Ajouter les tests collaborateurs (échouent)
Ajouter ces deux méthodes dans TaskNotificationListenerTest :
public function testAddingCollaboratorCreatesNotification(): void
{
$this->loginAs($this->actor);
$task = $this->makeTask();
$this->em->persist($task);
$this->em->flush();
$task->addCollaborator($this->alice);
$this->em->flush();
$rows = $this->notifications->findBy(['user' => $this->alice]);
self::assertCount(1, $rows);
self::assertSame('task_collaborator_added', $rows[0]->getType());
}
public function testAddingSelfAsCollaboratorCreatesNoNotification(): void
{
$this->loginAs($this->actor);
$task = $this->makeTask();
$this->em->persist($task);
$this->em->flush();
$task->addCollaborator($this->actor);
$this->em->flush();
self::assertCount(0, $this->notifications->findBy(['user' => $this->actor]));
}
- Step 2: Lancer les nouveaux tests pour vérifier qu'ils échouent
Run: docker exec php-lesstime-fpm php bin/phpunit tests/Functional/EventListener/TaskNotificationListenerTest.php --filter Collaborator
Expected: FAIL — testAddingCollaboratorCreatesNotification échoue (aucune notification créée).
- Step 3: Étendre
onFlushpour gérer les collaborateurs
Dans src/EventListener/TaskNotificationListener.php, ajouter ce bloc à la fin de onFlush(), juste avant la fin de méthode (après la boucle getScheduledEntityUpdates) :
// Ajout de collaborateur(s) (tâche nouvelle ou existante).
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
$owner = $collection->getOwner();
if (!$owner instanceof Task) {
continue;
}
if ('collaborators' !== $collection->getMapping()->fieldName) {
continue;
}
foreach ($collection->getInsertDiff() as $user) {
if ($user instanceof User && $user !== $actor) {
$this->pending[] = ['user' => $user, 'type' => 'task_collaborator_added', 'task' => $owner];
}
}
}
- Step 4: Lancer toute la classe de tests pour vérifier qu'elle passe
Run: docker exec php-lesstime-fpm php bin/phpunit tests/Functional/EventListener/TaskNotificationListenerTest.php
Expected: PASS (7 tests).
- Step 5: Commit
git add src/EventListener/TaskNotificationListener.php tests/Functional/EventListener/TaskNotificationListenerTest.php
git commit -m "feat(notification) : notifier les collaborateurs ajoutés à une tâche"
Task 3: Vérification globale & style
Files: aucun nouveau fichier.
- Step 1: Lancer la suite de tests complète
Run: make test
Expected: PASS (aucune régression).
- Step 2: Corriger le style PHP
Run: make php-cs-fixer-allow-risky
Expected: les nouveaux fichiers sont conformes (strict types, ordre des imports).
- Step 3: Commit si php-cs-fixer a modifié des fichiers
git add -A
git commit -m "style(notification) : php-cs-fixer sur le listener de notifications"
(Sauter cette étape si php-cs-fixer n'a rien changé.)
Self-review (auteur du plan)
- Couverture spec : assignation (création + update) ✔ Task 1 ; collaborateur ajouté ✔ Task 2 ; auto-exclusion ✔ (tests self) ; pas d'acteur → rien ✔ ; réassignation A→B ✔ ;
assignee=null✔ ; contenu réutilisant l'entité existante ✔ ; aucun changement front ✔. - Placeholders : aucun — tout le code (listener + tests) est complet.
- Cohérence des types :
pendingtypélist<array{user,type,task}>; typestask_assigned/task_collaborator_addedidentiques entre listener et tests ;getMapping()->fieldName(ORM 3) ;addCollaborator()confirmé sur l'entité Task.