Files
Lesstime/docs/superpowers/plans/2026-06-19-lst-63-module-core.md
T

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:diff doit 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; hors src/Module/Core/ ; (3) make test vert, 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é dans git 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 typecheck n'est PAS un gate vert sur ce stack (cf. plan LST-62) — gate front = zéro Cannot 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)

  1. UserInterface enrichi (contrat de lecture) — plutôt que de garder App\Entity\User partout, on enrichit App\Shared\Domain\Contract\UserInterface des 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.
  2. Move physique, table inchangéeUser change 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 mapping Core).
  3. Relations via le contrat — les 8 entités passent à targetEntity: UserInterface::class + type ?UserInterface, résolu par resolve_target_entities → Core\User. C'est le pattern Starseed.
  4. Notification dans Core + NotifierInterfaceNotification migre dans Core (couplée à l'identité) ; la création de notif passe par NotifierInterface (impl Core), TaskNotificationListener (qui reste legacy en Phase D) en dépend par contrat. L'API REST /api/notifications est préservée à l'identique.
  5. Front layer modules/core/ — login, profile, admin users déplacés de frontend/pages/ vers frontend/modules/core/pages/ (premier layer réel ; le scan readdirSync('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.
    • UserInterface enrichi (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 indique id(), label(), isRequired(), permissions() statiques. Si une méthode diffère (ex. non statique), aligner CoreModule ET 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 confirme getId, getUserIdentifier, getUsername, getRoles, getFirstName, getLastName, getAvatarUrl, isEmployee). Si une signature diffère (ex. getAvatarUrl(): string non-nullable), aligner le contrat sur le réel. Ne PAS ajouter au contrat une méthode absente de User.

  • 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.phpsrc/Module/Core/Domain/Entity/User.php (namespace App\Module\Core\Domain\Entity)
  • Modify: config/packages/doctrine.yaml (mapping Core + 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 (table user inchangée), résolue par resolve_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 use internes devenus nécessaires (l'entité référençait UserRepository, MeProvider, UserPasswordHasherProcessor, l'enum ContractType, le contrat UserInterface as SharedUserInterface). Mettre les use complets vers leurs emplacements ACTUELS (la plupart bougent en Tasks 3/4 ; pour cette task, pointer encore vers App\Repository\UserRepository, App\State\MeProvider, App\State\UserPasswordHasherProcessor, App\Entity\Enum\ContractType ou l'emplacement réel — vérifier les use d'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 le namespace et, si besoin, garder les use pointant 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 App existant qui scanne src/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 plus User.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.yaml n'a pas de mapping.paths explicite (auto-discovery), vérifier que les Resources sous src/Module/... sont bien découvertes (comme src/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\UserERREUR 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 User casse les 26 use 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éer src/Module/Core/_compat_user_alias.php (chargé via composer.json autoload.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"] sous autoload dans composer.json, puis composer dump-autoload. Cela garde les 26 consommateurs fonctionnels (et Doctrine targetEntity: User::class ré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 nouveau dump-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, Notification est encore src/Entity/Notification.php, la traiter ici aussi.

Pour CHAQUE relation vers User : remplacer use App\Entity\User; par use App\Shared\Domain\Contract\UserInterface;, le targetEntity: User::class par targetEntity: UserInterface::class, et le type de propriété/param ?User?UserInterface (idem getters/setters). Doctrine résout via resolve_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 et targetEntity.

  • 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.phpsrc/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php (implémente UserRepositoryInterface, namespace App\Module\Core\Infrastructure\Doctrine)

  • Move: src/State/MeProvider.phpsrc/Module/Core/Infrastructure/ApiPlatform/State/MeProvider.php

  • Move: src/State/UserPasswordHasherProcessor.phpsrc/Module/Core/Infrastructure/ApiPlatform/State/UserPasswordHasherProcessor.php

  • Modify: l'#[ApiResource] de l'entité User (les provider:/processor: pointent vers les nouveaux FQCN Core).

  • Delete (en fin de task): src/Module/Core/_compat_user_alias.php + entrée composer.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 signatures countUnreadByUser(User $user) etc. → UserInterface. Ne pas changer la logique DQL (n.user = :user fonctionne 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 concret App\Module\Core\Domain\Entity\User (les fixtures INSTANCIENT new User() et appellent des setters d'écriture — c'est légitime ; importer le concret Core, pas le contrat). C'est hors src/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 QUE src/DataFixtures/AppFixtures.php (qui importe désormais le FQCN Core, donc 0 occurrence de App\Entity\User). Viser 0 occurrence de App\Entity\User dans tout src/.

  • 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: grep de 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érifier git status avant le commit ; si reference.php est listé, l'exclure du git 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.phpsrc/Module/Core/Domain/Entity/Notification.php

  • Move: src/Repository/NotificationRepository.phpsrc/Module/Core/Infrastructure/Doctrine/DoctrineNotificationRepository.php

  • Move: src/State/NotificationProvider.phpsrc/Module/Core/Infrastructure/ApiPlatform/State/NotificationProvider.php

  • Create: src/Module/Core/Infrastructure/Notifier.php (implements NotifierInterface)

  • Modify: src/EventListener/TaskNotificationListener.php (dépend de NotifierInterface)

  • Modify: config/packages/doctrine.yaml (le mapping Core couvre déjà Domain/Entity → Notification incluse automatiquement)

  • Modify: tests/ — ajouter tests/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'échecNotifierInterface non instanciable / Notification introuvable 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;, relation usertargetEntity: 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;, signatures UserInterface.

  • 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 indique user, type, title, message, isRead default false, createdAt). Si createdAt n'est pas auto (prePersist), le poser ici. Si setUser attend le concret, accepter UserInterface (resolve_target_entities) — vérifier le type du setter.

  • Step 5: Recâbler TaskNotificationListener sur NotifierInterface

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 NotifierInterface inadapté (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 » : NotifierInterface doit 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 de core)

  • 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.vuefrontend/modules/core/pages/login.vue
  • Move: frontend/pages/profile.vuefrontend/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/ et frontend/components/ pour identifier précisément les pages/compos d'identité. Le scan readdirSync('modules/') (LST-62) ajoute ./modules/core à extends et modules/core/composables/stores à imports.dirs. Les pages/ 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/me 200, MCP apiToken 200, /api/notifications 200.
  • AC2 grep -rn "App\\Entity\\User" src/ config/VIDE (User vit dans src/Module/Core/Domain/Entity/, consommé via contrat ; fixtures importent le FQCN Core).
  • AC3 make test vert (≈119 tests), doctrine:schema:validate OK, doctrine:migrations:diff = « No changes detected » (aucune migration destructive ni même additive).
  • /api/modules renvoie core ; 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 ; aucun Cannot find module.
  • config/reference.php jamais 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.