## Migration modular monolith DDD — Lesstime (0.1 → 3.3) Cette MR regroupe l'intégralité de la refonte en monolithe modulaire (strangler progressif, additif). Elle remplace les MR stackées de Phase 1 (#12–#16), désormais incluses ici. **Ne pas merger avant validation fonctionnelle** : branche destinée à être testée telle quelle. ### Périmètre — 9 modules sous `src/Module/` | Phase | Module | Contenu | |------|--------|---------| | 0.1 | (socle) | infrastructure modulaire, `ModuleInterface`, mapping Doctrine par module | | 0.2 | (socle front) | auto-détection des layers Nuxt sous `frontend/modules/*` | | 1.1 | **Core** | Identité (User/Auth), Notifications, Notifier | | 1.2 | Core | RBAC fin (permissions `module.resource.action`, sidebar gated) | | 1.3 | Core | Audit log (`#[Auditable]`, listener, provider DBAL) | | 2.1 | **TimeTracking** | TimeEntry + MCP + export | | 2.2 | **ProjectManagement** | cœur métier Projets/Tâches + 38 MCP tools | | 2.3 | **Absence** | demandes, soldes, policies, justificatifs | | 2.4 | **Directory** | Clients (migrés) + **Prospects** (nouveau, conversion → Client) | | 2.5 | **Mail** | intégration IMAP OVH + liens tâches | | 2.6 | **Integration** | Gitea / BookStack / Zimbra / Share | | 3.1 | **Reporting** | rapports transverses (DBAL read-only, 0 import inter-module) | | 3.2 | **ClientPortal** | portail client (ROLE_CLIENT cloisonné, tickets, notifications) | | 3.3 | (finition) | nettoyage legacy — `src/Entity` vide, app 100% modulaire | ### Architecture - Découplage inter-modules par **contrats** (`UserInterface`, `ProjectInterface`, `TaskInterface`, `TaskTagInterface`, `ClientInterface`, `ClientTicketInterface`, `LeaveProfileInterface`) + `resolve_target_entities` 100% modulaire (aucune cible legacy). - Repositories : interface `Domain/Repository` + implémentation `Infrastructure/Doctrine`, bindées. - Reporting en DBAL read-only pur (aucun import d'entité d'un autre module). - Chaque migration de module : déplacement à comportement préservé (API publique et noms d'outils MCP inchangés), migrations **additives** uniquement (zéro destructif). ### Sécurité - ROLE_CLIENT cloisonné : un utilisateur client n'accède qu'à `/portal` et à ses propres tickets (filtrés par `allowedProjects`), interdit sur toute l'API interne. - Correctif : interdiction pour un client de créer un lien vers le partage SMB (upload uniquement). ### QA non-régression (branche reconstruite from scratch) - Migrations from scratch + fixtures : OK. - Compilation dev + prod : OK. - **180 tests PHPUnit verts**, php-cs-fixer clean, ~96 routes, **66 outils MCP** tous sous `App\Module\*`. - Smoke test runtime multi-rôles (admin / ROLE_USER / ROLE_CLIENT) : 44 vérifications HTTP, **0 écart**, cloisonnement client étanche. - Build Nuxt OK, 9 layers, 0 import legacy résiduel. ### Points à arbitrer (hors périmètre de cette migration) - Durcissement MCP/IDOR pré-existant (`userId` explicite sans scoping sur certains tools TimeTracking/Absence/TaskDocument) — ticket dédié recommandé. - Validation fonctionnelle de **Prospect** et **ClientPortal** (conçus depuis les specs disque). - **Harmonisation visuelle Malio finale** (3.3) — finition esthétique inter-modules laissée au PO. --- ## ⚠️ Déploiement / migration des données — à ne pas oublier ### 1. Resynchroniser les séquences PostgreSQL après tout import/restore de dump Si la prod (ou tout environnement) est **montée depuis un dump** (`pg_restore` / `COPY`), les lignes sont chargées avec leurs `id` explicites **sans avancer les séquences** → au premier `INSERT` : `duplicate key value violates unique constraint "..._pkey"` (constaté en local sur `notification`, `task`, `time_entry`…). À lancer **juste après chaque restore/import** : ```sql DO $$ DECLARE r RECORD; maxid BIGINT; seq TEXT; BEGIN FOR r IN SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public' LOOP seq := pg_get_serial_sequence(quote_ident(r.table_name), r.column_name); IF seq IS NOT NULL THEN EXECUTE format('SELECT COALESCE(MAX(%I),0) FROM %I', r.column_name, r.table_name) INTO maxid; PERFORM setval(seq, GREATEST(maxid,1), maxid > 0); END IF; END LOOP; END $$; ``` > Ne concerne **pas** une prod qui tourne déjà (séquences avancées organiquement) — uniquement le cas restore/import. Idempotent, sans risque. ### 2. Fix dénormalisation des collections typées-contrat (code, inclus dans la branche) Les relations **to-many** typées par une interface `Shared\Domain\Contract\*` (`TimeEntry::tags` → `TaskTagInterface`, `Task::collaborators` → `UserInterface`) étaient **indénormalisables par API Platform** (mono-valué OK via IRI, collection KO) → **tout POST/PATCH portant une telle collection renvoyait 400/500**. Corrigé par un dénormaliseur générique `ContractRelationDenormalizer` (réutilise `resolve_target_entities`, zéro couplage par-entité) + test fonctionnel de non-régression. --------- Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #17
38 KiB
LST-56 (0.1) — Socle back modular monolith — 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: Poser l'infrastructure backend d'un modular monolith DDD (endpoints /api/modules + /api/sidebar, registre de modules, garde-fous Timestampable/Blamable, helper de commentaires SQL) sans toucher au métier existant.
Architecture: On ajoute un noyau src/Shared/ (Domain/Contract, Domain/Trait, Infrastructure/ApiPlatform, Infrastructure/Doctrine, Infrastructure/Database). La logique métier (filtrage sidebar, extraction des IDs de modules, estampillage) est isolée dans des classes pures testées unitairement ; des Providers API Platform minces les exposent en HTTP. Aucune entité existante n'est déplacée. Strangler : 100 % additif.
Tech Stack: PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16, PHPUnit 13.
Global Constraints
declare(strict_types=1);en tête de tout fichier PHP.- Migrations additives nullable uniquement — aucun
DROP, aucunNOT NULLrétroactif (prod Docker, BDD peuplée). - Zéro import inter-modules : passer par
src/Shared/Domain/Contract/ou domain events. - Toute
GetCollectionreste paginée (pas concerné dans ce lot, aucune collection ajoutée). - Toute colonne créée porte un
COMMENT ON COLUMN(FR, ≤200 chars). - PostgreSQL : noms de colonnes en minuscules dans le SQL brut.
- Commits : format
<type>(<scope>) : <message>(espaces autour du:). Jamais de mention IA/Claude. - Tests : exécution via
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit ….
Définitions différées (hors 0.1, ne PAS implémenter ici) : mappings Doctrine de module + migrations_paths modulaire + api_platform.mapping.paths (arrivent avec le 1er module à entités, ticket 1.1). Filtrage sidebar par permission (ticket 1.2). #[Auditable] (ticket 1.3).
Task 1: Endpoint GET /api/modules + registre de modules
Files:
- Create:
src/Shared/Domain/Module/ModuleInterface.php - Create:
src/Shared/Domain/Module/ModuleRegistry.php - Create:
src/Shared/Infrastructure/ApiPlatform/Resource/ModulesResource.php - Create:
src/Shared/Infrastructure/ApiPlatform/State/ModulesProvider.php - Create:
config/modules.php - Modify:
config/packages/security.yaml(access_control, rendre/api/modulespublic) - Test:
tests/Unit/Shared/Module/ModuleRegistryTest.php - Test:
tests/Functional/Shared/ModulesEndpointTest.php
Interfaces:
-
Produces:
interface ModuleInterface { public static function id(): string; public static function label(): string; public static function isRequired(): bool; /** @return list<array{code: string, label: string}> */ public static function permissions(): array; }ModuleRegistry::ids(array $moduleClasses): array→list<string>(lesid()des classes implémentantModuleInterface, ignore les autres).config/modules.phpretournelist<class-string<ModuleInterface>>(vide en 0.1).
-
Step 1: Write the failing unit test for ModuleRegistry
Create tests/Unit/Shared/Module/ModuleRegistryTest.php:
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Module;
use App\Shared\Domain\Module\ModuleInterface;
use App\Shared\Domain\Module\ModuleRegistry;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class ModuleRegistryTest extends TestCase
{
public function testIdsExtractsDeclaredModuleIds(): void
{
$classes = [FakeAlphaModule::class, FakeBetaModule::class];
self::assertSame(['alpha', 'beta'], ModuleRegistry::ids($classes));
}
public function testIdsIgnoresClassesNotImplementingModuleInterface(): void
{
$classes = [FakeAlphaModule::class, \stdClass::class];
self::assertSame(['alpha'], ModuleRegistry::ids($classes));
}
public function testIdsReturnsEmptyArrayForNoModules(): void
{
self::assertSame([], ModuleRegistry::ids([]));
}
}
final class FakeAlphaModule implements ModuleInterface
{
public static function id(): string { return 'alpha'; }
public static function label(): string { return 'Alpha'; }
public static function isRequired(): bool { return false; }
public static function permissions(): array { return []; }
}
final class FakeBetaModule implements ModuleInterface
{
public static function id(): string { return 'beta'; }
public static function label(): string { return 'Beta'; }
public static function isRequired(): bool { return true; }
public static function permissions(): array { return []; }
}
- Step 2: Run the test, verify it fails
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Module/ModuleRegistryTest.php
Expected: FAIL — Class "App\Shared\Domain\Module\ModuleInterface" not found.
- Step 3: Create
ModuleInterface
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Module;
/**
* Implemented by every `*Module` declaration class. The set of active modules
* is listed in config/modules.php and exposed via GET /api/modules.
*/
interface ModuleInterface
{
public static function id(): string;
public static function label(): string;
public static function isRequired(): bool;
/**
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array;
}
- Step 4: Create
ModuleRegistry
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Module;
final class ModuleRegistry
{
/**
* @param list<class-string> $moduleClasses
*
* @return list<string>
*/
public static function ids(array $moduleClasses): array
{
$ids = [];
foreach ($moduleClasses as $moduleClass) {
if (is_a($moduleClass, ModuleInterface::class, true)) {
$ids[] = $moduleClass::id();
}
}
return $ids;
}
}
- Step 5: Run the unit test, verify it passes
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Module/ModuleRegistryTest.php
Expected: PASS (3 tests).
- Step 6: Create
config/modules.php
<?php
declare(strict_types=1);
/*
* Liste ordonnée des modules actifs (classes implémentant App\Shared\Domain\Module\ModuleInterface).
* Activer/désactiver un module = ajouter/commenter sa ligne. Exposé par GET /api/modules.
*/
return [
// Aucun module pour l'instant — les modules arrivent à partir du ticket 1.1 (Core).
];
- Step 7: Create
ModulesResourceandModulesProvider
src/Shared/Infrastructure/ApiPlatform/Resource/ModulesResource.php:
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Shared\Infrastructure\ApiPlatform\State\ModulesProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/modules',
normalizationContext: ['groups' => ['modules:read']],
provider: ModulesProvider::class,
),
],
)]
final class ModulesResource
{
/**
* @var list<string>
*/
#[Groups(['modules:read'])]
public array $modules = [];
}
src/Shared/Infrastructure/ApiPlatform/State/ModulesProvider.php:
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Shared\Domain\Module\ModuleRegistry;
use App\Shared\Infrastructure\ApiPlatform\Resource\ModulesResource;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class ModulesProvider implements ProviderInterface
{
public function __construct(
#[Autowire('%kernel.project_dir%')]
private string $projectDir,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ModulesResource
{
/** @var list<class-string> $classes */
$classes = require $this->projectDir.'/config/modules.php';
$dto = new ModulesResource();
$dto->modules = ModuleRegistry::ids($classes);
return $dto;
}
}
- Step 8: Make
/api/modulespublic insecurity.yaml
In config/packages/security.yaml, under access_control, add the rule immediately after the ^/api/version line (order matters — only the first matching rule applies):
# Liste des modules actifs en public (consommée au boot du front)
- { path: ^/api/modules, roles: PUBLIC_ACCESS, methods: [ GET ] }
- Step 9: Write the failing functional test
Create tests/Functional/Shared/ModulesEndpointTest.php:
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Shared;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
*/
final class ModulesEndpointTest extends WebTestCase
{
public function testModulesEndpointIsPublicAndReturnsModulesKey(): void
{
$client = static::createClient();
$client->request('GET', '/api/modules');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('modules', $data);
self::assertIsArray($data['modules']);
}
}
- Step 10: Run the functional test, verify it passes
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Shared/ModulesEndpointTest.php
Expected: PASS. (If FAIL with 404, confirm API Platform discovers src/Shared/Infrastructure/ApiPlatform/Resource — the default API Platform path config in API Platform 4 scans src/ApiResource + src/Entity only; if 404 persists, add mapping.paths for the Shared Resource dir in config/packages/api_platform.yaml and re-run. This is the one allowed config touch in Task 1.)
- Step 11: Commit
git add src/Shared/Domain/Module config/modules.php src/Shared/Infrastructure/ApiPlatform config/packages/security.yaml config/packages/api_platform.yaml tests/Unit/Shared/Module tests/Functional/Shared/ModulesEndpointTest.php
git commit -m "feat(modules) : expose GET /api/modules and module registry"
Task 2: Endpoint GET /api/sidebar + filtre par module actif
Files:
- Create:
src/Shared/Domain/Sidebar/SidebarFilter.php - Create:
src/Shared/Infrastructure/ApiPlatform/Resource/SidebarResource.php - Create:
src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php - Create:
config/sidebar.php - Test:
tests/Unit/Shared/Sidebar/SidebarFilterTest.php - Test:
tests/Functional/Shared/SidebarEndpointTest.php
Interfaces:
-
Consumes:
ModuleRegistry::ids()(Task 1),config/modules.php(Task 1). -
Produces:
SidebarFilter::filter(array $sections, array $activeModuleIds): array→array{sections: list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string}>}>, disabledRoutes: list<string>}. Règle : un item portantmoduleabsent de$activeModuleIdsest masqué et sontoajouté àdisabledRoutes; une section vidée de tous ses items est supprimée ; les clés internes (module) sont retirées de la sortie.config/sidebar.phpretournelist<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string, module?:string}>}>.
-
Step 1: Write the failing unit test for SidebarFilter
Create tests/Unit/Shared/Sidebar/SidebarFilterTest.php:
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Sidebar;
use App\Shared\Domain\Sidebar\SidebarFilter;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class SidebarFilterTest extends TestCase
{
public function testItemWithoutModuleIsAlwaysVisible(): void
{
$sections = [
['label' => 'sidebar.core.section', 'icon' => 'mdi:home', 'items' => [
['label' => 'sidebar.core.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard'],
]],
];
$result = SidebarFilter::filter($sections, []);
self::assertCount(1, $result['sections']);
self::assertSame('/', $result['sections'][0]['items'][0]['to']);
self::assertSame([], $result['disabledRoutes']);
self::assertArrayNotHasKey('module', $result['sections'][0]['items'][0]);
}
public function testItemWithInactiveModuleIsHiddenAndRouteDisabled(): void
{
$sections = [
['label' => 'sidebar.tt.section', 'icon' => 'mdi:clock', 'items' => [
['label' => 'sidebar.tt.timesheet', 'to' => '/time-tracking', 'icon' => 'mdi:clock', 'module' => 'time_tracking'],
]],
];
$result = SidebarFilter::filter($sections, []);
self::assertSame([], $result['sections']);
self::assertSame(['/time-tracking'], $result['disabledRoutes']);
}
public function testItemWithActiveModuleIsVisible(): void
{
$sections = [
['label' => 'sidebar.tt.section', 'icon' => 'mdi:clock', 'items' => [
['label' => 'sidebar.tt.timesheet', 'to' => '/time-tracking', 'icon' => 'mdi:clock', 'module' => 'time_tracking'],
]],
];
$result = SidebarFilter::filter($sections, ['time_tracking']);
self::assertCount(1, $result['sections']);
self::assertSame('/time-tracking', $result['sections'][0]['items'][0]['to']);
self::assertSame([], $result['disabledRoutes']);
}
}
- Step 2: Run the test, verify it fails
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Sidebar/SidebarFilterTest.php
Expected: FAIL — Class "App\Shared\Domain\Sidebar\SidebarFilter" not found.
- Step 3: Create
SidebarFilter
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Sidebar;
final class SidebarFilter
{
/**
* @param list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string, module?:string}>}> $sections
* @param list<string> $activeModuleIds
*
* @return array{sections: list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string}>}>, disabledRoutes: list<string>}
*/
public static function filter(array $sections, array $activeModuleIds): array
{
$outSections = [];
$disabledRoutes = [];
foreach ($sections as $section) {
$items = [];
foreach ($section['items'] as $item) {
$module = $item['module'] ?? null;
if (null !== $module && !in_array($module, $activeModuleIds, true)) {
$disabledRoutes[] = $item['to'];
continue;
}
$items[] = ['label' => $item['label'], 'to' => $item['to'], 'icon' => $item['icon']];
}
if ([] !== $items) {
$outSections[] = ['label' => $section['label'], 'icon' => $section['icon'], 'items' => $items];
}
}
return ['sections' => $outSections, 'disabledRoutes' => $disabledRoutes];
}
}
- Step 4: Run the unit test, verify it passes
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Sidebar/SidebarFilterTest.php
Expected: PASS (3 tests).
- Step 5: Create
config/sidebar.php
Toutes les entrées actuelles sont sans clé module (donc visibles) ; les futurs modules ajouteront leur module. Labels = clés i18n.
<?php
declare(strict_types=1);
/*
* Définition de la sidebar (sections + items). Filtrée par SidebarFilter selon les modules actifs.
* Un item porte une clé `module` quand il appartient à un module activable ; sans clé, il est toujours visible.
* Les labels sont des clés i18n (sidebar.<domaine>.<item>).
*/
return [
[
'label' => 'sidebar.general.section',
'icon' => 'mdi:view-dashboard-outline',
'items' => [
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:checkbox-marked-circle-outline'],
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-multiple-outline'],
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:clock-outline'],
],
],
[
'label' => 'sidebar.hr.section',
'icon' => 'mdi:calendar-account-outline',
'items' => [
['label' => 'sidebar.hr.absences', 'to' => '/absences', 'icon' => 'mdi:calendar-remove-outline'],
['label' => 'sidebar.hr.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:account-group-outline'],
],
],
];
- Step 6: Create
SidebarResourceandSidebarProvider
src/Shared/Infrastructure/ApiPlatform/Resource/SidebarResource.php:
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Shared\Infrastructure\ApiPlatform\State\SidebarProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/sidebar',
normalizationContext: ['groups' => ['sidebar:read']],
provider: SidebarProvider::class,
),
],
)]
final class SidebarResource
{
/**
* @var list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string}>}>
*/
#[Groups(['sidebar:read'])]
public array $sections = [];
/**
* @var list<string>
*/
#[Groups(['sidebar:read'])]
public array $disabledRoutes = [];
}
src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php:
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Shared\Domain\Module\ModuleRegistry;
use App\Shared\Domain\Sidebar\SidebarFilter;
use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class SidebarProvider implements ProviderInterface
{
public function __construct(
#[Autowire('%kernel.project_dir%')]
private string $projectDir,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): SidebarResource
{
/** @var list<class-string> $moduleClasses */
$moduleClasses = require $this->projectDir.'/config/modules.php';
/** @var list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string, module?:string}>}> $sidebar */
$sidebar = require $this->projectDir.'/config/sidebar.php';
$filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses));
$dto = new SidebarResource();
$dto->sections = $filtered['sections'];
$dto->disabledRoutes = $filtered['disabledRoutes'];
return $dto;
}
}
- Step 7: Write the failing functional test
Create tests/Functional/Shared/SidebarEndpointTest.php:
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Shared;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
*/
final class SidebarEndpointTest extends WebTestCase
{
public function testSidebarRequiresAuthentication(): void
{
$client = static::createClient();
$client->request('GET', '/api/sidebar');
self::assertResponseStatusCodeSame(401);
}
public function testSidebarReturnsSectionsForAuthenticatedUser(): void
{
$client = static::createClient();
$container = static::getContainer();
$em = $container->get('doctrine.orm.entity_manager');
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$client->loginUser($user);
$client->request('GET', '/api/sidebar');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('sections', $data);
self::assertArrayHasKey('disabledRoutes', $data);
self::assertNotEmpty($data['sections']);
}
}
- Step 8: Run the functional test, verify it passes
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Shared/SidebarEndpointTest.php
Expected: PASS (2 tests).
- Step 9: Commit
git add src/Shared/Domain/Sidebar src/Shared/Infrastructure/ApiPlatform config/sidebar.php tests/Unit/Shared/Sidebar tests/Functional/Shared/SidebarEndpointTest.php
git commit -m "feat(sidebar) : expose GET /api/sidebar filtered by active modules"
Task 3: Garde-fou Timestampable / Blamable (trait + subscriber)
Files:
- Create:
src/Shared/Domain/Contract/UserInterface.php - Create:
src/Shared/Domain/Contract/TimestampableInterface.php - Create:
src/Shared/Domain/Contract/BlamableInterface.php - Create:
src/Shared/Application/CurrentUserProviderInterface.php - Create:
src/Shared/Infrastructure/Security/SecurityCurrentUserProvider.php - Create:
src/Shared/Domain/Trait/TimestampableBlamableTrait.php - Create:
src/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber.php - Modify:
src/Entity/User.php(implementUserInterface) - Modify:
config/packages/doctrine.yaml(resolve_target_entities) - Test:
tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php
Interfaces:
- Produces:
interface UserInterface { public function getId(): ?int; }interface TimestampableInterface { public function getCreatedAt(): ?\DateTimeImmutable; public function setCreatedAt(\DateTimeImmutable $createdAt): void; public function getUpdatedAt(): ?\DateTimeImmutable; public function setUpdatedAt(\DateTimeImmutable $updatedAt): void; }interface BlamableInterface { public function getCreatedBy(): ?UserInterface; public function setCreatedBy(?UserInterface $user): void; public function getUpdatedBy(): ?UserInterface; public function setUpdatedBy(?UserInterface $user): void; }interface CurrentUserProviderInterface { public function getCurrentUser(): ?UserInterface; }TimestampableBlamableSubscriber::applyOnCreate(object $entity): voidand::applyOnUpdate(object $entity): void— pure-ish entry points used by the unit test; the Doctrine hooks delegate to them.
Note (strangler): en 0.1 le trait/subscriber n'est encore appliqué à aucune entité (les entités restent legacy). Le contrat
UserInterfaceest mappé surApp\Entity\Userviaresolve_target_entities; il sera re-pointé versApp\Module\Core\Domain\Entity\Userau ticket 1.1.
- Step 1: Write the failing unit test for the subscriber
Create tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php:
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Doctrine;
use App\Shared\Application\CurrentUserProviderInterface;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\UserInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Infrastructure\Doctrine\TimestampableBlamableSubscriber;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class TimestampableBlamableSubscriberTest extends TestCase
{
public function testApplyOnCreateSetsTimestampsAndAuthor(): void
{
$user = $this->makeUser(7);
$subscriber = new TimestampableBlamableSubscriber($this->providerReturning($user));
$entity = $this->makeEntity();
$subscriber->applyOnCreate($entity);
self::assertInstanceOf(\DateTimeImmutable::class, $entity->getCreatedAt());
self::assertInstanceOf(\DateTimeImmutable::class, $entity->getUpdatedAt());
self::assertSame($user, $entity->getCreatedBy());
self::assertSame($user, $entity->getUpdatedBy());
}
public function testApplyOnUpdateLeavesCreatedUntouched(): void
{
$creator = $this->makeUser(1);
$editor = $this->makeUser(2);
$entity = $this->makeEntity();
(new TimestampableBlamableSubscriber($this->providerReturning($creator)))->applyOnCreate($entity);
$createdAt = $entity->getCreatedAt();
(new TimestampableBlamableSubscriber($this->providerReturning($editor)))->applyOnUpdate($entity);
self::assertSame($createdAt, $entity->getCreatedAt());
self::assertSame($creator, $entity->getCreatedBy());
self::assertSame($editor, $entity->getUpdatedBy());
}
public function testApplyOnCreateIgnoresNonTimestampableEntities(): void
{
$subscriber = new TimestampableBlamableSubscriber($this->providerReturning(null));
// Must not throw.
$subscriber->applyOnCreate(new \stdClass());
$this->addToAssertionCount(1);
}
private function providerReturning(?UserInterface $user): CurrentUserProviderInterface
{
return new class($user) implements CurrentUserProviderInterface {
public function __construct(private ?UserInterface $user) {}
public function getCurrentUser(): ?UserInterface
{
return $this->user;
}
};
}
private function makeUser(int $id): UserInterface
{
return new class($id) implements UserInterface {
public function __construct(private int $id) {}
public function getId(): ?int
{
return $this->id;
}
};
}
private function makeEntity(): object
{
return new class implements TimestampableInterface, BlamableInterface {
use TimestampableBlamableTrait;
};
}
}
- Step 2: Run the test, verify it fails
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php
Expected: FAIL — interfaces/classes not found.
- Step 3: Create the contracts
src/Shared/Domain/Contract/UserInterface.php:
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
interface UserInterface
{
public function getId(): ?int;
}
src/Shared/Domain/Contract/TimestampableInterface.php:
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
interface TimestampableInterface
{
public function getCreatedAt(): ?\DateTimeImmutable;
public function setCreatedAt(\DateTimeImmutable $createdAt): void;
public function getUpdatedAt(): ?\DateTimeImmutable;
public function setUpdatedAt(\DateTimeImmutable $updatedAt): void;
}
src/Shared/Domain/Contract/BlamableInterface.php:
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
interface BlamableInterface
{
public function getCreatedBy(): ?UserInterface;
public function setCreatedBy(?UserInterface $user): void;
public function getUpdatedBy(): ?UserInterface;
public function setUpdatedBy(?UserInterface $user): void;
}
- Step 4: Create the current-user provider (contract + Security impl)
src/Shared/Application/CurrentUserProviderInterface.php:
<?php
declare(strict_types=1);
namespace App\Shared\Application;
use App\Shared\Domain\Contract\UserInterface;
interface CurrentUserProviderInterface
{
public function getCurrentUser(): ?UserInterface;
}
src/Shared/Infrastructure/Security/SecurityCurrentUserProvider.php:
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Security;
use App\Shared\Application\CurrentUserProviderInterface;
use App\Shared\Domain\Contract\UserInterface;
use Symfony\Bundle\SecurityBundle\Security;
final readonly class SecurityCurrentUserProvider implements CurrentUserProviderInterface
{
public function __construct(
private Security $security,
) {}
public function getCurrentUser(): ?UserInterface
{
$user = $this->security->getUser();
return $user instanceof UserInterface ? $user : null;
}
}
- Step 5: Create the trait
src/Shared/Domain/Trait/TimestampableBlamableTrait.php:
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Trait;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
trait TimestampableBlamableTrait
{
#[ORM\Column(name: 'created_at', type: 'datetime_immutable', nullable: true)]
#[Groups(['timestampable:read'])]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(name: 'updated_at', type: 'datetime_immutable', nullable: true)]
#[Groups(['timestampable:read'])]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'created_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['blamable:read'])]
private ?UserInterface $createdBy = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'updated_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['blamable:read'])]
private ?UserInterface $updatedBy = null;
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): void
{
$this->createdAt = $createdAt;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(\DateTimeImmutable $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
public function getCreatedBy(): ?UserInterface
{
return $this->createdBy;
}
public function setCreatedBy(?UserInterface $user): void
{
$this->createdBy = $user;
}
public function getUpdatedBy(): ?UserInterface
{
return $this->updatedBy;
}
public function setUpdatedBy(?UserInterface $user): void
{
$this->updatedBy = $user;
}
}
- Step 6: Create the Doctrine subscriber
src/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber.php:
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Doctrine;
use App\Shared\Application\CurrentUserProviderInterface;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
#[AsDoctrineListener(event: Events::prePersist)]
#[AsDoctrineListener(event: Events::preUpdate)]
final readonly class TimestampableBlamableSubscriber
{
public function __construct(
private CurrentUserProviderInterface $currentUserProvider,
) {}
public function prePersist(PrePersistEventArgs $args): void
{
$this->applyOnCreate($args->getObject());
}
public function preUpdate(PreUpdateEventArgs $args): void
{
$this->applyOnUpdate($args->getObject());
}
public function applyOnCreate(object $entity): void
{
$now = new \DateTimeImmutable();
if ($entity instanceof TimestampableInterface) {
if (null === $entity->getCreatedAt()) {
$entity->setCreatedAt($now);
}
$entity->setUpdatedAt($now);
}
if ($entity instanceof BlamableInterface) {
$user = $this->currentUserProvider->getCurrentUser();
if (null === $entity->getCreatedBy()) {
$entity->setCreatedBy($user);
}
$entity->setUpdatedBy($user);
}
}
public function applyOnUpdate(object $entity): void
{
if ($entity instanceof TimestampableInterface) {
$entity->setUpdatedAt(new \DateTimeImmutable());
}
if ($entity instanceof BlamableInterface) {
$entity->setUpdatedBy($this->currentUserProvider->getCurrentUser());
}
}
}
- Step 7: Make legacy
Userimplement the contract + addresolve_target_entities
In src/Entity/User.php, add the interface to the class declaration (the entity already has getId(): ?int, so no method to add):
use App\Shared\Domain\Contract\UserInterface as SharedUserInterface;
// ...
class User implements /* existing interfaces, */ SharedUserInterface
Keep all existing
implementsclauses; appendSharedUserInterface. Alias avoids any clash withSymfony\...\UserInterfacealready imported.
In config/packages/doctrine.yaml, under orm:, add:
resolve_target_entities:
App\Shared\Domain\Contract\UserInterface: App\Entity\User
- Step 8: Run the unit test, verify it passes
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php
Expected: PASS (3 tests).
- Step 9: Run the full suite to confirm no regression
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
Expected: PASS (no entity uses the trait yet; resolve_target_entities is inert until consumed). Confirm the prior 96 tests still pass.
- Step 10: Commit
git add src/Shared/Domain/Contract src/Shared/Application src/Shared/Infrastructure/Security src/Shared/Domain/Trait src/Shared/Infrastructure/Doctrine src/Entity/User.php config/packages/doctrine.yaml tests/Unit/Shared/Doctrine
git commit -m "feat(shared) : add timestampable/blamable trait and doctrine subscriber"
Task 4: Helper ColumnCommentsCatalog (COMMENT ON COLUMN)
Files:
- Create:
src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php - Test:
tests/Unit/Shared/Database/ColumnCommentsCatalogTest.php
Interfaces:
-
Produces:
ColumnCommentsCatalog::timestampableBlamableComments(string $table): list<string>→ la liste des instructionsCOMMENT ON COLUMN <table>.<col> IS '...'pour les 4 colonnes standard. Utilisé dans les migrations des modules (à partir de 1.1) via$this->addSql(...). -
Step 1: Write the failing unit test
Create tests/Unit/Shared/Database/ColumnCommentsCatalogTest.php:
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Database;
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class ColumnCommentsCatalogTest extends TestCase
{
public function testTimestampableBlamableCommentsCoverFourColumns(): void
{
$sql = ColumnCommentsCatalog::timestampableBlamableComments('task');
self::assertCount(4, $sql);
self::assertSame(
"COMMENT ON COLUMN task.created_at IS 'Date de creation (UTC). Rempli automatiquement (Timestampable).'",
$sql[0],
);
self::assertStringContainsString('COMMENT ON COLUMN task.created_by IS', $sql[2]);
}
public function testTableNameIsInterpolatedForEveryColumn(): void
{
foreach (ColumnCommentsCatalog::timestampableBlamableComments('time_entry') as $statement) {
self::assertStringContainsString('COMMENT ON COLUMN time_entry.', $statement);
}
}
}
- Step 2: Run the test, verify it fails
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Database/ColumnCommentsCatalogTest.php
Expected: FAIL — class not found.
- Step 3: Create
ColumnCommentsCatalog
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Database;
final class ColumnCommentsCatalog
{
/**
* SQL `COMMENT ON COLUMN` statements for the 4 standard Timestampable/Blamable columns.
* Call from a migration: foreach (...) { $this->addSql($statement); }.
*
* @return list<string>
*/
public static function timestampableBlamableComments(string $table): array
{
return [
"COMMENT ON COLUMN {$table}.created_at IS 'Date de creation (UTC). Rempli automatiquement (Timestampable).'",
"COMMENT ON COLUMN {$table}.updated_at IS 'Date de derniere modification (UTC). Rempli automatiquement (Timestampable).'",
"COMMENT ON COLUMN {$table}.created_by IS 'Auteur de la creation (FK user, SET NULL). Rempli automatiquement (Blamable).'",
"COMMENT ON COLUMN {$table}.updated_by IS 'Auteur de la derniere modification (FK user, SET NULL). Rempli automatiquement (Blamable).'",
];
}
}
- Step 4: Run the unit test, verify it passes
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Database/ColumnCommentsCatalogTest.php
Expected: PASS (2 tests).
- Step 5: Run the full suite + cs-fixer
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
Expected: PASS (all green, including the 96 pre-existing tests).
Run: make php-cs-fixer-allow-risky
Expected: no remaining violations in src/Shared / tests.
- Step 6: Commit
git add src/Shared/Infrastructure/Database tests/Unit/Shared/Database
git commit -m "feat(shared) : add column comments catalog helper for migrations"
Acceptance check (run after all tasks)
GET /api/modulesreturns{ "modules": [] }(public, 200).GET /api/sidebarreturns{ sections, disabledRoutes }(401 unauth, 200 auth).src/Shared/holds contracts, trait, subscriber, helper, providers.make testgreen (96 prior + new unit/functional tests).- No destructive migration; no business entity moved; no inter-module import.
Notes for the next ticket (0.2 — Socle front)
Le front consommera /api/modules + /api/sidebar via useModules/useSidebar, montera le shell app/ + shared/ et l'auto-détection des layers. Le filtrage par module deviendra réellement visible quand le 1er module (1.1 Core, puis 2.1 TimeTracking) déclarera sa clé module dans config/sidebar.php.