Merge pull request 'feat(notification) : recâbler les notifications sur les événements de tâche' (#10) from feat/task-notifications into develop
Auto Tag Develop / tag (push) Successful in 9s
Auto Tag Develop / tag (push) Successful in 9s
Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
# 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
|
||||
<?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
|
||||
<?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**
|
||||
|
||||
```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<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.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Notifications sur événements de tâche — Design
|
||||
|
||||
**Date :** 2026-06-15
|
||||
**Ticket lié :** (à créer) — recâblage du système de notifications
|
||||
|
||||
## Contexte & problème
|
||||
|
||||
Le système de notifications de Lesstime est aujourd'hui une **coquille vide** : toute la
|
||||
plomberie consommatrice existe encore (entité `Notification`, `NotificationProvider`,
|
||||
`NotificationRepository`, `NotificationUnreadCountController`, `MarkAllReadController`,
|
||||
et côté front `NotificationBell.vue` + `useNotifications.ts` + `services/notifications.ts`
|
||||
qui poll toutes les 2 min), **mais plus aucun producteur ne crée de notification**.
|
||||
|
||||
Cause : le seul producteur était `NotificationService`, déclenché par les `ClientTicket`
|
||||
du portail client. Le commit `2a0b202` (« suppression du portail client ») a retiré
|
||||
`ClientTicket`, `NotificationService` et les processors associés, laissant la cloche
|
||||
interroger `/notifications/unread-count` dans le vide. Le compteur reste donc à 0 et le
|
||||
dropdown est toujours vide.
|
||||
|
||||
> Le travail récent LST-52 (pagination du `NotificationProvider`) est correct mais portait
|
||||
> sur une liste structurellement toujours vide.
|
||||
|
||||
## Objectif
|
||||
|
||||
Rebrancher la **création** de notifications sur des événements **réels** qui existent
|
||||
encore dans l'app : les événements de **tâche**.
|
||||
|
||||
## Périmètre (MVP)
|
||||
|
||||
### Déclencheurs & destinataires
|
||||
|
||||
| Événement | Détection (changeset Doctrine) | Destinataire | Type |
|
||||
|-----------|-------------------------------|--------------|------|
|
||||
| Tâche assignée (création **ou** modif où `assignee` passe à un nouvel user) | `assignee` : `old ≠ new` et `new ≠ null` | le nouvel assigné | `task_assigned` |
|
||||
| Collaborateur ajouté | `insertDiff` sur la collection `collaborators` | chaque user ajouté | `task_collaborator_added` |
|
||||
|
||||
Règles :
|
||||
- **Auto-exclusion** : si le destinataire == l'acteur courant, aucune notification.
|
||||
- Réassignation A→B : seul **B** est notifié (pas de notification « désassigné » — hors scope).
|
||||
- `assignee` passe à `null` : aucune notification.
|
||||
- Si plusieurs personnes deviennent destinataires dans un même flush, chacune reçoit
|
||||
sa notification.
|
||||
|
||||
### Contenu des notifications
|
||||
|
||||
Réutilise l'entité `Notification` existante (`user`, `type`, `title`, `message`,
|
||||
`isRead`, `createdAt`) — **aucune migration**.
|
||||
|
||||
- `task_assigned` → titre « Nouvelle tâche assignée », message `«{titre tâche}» — {nom projet}`.
|
||||
- `task_collaborator_added` → titre « Ajout à une tâche », message `«{titre tâche}» — {nom projet}`.
|
||||
|
||||
### Décisions de comportement
|
||||
|
||||
1. **Pas d'acteur authentifié → pas de notification.** Les deux chemins utilisateurs réels
|
||||
(frontend JWT, MCP token) ont toujours un user authentifié. CLI / fixtures / cron de
|
||||
récurrence n'ont pas d'acteur → aucune notification. Effet de bord positif : `make fixtures`
|
||||
ne génère pas de notifications parasites.
|
||||
2. **Pas de lien cliquable** vers la tâche dans cette itération (l'entité `Notification`
|
||||
n'a pas de champ URL ; la cloche affiche titre + message + date relative). Extension
|
||||
future possible, hors scope MVP.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Approche retenue : listener Doctrine `onFlush` / `postFlush`** (un seul point de vérité
|
||||
qui couvre tous les chemins d'écriture — frontend API Platform, MCP, et tout futur chemin —
|
||||
puisque tous persistent via `EntityManager::flush()`).
|
||||
|
||||
Approches écartées :
|
||||
- *Décorateur de processor API Platform + hooks dans les tools MCP* : logique dupliquée sur
|
||||
plusieurs endroits, risque d'oublier un chemin (c'est exactement ce type d'oubli qui a créé
|
||||
le bug initial).
|
||||
- *Événements de domaine + Symfony Messenger async* : surdimensionné pour 2 événements,
|
||||
ajoute transport + worker (YAGNI).
|
||||
|
||||
### Composant : `App\EventListener\TaskNotificationListener`
|
||||
|
||||
Enregistré via `#[AsDoctrineListener]` sur les événements `onFlush` et `postFlush`.
|
||||
Dépendances injectées : `Symfony\Bundle\SecurityBundle\Security` (acteur courant).
|
||||
|
||||
**`onFlush(OnFlushEventArgs $args)`** — collecte (ne persiste rien encore) :
|
||||
1. `$uow = $em->getUnitOfWork();`
|
||||
2. Acteur : `$actor = $this->security->getUser();` → si `null`, **on sort** (aucune notif).
|
||||
3. Assignations :
|
||||
- `getScheduledEntityInsertions()` : pour chaque `Task` insérée avec `assignee !== null`
|
||||
et `assignee !== actor` → file `(assignee, 'task_assigned', task)`.
|
||||
- `getScheduledEntityUpdates()` : pour chaque `Task`, `getEntityChangeSet($task)` ;
|
||||
si `isset($cs['assignee'])` avec `[$old, $new] = $cs['assignee']`, `$new !== null`
|
||||
et `$new !== actor` → file `(new, 'task_assigned', task)`.
|
||||
4. Collaborateurs :
|
||||
- `getScheduledCollectionUpdates()` : pour chaque `PersistentCollection` dont l'owner est
|
||||
une `Task` et le champ vaut `collaborators`, `getInsertDiff()` donne les users ajoutés ;
|
||||
pour chacun `!== actor` → file `(user, 'task_collaborator_added', task)`.
|
||||
5. Stocke la file dans une propriété privée du listener.
|
||||
|
||||
**`postFlush(PostFlushEventArgs $args)`** — persiste :
|
||||
1. Si la file est vide, retour immédiat.
|
||||
2. Vide la file dans une variable locale puis **réinitialise la propriété** (anti-réentrance).
|
||||
3. Pour chaque entrée, crée une `Notification` (user, type, title, message, createdAt),
|
||||
`persist`.
|
||||
4. `$em->flush()` une seconde fois. Pas de boucle infinie : les `Notification` ne sont pas
|
||||
des `Task`, donc ce second flush ne reschedule aucune assignation/collaboration.
|
||||
|
||||
## Tests (PHPUnit, `make test`)
|
||||
|
||||
Cas couverts :
|
||||
- Assignation d'une tâche à un user (par un autre acteur) → 1 notification `task_assigned`
|
||||
pour cet user.
|
||||
- Auto-assignation (acteur s'assigne la tâche) → **aucune** notification.
|
||||
- Ajout d'un collaborateur → 1 notification `task_collaborator_added` pour cet user.
|
||||
- Réassignation A→B → seul **B** reçoit une notification.
|
||||
- `assignee` passé à `null` → aucune notification.
|
||||
- Pas d'acteur authentifié (contexte CLI) → aucune notification.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Notifications de changement de statut, d'échéance proche, de désassignation.
|
||||
- Lien cliquable / navigation vers la tâche depuis la notification.
|
||||
- Préférences utilisateur (opt-in/opt-out par type), notifications e-mail.
|
||||
- Modification du front (la cloche consomme déjà l'API et s'affichera dès que des
|
||||
notifications existent).
|
||||
|
||||
## Fichiers impactés
|
||||
|
||||
- **Nouveau** : `src/EventListener/TaskNotificationListener.php`
|
||||
- **Nouveau** : tests PHPUnit (`tests/EventListener/` ou emplacement équivalent au projet).
|
||||
- **Aucune** migration, **aucun** changement d'entité, **aucun** changement front.
|
||||
@@ -0,0 +1,123 @@
|
||||
<?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];
|
||||
}
|
||||
}
|
||||
|
||||
// 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\EventListener;
|
||||
|
||||
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]));
|
||||
}
|
||||
|
||||
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]));
|
||||
}
|
||||
|
||||
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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user