From a510b2ca73a31ecc5fe3bc8cb19e9b76d1cf5736 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 19 Jun 2026 10:50:14 +0200 Subject: [PATCH 01/99] docs : add modular monolith migration roadmap and socle design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan de migration complet Lesstime vers modular monolith DDD (archi Starseed) : roadmap en 14 tickets ordonnés par dépendances + design technique détaillé du socle (Shared/, contrats, endpoints modules/sidebar, plan strangler). --- ...26-06-19-lst-56-modular-monolith-design.md | 192 ++++++++++++++++++ ...6-19-migration-modular-monolith-roadmap.md | 161 +++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-19-lst-56-modular-monolith-design.md create mode 100644 docs/superpowers/specs/2026-06-19-migration-modular-monolith-roadmap.md diff --git a/docs/superpowers/specs/2026-06-19-lst-56-modular-monolith-design.md b/docs/superpowers/specs/2026-06-19-lst-56-modular-monolith-design.md new file mode 100644 index 0000000..5f58cb6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-lst-56-modular-monolith-design.md @@ -0,0 +1,192 @@ +# LST-56 — Socle modular monolith DDD + pilote « Projets/Tâches » + +> Ticket Lesstime **#56** (1/5 — groupe « Refonte / Alignement Starseed »). +> Design validé le 2026-06-19. Référence vivante : repo **Starseed** (`.claude/rules/*.md` + implémentation réelle), et `Starseed/doc/architecture-modulaire-malio.md` (vision cible théorique — **non contraignante** là où elle diverge du code réel). + +## 1. Objectif & contraintes + +Poser dans Lesstime l'**infrastructure d'un modular monolith DDD** calquée sur Starseed, et **migrer un premier module pilote** (Projets/Tâches) de bout en bout comme preuve que la mécanique tient sur le cœur métier. + +Contraintes **non négociables** : + +- **Ne rien casser de l'existant.** Migration **strangler progressive** : le code legacy (`src/Entity/…`) et les modules (`src/Module/…`) coexistent ; l'application reste fonctionnelle et `make test` vert à **chaque** étape. +- **Prod = Docker, BDD peuplée** → uniquement des migrations **additives et nullable** (aucun `DROP`, aucun `NOT NULL` rétroactif, aucun déplacement de données). +- **Profondeur DDD : pragmatique**, alignée sur le **Starseed réel** (pas la doc théorique) : ORM attributs conservés dans les entités Domain, Repository = interface (Domain) + impl Doctrine (Infrastructure), Provider/Processor API Platform, contrats `Shared/Domain/Contract` pour le cross-module. **Pas de CQRS bus systématique, pas de multi-tenant.** + +### Décisions de cadrage (figées) + +| Sujet | Décision | +|-------|----------| +| Périmètre #56 | Socle complet + **1 module pilote** migré de bout en bout | +| Stratégie | **Strangler progressif** (legacy + modules en parallèle) | +| Profondeur DDD | **Pragmatique** (= Starseed réel) | +| Module pilote | **Projets/Tâches** (cœur métier) | +| Dépendances du pilote (User/Client/Notification) | Restent **legacy**, câblées via **contrats `Shared/Domain/Contract`** + `resolve_target_entities` | +| Infra d'audit Starseed | **Différée** → ticket Lesstime dédié (créé séparément) | +| Périmètre front #56 | **Câblage shell/shared/middlewares + migration du pilote en layer**, sans relooking (le relooking Malio reste #60) | +| Exposition API du pilote | **Garder les `#[ApiResource]` actuels** (étendre seulement les chemins de scan) — zéro régression API | +| Tâche → Notification | **Contrat `NotifierInterface`** (impl legacy crée la `Notification`) | +| Nom/ID du module | back `ProjectManagement` / front `project-management` / ID `project_management` | + +## 2. Garde-fous Starseed retenus pour #56 + +Repris : `declare(strict_types=1)`, `src/Module//{Domain,Application,Infrastructure}`, `Shared/Domain/Contract` + `resolve_target_entities` (zéro import inter-modules), `config/modules.php` + `config/sidebar.php`, endpoints `/api/modules` + `/api/sidebar` + `/api/version`, `TimestampableBlamableTrait` + subscriber, pagination obligatoire, `COMMENT ON COLUMN` (helper `ColumnCommentsCatalog`), front layers auto-détectés + `useSidebar`/`useModules` + `auth.global.ts`/`modules.global.ts`. + +Reportés (hors #56) : **infra d'audit** (`#[Auditable]`/`#[AuditIgnore]`, table `audit_log`, listener, resource) → ticket dédié. **RBAC fin** (`module.resource.action`) → #57 ; en #56 la sidebar filtre **par module actif** (au plus un gate `ROLE_ADMIN`). + +## 3. Backend — arborescence cible + +``` +src/Shared/ +├── Domain/ +│ ├── Contract/ UserInterface, UserResolverInterface, ClientInterface, NotifierInterface +│ ├── Event/ DomainEventInterface +│ └── Trait/ TimestampableBlamableTrait +├── Infrastructure/ +│ ├── Doctrine/ TimestampableBlamableSubscriber +│ ├── Database/ ColumnCommentsCatalog (helper COMMENT ON COLUMN + 4 colonnes std) +│ └── ApiPlatform/ +│ ├── Resource/ ModulesResource, SidebarResource +│ └── State/ ModulesProvider, SidebarProvider +│ +src/Module/ProjectManagement/ +├── ProjectManagementModule.php ID='project_management', LABEL='Projets', REQUIRED=false, permissions()=[] (stub, RBAC réel #57) +├── Domain/ +│ ├── Entity/ Project, Task, Workflow, TaskStatus, TaskGroup, TaskEffort, +│ │ TaskPriority, TaskTag, TaskRecurrence, TaskDocument +│ └── Repository/ *RepositoryInterface (une interface par agrégat consommé) +├── Application/ RecurrenceCalculator/RecurrenceHandler + services task-centric déplacés +└── Infrastructure/ + ├── Doctrine/ Doctrine*Repository + Migrations/ (additif Timestampable) + ├── ApiPlatform/ State/Provider + State/Processor déplacés (TaskNumber, TaskCalendar, + │ TaskDocument*, SwitchProjectWorkflow, WorkflowDelete, ActiveTimeEntry resté legacy…) + └── Mcp/Tool/ MCP tools Project/, Task/, TaskMeta/, Workflow/ déplacés +``` + +`src/Entity/` conserve **intacts** : `User`, `Client`, `Notification`, `TimeEntry`, `AbsenceRequest`/`AbsencePolicy`/`AbsenceBalance`, `Mail*`, `Gitea*`/`BookStack*`/`Zimbra*`/`Share*Configuration`. Ces domaines seront modularisés dans des tickets ultérieurs. + +> **Note de découpage** : `TimeEntry` reste legacy en #56 (domaine Time tracking séparé). Le lien `Task ↔ TimeEntry` est porté côté `TimeEntry` (FK nullable vers la table `task`) ; aucune contrainte ne casse car la table `task` ne change pas de nom. + +## 4. Câblage des dépendances (zéro import inter-modules) + +1. Interfaces dans `src/Shared/Domain/Contract/` : + - `UserInterface` (id + identifiants nécessaires aux entités du module : assignee, collaborators, createdBy/updatedBy), + - `ClientInterface` (id + nom, pour `Project.client`), + - `UserResolverInterface` (résoudre un user par id, pour les State/MCP du module), + - `NotifierInterface` (créer une notification — impl legacy). +2. Les entités du module **type-hintent les interfaces**, jamais `App\Entity\*`. +3. `config/packages/doctrine.yaml → orm.resolve_target_entities` : + ```yaml + resolve_target_entities: + App\Shared\Domain\Contract\UserInterface: App\Entity\User + App\Shared\Domain\Contract\ClientInterface: App\Entity\Client + ``` +4. `App\Entity\User` `implements UserInterface`, `App\Entity\Client` `implements ClientInterface` (legacy modifié à minima, additif). +5. Notifications : `App\Module\ProjectManagement\…` appelle `NotifierInterface` ; impl `App\…\LegacyNotifier` (wrappe le `NotificationService` actuel). Le `TaskNotificationListener` est déplacé/adapté pour passer par le contrat. + +## 5. Config backend (toutes additives) + +- **`doctrine.yaml`** — ajouter un mapping module (garder `App → src/Entity`) : + ```yaml + mappings: + App: { type: attribute, is_bundle: false, dir: '%kernel.project_dir%/src/Entity', prefix: 'App\Entity', alias: App } + ProjectManagement: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity' + prefix: 'App\Module\ProjectManagement\Domain\Entity' + ``` + Les entités déplacées **gardent leur `#[ORM\Table(name: '…')]` actuel** (table inchangée → aucune donnée déplacée). `#[ORM\Entity(repositoryClass: DoctrineXxxRepository::class)]` mis à jour vers la nouvelle classe. +- **`doctrine_migrations.yaml`** — ajouter le namespace module (garder `DoctrineMigrations`) : + ```yaml + migrations_paths: + DoctrineMigrations: '%kernel.project_dir%/migrations' + 'App\Module\ProjectManagement\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/ProjectManagement/Infrastructure/Doctrine/Migrations' + ``` + > ⚠️ Doctrine Migrations trie par FQCN entre namespaces : le legacy `DoctrineMigrations` (setup initial) passe avant les migrations modulaires sur base vide. Sur la prod déjà migrée, seules les **nouvelles** migrations additives s'appliquent → pas d'impact d'ordre. +- **`api_platform.yaml`** — déclarer les chemins de mapping (entités + resources legacy **et** module) pour que les `#[ApiResource]` du pilote restent découverts : + ```yaml + mapping: + paths: + - '%kernel.project_dir%/src/Entity' + - '%kernel.project_dir%/src/ApiResource' + - '%kernel.project_dir%/src/Shared/Infrastructure/ApiPlatform/Resource' + - '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity' + ``` +- **`services.yaml`** — mettre à jour les FQCN explicites déplacés : `App\EventListener\TaskDocumentListener`, `App\State\TaskDocumentProcessor`, `App\Controller\TaskDocumentDownloadController`, `App\Mcp\Tool\Task\AddTaskDocumentTool`, `App\Mcp\Tool\Task\UpdateTaskDocumentTool` → nouveaux namespaces module. Le glob `App\: '../src/'` continue d'autowire les classes déplacées. + +## 6. Garde-fous portés dans #56 + +- **TimestampableBlamable** : trait `Shared/Domain/Trait/TimestampableBlamableTrait` (4 colonnes `created_at`, `updated_at`, `created_by`, `updated_by` — toutes **nullable**), rempli par `TimestampableBlamableSubscriber` (prePersist/preUpdate). Appliqué aux entités du pilote → **1 migration additive** par table concernée, avec `COMMENT ON COLUMN` via `ColumnCommentsCatalog::addStandardTimestampableBlamableComments()`. +- **Pagination** : conserver le standard API Platform actuel (les collections du pilote restent paginées comme aujourd'hui). +- **`COMMENT ON COLUMN`** : appliqué sur les colonnes ajoutées par #56 (pas de rétro-commentaire forcé sur le legacy). + +## 7. Endpoints modules / sidebar / version + +- `GET /api/modules` (public) — `ModulesResource` + `ModulesProvider` lisant `config/modules.php` (renvoie `{ modules: ["project_management", …] }`). +- `GET /api/sidebar` (auth) — `SidebarResource` + `SidebarProvider` lisant `config/sidebar.php` ; filtrage **par module actif** (item `module` absent de la liste active → masqué + route ajoutée à `disabledRoutes`) ; gate de section optionnel `ROLE_ADMIN`. Le filtrage par **permissions fines** est explicitement reporté à #57. +- `GET /api/version` — **déjà présent** (`AppVersion`) ; vérifier le format `{ version }`, ré-aligner si besoin (déplacement optionnel vers `Shared/`). +- `config/modules.php` : `return [ ProjectManagementModule::class ];` (Core viendra plus tard ; pas de module REQUIRED bloquant en #56). +- `config/sidebar.php` : sections « Projets » / « Mes tâches » avec `module => 'project_management'` ; les entrées des domaines encore legacy (Time tracking, Absences, Mail, Admin…) listées **sans** clé `module` (donc toujours visibles) pour ne rien masquer. + +## 8. Frontend — câblage + pilote en layer (sans relooking) + +``` +frontend/app/ +├── layouts/default.vue shell : sidebar (depuis /api/sidebar) + main +├── middleware/auth.global.ts protège routes, charge sidebar+modules après login +└── middleware/modules.global.ts redirige si route ∈ disabledRoutes +frontend/shared/ +├── composables/ useApi (déplacé), useSidebar, useModules, + existants réutilisés +├── stores/ auth, ui, timer (timer reste partagé : Time tracking encore legacy) +├── utils/ api.ts (extractHydraMembers/fetchAllHydra), … +└── types/ +frontend/modules/project-management/ +├── nuxt.config.ts defineNuxtConfig({}) +├── pages/ my-tasks.vue, projects/index.vue, projects/[id]/* (déplacés tels quels) +├── components/ task/*, project/* (déplacés) +├── services/ tasks.ts, projects.ts, task-*.ts, workflows.ts (déplacés) +└── stores/ (si spécifiques au domaine) +``` + +- **`nuxt.config.ts`** : auto-détection des layers `modules/*/` (scan `readdirSync` comme Starseed) ajoutés à `extends`, + dirs d'auto-import des composables/stores par layer. `extends: ['@malio/layer-ui']` conservé en tête. +- **`useSidebar`/`useModules`** : état singleton, `loadSidebar()`/`loadModules()` appelés dans `auth.global.ts`, `reset*()` au logout. +- **`modules.global.ts`** : `isRouteDisabled(to.path)` → `navigateTo('/')`. +- **Migration des pages** : déplacement **sans réécriture visuelle** ; les pages des autres domaines (time-tracking, absences, mail, admin, profile…) **restent dans `frontend/pages/`** (legacy) tant que leurs modules ne sont pas migrés. Nuxt fusionne les routes du shell + des layers → cohabitation transparente. + +> Point de vigilance front : vérifier que la cohabitation `frontend/pages/` (legacy) + `frontend/modules/*/pages/` (layer) ne crée pas de collision de routes ; `my-tasks`/`projects` sont déplacés **et retirés** de `frontend/pages/` pour éviter le doublon. + +## 9. Plan strangler (ordre d'exécution — app verte à chaque palier) + +1. **Shared/ + garde-fous** : trait, subscriber, `ColumnCommentsCatalog`. Neutre (rien ne les consomme encore). +2. **Endpoints modules/sidebar** + `config/modules.php` + `config/sidebar.php` (toutes entrées legacy sans `module` → rien masqué). Additif. +3. **Contrats `Shared/Domain/Contract`** + `resolve_target_entities` + `User`/`Client` `implements …Interface`. Neutre. +4. **Déplacement back du module** ProjectManagement (entités → Domain/Entity, repos → Infra/Doctrine + interfaces Domain, State, MCP) + mises à jour `doctrine.yaml`/`api_platform.yaml`/`doctrine_migrations.yaml`/`services.yaml`. **`make test` vert.** +5. **Migration additive Timestampable** sur les tables du pilote (+ `COMMENT ON COLUMN`). +6. **Front shell** : `app/` + `shared/` + middlewares + auto-détection `nuxt.config.ts`. App encore en pages plates. +7. **Déplacement front du pilote** vers `modules/project-management/` (pages/components/services), retrait des doublons de `frontend/pages/`. +8. **Vérification bout-en-bout** : commenter `ProjectManagementModule::class` dans `config/modules.php` → `/api/modules` ne le liste plus, `/api/sidebar` masque ses entrées + peuple `disabledRoutes`, le front redirige `/my-tasks`→`/`. Décommenter → tout revient. Documenter le test. + +## 10. Critères d'acceptation (repris du ticket, raffinés) + +- [ ] `src/Shared/` + `src/Module/ProjectManagement/{Domain,Application,Infrastructure}` en place. +- [ ] `/api/modules`, `/api/sidebar` fonctionnels ; `/api/version` aligné. +- [ ] Aucun import direct `App\Entity\User`/`Client` depuis le module (contrats + `resolve_target_entities`). +- [ ] Front : layers `frontend/modules/*/` auto-détectés ; `useSidebar`/`useModules` + `auth.global.ts`/`modules.global.ts` opérationnels ; pilote migré sans régression visuelle. +- [ ] Garde-fous : TimestampableBlamable (migration additive + `COMMENT ON COLUMN`) ; pagination conservée. **Audit explicitement hors périmètre** (ticket dédié). +- [ ] `make test` vert ; activation/désactivation du module validée de bout en bout. +- [ ] Aucune migration destructive ; prod déployable sans perte. + +## 11. Risques & points de vigilance + +- **Prod peuplée** : seules migrations additives nullable. `created_by`/`updated_by` non backfillés (historique) — conforme Starseed. +- **Changement de namespace des entités** : sans impact DB (Doctrine mappe par table). Vérifier qu'aucun code legacy ne référence en dur `App\Entity\Task` etc. → grep + remplacement (le pilote tire Task/Project, consommés par TimeEntry/Mail/BookStack links restés legacy : ces liens passeront par les contrats ou un type-hint relâché). +- **Collision de routes front** legacy vs layer (cf. §8). +- **MCP tools** (spécificité Lesstime) : déplacés sous `Module/*/Infrastructure/Mcp/` ; confirmer que `McpSchemaGeneratorPass` les redécouvre (scan `src/`). +- **`auto_mapping: true`** : valider que l'ajout d'un mapping explicite ne perturbe pas la résolution (sinon désactiver `auto_mapping` et lister explicitement). + +## 12. Suite + +- Ticket **audit** dédié à créer (infra `#[Auditable]` + `audit_log` + listener + resource), prérequis souple de #57. +- #57 RBAC fin (permissions `module.resource.action`, sidebar filtrée par permission). +- #58 Répertoire (Clients/Prospects), #59 Reporting, #60 Refonte front Malio. diff --git a/docs/superpowers/specs/2026-06-19-migration-modular-monolith-roadmap.md b/docs/superpowers/specs/2026-06-19-migration-modular-monolith-roadmap.md new file mode 100644 index 0000000..bfd5fd5 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-migration-modular-monolith-roadmap.md @@ -0,0 +1,161 @@ +# Roadmap — Migration Lesstime → modular monolith DDD (archi Starseed) + +> Plan de migration **complet** validé le 2026-06-19. Référence architecture : repo **Starseed** +> (`.claude/rules/*.md` + implémentation réelle). Détail technique du socle : voir +> `2026-06-19-lst-56-modular-monolith-design.md`. + +## Principes directeurs + +- **Strangler progressif** : legacy (`src/Entity/…`) et modules (`src/Module/…`) coexistent ; l'app + reste fonctionnelle et `make test` vert à **chaque** merge. Aucune migration destructive (prod Docker, BDD peuplée → migrations **additives nullable** uniquement). +- **DDD pragmatique** (= Starseed réel) : ORM attrs dans l'entité Domain, Repository interface (Domain) + + impl Doctrine (Infra), Provider/Processor API Platform, contrats `Shared/Domain/Contract` pour le + cross-module. **Pas de CQRS bus, pas de multi-tenant.** +- **Tranches verticales** : chaque module de Phase 2 est livré **back + front (layer Malio) + MCP** + d'un coup → fonctionnel de bout en bout à son merge. L'ancienne idée d'un « ticket refonte front » + global est dissoute : chaque module arrive déjà en Malio ; un ticket de finition harmonise à la fin. +- **Ordre par dépendances** : socle → Core (identité/RBAC/audit) → modules métier → transverse/finition. +- **Zéro import inter-modules** : interfaces `Shared/Domain/Contract` + `resolve_target_entities`, + ou domain events / contrat `NotifierInterface`. + +## Garde-fous Starseed (appliqués à chaque entité migrée) + +`declare(strict_types=1)` · `TimestampableBlamableTrait` (4 colonnes nullable) + subscriber · +pagination obligatoire · `COMMENT ON COLUMN` (helper `ColumnCommentsCatalog`) · +`#[Auditable]`/`#[AuditIgnore]` (dès que 1.3 est livré) · front `Malio*` + `usePaginatedList` + +`useFormErrors` · RBAC `module.resource.action` (dès 1.2). + +--- + +## Phase 0 — Socle (fondations, ne touche aucun métier) + +### 0.1 · Socle back — infrastructure modulaire *(réécrit depuis #56)* +**Dépend de** : — +`src/Shared/Domain/Contract/` (UserInterface, UserResolverInterface, ClientInterface, NotifierInterface), +`Shared/Domain/Event/DomainEventInterface`, `Shared/Domain/Trait/TimestampableBlamableTrait`, +`Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber`, +`Shared/Infrastructure/Database/ColumnCommentsCatalog`, +`Shared/Infrastructure/ApiPlatform/{Resource,State}` (`ModulesResource`/`ModulesProvider`, +`SidebarResource`/`SidebarProvider`), `config/modules.php`, `config/sidebar.php`, `/api/version` aligné. +Config additive : mapping Doctrine module prêt, `migrations_paths` modulaire, `api_platform.mapping.paths`. +**AC** : `/api/modules` + `/api/sidebar` répondent ; app verte ; aucune migration destructive. + +### 0.2 · Socle front — shell + auto-détection des layers +**Dépend de** : 0.1 +`frontend/app/` (shell `layouts/default.vue`), `frontend/shared/` (`useApi` déplacé, `useSidebar`, +`useModules`, stores), middlewares `auth.global.ts` + `modules.global.ts`, auto-détection des layers +`modules/*/` dans `nuxt.config.ts`. **Aucune page métier déplacée** (app encore plate). +**AC** : sidebar dynamique depuis `/api/sidebar` ; routes désactivées redirigées ; app verte. + +--- + +## Phase 1 — Module Core (identité, sécurité, traçabilité — transverse) + +### 1.1 · Core — Identité & Notifications +**Dépend de** : 0.1, 0.2 +Migrer `User` + Auth/JWT dans `src/Module/Core/` (Domain/Entity, Repository interface + Doctrine impl, +`MeProvider`, password hasher), `User implements UserInterface`, `resolve_target_entities → Core\User`. +`Notification` exposée via `NotifierInterface`. `CoreModule.php` (**REQUIRED=true**). Front : layer +`modules/core/` (login, profile, admin users). +**AC** : login/JWT OK ; app verte ; aucun import direct `App\Entity\User` hors Core. + +### 1.2 · RBAC fin *(réécrit depuis #57)* +**Dépend de** : 1.1 +`Role`/`Permission`, `permissions()` par module, commande `app:sync-permissions`, `PermissionVoter`, +`SidebarProvider` filtrant **par permission** (en plus du module actif), seed RBAC. Front : gestion des +rôles + `usePermissions`. +**AC** : permissions `module.resource.action` ; sidebar gated par permission. + +### 1.3 · Audit log *(réécrit depuis #61)* +**Dépend de** : 1.1 +`#[Auditable]`/`#[AuditIgnore]` (`Shared/Domain/Attribute`), table `audit_log` (migration additive + +`COMMENT ON COLUMN`), `AuditListener`/`AuditLogWriter`/`RequestIdProvider`, `AuditLogResource` + +`/api/audit-logs` paginé/filtrable, page front + labels i18n `audit.entity.*`. +**AC** : CRUD des entités `#[Auditable]` tracé ; endpoint paginé ; aucune migration destructive. + +--- + +## Phase 2 — Modules métier (tranches verticales back + front + MCP, strangler) + +### 2.1 · Module TimeTracking *(premier module — rodage)* +**Dépend de** : 1.1 +Migrer `TimeEntry` → `src/Module/TimeTracking/` (Domain/Entity, repo, `ActiveTimeEntryProvider`, +`TimeEntryExportService`/controller, MCP TimeEntry tools), front layer `modules/time-tracking/` +(`time-tracking.vue`, components, services, store `timer`). Timestampable additif. **Rode toute la +mécanique modulaire à risque quasi nul.** +**AC** : time tracking fonctionnel en module ; activation/désactivation testée ; app verte. + +### 2.2 · Module ProjectManagement *(cœur métier — réécrit depuis #56 pilote)* +**Dépend de** : 2.1, 1.1 +`Project, Task, Workflow, TaskStatus, TaskGroup, TaskEffort, TaskPriority, TaskTag, TaskRecurrence, +TaskDocument` → `src/Module/ProjectManagement/` (vertical back + MCP Task/Project/TaskMeta/Workflow + +front layer `modules/project-management/`). User/Client via contrats (Client encore legacy jusqu'à 2.4). +Notifications via `NotifierInterface`. `#[ApiResource]` conservés (étendre le scan). Timestampable additif. +**AC** : cœur en module sans régression API ; app verte. + +### 2.3 · Module Absence +**Dépend de** : 1.1 +`AbsenceRequest/AbsencePolicy/AbsenceBalance` + services (`AbsenceBalanceService`, `AbsenceDayCalculator`, +`PublicHolidayProvider`) + controllers (calendar, preview, justificatif) + MCP absence tools → +`src/Module/Absence/`, front layer `modules/absence/`. +**AC** : module absences complet ; app verte. + +### 2.4 · Module Directory — Clients + Prospects *(réécrit depuis #58)* +**Dépend de** : 1.1 (et après 2.2 qui référence Client via contrat) +`Client` → `src/Module/Directory/` + nouvelle entité `Prospect`. L'impl de `ClientInterface` migre du +legacy vers le module (`resolve_target_entities` mis à jour). Front répertoire (clients + prospects). +**AC** : Clients + Prospects en module ; contrats à jour ; app verte. + +### 2.5 · Module Mail +**Dépend de** : 1.1, 2.2 (TaskMailLink → Task) +`Mail*` + `TaskMailLink` + `MailSyncService` + controllers + settings → `src/Module/Mail/`, front layer. +Intègre le WIP `feat/mail-integration`. +**AC** : mail en module ; app verte. + +### 2.6 · Module Integration — Gitea / BookStack / Zimbra / Share +**Dépend de** : 1.1, 2.2 (liens Task) +Configs + services API (`GiteaApiService`, `BookStackApiService`, `CalDavService`, Share) + controllers + +liens → `src/Module/Integration/`, front (onglets admin + sections task). +**AC** : intégrations en module ; app verte. + +--- + +## Phase 3 — Transverse & finition + +### 3.1 · Module Reporting *(réécrit depuis #59)* +**Dépend de** : Phase 2 (consomme les modules) +Reporting natif transverse (agrège time tracking, tâches, absences) via contrats / API. Module +`src/Module/Reporting/` + front. +**AC** : rapports natifs ; aucune dépendance directe inter-modules. + +### 3.2 · Module Portail client +**Dépend de** : 1.1, 2.2, 2.4 +Portail client (accès restreint), module `src/Module/ClientPortal/` + front layer + RBAC dédié. +**AC** : portail fonctionnel ; gated RBAC. + +### 3.3 · Finition Malio + nettoyage legacy *(réécrit depuis #60)* +**Dépend de** : tout +Harmonisation visuelle Malio finale, **vidage de `src/Entity/` legacy résiduel**, suppression du mapping +Doctrine legacy + des pages plates `frontend/pages/` résiduelles, durcissement `resolve_target_entities`. +**AC** : `src/Entity` vide ; 100 % modulaire ; app verte ; aucune route/legacy orpheline. + +--- + +## Ordre d'exécution recommandé + +`0.1 → 0.2 → 1.1 → 1.2 → 1.3 → 2.1 → 2.2 → 2.3 → 2.4 → 2.5 → 2.6 → 3.1 → 3.2 → 3.3` + +Les tickets 1.2 et 1.3 peuvent se paralléliser après 1.1. Les modules 2.3 (Absence) et 2.4 (Directory) +peuvent se paralléliser après 2.2. Mail (2.5) et Integration (2.6) suivent 2.2. + +## Mapping avec les tickets Lesstime existants + +| Ancien | Devient | +|--------|---------| +| #56 (1/5 Aligner archi) | **0.1 Socle back** (le reste éclaté en 0.2 + 2.2) | +| #57 (2/5 RBAC) | **1.2 RBAC fin** | +| #58 (3/5 Répertoire) | **2.4 Directory** | +| #59 (4/5 Reporting) | **3.1 Reporting** | +| #60 (5/5 Front Malio) | **3.3 Finition Malio + nettoyage** (le front se fait par module) | +| #61 (Audit) | **1.3 Audit log** | +| *(créés)* | 0.2, 1.1, 2.1, 2.2, 2.3, 2.5, 2.6, 3.2 | -- 2.39.5 From 2d0e9de15551334afb0324f758742f91adc1cc36 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 19 Jun 2026 10:56:27 +0200 Subject: [PATCH 02/99] 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`. -- 2.39.5 From 748289b61ae8d7ab7b860ee7a7aeba6ed38b3f48 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 19 Jun 2026 14:33:53 +0200 Subject: [PATCH 03/99] feat(modules) : expose GET /api/modules and module registry --- config/modules.php | 11 +++ config/packages/security.yaml | 2 + src/Shared/Domain/Module/ModuleInterface.php | 23 ++++++ src/Shared/Domain/Module/ModuleRegistry.php | 25 ++++++ .../ApiPlatform/Resource/ModulesResource.php | 28 +++++++ .../ApiPlatform/State/ModulesProvider.php | 30 +++++++ .../Functional/Shared/ModulesEndpointTest.php | 24 ++++++ .../Unit/Shared/Module/ModuleRegistryTest.php | 81 +++++++++++++++++++ 8 files changed, 224 insertions(+) create mode 100644 config/modules.php create mode 100644 src/Shared/Domain/Module/ModuleInterface.php create mode 100644 src/Shared/Domain/Module/ModuleRegistry.php create mode 100644 src/Shared/Infrastructure/ApiPlatform/Resource/ModulesResource.php create mode 100644 src/Shared/Infrastructure/ApiPlatform/State/ModulesProvider.php create mode 100644 tests/Functional/Shared/ModulesEndpointTest.php create mode 100644 tests/Unit/Shared/Module/ModuleRegistryTest.php diff --git a/config/modules.php b/config/modules.php new file mode 100644 index 0000000..c6df2ac --- /dev/null +++ b/config/modules.php @@ -0,0 +1,11 @@ + + */ + public static function permissions(): array; +} diff --git a/src/Shared/Domain/Module/ModuleRegistry.php b/src/Shared/Domain/Module/ModuleRegistry.php new file mode 100644 index 0000000..894b6af --- /dev/null +++ b/src/Shared/Domain/Module/ModuleRegistry.php @@ -0,0 +1,25 @@ + $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; + } +} diff --git a/src/Shared/Infrastructure/ApiPlatform/Resource/ModulesResource.php b/src/Shared/Infrastructure/ApiPlatform/Resource/ModulesResource.php new file mode 100644 index 0000000..b00fd3a --- /dev/null +++ b/src/Shared/Infrastructure/ApiPlatform/Resource/ModulesResource.php @@ -0,0 +1,28 @@ + ['modules:read']], + provider: ModulesProvider::class, + ), + ], +)] +final class ModulesResource +{ + /** + * @var list + */ + #[Groups(['modules:read'])] + public array $modules = []; +} diff --git a/src/Shared/Infrastructure/ApiPlatform/State/ModulesProvider.php b/src/Shared/Infrastructure/ApiPlatform/State/ModulesProvider.php new file mode 100644 index 0000000..b5204b7 --- /dev/null +++ b/src/Shared/Infrastructure/ApiPlatform/State/ModulesProvider.php @@ -0,0 +1,30 @@ + $classes */ + $classes = require $this->projectDir.'/config/modules.php'; + + $dto = new ModulesResource(); + $dto->modules = ModuleRegistry::ids($classes); + + return $dto; + } +} diff --git a/tests/Functional/Shared/ModulesEndpointTest.php b/tests/Functional/Shared/ModulesEndpointTest.php new file mode 100644 index 0000000..9279ca5 --- /dev/null +++ b/tests/Functional/Shared/ModulesEndpointTest.php @@ -0,0 +1,24 @@ +request('GET', '/api/modules'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertArrayHasKey('modules', $data); + self::assertIsArray($data['modules']); + } +} diff --git a/tests/Unit/Shared/Module/ModuleRegistryTest.php b/tests/Unit/Shared/Module/ModuleRegistryTest.php new file mode 100644 index 0000000..a5ea1aa --- /dev/null +++ b/tests/Unit/Shared/Module/ModuleRegistryTest.php @@ -0,0 +1,81 @@ + Date: Fri, 19 Jun 2026 14:35:17 +0200 Subject: [PATCH 04/99] feat(sidebar) : expose GET /api/sidebar filtered by active modules --- config/sidebar.php | 29 +++++++++ src/Shared/Domain/Sidebar/SidebarFilter.php | 40 +++++++++++++ .../ApiPlatform/Resource/SidebarResource.php | 34 +++++++++++ .../ApiPlatform/State/SidebarProvider.php | 37 ++++++++++++ .../Functional/Shared/SidebarEndpointTest.php | 40 +++++++++++++ .../Unit/Shared/Sidebar/SidebarFilterTest.php | 59 +++++++++++++++++++ 6 files changed, 239 insertions(+) create mode 100644 config/sidebar.php create mode 100644 src/Shared/Domain/Sidebar/SidebarFilter.php create mode 100644 src/Shared/Infrastructure/ApiPlatform/Resource/SidebarResource.php create mode 100644 src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php create mode 100644 tests/Functional/Shared/SidebarEndpointTest.php create mode 100644 tests/Unit/Shared/Sidebar/SidebarFilterTest.php diff --git a/config/sidebar.php b/config/sidebar.php new file mode 100644 index 0000000..15bd7a1 --- /dev/null +++ b/config/sidebar.php @@ -0,0 +1,29 @@ +.). + */ +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'], + ], + ], +]; diff --git a/src/Shared/Domain/Sidebar/SidebarFilter.php b/src/Shared/Domain/Sidebar/SidebarFilter.php new file mode 100644 index 0000000..e97ca8d --- /dev/null +++ b/src/Shared/Domain/Sidebar/SidebarFilter.php @@ -0,0 +1,40 @@ +}> $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]; + } +} diff --git a/src/Shared/Infrastructure/ApiPlatform/Resource/SidebarResource.php b/src/Shared/Infrastructure/ApiPlatform/Resource/SidebarResource.php new file mode 100644 index 0000000..c90877e --- /dev/null +++ b/src/Shared/Infrastructure/ApiPlatform/Resource/SidebarResource.php @@ -0,0 +1,34 @@ + ['sidebar:read']], + provider: SidebarProvider::class, + ), + ], +)] +final class SidebarResource +{ + /** + * @var list}> + */ + #[Groups(['sidebar:read'])] + public array $sections = []; + + /** + * @var list + */ + #[Groups(['sidebar:read'])] + public array $disabledRoutes = []; +} diff --git a/src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php b/src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php new file mode 100644 index 0000000..84333d5 --- /dev/null +++ b/src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php @@ -0,0 +1,37 @@ + $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; + } +} diff --git a/tests/Functional/Shared/SidebarEndpointTest.php b/tests/Functional/Shared/SidebarEndpointTest.php new file mode 100644 index 0000000..55f0628 --- /dev/null +++ b/tests/Functional/Shared/SidebarEndpointTest.php @@ -0,0 +1,40 @@ +request('GET', '/api/sidebar'); + + self::assertResponseStatusCodeSame(401); + } + + public function testSidebarReturnsSectionsForAuthenticatedUser(): void + { + $client = self::createClient(); + $container = self::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']); + } +} diff --git a/tests/Unit/Shared/Sidebar/SidebarFilterTest.php b/tests/Unit/Shared/Sidebar/SidebarFilterTest.php new file mode 100644 index 0000000..22dbf74 --- /dev/null +++ b/tests/Unit/Shared/Sidebar/SidebarFilterTest.php @@ -0,0 +1,59 @@ + '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']); + } +} -- 2.39.5 From 3053c09522f5421b73dc51f1b18147f452e4d969 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 19 Jun 2026 14:37:28 +0200 Subject: [PATCH 05/99] feat(shared) : add timestampable/blamable trait and doctrine subscriber --- config/packages/doctrine.yaml | 2 + src/Entity/User.php | 3 +- .../CurrentUserProviderInterface.php | 12 +++ .../Domain/Contract/BlamableInterface.php | 16 ++++ .../Contract/TimestampableInterface.php | 18 ++++ src/Shared/Domain/Contract/UserInterface.php | 10 ++ .../Trait/TimestampableBlamableTrait.php | 71 +++++++++++++++ .../TimestampableBlamableSubscriber.php | 64 +++++++++++++ .../Security/SecurityCurrentUserProvider.php | 23 +++++ .../TimestampableBlamableSubscriberTest.php | 91 +++++++++++++++++++ 10 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 src/Shared/Application/CurrentUserProviderInterface.php create mode 100644 src/Shared/Domain/Contract/BlamableInterface.php create mode 100644 src/Shared/Domain/Contract/TimestampableInterface.php create mode 100644 src/Shared/Domain/Contract/UserInterface.php create mode 100644 src/Shared/Domain/Trait/TimestampableBlamableTrait.php create mode 100644 src/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber.php create mode 100644 src/Shared/Infrastructure/Security/SecurityCurrentUserProvider.php create mode 100644 tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 6c57caf..303521a 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -13,6 +13,8 @@ doctrine: identity_generation_preferences: Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity auto_mapping: true + resolve_target_entities: + App\Shared\Domain\Contract\UserInterface: App\Entity\User mappings: App: type: attribute diff --git a/src/Entity/User.php b/src/Entity/User.php index 2bc49c9..38e789e 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -13,6 +13,7 @@ use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use App\Enum\ContractType; use App\Repository\UserRepository; +use App\Shared\Domain\Contract\UserInterface as SharedUserInterface; use App\State\MeProvider; use App\State\UserPasswordHasherProcessor; use DateTimeImmutable; @@ -44,7 +45,7 @@ use Symfony\Component\Serializer\Attribute\Groups; )] #[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Table(name: '`user`')] -class User implements UserInterface, PasswordAuthenticatedUserInterface +class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedUserInterface { #[ORM\Id] #[ORM\GeneratedValue] diff --git a/src/Shared/Application/CurrentUserProviderInterface.php b/src/Shared/Application/CurrentUserProviderInterface.php new file mode 100644 index 0000000..da670e2 --- /dev/null +++ b/src/Shared/Application/CurrentUserProviderInterface.php @@ -0,0 +1,12 @@ +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; + } +} diff --git a/src/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber.php b/src/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber.php new file mode 100644 index 0000000..2aa832a --- /dev/null +++ b/src/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber.php @@ -0,0 +1,64 @@ +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()); + } + } +} diff --git a/src/Shared/Infrastructure/Security/SecurityCurrentUserProvider.php b/src/Shared/Infrastructure/Security/SecurityCurrentUserProvider.php new file mode 100644 index 0000000..2d9a5b3 --- /dev/null +++ b/src/Shared/Infrastructure/Security/SecurityCurrentUserProvider.php @@ -0,0 +1,23 @@ +security->getUser(); + + return $user instanceof UserInterface ? $user : null; + } +} diff --git a/tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php b/tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php new file mode 100644 index 0000000..669acf5 --- /dev/null +++ b/tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php @@ -0,0 +1,91 @@ +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; + }; + } +} -- 2.39.5 From b301c543bbde75854736821a549299e1ffa8b99c Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 19 Jun 2026 14:38:40 +0200 Subject: [PATCH 06/99] feat(shared) : add column comments catalog helper for migrations --- .../Database/ColumnCommentsCatalog.php | 24 ++++++++++++++ .../Database/ColumnCommentsCatalogTest.php | 33 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php create mode 100644 tests/Unit/Shared/Database/ColumnCommentsCatalogTest.php diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php new file mode 100644 index 0000000..16d070e --- /dev/null +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -0,0 +1,24 @@ +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).'", + ]; + } +} diff --git a/tests/Unit/Shared/Database/ColumnCommentsCatalogTest.php b/tests/Unit/Shared/Database/ColumnCommentsCatalogTest.php new file mode 100644 index 0000000..bb41001 --- /dev/null +++ b/tests/Unit/Shared/Database/ColumnCommentsCatalogTest.php @@ -0,0 +1,33 @@ + Date: Fri, 19 Jun 2026 15:00:17 +0200 Subject: [PATCH 07/99] docs : log LST-56 socle back session learnings --- .claude/skills/ticket-executor/LEARNINGS.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.claude/skills/ticket-executor/LEARNINGS.md b/.claude/skills/ticket-executor/LEARNINGS.md index a5f24ac..4799a5a 100644 --- a/.claude/skills/ticket-executor/LEARNINGS.md +++ b/.claude/skills/ticket-executor/LEARNINGS.md @@ -54,6 +54,25 @@ - **Pattern**: Retirer de composer.json + bundles.php + supprimer config YAML + templates - **Learning**: API Platform ne requiert PAS twig, c'est juste suggéré pour Swagger UI +## Session 2026-06-19 (LST-56 / 0.1 — Socle back modular monolith) + +### Contexte +- Ticket exécuté via plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-56-socle-back.md`) délégué à un sous-agent (contexte isolé), pilotage MCP/chrono/vérif depuis la session principale. +- 4 tâches, 14 nouveaux tests (110 total, 216 assertions, vert), 4 commits (un par tâche). + +### Patterns +- **Strangler 100 % additif** : nouveau noyau `src/Shared/` (Domain/Contract, Domain/Module, Domain/Sidebar, Domain/Trait, Application, Infrastructure/{ApiPlatform,Doctrine,Security,Database}) sans toucher au métier — `make test` reste vert sans migration. +- **Endpoints DTO purs** : logique métier dans classes pures testées unitairement (`ModuleRegistry`, `SidebarFilter`), exposées par Providers API Platform minces (`ModulesProvider`/`SidebarProvider`) sur des Resources DTO. +- **resolve_target_entities** : contrat `Shared\Domain\Contract\UserInterface` mappé sur `App\Entity\User` (sera re-pointé vers `Module\Core\User` en 1.1). Inert tant qu'aucune entité n'utilise le trait. + +### Gotchas +- **API Platform 4 découvre les Resources sous `src/Shared/...` sans config `mapping.paths`** — le 404 anticipé dans le plan ne s'est pas produit, aucun ajout dans `api_platform.yaml` nécessaire. +- **Hook pre-commit php-cs-fixer** normalise le style du code fourni dans le plan : `\DateTimeImmutable`→`DateTimeImmutable` importé, FQN→`use`, `static::createClient()`→`self::`. Pur style, tests inchangés. Ne pas lutter contre. +- **`config/reference.php`** : fichier auto-généré qui apparaît modifié dans `git status` — ne jamais le committer. + +### Time tracking +- Le sous-agent a stoppé lui-même le timer d'implémentation (id 1005, 35 min) — garder le time-tracking sur la session principale pour rester maître du chrono si un sous-agent a accès aux tools MCP lesstime. + ## Meta-learnings - **Parallélisation**: Les tickets touchant des fichiers indépendants peuvent tourner en parallèle sans problème - **MCP status**: Toujours mettre "En cours" AVANT de commencer, "Terminé" APRÈS validation -- 2.39.5 From 111f37a0c998df5174e21974f223e59f5a34d3cc Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 19 Jun 2026 15:00:23 +0200 Subject: [PATCH 08/99] docs : add implementation plan for socle front (LST-62 / 0.2) --- .../plans/2026-06-19-lst-62-socle-front.md | 969 ++++++++++++++++++ 1 file changed, 969 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-19-lst-62-socle-front.md diff --git a/docs/superpowers/plans/2026-06-19-lst-62-socle-front.md b/docs/superpowers/plans/2026-06-19-lst-62-socle-front.md new file mode 100644 index 0000000..9885c9a --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-lst-62-socle-front.md @@ -0,0 +1,969 @@ +# LST-62 (0.2) — Socle front : shell + auto-détection des layers Nuxt — 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'ossature frontend modulaire (shell `app/`, code partagé `shared/`, auto-détection des layers `modules/*/`, sidebar dynamique alimentée par `/api/sidebar`, redirection des routes désactivées) **sans déplacer aucune page métier** — l'app reste « plate » et la navigation ne régresse pas. + +**Architecture:** On s'aligne sur le pattern Starseed : `srcDir: '.'`, layouts/middleware sous `frontend/app/`, composables/stores transverses sous `frontend/shared/` (auto-importés via `imports.dirs`), et un scan `readdirSync('modules/')` qui ajoute chaque `modules/*/` à `extends`. Le backend `/api/modules` + `/api/sidebar` existe déjà (LST-56). On ajoute un **gate de rôle minimal** côté `SidebarProvider`/`SidebarFilter` (ROLE_ADMIN) pour préserver la visibilité de l'Administration sans attendre le RBAC fin (#1.2). Les items **contextuels** (Kanban/Groupes/Archives), **feature-flag** (Documents, Mail) et **user-flag** (Mes absences) restent rendus côté layout, hors `/api/sidebar`. + +**Tech Stack:** Nuxt 4.3, Vue 3.5, Pinia 3, @malio/layer-ui 1.7, @nuxtjs/i18n 10, @nuxt/icon — côté back PHP 8.4 / Symfony 8 / API Platform 4 / PHPUnit 13. + +## Global Constraints + +- **Aucune page métier déplacée** : `frontend/pages/` reste tel quel ; on ne crée AUCUN `frontend/modules//pages/` peuplé en 0.2 (le dossier `modules/` est créé vide pour le scan). +- **Zéro régression de navigation** : tous les liens actuels restent atteignables et correctement gardés (admin reste admin-only). +- **Auto-import Nuxt** : les composants/pages référencent les composables/stores **par nom** (`useApi()`, `useAuthStore()`), jamais par chemin → déplacer un fichier entre deux dossiers auto-scannés est transparent. Toujours le vérifier par un `typecheck` après déplacement. +- **Commits** : format `() : ` (espaces autour du `:`). **Jamais** de mention IA/Claude/Anthropic (message, body, trailers). +- **PHP** : `declare(strict_types=1);` en tête ; tests via `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit …`. +- **TS** : strict, 4 espaces d'indentation, pas de `any`. +- **Pas de migration BDD** dans ce lot (aucune entité touchée). + +## Décisions de conception (actées avec le PO) + +1. **Gate de rôle minimal côté back** : les items/sections réservés (`/team-absences`, `/admin`) portent une clé `roles` dans `config/sidebar.php` ; `SidebarProvider` passe les rôles de l'utilisateur courant à `SidebarFilter` qui masque ce qui n'est pas autorisé. Ce n'est **pas** le RBAC fin (#1.2) — juste ROLE_ADMIN/ROLE_USER. +2. **Items contextuels / feature-flag / user-flag hors `/api/sidebar`** : Kanban/Groupes/Archives (contexte `currentProjectId`), Documents (`shareEnabled`), Mail (+ badge non lus), Mes absences (`isEmployee`) restent rendus par le layout comme aujourd'hui. +3. **Délta cosmétique assumé** : la sidebar dynamique regroupe le Tableau de bord avec « Mes tâches / Projets / Suivi de temps » sous un même en-tête, et le bloc statique (contextuel/flag/Mes absences) s'insère après cette première section. Léger réordonnancement visuel, **à valider**, harmonisé en #60 (Finition Malio). Aucun lien perdu. + +## Vérification (pas de runner de tests JS dans ce projet) + +- **Back (Task 1)** : vraie TDD PHPUnit. +- **Front (Tasks 2-7)** : la verif = `typecheck` Nuxt + smoke test runtime. Commandes : + - Typecheck : `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck` + - Runtime : dev server `make dev-nuxt` (port 3002, proxy `/api` → nginx) ; vérifier manuellement la navigation + `curl` des endpoints via nginx (`http://localhost:8082/api/...`). Les containers sont up. + +--- + +### Task 1: Backend — gate de rôle dans la sidebar (`roles`) + config complète + +**Files:** +- Modify: `src/Shared/Domain/Sidebar/SidebarFilter.php` (signature + gate `roles`) +- Modify: `src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php` (injecter `Security`, passer les rôles) +- Modify: `config/sidebar.php` (navigation globale + section Administration gated ROLE_ADMIN ; retrait de `/absences` qui reste client-side) +- Modify: `tests/Unit/Shared/Sidebar/SidebarFilterTest.php` (adapter à la nouvelle signature + cas `roles`) +- Modify: `tests/Functional/Shared/SidebarEndpointTest.php` (vérifier le gate admin) + +**Interfaces:** +- Produces : `SidebarFilter::filter(array $sections, array $activeModuleIds, array $activeRoles = []): array`. Règles ajoutées : une **section** ou un **item** portant une clé `roles` (non vide) n'est conservé que si `$activeRoles` contient au moins un des rôles listés ; sinon la section/l'item est retiré (les `to` des items retirés **par rôle** ne sont PAS ajoutés à `disabledRoutes` — `disabledRoutes` reste réservé au filtrage **par module**, qui pilote la redirection front). Les clés internes `module` et `roles` sont retirées de la sortie. +- Consumes : `Symfony\Bundle\SecurityBundle\Security` (rôles via `getUser()`). + +- [ ] **Step 1: Adapter le test unitaire existant + ajouter les cas `roles`** + +Remplace INTÉGRALEMENT `tests/Unit/Shared/Sidebar/SidebarFilterTest.php` par : + +```php + 'sidebar.core.section', 'icon' => 'mdi:home', 'items' => [ + ['label' => 'sidebar.core.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard'], + ]], + ]; + + $result = SidebarFilter::filter($sections, [], ['ROLE_USER']); + + 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, [], ['ROLE_USER']); + + 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'], ['ROLE_USER']); + + self::assertCount(1, $result['sections']); + self::assertSame('/time-tracking', $result['sections'][0]['items'][0]['to']); + self::assertSame([], $result['disabledRoutes']); + } + + public function testSectionWithRolesIsHiddenWhenRoleMissing(): void + { + $sections = [ + ['label' => 'sidebar.admin.section', 'icon' => 'mdi:cog', 'roles' => ['ROLE_ADMIN'], 'items' => [ + ['label' => 'sidebar.admin.admin', 'to' => '/admin', 'icon' => 'mdi:cog'], + ]], + ]; + + $result = SidebarFilter::filter($sections, [], ['ROLE_USER']); + + self::assertSame([], $result['sections']); + // Filtrage par rôle => PAS de disabledRoutes (réservé au filtrage par module). + self::assertSame([], $result['disabledRoutes']); + } + + public function testSectionWithRolesIsVisibleWhenRolePresent(): void + { + $sections = [ + ['label' => 'sidebar.admin.section', 'icon' => 'mdi:cog', 'roles' => ['ROLE_ADMIN'], 'items' => [ + ['label' => 'sidebar.admin.admin', 'to' => '/admin', 'icon' => 'mdi:cog'], + ]], + ]; + + $result = SidebarFilter::filter($sections, [], ['ROLE_USER', 'ROLE_ADMIN']); + + self::assertCount(1, $result['sections']); + self::assertSame('/admin', $result['sections'][0]['items'][0]['to']); + self::assertArrayNotHasKey('roles', $result['sections'][0]); + } + + public function testItemWithRolesIsHiddenWhenRoleMissing(): void + { + $sections = [ + ['label' => 'sidebar.hr.section', 'icon' => 'mdi:calendar', 'items' => [ + ['label' => 'sidebar.hr.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:account-group', 'roles' => ['ROLE_ADMIN']], + ['label' => 'sidebar.hr.x', 'to' => '/x', 'icon' => 'mdi:x'], + ]], + ]; + + $result = SidebarFilter::filter($sections, [], ['ROLE_USER']); + + self::assertCount(1, $result['sections']); + self::assertCount(1, $result['sections'][0]['items']); + self::assertSame('/x', $result['sections'][0]['items'][0]['to']); + self::assertArrayNotHasKey('roles', $result['sections'][0]['items'][0]); + } +} +``` + +- [ ] **Step 2: Lancer le test, vérifier l'échec** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Sidebar/SidebarFilterTest.php` +Expected: FAIL — `filter()` actuel n'accepte que 2 args / ne gère pas `roles` (erreur d'arité ou assertions rouges). + +- [ ] **Step 3: Étendre `SidebarFilter`** + +Remplace INTÉGRALEMENT `src/Shared/Domain/Sidebar/SidebarFilter.php` par : + +```php +, items: list}>}> $sections + * @param list $activeModuleIds + * @param list $activeRoles + * + * @return array{sections: list}>, disabledRoutes: list} + */ + public static function filter(array $sections, array $activeModuleIds, array $activeRoles = []): array + { + $outSections = []; + $disabledRoutes = []; + + foreach ($sections as $section) { + // Gate de rôle au niveau section (ne pollue pas disabledRoutes : réservé au filtrage module). + if (!self::rolesSatisfied($section['roles'] ?? null, $activeRoles)) { + continue; + } + + $items = []; + foreach ($section['items'] as $item) { + // Gate de rôle au niveau item. + if (!self::rolesSatisfied($item['roles'] ?? null, $activeRoles)) { + continue; + } + + // Filtrage par module actif (pilote la redirection front via disabledRoutes). + $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]; + } + + /** + * @param list|null $required + * @param list $activeRoles + */ + private static function rolesSatisfied(?array $required, array $activeRoles): bool + { + if (null === $required || [] === $required) { + return true; + } + + foreach ($required as $role) { + if (in_array($role, $activeRoles, true)) { + return true; + } + } + + return false; + } +} +``` + +- [ ] **Step 4: Lancer le test unitaire, vérifier le vert** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Sidebar/SidebarFilterTest.php` +Expected: PASS (6 tests). + +- [ ] **Step 5: Injecter les rôles dans `SidebarProvider`** + +Remplace INTÉGRALEMENT `src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php` par : + +```php + $moduleClasses */ + $moduleClasses = require $this->projectDir.'/config/modules.php'; + + /** @var list, items: list}>}> $sidebar */ + $sidebar = require $this->projectDir.'/config/sidebar.php'; + + $user = $this->security->getUser(); + $roles = null !== $user ? $user->getRoles() : []; + + $filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses), array_values($roles)); + + $dto = new SidebarResource(); + $dto->sections = $filtered['sections']; + $dto->disabledRoutes = $filtered['disabledRoutes']; + + return $dto; + } +} +``` + +- [ ] **Step 6: Compléter `config/sidebar.php`** + +Remplace INTÉGRALEMENT `config/sidebar.php` par (icônes alignées sur le layout actuel ; `/absences` retiré car gardé client-side via `isEmployee`) : + +```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:clipboard-check-outline'], + ['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline'], + ['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline'], + ], + ], + [ + 'label' => 'sidebar.admin.section', + 'icon' => 'mdi:cog-outline', + 'roles' => ['ROLE_ADMIN'], + 'items' => [ + ['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'], + ['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline'], + ], + ], +]; +``` + +- [ ] **Step 7: Renforcer le test fonctionnel sidebar (gate admin)** + +Remplace INTÉGRALEMENT `tests/Functional/Shared/SidebarEndpointTest.php` par : + +```php +request('GET', '/api/sidebar'); + + self::assertResponseStatusCodeSame(401); + } + + public function testSidebarReturnsSectionsForAuthenticatedUser(): void + { + $client = self::createClient(); + $em = self::getContainer()->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']); + } + + public function testAdminSectionHiddenForNonAdmin(): void + { + $client = self::createClient(); + $em = self::getContainer()->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); // ROLE_USER + $client->loginUser($user); + + $client->request('GET', '/api/sidebar'); + $data = json_decode($client->getResponse()->getContent(), true); + $labels = array_column($data['sections'], 'label'); + + self::assertNotContains('sidebar.admin.section', $labels); + } + + public function testAdminSectionVisibleForAdmin(): void + { + $client = self::createClient(); + $em = self::getContainer()->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); // ROLE_ADMIN + $client->loginUser($user); + + $client->request('GET', '/api/sidebar'); + $data = json_decode($client->getResponse()->getContent(), true); + $labels = array_column($data['sections'], 'label'); + + self::assertContains('sidebar.admin.section', $labels); + } +} +``` + +- [ ] **Step 8: Lancer la suite complète, vérifier le vert** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit` +Expected: PASS (les 110 tests précédents adaptés + nouveaux cas). Si `admin`/`alice` n'existent pas en base de test, vérifier les fixtures (`admin`/`admin`, `alice`/`alice` d'après CLAUDE.md). + +- [ ] **Step 9: php-cs-fixer + commit** + +Run: `make php-cs-fixer-allow-risky` +```bash +git add src/Shared/Domain/Sidebar/SidebarFilter.php src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php config/sidebar.php tests/Unit/Shared/Sidebar/SidebarFilterTest.php tests/Functional/Shared/SidebarEndpointTest.php +git commit -m "feat(sidebar) : add role gate to sidebar provider and global nav config" +``` + +--- + +### Task 2: Frontend — types + composables partagés (`useModules`, `useSidebar`) + +**Files:** +- Create: `frontend/shared/types/sidebar.ts` +- Create: `frontend/shared/composables/useModules.ts` +- Create: `frontend/shared/composables/useSidebar.ts` + +> Note : à cette étape `shared/` n'est pas encore dans `imports.dirs` (fait en Task 4). Ces fichiers sont créés ici mais référencés/auto-importés seulement après Task 4 ; le typecheck final de validation se fait donc en fin de Task 4. Cette task se termine sans verif runtime (pur ajout de fichiers). + +**Interfaces:** +- Produces : + - `useModules(): { activeModuleIds: Ref, loaded: Ref, loadModules(): Promise, isModuleActive(id: string): boolean, resetModules(): void }` + - `useSidebar(): { sections: Ref, disabledRoutes: Ref, loaded: Ref, loadSidebar(): Promise, isRouteDisabled(path: string): boolean, resetSidebar(): void }` + - `SidebarSection`, `SidebarItem` (types). +- Consumes : `useApi()` (auto-importé, déplacé en Task 3 — toujours appelé par nom). + +- [ ] **Step 1: Créer les types** + +`frontend/shared/types/sidebar.ts` : + +```ts +export type SidebarItem = { + label: string + to: string + icon: string +} + +export type SidebarSection = { + label: string + icon: string + items: SidebarItem[] +} +``` + +- [ ] **Step 2: Créer `useModules`** + +`frontend/shared/composables/useModules.ts` (état singleton au niveau module) : + +```ts +const activeModuleIds = ref([]) +const loaded = ref(false) + +export function useModules() { + async function loadModules(): Promise { + const api = useApi() + const data = await api.get<{ modules: string[] }>('/modules', {}, { toast: false }) + activeModuleIds.value = data.modules ?? [] + loaded.value = true + } + + function isModuleActive(id: string): boolean { + return activeModuleIds.value.includes(id) + } + + function resetModules(): void { + activeModuleIds.value = [] + loaded.value = false + } + + return { activeModuleIds, loaded, loadModules, isModuleActive, resetModules } +} +``` + +> Vérifier la signature réelle de `useApi().get` (Task 3 / source actuelle) : `get(url, query?, options?)`. L'option `{ toast: false }` doit exister dans `ApiFetchOptions` ; si la clé diffère (ex. `toastSuccessKey`/`toast`), aligner sur la signature réelle de `useApi.ts`. Si aucune option « silencieux » n'existe, passer `{}`. + +- [ ] **Step 3: Créer `useSidebar`** + +`frontend/shared/composables/useSidebar.ts` : + +```ts +import type { SidebarSection } from '~/shared/types/sidebar' + +const sections = ref([]) +const disabledRoutes = ref([]) +const loaded = ref(false) + +export function useSidebar() { + async function loadSidebar(): Promise { + const api = useApi() + const data = await api.get<{ sections: SidebarSection[]; disabledRoutes: string[] }>( + '/sidebar', {}, { toast: false }, + ) + sections.value = data.sections ?? [] + disabledRoutes.value = data.disabledRoutes ?? [] + loaded.value = true + } + + function isRouteDisabled(path: string): boolean { + return disabledRoutes.value.some( + (disabled) => path === disabled || path.startsWith(disabled + '/'), + ) + } + + function resetSidebar(): void { + sections.value = [] + disabledRoutes.value = [] + loaded.value = false + } + + return { sections, disabledRoutes, loaded, loadSidebar, isRouteDisabled, resetSidebar } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add frontend/shared/types/sidebar.ts frontend/shared/composables/useModules.ts frontend/shared/composables/useSidebar.ts +git commit -m "feat(front) : add shared useModules/useSidebar composables and sidebar types" +``` + +--- + +### Task 3: Frontend — déplacer `useApi` et les stores transverses vers `shared/` + +**Files:** +- Move: `frontend/composables/useApi.ts` → `frontend/shared/composables/useApi.ts` +- Move: `frontend/stores/auth.ts` → `frontend/shared/stores/auth.ts` +- Move: `frontend/stores/ui.ts` → `frontend/shared/stores/ui.ts` + +> `timer.ts` et `mail.ts` **restent** dans `frontend/stores/` (domaines métier non encore migrés en module). On ne déplace que les deux stores transverses (auth, ui) + `useApi`. La résolution effective (auto-import depuis `shared/`) est activée en Task 4 ; cette task fait les `git mv` et termine par un commit. Le typecheck de validation est en Task 4 (après config). + +- [ ] **Step 1: Déplacer les fichiers (git mv pour préserver l'historique)** + +```bash +cd /home/matthieu/dev_malio/Lesstime/frontend +mkdir -p shared/stores +git mv composables/useApi.ts shared/composables/useApi.ts +git mv stores/auth.ts shared/stores/auth.ts +git mv stores/ui.ts shared/stores/ui.ts +``` + +- [ ] **Step 2: Vérifier qu'aucun import par CHEMIN ne pointe vers les anciens emplacements** + +Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && grep -rn "composables/useApi\|stores/auth\|stores/ui" --include=*.ts --include=*.vue . | grep -v node_modules | grep -v "shared/"` +Expected: aucun résultat (tout passe par auto-import). Si un import explicite existe (ex. `from '~/composables/useApi'`), le corriger en `from '~/shared/composables/useApi'` ou retirer l'import (auto-import). Noter chaque correction. + +> `layouts/default.vue` importe actuellement `useAppVersion` depuis `~/composables/useAppVersion` (NON déplacé) — ne pas y toucher ici. + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "refactor(front) : move useApi and shared stores (auth, ui) to shared/" +``` + +--- + +### Task 4: Frontend — `nuxt.config.ts` (srcDir, dossiers `app/`, scan des layers, auto-imports) + +**Files:** +- Modify: `frontend/nuxt.config.ts` +- Create: `frontend/modules/.gitkeep` (dossier vide prêt pour le scan) +- Move: `frontend/layouts/` → `frontend/app/layouts/` (default.vue, auth.vue) +- Move: `frontend/middleware/` → `frontend/app/middleware/` (auth.global.ts, admin.ts, employee.ts) + +**Interfaces:** +- Produces : structure `app/{layouts,middleware}`, `modules/` scannable, `shared/*` auto-importé. + +- [ ] **Step 1: Déplacer layouts et middleware sous `app/`** + +```bash +cd /home/matthieu/dev_malio/Lesstime/frontend +mkdir -p app modules +git mv layouts app/layouts +git mv middleware app/middleware +touch modules/.gitkeep +git add modules/.gitkeep +``` + +- [ ] **Step 2: Réécrire `nuxt.config.ts`** + +Remplace INTÉGRALEMENT `frontend/nuxt.config.ts` par (conserve `vite`/`toast` existants — repris depuis la version actuelle) : + +```ts +import { existsSync, readdirSync } from 'node:fs' +import { resolve } from 'node:path' + +const modulesDir = resolve(__dirname, 'modules') +const moduleDirs = existsSync(modulesDir) + ? readdirSync(modulesDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + : [] +const moduleLayers = moduleDirs.map((name) => `./modules/${name}`) +const moduleComposableDirs = moduleDirs + .map((name) => `modules/${name}/composables`) + .filter((path) => existsSync(resolve(__dirname, path))) +const moduleStoreDirs = moduleDirs + .map((name) => `modules/${name}/stores`) + .filter((path) => existsSync(resolve(__dirname, path))) + +export default defineNuxtConfig({ + compatibilityDate: '2025-07-15', + devtools: { enabled: false }, + ssr: false, + srcDir: '.', + css: ['~/assets/css/app.css', '~/assets/css/dark.css'], + app: { + baseURL: process.env.NODE_ENV === 'production' + ? (process.env.NUXT_PUBLIC_APP_BASE || '/') + : '/', + }, + extends: ['@malio/layer-ui', ...moduleLayers], + modules: [ + '@nuxtjs/tailwindcss', + '@pinia/nuxt', + 'nuxt-toast', + '@nuxtjs/i18n', + '@nuxt/icon', + ], + dir: { + layouts: 'app/layouts', + middleware: 'app/middleware', + }, + imports: { + dirs: [ + 'shared/composables', + 'shared/stores', + 'shared/utils', + 'composables', + 'stores', + 'utils', + ...moduleComposableDirs, + ...moduleStoreDirs, + ], + }, + pinia: { + storesDirs: ['shared/stores/**', 'stores/**', 'modules/*/stores/**'], + }, + runtimeConfig: { + public: { + apiBase: process.env.NUXT_PUBLIC_API_BASE, + }, + }, + devServer: { + port: 3002, + }, + components: [ + { path: '~/components', pathPrefix: false }, + ], + // ⬇️ Reprendre VERBATIM les blocs `vite: {...}`, `toast: {...}`, `i18n: {...}`, + // `typescript: {...}`, `build: {...}` de l'ancien nuxt.config.ts (inchangés). + typescript: { strict: true }, + build: { transpile: ['@vuepic/vue-datepicker'] }, +}) +``` + +> ⚠️ Les blocs `vite`, `toast`, `i18n` de l'ancienne config ne sont pas réécrits ici : **les recopier à l'identique** depuis la version d'origine (récupérable via `git show HEAD~1:frontend/nuxt.config.ts` après les déplacements). Le `i18n.langDir: 'locales'` reste résolu depuis `i18n/`. + +- [ ] **Step 3: Typecheck complet (valide Tasks 2, 3 et 4)** + +Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck` +Expected: 0 erreur. Pièges probables : +- Store non trouvé → vérifier `pinia.storesDirs` inclut bien `shared/stores/**`. +- Composable non auto-importé → vérifier `imports.dirs` inclut `shared/composables`. +- `~/composables/useApi` cassé → un import explicite a survécu (corriger comme Task 3 Step 2). + +- [ ] **Step 4: Smoke test runtime — l'app boote et la nav existante fonctionne** + +Run: `cd /home/matthieu/dev_malio/Lesstime && make dev-nuxt` (ou rebuild SPA selon le workflow). Ouvrir l'app, se connecter (`alice`/`alice`), vérifier que la sidebar **statique actuelle** s'affiche encore et que la navigation marche (le layout n'est pas encore dynamisé — c'est normal). Aucun écran blanc / erreur console bloquante. +Expected: app fonctionnelle, identique à avant (les déplacements sont transparents). + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat(front) : modular nuxt config with app/ shell dirs and modules/* layer auto-detection" +``` + +--- + +### Task 5: Frontend — middlewares (`auth.global.ts` étendu + `modules.global.ts`) + +**Files:** +- Modify: `frontend/app/middleware/auth.global.ts` (charge sidebar + modules après login ; reset au logout) +- Create: `frontend/app/middleware/modules.global.ts` (redirige les routes désactivées) + +**Interfaces:** +- Consumes : `useAuthStore()`, `useSidebar()`, `useModules()` (auto-importés). + +- [ ] **Step 1: Étendre `auth.global.ts`** + +Remplace INTÉGRALEMENT `frontend/app/middleware/auth.global.ts` par : + +```ts +export default defineNuxtRouteMiddleware(async (to) => { + const auth = useAuthStore() + const isLogin = to.path === '/login' + + if (!auth.checked) { + await auth.ensureSession() + } + + if (!isLogin && !auth.isAuthenticated) { + return navigateTo('/login') + } + + if (isLogin && auth.isAuthenticated) { + return navigateTo('/') + } + + const { loaded: sidebarLoaded, loadSidebar, resetSidebar } = useSidebar() + const { loaded: modulesLoaded, loadModules, resetModules } = useModules() + + if (auth.isAuthenticated) { + await Promise.all([ + sidebarLoaded.value ? Promise.resolve() : loadSidebar(), + modulesLoaded.value ? Promise.resolve() : loadModules(), + ]) + } else { + // Logout / session expirée : purge l'état partagé pour le prochain login. + resetSidebar() + resetModules() + } +}) +``` + +- [ ] **Step 2: Créer `modules.global.ts`** + +`frontend/app/middleware/modules.global.ts` : + +```ts +export default defineNuxtRouteMiddleware(async (to) => { + const auth = useAuthStore() + if (!auth.isAuthenticated) { + return + } + + const { loaded, loadSidebar, isRouteDisabled } = useSidebar() + if (!loaded.value) { + await loadSidebar() + } + + if (isRouteDisabled(to.path)) { + return navigateTo('/') + } +}) +``` + +> Ordre des middlewares globaux : Nuxt les exécute par ordre alphabétique de nom de fichier → `auth.global.ts` puis `modules.global.ts`. C'est l'ordre voulu (auth charge la sidebar avant que modules teste les routes désactivées). + +- [ ] **Step 3: Typecheck** + +Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck` +Expected: 0 erreur. + +- [ ] **Step 4: Smoke test — chargement sidebar/modules + redirection** + +Avec le dev server : se connecter (`alice`), ouvrir l'onglet Réseau → confirmer un `GET /api/sidebar` et `GET /api/modules` après login. Vérifier la redirection : ajouter TEMPORAIREMENT dans `config/sidebar.php` un item avec `'module' => 'demo'` (module inactif) et un `'to' => '/demo-disabled'`, recharger, confirmer que `/demo-disabled` apparaît dans `disabledRoutes` (réponse `/api/sidebar`) et qu'y naviguer redirige vers `/`. **Puis retirer l'item de démo** (ne pas committer ce stub). +Expected: appels présents, redirection effective. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/app/middleware/auth.global.ts frontend/app/middleware/modules.global.ts +git commit -m "feat(front) : load sidebar/modules after login and redirect disabled routes" +``` + +--- + +### Task 6: Frontend — layout `default.vue` : sidebar dynamique + items conservés + +**Files:** +- Modify: `frontend/app/layouts/default.vue` + +**Interfaces:** +- Consumes : `useSidebar()` (sections dynamiques traduites), `useUiStore()`, `useAuthStore()`, `useI18n()`, + le reste de la logique existante (timer, mail, refData) conservée VERBATIM. + +> Stratégie : on remplace le bloc statique des items **globaux** (Tableau de bord, Mes tâches, Projets, Suivi de temps, Absences équipe, Administration) par un rendu **dynamique** issu de `useSidebar()`. On **conserve** les `SidebarLink` des items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail + badge) et user-flag (Mes absences) tels quels. Tout le ` diff --git a/frontend/components/admin/RoleDrawer.vue b/frontend/components/admin/RoleDrawer.vue new file mode 100644 index 0000000..4db0dc5 --- /dev/null +++ b/frontend/components/admin/RoleDrawer.vue @@ -0,0 +1,186 @@ + + + diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 5711583..321bfed 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -195,6 +195,27 @@ "addUser": "Ajouter un utilisateur", "editUser": "Modifier un utilisateur" }, + "admin": { + "roles": { + "title": "Rôles", + "addRole": "Ajouter un rôle", + "editRole": "Modifier un rôle", + "empty": "Aucun rôle trouvé.", + "system": "Système", + "code": "Code", + "codeHint": "Identifiant technique en snake_case (immuable).", + "codeImmutable": "Le code ne peut pas être modifié après création.", + "codeInvalid": "Code invalide (attendu snake_case : minuscules, chiffres et underscores).", + "label": "Libellé", + "labelRequired": "Le libellé est requis.", + "description": "Description", + "permissions": "Permissions", + "noPermissions": "Aucune permission disponible.", + "created": "Rôle créé avec succès.", + "updated": "Rôle mis à jour avec succès.", + "deleted": "Rôle supprimé avec succès." + } + }, "timeEntries": { "created": "Temps enregistré", "updated": "Temps modifié", diff --git a/frontend/modules/core/composables/usePermissions.ts b/frontend/modules/core/composables/usePermissions.ts new file mode 100644 index 0000000..0a9425a --- /dev/null +++ b/frontend/modules/core/composables/usePermissions.ts @@ -0,0 +1,27 @@ +export function usePermissions() { + const auth = useAuthStore() + + function isAdmin(): boolean { + return auth.user?.roles?.includes('ROLE_ADMIN') ?? false + } + + function can(code: string): boolean { + if (!auth.user) { + return false + } + if (isAdmin()) { + return true + } + return auth.user.effectivePermissions?.includes(code) ?? false + } + + function canAny(codes: string[]): boolean { + return codes.some((c) => can(c)) + } + + function canAll(codes: string[]): boolean { + return codes.every((c) => can(c)) + } + + return { can, canAny, canAll, isAdmin } +} diff --git a/frontend/modules/core/services/permissions.ts b/frontend/modules/core/services/permissions.ts new file mode 100644 index 0000000..bcad3ea --- /dev/null +++ b/frontend/modules/core/services/permissions.ts @@ -0,0 +1,22 @@ +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +export type Permission = { + id: number + '@id'?: string + code: string + label: string + module: string + orphan?: boolean +} + +export function usePermissionService() { + const api = useApi() + + async function list(): Promise { + const data = await api.get>('/permissions') + return extractHydraMembers(data) + } + + return { list } +} diff --git a/frontend/modules/core/services/roles.ts b/frontend/modules/core/services/roles.ts new file mode 100644 index 0000000..24bcdea --- /dev/null +++ b/frontend/modules/core/services/roles.ts @@ -0,0 +1,50 @@ +import type { Permission } from './permissions' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +export type Role = { + id: number + '@id'?: string + code: string + label: string + description?: string | null + isSystem: boolean + permissions: Permission[] +} + +export type RoleWrite = { + code?: string + label: string + description?: string | null + /** IRIs of the granted permissions (e.g. /api/permissions/3). */ + permissions: string[] +} + +export function useRoleService() { + const api = useApi() + + async function list(): Promise { + const data = await api.get>('/roles') + return extractHydraMembers(data) + } + + async function create(payload: RoleWrite): Promise { + return api.post('/roles', payload as Record, { + toastSuccessKey: 'admin.roles.created', + }) + } + + async function update(id: number, payload: Partial): Promise { + return api.patch(`/roles/${id}`, payload as Record, { + toastSuccessKey: 'admin.roles.updated', + }) + } + + async function remove(id: number): Promise { + await api.delete(`/roles/${id}`, {}, { + toastSuccessKey: 'admin.roles.deleted', + }) + } + + return { list, create, update, remove } +} diff --git a/frontend/pages/admin.vue b/frontend/pages/admin.vue index 998deac..f371f09 100644 --- a/frontend/pages/admin.vue +++ b/frontend/pages/admin.vue @@ -6,7 +6,7 @@