# 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és `Notification`. - **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 `PersistentCollection` s'obtient via `$collection->getMapping()->fieldName` (objet `AssociationMapping`, **pas** un tableau). - `Task` non-nullables : `number` (int), `title` (string), `project` (relation). `assignee` est nullable, `collaborators` est une `Collection`. - En test, on réutilise un `Project` existant (chargé par les fixtures) et on crée des `User` frais (isolation par `uniqid`). - L'acteur courant : `Security::getUser()` lit le token storage. En test, on pose un token via `TokenStorageInterface::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 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 */ 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** ```bash 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éthode `onFlush`) - Test: `tests/Functional/EventListener/TaskNotificationListenerTest.php` (ajout de tests) - [ ] **Step 1: Ajouter les tests collaborateurs (échouent)** Ajouter ces deux méthodes dans `TaskNotificationListenerTest` : ```php 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 `onFlush` pour 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`) : ```php // 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** ```bash 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** ```bash 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 :** `pending` typé `list` ; types `task_assigned` / `task_collaborator_added` identiques entre listener et tests ; `getMapping()->fieldName` (ORM 3) ; `addCollaborator()` confirmé sur l'entité Task.