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

6.4 KiB

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.