Files
Lesstime/docs/superpowers/plans/2026-06-19-lst-56-socle-back.md
T
Matthieu 2d0e9de155 docs : add implementation plan for socle back (LST-56 / 0.1)
Plan TDD en 4 tâches : endpoints /api/modules et /api/sidebar, garde-fou Timestampable/Blamable, helper ColumnCommentsCatalog.
2026-06-19 10:56:27 +02:00

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, aucun NOT NULL rétroactif (prod Docker, BDD peuplée).
  • Zéro import inter-modules : passer par src/Shared/Domain/Contract/ ou domain events.
  • Toute GetCollection reste 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/modules public)
  • 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): arraylist<string> (les id() des classes implémentant ModuleInterface, ignore les autres).
    • config/modules.php retourne list<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 ModulesResource and ModulesProvider

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/modules public in security.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): arrayarray{sections: list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string}>}>, disabledRoutes: list<string>}. Règle : un item portant module absent de $activeModuleIds est masqué et son to ajouté à 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.php retourne list<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 SidebarResource and SidebarProvider

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 (implement UserInterface)
  • 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): void and ::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 UserInterface est mappé sur App\Entity\User via resolve_target_entities ; il sera re-pointé vers App\Module\Core\Domain\Entity\User au 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 User implement the contract + add resolve_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 implements clauses; append SharedUserInterface. Alias avoids any clash with Symfony\...\UserInterface already 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 instructions COMMENT 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/modules returns { "modules": [] } (public, 200).
  • GET /api/sidebar returns { sections, disabledRoutes } (401 unauth, 200 auth).
  • src/Shared/ holds contracts, trait, subscriber, helper, providers.
  • make test green (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.