39 KiB
LST-63 (1.1) — Module Core : Identité (User/Auth/JWT) & 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: Migrer l'identité (User + Auth/JWT + password hashing + MeProvider) et les notifications dans src/Module/Core/, exposer le contrat UserInterface enrichi + NotifierInterface, déclarer CoreModule (REQUIRED), et créer le premier vrai layer front modules/core/ — sans aucune migration destructive et sans casser le login à aucune étape.
Architecture: Strangler 100 % additif, phasé. On déplace physiquement la classe User vers App\Module\Core\Domain\Entity\User (table user inchangée → zéro migration), on re-pointe resolve_target_entities et le provider de sécurité, puis on bascule les 8 relations d'entités et les 26 consommateurs du concret App\Entity\User vers le contrat App\Shared\Domain\Contract\UserInterface (enrichi des accessors réellement utilisés). Les notifications passent par un NotifierInterface (impl Core). Chaque phase laisse make test vert ET le login JWT fonctionnel (re-vérifié par curl).
Tech Stack: PHP 8.4 / Symfony 8 / API Platform 4 / Doctrine ORM / lexik/jwt-authentication / PostgreSQL 16 / PHPUnit 13 — front Nuxt 4.3 / Vue 3.5 / Pinia 3.
Global Constraints
declare(strict_types=1);en tête de chaque fichier PHP.- Zéro migration destructive : le déplacement de namespace ne change ni la table (
user) ni les colonnes →doctrine:migrations:diffdoit produire un diff VIDE. Si un diff non vide apparaît, c'est un bug (mapping mal recopié) — corriger, ne pas générer la migration. - Login JWT fonctionnel à chaque phase : vérif curl obligatoire (voir « Vérification login » ci-dessous) après toute phase touchant
User/sécurité. - AC ticket : (1) login/JWT OK via le module ; (2) aucun
use App\Entity\User;horssrc/Module/Core/; (3)make testvert, aucune migration destructive. - Commits :
<type>(<scope>) : <message>(espaces autour du:). Jamais de mention IA/Claude/Anthropic. config/reference.php: auto-généré, jamais committé (apparaît modifié dansgit status).- Tests :
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit. Baseline avant ce ticket : 115 tests, 227 assertions (16 PHPUnit Notices préexistantes, non bloquantes). - Front :
nuxt typecheckn'est PAS un gate vert sur ce stack (cf. plan LST-62) — gate front = zéroCannot find module, auto-imports présents dans.nuxt/imports.d.ts, smoke runtime. - PostgreSQL : noms de colonnes en minuscules dans le SQL brut.
Vérification login (à exécuter après chaque phase back touchant User/sécurité)
# Doit renvoyer http=204 (cookie BEARER posé) puis le profil courant
curl -s -c /tmp/cj.txt -X POST http://localhost:8082/api/login_check \
-H "Content-Type: application/json" -d '{"username":"alice","password":"alice"}' \
-o /dev/null -w "login http=%{http_code}\n"
curl -s -b /tmp/cj.txt http://localhost:8082/api/me -w "\nme http=%{http_code}\n" | head -c 400
# MCP apiToken (ApiTokenAuthenticator) — admin
curl -s -X POST http://localhost:8082/_mcp -H "Authorization: Bearer dev-mcp-token-for-testing-only-do-not-use-in-production" \
-H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"ping"}' -o /dev/null -w "mcp http=%{http_code}\n"
Attendu : login http=204, me http=200 avec le JSON de l'utilisateur (username, roles), MCP répond (200). Si l'un casse, arrêter la phase et corriger avant de committer.
Décisions de conception (actées, à valider PO a posteriori)
UserInterfaceenrichi (contrat de lecture) — plutôt que de garderApp\Entity\Userpartout, on enrichitApp\Shared\Domain\Contract\UserInterfacedes accessors réellement consommés hors Core (lecture). Les setters/écriture restent sur le concret (Core uniquement). Cela permet de typer les 8 relations et les 26 consommateurs sur le contrat sans casse.- Move physique, table inchangée —
Userchange de namespace mais garde#[ORM\Table(name: 'user')]et toutes ses colonnes → aucune migration. La classe reste une entité Doctrine mappée (nouveau dir de mappingCore). - Relations via le contrat — les 8 entités passent à
targetEntity: UserInterface::class+ type?UserInterface, résolu parresolve_target_entities → Core\User. C'est le pattern Starseed. - Notification dans Core +
NotifierInterface—Notificationmigre dans Core (couplée à l'identité) ; la création de notif passe parNotifierInterface(impl Core),TaskNotificationListener(qui reste legacy en Phase D) en dépend par contrat. L'API REST/api/notificationsest préservée à l'identique. - Front layer
modules/core/— login, profile, admin users déplacés defrontend/pages/versfrontend/modules/core/pages/(premier layer réel ; le scanreaddirSync('modules/')de LST-62 l'enregistre automatiquement). Le routage Nuxt est préservé (mêmes chemins d'URL).
Phase A — Squelette Core + contrats (100 % additif, app inchangée)
Task 1: CoreModule + UserRepositoryInterface + NotifierInterface + contrat UserInterface enrichi
Files:
- Create:
src/Module/Core/CoreModule.php - Create:
src/Module/Core/Domain/Repository/UserRepositoryInterface.php - Create:
src/Shared/Domain/Contract/NotifierInterface.php - Modify:
src/Shared/Domain/Contract/UserInterface.php(enrichir) - Create:
tests/Unit/Module/Core/CoreModuleTest.php
Interfaces:
-
Produces :
App\Module\Core\CoreModule implements ModuleInterface:id()='core',label()='Core',isRequired()=true,permissions()(stub pour 1.2, voir code).App\Module\Core\Domain\Repository\UserRepositoryInterface:findByRole(string $role): array,findActiveEmployees(\DateTimeInterface $date): array,findOneByUsername(string $username): ?UserInterface.App\Shared\Domain\Contract\NotifierInterface:notify(UserInterface $user, string $type, string $title, string $message): void.UserInterfaceenrichi (lecture) :getId(): ?int,getUserIdentifier(): string,getUsername(): string,getRoles(): array,getFirstName(): ?string,getLastName(): ?string,getAvatarUrl(): ?string,isEmployee(): bool.
-
Consumes :
App\Shared\Domain\Module\ModuleInterface(existant). -
Step 1: Écrire le test unitaire
CoreModule
tests/Unit/Module/Core/CoreModuleTest.php :
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Module\Core;
use App\Module\Core\CoreModule;
use App\Shared\Domain\Module\ModuleInterface;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class CoreModuleTest extends TestCase
{
public function testItIsAModule(): void
{
self::assertInstanceOf(ModuleInterface::class, new CoreModule());
}
public function testIdentity(): void
{
self::assertSame('core', CoreModule::id());
self::assertTrue(CoreModule::isRequired());
self::assertNotSame('', CoreModule::label());
}
public function testPermissionsAreWellFormed(): void
{
foreach (CoreModule::permissions() as $permission) {
self::assertArrayHasKey('code', $permission);
self::assertArrayHasKey('label', $permission);
}
}
}
- Step 2: Lancer le test, vérifier l'échec
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Module/Core/CoreModuleTest.php
Expected: FAIL (classe CoreModule inexistante).
- Step 3: Créer
CoreModule
src/Module/Core/CoreModule.php :
<?php
declare(strict_types=1);
namespace App\Module\Core;
use App\Shared\Domain\Module\ModuleInterface;
final class CoreModule implements ModuleInterface
{
public static function id(): string
{
return 'core';
}
public static function label(): string
{
return 'Core';
}
public static function isRequired(): bool
{
return true;
}
/**
* Permissions posées pour le RBAC fin (1.2). Inertes tant que 1.2 n'est pas livré.
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'core.user.read', 'label' => 'Consulter les utilisateurs'],
['code' => 'core.user.manage', 'label' => 'Gérer les utilisateurs'],
['code' => 'core.notification.read', 'label' => 'Consulter ses notifications'],
];
}
}
⚠️ Confirmer la signature EXACTE de
ModuleInterface(src/Shared/Domain/Module/ModuleInterface.php) avant d'écrire : la cartographie indiqueid(),label(),isRequired(),permissions()statiques. Si une méthode diffère (ex. non statique), alignerCoreModuleET le test dessus.
- Step 4: Lancer le test, vérifier le vert
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Module/Core/CoreModuleTest.php
Expected: PASS (3 tests).
- Step 5: Enrichir le contrat
UserInterface
Remplace src/Shared/Domain/Contract/UserInterface.php par :
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat de LECTURE de l'identité, consommé hors du module Core.
* Les écritures (setPassword, setters HR…) restent sur le concret Core\Domain\Entity\User.
*/
interface UserInterface
{
public function getId(): ?int;
public function getUserIdentifier(): string;
public function getUsername(): string;
/** @return list<string> */
public function getRoles(): array;
public function getFirstName(): ?string;
public function getLastName(): ?string;
public function getAvatarUrl(): ?string;
public function isEmployee(): bool;
}
⚠️ Cet enrichissement DOIT correspondre à des méthodes existantes de l'entité
User(la cartographie confirmegetId,getUserIdentifier,getUsername,getRoles,getFirstName,getLastName,getAvatarUrl,isEmployee). Si une signature diffère (ex.getAvatarUrl(): stringnon-nullable), aligner le contrat sur le réel. Ne PAS ajouter au contrat une méthode absente deUser.
- Step 6: Créer
UserRepositoryInterface
src/Module/Core/Domain/Repository/UserRepositoryInterface.php :
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Shared\Domain\Contract\UserInterface;
interface UserRepositoryInterface
{
/**
* @return list<UserInterface>
*/
public function findByRole(string $role): array;
/**
* @return list<UserInterface>
*/
public function findActiveEmployees(\DateTimeInterface $date): array;
public function findOneByUsername(string $username): ?UserInterface;
}
- Step 7: Créer
NotifierInterface
src/Shared/Domain/Contract/NotifierInterface.php :
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
interface NotifierInterface
{
public function notify(UserInterface $user, string $type, string $title, string $message): void;
}
- Step 8: Suite complète + commit
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
Expected: PASS (115 + 3 = 118 tests). L'enrichissement du contrat ne casse rien (l'entité User implémente déjà ces méthodes ; resolve_target_entities pointe encore App\Entity\User).
Run: make php-cs-fixer-allow-risky
git add src/Module/Core/CoreModule.php src/Module/Core/Domain/Repository/UserRepositoryInterface.php src/Shared/Domain/Contract/NotifierInterface.php src/Shared/Domain/Contract/UserInterface.php tests/Unit/Module/Core/CoreModuleTest.php
git commit -m "feat(core) : add CoreModule, user repository contract, notifier contract and enriched user contract"
Phase B — Déplacer User + Auth dans Core (re-pointage, zéro migration)
Task 2: Déplacer la classe User vers Core + mapping Doctrine + provider sécurité
Files:
- Move:
src/Entity/User.php→src/Module/Core/Domain/Entity/User.php(namespaceApp\Module\Core\Domain\Entity) - Modify:
config/packages/doctrine.yaml(mappingCore+resolve_target_entities) - Modify:
config/packages/security.yaml(app_user_provider.entity.class) - Modify:
config/packages/api_platform.yaml(mapping paths : ajouter le dir entité Core)
Interfaces:
-
Produces : entité
App\Module\Core\Domain\Entity\User(tableuserinchangée), résolue parresolve_target_entities. -
Step 1: Déplacer le fichier (git mv) et changer le namespace
cd /home/matthieu/dev_malio/Lesstime
mkdir -p src/Module/Core/Domain/Entity
git mv src/Entity/User.php src/Module/Core/Domain/Entity/User.php
Puis éditer src/Module/Core/Domain/Entity/User.php :
namespace App\Entity;→namespace App\Module\Core\Domain\Entity;- Adapter les
useinternes devenus nécessaires (l'entité référençaitUserRepository,MeProvider,UserPasswordHasherProcessor, l'enumContractType, le contratUserInterface as SharedUserInterface). Mettre lesusecomplets vers leurs emplacements ACTUELS (la plupart bougent en Tasks 3/4 ; pour cette task, pointer encore versApp\Repository\UserRepository,App\State\MeProvider,App\State\UserPasswordHasherProcessor,App\Entity\Enum\ContractTypeou l'emplacement réel — vérifier lesused'origine et les conserver tels quels tant que ces classes n'ont pas bougé). - Garder VERBATIM : tous les attributs
#[ORM\...](dont#[ORM\Table(name: 'user')]),#[ApiResource(...)],#[ApiProperty(...)], toutes les propriétés/méthodes,implements UserInterface, PasswordAuthenticatedUserInterface, SharedUserInterface.
⚠️ Lire le fichier d'origine en entier AVANT de déplacer pour relever tous les
use. Ne changer QUE lenamespaceet, si besoin, garder lesusepointant vers les emplacements actuels des classes non encore déplacées.
- Step 2: Mapping Doctrine + resolve_target_entities
Dans config/packages/doctrine.yaml, sous orm: :
resolve_target_entities:
resolve_target_entities:
App\Shared\Domain\Contract\UserInterface: App\Module\Core\Domain\Entity\User
- Ajouter un mapping pour les entités Core (en plus du mapping
Appexistant qui scannesrc/Entity) :
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
Core:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
prefix: 'App\Module\Core\Domain\Entity'
Le mapping
App(src/Entity) ne contient plusUser.php(déplacé) → cohérent. Aucune entité orpheline.
- Step 3: Provider de sécurité
Dans config/packages/security.yaml :
providers:
app_user_provider:
entity:
class: App\Module\Core\Domain\Entity\User
property: username
- Step 4: API Platform mapping paths
Dans config/packages/api_platform.yaml, ajouter au mapping.paths le dossier entité Core (l'#[ApiResource] est porté par l'entité User déplacée) :
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
Conserver tous les paths existants. Si
api_platform.yamln'a pas demapping.pathsexplicite (auto-discovery), vérifier que les Resources soussrc/Module/...sont bien découvertes (commesrc/Shared/...l'a été en #56 — cf. LEARNINGS : API Platform 4 auto-découvre). Si la découverte auto suffit, NE PAS ajouter de path ; sinon ajouter celui ci-dessus.
- Step 5: Vider le cache + vérifier qu'AUCUNE migration n'est nécessaire
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction 2>&1 | tail -20
Expected : schema VALID (mapping ok, sync DB ok). Le diff doit annoncer « No changes detected » (table/colonnes identiques). Si une migration est générée, la SUPPRIMER (git status → retirer le fichier sous migrations/) : un diff non vide = mapping mal recopié, corriger l'entité.
- Step 6: Vérif login + suite complète
Exécuter le bloc « Vérification login » (curl) → login http=204, me http=200, MCP 200.
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
Expected: PASS (118). Les consommateurs importent encore App\Entity\User → ERREUR attendue : la classe n'existe plus à cet emplacement. ⇒ Cette task NE PASSE PAS seule ; elle est indissociable de la Task 3 (rewire). Voir note ci-dessous.
🔴 Note d'ordonnancement : déplacer
Usercasse les 26use App\Entity\User;. Pour garder l'app bootable entre Task 2 et Task 3, ajouter un alias de compatibilité TEMPORAIRE au tout début de Task 2 et le retirer en fin de Task 3 : Créersrc/Module/Core/_compat_user_alias.php(chargé viacomposer.jsonautoload.files) :<?php declare(strict_types=1); if (!class_exists(\App\Entity\User::class, false)) { class_alias(\App\Module\Core\Domain\Entity\User::class, \App\Entity\User::class); }Ajouter
"files": ["src/Module/Core/_compat_user_alias.php"]sousautoloaddanscomposer.json, puiscomposer dump-autoload. Cela garde les 26 consommateurs fonctionnels (et DoctrinetargetEntity: User::classrésolu via l'alias) le temps de la Task 3. L'alias est SUPPRIMÉ en Task 3 Step final (avec le retrait du fichier, l'entrée composer et un nouveaudump-autoload) une fois tous les consommateurs basculés sur le contrat. La verif login de cette Step utilise donc l'alias — c'est attendu.
- Step 7: php-cs-fixer + commit (Phase B, avec alias temporaire)
Run: make php-cs-fixer-allow-risky
git add src/Module/Core/Domain/Entity/User.php src/Module/Core/_compat_user_alias.php composer.json composer.lock config/packages/doctrine.yaml config/packages/security.yaml config/packages/api_platform.yaml
git commit -m "feat(core) : move user entity into core module and repoint security/doctrine (temp legacy alias)"
Phase C — Basculer relations + consommateurs sur le contrat, retirer l'alias
Task 3: Relations d'entités → UserInterface::class
Files (8 entités):
- Modify:
src/Entity/Task.php(assignee ManyToOne, collaborators ManyToMany) - Modify:
src/Entity/TimeEntry.php(user) - Modify:
src/Entity/AbsenceRequest.php(user) - Modify:
src/Entity/AbsenceBalance.php(user) - Modify:
src/Entity/TaskDocument.php(user) - Modify:
src/Entity/TaskMailLink.php(user) - Modify:
src/Module/Core/Domain/Entity/Notification.php(user) — après son déplacement en Phase D ; en Phase C,Notificationest encoresrc/Entity/Notification.php, la traiter ici aussi.
Pour CHAQUE relation vers User : remplacer
use App\Entity\User;paruse App\Shared\Domain\Contract\UserInterface;, letargetEntity: User::classpartargetEntity: UserInterface::class, et le type de propriété/param?User→?UserInterface(idem getters/setters). Doctrine résout viaresolve_target_entities. La colonne FK et son nom restent identiques → aucune migration.
- Step 1: Modifier les relations (entité par entité)
Pour chaque fichier ci-dessus, lire puis appliquer le remplacement décrit. Exemple Task.php (assignee) :
// avant
use App\Entity\User;
#[ORM\ManyToOne(targetEntity: User::class)]
private ?User $assignee = null;
public function getAssignee(): ?User { return $this->assignee; }
public function setAssignee(?User $assignee): static { $this->assignee = $assignee; return $this; }
// collaborators
#[ORM\ManyToMany(targetEntity: User::class)]
private Collection $collaborators;
// après
use App\Shared\Domain\Contract\UserInterface;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
private ?UserInterface $assignee = null;
public function getAssignee(): ?UserInterface { return $this->assignee; }
public function setAssignee(?UserInterface $assignee): static { $this->assignee = $assignee; return $this; }
#[ORM\ManyToMany(targetEntity: UserInterface::class)]
private Collection $collaborators;
⚠️ Conserver tous les autres attributs de relation (
inversedBy,joinTable,joinColumn,nullable,onDelete, Groups…) VERBATIM. Ne changer que le type ettargetEntity.
- Step 2: Valider le schéma (toujours zéro migration)
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction 2>&1 | tail -5
Expected : « No changes detected ». Sinon corriger (un joinColumn/onDelete a été perdu).
Task 4: Consommateurs (26 fichiers) → contrat + repository interface, MeProvider/Processor dans Core, retrait alias
Files: les 26 fichiers listés dans la cartographie (Controllers, Repositories, State, Services, EventListener, Security, DataFixtures, Mcp). Déplacements vers Core :
-
Move:
src/Repository/UserRepository.php→src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php(implémenteUserRepositoryInterface, namespaceApp\Module\Core\Infrastructure\Doctrine) -
Move:
src/State/MeProvider.php→src/Module/Core/Infrastructure/ApiPlatform/State/MeProvider.php -
Move:
src/State/UserPasswordHasherProcessor.php→src/Module/Core/Infrastructure/ApiPlatform/State/UserPasswordHasherProcessor.php -
Modify: l'
#[ApiResource]de l'entitéUser(lesprovider:/processor:pointent vers les nouveaux FQCN Core). -
Delete (en fin de task):
src/Module/Core/_compat_user_alias.php+ entréecomposer.json. -
Step 1: Déplacer le repository et l'aligner sur l'interface
mkdir -p src/Module/Core/Infrastructure/Doctrine src/Module/Core/Infrastructure/ApiPlatform/State
git mv src/Repository/UserRepository.php src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php
git mv src/State/MeProvider.php src/Module/Core/Infrastructure/ApiPlatform/State/MeProvider.php
git mv src/State/UserPasswordHasherProcessor.php src/Module/Core/Infrastructure/ApiPlatform/State/UserPasswordHasherProcessor.php
Éditer DoctrineUserRepository.php : namespace App\Module\Core\Infrastructure\Doctrine;, class DoctrineUserRepository extends ServiceEntityRepository implements UserRepositoryInterface, use App\Module\Core\Domain\Entity\User;, use App\Module\Core\Domain\Repository\UserRepositoryInterface;, et passer User::class au constructeur parent. Ajouter findOneByUsername() si absent (return $this->findOneBy(['username' => $username]);). Conserver findByRole() (SQL natif roles::text LIKE) et findActiveEmployees().
Éditer User.php : #[ORM\Entity(repositoryClass: DoctrineUserRepository::class)] avec le bon use.
Éditer MeProvider.php / UserPasswordHasherProcessor.php : nouveaux namespaces ; use App\Module\Core\Domain\Entity\User; (le processor manipule le concret — c'est dans Core, autorisé).
Mettre à jour les provider:/processor: dans l'#[ApiResource] de User vers les nouveaux FQCN.
- Step 2: Lier l'interface repository au service Doctrine
Dans config/services.yaml, alias pour l'injection par interface :
App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository'
- Step 3: Basculer les 25 autres consommateurs sur le contrat
Pour chaque fichier important App\Entity\User (hors Core), remplacer use App\Entity\User; par use App\Shared\Domain\Contract\UserInterface; et le type-hint User par UserInterface (params, retours, propriétés, @var, expressions). Cas particuliers :
src/Repository/{Notification,AbsenceBalance,AbsenceRequest,TimeEntry}Repository.php: les signaturescountUnreadByUser(User $user)etc. →UserInterface. Ne pas changer la logique DQL (n.user = :userfonctionne avec l'instance).src/State/Absence*,TaskDocumentProvider,src/Service/AbsenceBalanceService,src/Security/MailAccessChecker,src/EventListener/TaskNotificationListener(sera retravaillé en Phase D mais peut déjà passer au contrat ici),src/Controller/*(7),src/Mcp/Tool/Absence/ReviewAbsenceRequestTool,src/Mcp/Tool/Serializer: remplacer le type-hint.src/DataFixtures/AppFixtures.php: garde le concretApp\Module\Core\Domain\Entity\User(les fixtures INSTANCIENTnew User()et appellent des setters d'écriture — c'est légitime ; importer le concret Core, pas le contrat). C'est horssrc/Module/Core/mais c'est de l'écriture d'identité → exception documentée (les fixtures sont un cas d'amorçage, pas un consommateur métier).
Liste de contrôle : après cette step,
grep -rn "use App\\\\Entity\\\\User;" src/ne doit retourner QUEsrc/DataFixtures/AppFixtures.php(qui importe désormais le FQCN Core, donc 0 occurrence deApp\Entity\User). Viser 0 occurrence deApp\Entity\Userdans toutsrc/.
- Step 4: Retirer l'alias de compatibilité
git rm src/Module/Core/_compat_user_alias.php
Retirer l'entrée "files": [...] ajoutée sous autoload dans composer.json (Task 2), puis :
docker exec -t -u www-data php-lesstime-fpm composer dump-autoload
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
- Step 5:
grepde garde (AC 2) + schéma + tests + login
grep -rn "App\\\\Entity\\\\User" src/ config/ ; echo "(doit être VIDE)"
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction 2>&1 | tail -5
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
Expected : grep VIDE, schéma valide, « No changes detected », 118 tests verts. Puis bloc « Vérification login » (login 204, me 200, MCP 200).
- Step 6: php-cs-fixer + commit (Phase C)
Run: make php-cs-fixer-allow-risky
git add -A -- src config composer.json composer.lock
git commit -m "refactor(core) : wire user relations and consumers to the shared contract, drop legacy alias"
⚠️ NE PAS
git add config/reference.php. Vérifiergit statusavant le commit ; sireference.phpest listé, l'exclure dugit add(stager explicitement les fichiers voulus).
Phase D — Notifications via NotifierInterface (impl Core)
Task 5: Déplacer Notification dans Core + Notifier (impl) + recâbler le listener
Files:
-
Move:
src/Entity/Notification.php→src/Module/Core/Domain/Entity/Notification.php -
Move:
src/Repository/NotificationRepository.php→src/Module/Core/Infrastructure/Doctrine/DoctrineNotificationRepository.php -
Move:
src/State/NotificationProvider.php→src/Module/Core/Infrastructure/ApiPlatform/State/NotificationProvider.php -
Create:
src/Module/Core/Infrastructure/Notifier.php(implementsNotifierInterface) -
Modify:
src/EventListener/TaskNotificationListener.php(dépend deNotifierInterface) -
Modify:
config/packages/doctrine.yaml(le mappingCorecouvre déjàDomain/Entity→ Notification incluse automatiquement) -
Modify:
tests/— ajoutertests/Unit/Module/Core/NotifierTest.php(ou Functional) si testable unitairement. -
Step 1: Écrire un test du
Notifier
tests/Functional/Module/Core/NotifierTest.php (crée une notif et vérifie la persistance) :
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Core;
use App\Module\Core\Domain\Entity\User;
use App\Shared\Domain\Contract\NotifierInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*/
final class NotifierTest extends KernelTestCase
{
public function testNotifyPersistsANotificationForTheUser(): void
{
self::bootKernel();
$em = self::getContainer()->get(EntityManagerInterface::class);
$notifier = self::getContainer()->get(NotifierInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
self::assertNotNull($user);
$notifier->notify($user, 'task_assigned', 'Titre', 'Message');
$count = (int) $em->createQuery(
'SELECT COUNT(n.id) FROM App\\Module\\Core\\Domain\\Entity\\Notification n WHERE n.user = :u AND n.title = :t'
)->setParameter('u', $user)->setParameter('t', 'Titre')->getSingleScalarResult();
self::assertSame(1, $count);
}
}
- Step 2: Lancer, vérifier l'échec —
NotifierInterfacenon instanciable /Notificationintrouvable au nouveau namespace.
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/NotifierTest.php
Expected: FAIL.
- Step 3: Déplacer
Notification+ repository + provider
git mv src/Entity/Notification.php src/Module/Core/Domain/Entity/Notification.php
git mv src/Repository/NotificationRepository.php src/Module/Core/Infrastructure/Doctrine/DoctrineNotificationRepository.php
git mv src/State/NotificationProvider.php src/Module/Core/Infrastructure/ApiPlatform/State/NotificationProvider.php
-
Notification.php:namespace App\Module\Core\Domain\Entity;,use App\Shared\Domain\Contract\UserInterface;, relationuser→targetEntity: UserInterface::class+ type?UserInterface,repositoryClass: DoctrineNotificationRepository::class, conserver#[ORM\Table(name:'notification')]+ index VERBATIM, ApiResource (provider → nouveau FQCN). Table/colonnes inchangées. -
DoctrineNotificationRepository.php: namespace Core,use App\Module\Core\Domain\Entity\Notification;, signaturesUserInterface. -
NotificationProvider.php: namespace Core, mêmes dépendances. -
Step 4: Implémenter
Notifier
src/Module/Core/Infrastructure/Notifier.php :
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure;
use App\Module\Core\Domain\Entity\Notification;
use App\Shared\Domain\Contract\NotifierInterface;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\ORM\EntityManagerInterface;
final readonly class Notifier implements NotifierInterface
{
public function __construct(private EntityManagerInterface $em) {}
public function notify(UserInterface $user, string $type, string $title, string $message): void
{
$notification = new Notification();
$notification->setUser($user);
$notification->setType($type);
$notification->setTitle($title);
$notification->setMessage($message);
$this->em->persist($notification);
$this->em->flush();
}
}
⚠️ Aligner sur les setters réels de
Notification(la cartographie indiqueuser,type,title,message,isReaddefault false,createdAt). SicreatedAtn'est pas auto (prePersist), le poser ici. SisetUserattend le concret, accepterUserInterface(resolve_target_entities) — vérifier le type du setter.
- Step 5: Recâbler
TaskNotificationListenersurNotifierInterface
Lire le listener ; remplacer la création directe de Notification (new Notification() + persist) par l'injection et l'appel de NotifierInterface::notify(...). Attention : le listener tourne sur onFlush/postFlush — un flush() dans notify() pendant un onFlush est dangereux. Conserver le pattern existant (accumulation en onFlush, écriture en postFlush). Si notify() flush, l'appeler UNIQUEMENT en postFlush (jamais pendant onFlush). Préserver le comportement exact (mêmes types task_assigned/task_collaborator_added, mêmes destinataires). Adapter le test existant du listener s'il y en a un.
Si l'intrication onFlush/postFlush rend
NotifierInterfaceinadapté (flush imbriqué), documenter et garder le listener en écriture directe via le repository Core, mais TOUJOURS dépendre du contrat pour le type User. Le but AC est « Notification exposée via NotifierInterface » :NotifierInterfacedoit exister et être l'API publique pour les autres modules ; le listener interne Core peut écrire directement.
- Step 6: Tests + login + endpoints notifications
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
Expected: PASS (118 + 1 = 119). Vérifier doctrine:migrations:diff → « No changes detected ». Bloc login. Puis curl notifications :
curl -s -b /tmp/cj.txt "http://localhost:8082/api/notifications" -w "\nnotif http=%{http_code}\n" | head -c 200
curl -s -b /tmp/cj.txt "http://localhost:8082/api/notifications/unread-count" -w "\nunread http=%{http_code}\n"
Expected : 200 sur les deux.
- Step 7: php-cs-fixer + commit
Run: make php-cs-fixer-allow-risky
git add -A -- src config tests
git commit -m "feat(core) : move notification into core and expose notifier contract"
Phase E — Déclarer CoreModule actif
Task 6: Enregistrer Core dans config/modules.php
Files:
-
Modify:
config/modules.php -
Modify:
tests/Functional/Shared/ModulesEndpointTest.php(ou équivalent — adapter l'assertion à la présence decore) -
Step 1: Adapter/écrire le test de l'endpoint modules
Vérifier le test existant de /api/modules (cartographie : ModulesProvider/ModulesResource créés en #56). Ajouter une assertion :
public function testCoreModuleIsActive(): void
{
$client = self::createClient();
// /api/modules est public (GET) d'après security.yaml
$client->request('GET', '/api/modules');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertContains('core', $data['modules']);
}
Adapter le nom de classe/fichier de test à l'existant (#56). Si aucun test fonctionnel modules n'existe, créer
tests/Functional/Shared/ModulesEndpointTest.php.
-
Step 2: Lancer, vérifier l'échec (modules.php retourne
[]). -
Step 3: Activer Core
config/modules.php :
<?php
declare(strict_types=1);
use App\Module\Core\CoreModule;
return [
CoreModule::class,
];
- Step 4: Tests + curl
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
Expected: PASS. Curl :
curl -s http://localhost:8082/api/modules | head -c 200 # doit contenir "core"
- Step 5: commit
git add config/modules.php tests/
git commit -m "feat(core) : activate core module in modules registry"
Phase F — Layer front modules/core/
Task 7: Déplacer login / profile / admin users dans frontend/modules/core/
Files:
- Create:
frontend/modules/core/nuxt.config.ts(export default defineNuxtConfig({})) - Move:
frontend/pages/login.vue→frontend/modules/core/pages/login.vue - Move:
frontend/pages/profile.vue→frontend/modules/core/pages/profile.vue - Move:
frontend/pages/admin/**(gestion users) →frontend/modules/core/pages/admin/** - Move (si pertinent): composants/services liés à l'identité (ex.
frontend/components/user/**,frontend/components/admin/**,frontend/services/user.ts) →frontend/modules/core/{components,services}/**
⚠️ AVANT de déplacer, LIRE
frontend/pages/etfrontend/components/pour identifier précisément les pages/compos d'identité. Le scanreaddirSync('modules/')(LST-62) ajoute./modules/coreàextendsetmodules/core/composables/storesàimports.dirs. Lespages/d'un layer Nuxt sont fusionnées automatiquement → les URLs (/login,/profile,/admin/...) restent identiques. Vérifier qu'aucune page déplacée n'utilise un import PAR CHEMIN cassé (auto-import sinon).
- Step 1: Créer le layer + déplacer les pages d'identité
cd /home/matthieu/dev_malio/Lesstime/frontend
mkdir -p modules/core/pages
printf 'export default defineNuxtConfig({})\n' > modules/core/nuxt.config.ts
git mv pages/login.vue modules/core/pages/login.vue
git mv pages/profile.vue modules/core/pages/profile.vue
# admin users : adapter au réel (git mv pages/admin/... modules/core/pages/admin/...)
Lister
frontend/pages/admin/d'abord ; déplacer UNIQUEMENT les pages de gestion des utilisateurs (pas les pages admin d'autres domaines). En cas de doute, déplacer seulement login + profile en 1.1 et laisser admin users (documenter).
- Step 2: Corriger les imports par chemin éventuels
Run: cd /home/matthieu/dev_malio/Lesstime/frontend && grep -rn "pages/login\|pages/profile\|~/pages" --include=*.ts --include=*.vue . | grep -v node_modules
Corriger toute référence cassée (les redirections navigateTo('/login') restent valides — c'est une URL, pas un chemin de fichier).
- Step 3: Gate front (cf. LST-62) + smoke
Run: cd frontend && npx nuxt typecheck 2>&1 | grep "Cannot find module" | grep -E "modules/core|login|profile" → doit être VIDE.
Run: grep -E "login|profile" frontend/.nuxt/routes.* 2>/dev/null ou démarrer make dev-nuxt et confirmer que /login, /profile répondent (la fusion des pages du layer est effective).
Smoke runtime (login via navigateur) : laisser au PO si pas de navigateur côté exécutant.
- Step 4: commit
git add -A -- frontend
git commit -m "feat(core) : add core front layer with login, profile and admin users pages"
Acceptance check (après toutes les phases)
- AC1 Login/JWT OK via le module :
login http=204,/api/me200, MCP apiToken 200,/api/notifications200. - AC2
grep -rn "App\\Entity\\User" src/ config/→ VIDE (User vit danssrc/Module/Core/Domain/Entity/, consommé via contrat ; fixtures importent le FQCN Core). - AC3
make testvert (≈119 tests),doctrine:schema:validateOK,doctrine:migrations:diff= « No changes detected » (aucune migration destructive ni même additive). /api/modulesrenvoiecore;CoreModule::isRequired() === true.resolve_target_entities: UserInterface → App\Module\Core\Domain\Entity\User.- Front : layer
modules/core/détecté ;/login,/profile(+ admin users) accessibles aux mêmes URLs ; aucunCannot find module. config/reference.phpjamais committé.
Notes pour le ticket suivant (1.2 — RBAC fin)
CoreModule::permissions() est déjà posé (stub). 1.2 ajoutera Role/Permission, app:sync-permissions, PermissionVoter, et fera filtrer SidebarProvider par permission (en plus du module actif + du gate rôle minimal posé en 0.2). Le contrat UserInterface enrichi est prêt à recevoir getPermissions() si besoin.