# 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): array` → `list<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
<?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
<?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
<?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
<?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
<?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
<?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):

```yaml
        # 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
<?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**

```bash
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 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
<?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
<?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
<?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
<?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
<?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
<?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**

```bash
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
<?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
<?php

declare(strict_types=1);

namespace App\Shared\Domain\Contract;

interface UserInterface
{
    public function getId(): ?int;
}
```

`src/Shared/Domain/Contract/TimestampableInterface.php`:

```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
<?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
<?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
<?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
<?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
<?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):

```php
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:

```yaml
        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**

```bash
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
<?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
<?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**

```bash
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`.
