From 2d0e9de15551334afb0324f758742f91adc1cc36 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 19 Jun 2026 10:56:27 +0200 Subject: [PATCH] docs : add implementation plan for socle back (LST-56 / 0.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan TDD en 4 tâches : endpoints /api/modules et /api/sidebar, garde-fou Timestampable/Blamable, helper ColumnCommentsCatalog. --- .../plans/2026-06-19-lst-56-socle-back.md | 1155 +++++++++++++++++ 1 file changed, 1155 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-19-lst-56-socle-back.md diff --git a/docs/superpowers/plans/2026-06-19-lst-56-socle-back.md b/docs/superpowers/plans/2026-06-19-lst-56-socle-back.md new file mode 100644 index 0000000..472349e --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-lst-56-socle-back.md @@ -0,0 +1,1155 @@ +# 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 `() : ` (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 */ public static function permissions(): array; }` + - `ModuleRegistry::ids(array $moduleClasses): array` → `list` (les `id()` des classes implémentant `ModuleInterface`, ignore les autres). + - `config/modules.php` retourne `list>` (vide en 0.1). + +- [ ] **Step 1: Write the failing unit test for ModuleRegistry** + +Create `tests/Unit/Shared/Module/ModuleRegistryTest.php`: + +```php + + */ + public static function permissions(): array; +} +``` + +- [ ] **Step 4: Create `ModuleRegistry`** + +```php + $moduleClasses + * + * @return list + */ + 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 + ['modules:read']], + provider: ModulesProvider::class, + ), + ], +)] +final class ModulesResource +{ + /** + * @var list + */ + #[Groups(['modules:read'])] + public array $modules = []; +} +``` + +`src/Shared/Infrastructure/ApiPlatform/State/ModulesProvider.php`: + +```php + $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 +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}>, disabledRoutes: list}`. 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}>`. + +- [ ] **Step 1: Write the failing unit test for SidebarFilter** + +Create `tests/Unit/Shared/Sidebar/SidebarFilterTest.php`: + +```php + '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 +}> $sections + * @param list $activeModuleIds + * + * @return array{sections: list}>, disabledRoutes: list} + */ + 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 +.). + */ +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 + ['sidebar:read']], + provider: SidebarProvider::class, + ), + ], +)] +final class SidebarResource +{ + /** + * @var list}> + */ + #[Groups(['sidebar:read'])] + public array $sections = []; + + /** + * @var list + */ + #[Groups(['sidebar:read'])] + public array $disabledRoutes = []; +} +``` + +`src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php`: + +```php + $moduleClasses */ + $moduleClasses = require $this->projectDir.'/config/modules.php'; + /** @var list}> $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 +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 +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 +security->getUser(); + + return $user instanceof UserInterface ? $user : null; + } +} +``` + +- [ ] **Step 5: Create the trait** + +`src/Shared/Domain/Trait/TimestampableBlamableTrait.php`: + +```php +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 +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` → la liste des instructions `COMMENT ON COLUMN .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 +addSql($statement); }. + * + * @return list + */ + 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`.