2d0e9de155
Plan TDD en 4 tâches : endpoints /api/modules et /api/sidebar, garde-fou Timestampable/Blamable, helper ColumnCommentsCatalog.
1156 lines
38 KiB
Markdown
1156 lines
38 KiB
Markdown
# 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`.
|