Files
Lesstime/docs/superpowers/plans/2026-06-15-task-notifications.md
T

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é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

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é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 :

    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) :

        // 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 : pending typé list<array{user,type,task}> ; types task_assigned / task_collaborator_added identiques entre listener et tests ; getMapping()->fieldName (ORM 3) ; addCollaborator() confirmé sur l'entité Task.