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