Compare commits

...

55 Commits

Author SHA1 Message Date
Matthieu 7446b7dca9 feat(project-management) : extract Projects/Tasks front into Nuxt module layer
Tranche 4 of LST-65. Companion to the backend module migration.

- Move pages (my-tasks, projects, projects/[id]/{index,groups,archives}),
  18 components (project + task), 10 services and 10 DTOs into
  frontend/modules/project-management/ (auto-detected layer).
- Rewrite explicit ~/services/* and ~/services/dto/* imports across 38
  consumers (admin tabs, mail modals, dashboard, mail page, layout) including
  the time-tracking module whose DTOs referenced project/task/task-tag.
- clients.ts and shared DTOs (client, user-data) stay at the root.
- Routes /my-tasks, /projects, /projects/:id(/groups|/archives) preserved;
  i18n stays global.

nuxt build passes; routes confirmed.
2026-06-20 17:06:13 +02:00
Matthieu c90d91d6c4 feat(project-management) : add timestampable/blamable to Task and Project (additive)
Tranche 3 of LST-65. Task and Project adopt TimestampableBlamableTrait.

- Additive migration on task and project: created_at/updated_at (nullable),
  created_by/updated_by (nullable INT, FK to "user" ON DELETE SET NULL) +
  indexes + COMMENT ON COLUMN. down() drops only the added objects.
- Trait fields stay out of the existing API groups (trait carries its own).
- Functional test (TaskTimestampableTest) confirms created_at on persist and
  updated_at refresh on update.

161 tests green, no destructive migration.
2026-06-20 17:05:47 +02:00
Matthieu 23809f165e feat(project-management) : migrate core Projects/Tasks domain into module (back)
Tranche 2 of LST-65. Mechanical, behaviour-preserving move of the core
business domain into src/Module/ProjectManagement/. API operations,
securities, uriTemplates and the 38 MCP tool names are all unchanged.

- 10 entities + 2 enums moved to Domain/{Entity,Enum}; intra-module
  relations stay concrete, cross-module relations go through contracts
  (Project.client -> ClientInterface, Task/TaskDocument users ->
  UserInterface).
- 9 repositories split into Domain/Repository interfaces + Doctrine impls,
  bound in services.yaml; consumers inject the interfaces. find() kept off
  the interfaces (ServiceEntityRepository ?object compat) -> findById().
- State (7), MCP tools (38), controller, CalDavService/RecurrenceCalculator,
  3 Doctrine listeners and SwitchWorkflowOutput moved under Infrastructure/.
- doctrine.yaml: ProjectManagement mapping + resolve_target_entities of the
  3 module contracts repointed to the module (ClientInterface stays legacy).
- ProjectManagementModule registered (id project-management, 4 RBAC perms,
  not re-wired); sidebar my-tasks/projects gated by the module.
- Legacy not-yet-modularised consumers (Mail/Gitea/BookStack, Serializer,
  fixtures, tests) swapped to the module FQCN — transitional coupling to be
  cleaned in 2.4/2.5/2.6.

159 tests green, mapping valid, no API route regression, cs-fixer clean.
2026-06-20 16:54:59 +02:00
Matthieu f119ec30ca refactor(project-management) : introduce Project/Task/TaskTag/Client contracts
Tranche 1 of LST-65 (ProjectManagement module migration). Decouples the
TimeTracking module from the core-business entities before they move, with
no entity relocation yet — keeps the diff minimal and the risk isolated.

- New read contracts in Shared/Domain/Contract (minimal surface, aligned on
  the entities' real nullable signatures): ProjectInterface (id/code/name),
  TaskInterface (id/number/title), TaskTagInterface (id/label/color),
  ClientInterface (id/name).
- Project/Task/TaskTag/Client implement their contract (entities stay in
  src/Entity for now). Project.client typed as ClientInterface.
- TimeEntry (TimeTracking) now references ProjectInterface/TaskInterface/
  TaskTagInterface instead of the concrete entities; repository + DQL
  untouched in behaviour.
- resolve_target_entities maps the 4 contracts to the legacy entities (will
  be repointed to the module in tranche 2).
- Adds the migration plan doc.

159 tests green, mapping valid, cs-fixer clean.
2026-06-20 16:34:15 +02:00
Matthieu 1b652ef680 feat(time-tracking) : extract time-tracking front into Nuxt module layer
Companion to the backend module migration (LST-64). The Nuxt layer is
auto-detected from frontend/modules/* — no nuxt.config change needed.

- Move page, timer store, time-entries service + DTO and the 6 time-tracking
  components into frontend/modules/time-tracking/.
- Rewrite explicit service/DTO imports to ~/modules/time-tracking/* (store and
  components stay auto-imported); update the dashboard (index.vue) consumer.
- Route /time-tracking preserved; i18n keys kept in the global locale file.

nuxt build passes; /time-tracking routed.
2026-06-20 16:16:49 +02:00
Matthieu d1516c3f5d feat(time-tracking) : migrate TimeEntry into TimeTracking module (back)
First business module of Phase 2 (LST-64, rodage). Strangler-style,
additive move — no behavioural change to the public API or MCP tools.

- New module App\Module\TimeTracking (TimeTrackingModule, id "time-tracking",
  declares time-tracking.entries.view/export permissions in the RBAC catalog;
  operation security left on ROLE_USER, not re-wired here).
- Move TimeEntry entity, repository (now interface + Doctrine impl bound in
  services.yaml), ActiveTimeEntryProvider, export service/controller and the
  4 MCP TimeEntry tools into the module. #[ApiResource] (operations, security,
  uriTemplates /time_entries/*), filters and serialization groups preserved.
- Doctrine mapping "TimeTracking" added; table time_entry unchanged.
- Sidebar item gated with module "time-tracking" (SidebarFilter disables the
  route when the module is inactive).
- Timestampable/Blamable adopted (first adopter): additive migration adds
  created_at/updated_at/created_by/updated_by (nullable, FK SET NULL) +
  COMMENT ON COLUMN. Functional test confirms created_at on persist and
  updated_at refresh on update — the suspected preUpdate recompute issue does
  not occur (Doctrine ORM 3.6.2 recomputes change sets after preUpdate).

159 tests green, schema mapping valid, php-cs-fixer clean.
2026-06-20 16:16:13 +02:00
Matthieu a88cb1bc35 fix(core) : harden review findings (me-provider null guard, audit-ignore plainpassword, rbac self-edit guard, module id dedup, audit pagination guard) 2026-06-19 22:39:26 +02:00
Matthieu 7686904c43 docs : log LST-61 audit log session learnings 2026-06-19 21:19:27 +02:00
Matthieu 9b26b43aca fix(core) : align audit entity-types front service with single-resource api shape 2026-06-19 21:18:22 +02:00
Matthieu e7af415a1f feat(core) : add audit log consultation tab in admin gated by permission 2026-06-19 21:15:13 +02:00
Matthieu 90b8ca15cd feat(core) : expose read-only audit-logs api with dbal provider and pagination 2026-06-19 21:09:55 +02:00
Matthieu 8c3699a9b0 feat(core) : add doctrine audit listener and mark core entities auditable 2026-06-19 21:05:34 +02:00
Matthieu d8553f06f5 feat(core) : add audit log writer and request id provider 2026-06-19 21:01:15 +02:00
Matthieu 934cf0835f feat(core) : add audit attributes, audit_log table and dedicated dbal connection 2026-06-19 20:56:32 +02:00
Matthieu fda03bd1f5 docs : add LST-61 audit log implementation plan 2026-06-19 20:53:36 +02:00
Matthieu 4760c386ed docs : log LST-57 rbac fin session learnings 2026-06-19 17:38:26 +02:00
Matthieu 511353c3f5 feat(core) : add usePermissions composable and rbac roles admin front 2026-06-19 17:35:51 +02:00
Matthieu 544d4cf44f feat(core) : gate sidebar by effective permissions 2026-06-19 17:28:42 +02:00
Matthieu 1a9eba93a0 feat(core) : add rbac seeder and seed-rbac command for system roles 2026-06-19 17:22:42 +02:00
Matthieu 48c67a5fb9 feat(core) : expose role and user-rbac api endpoints with processors 2026-06-19 17:16:38 +02:00
Matthieu 5060fb689b feat(core) : add permission voter and expose effective permissions on /api/me 2026-06-19 17:03:34 +02:00
Matthieu ac662e701b feat(core) : aggregate module permissions and add sync-permissions command 2026-06-19 17:00:14 +02:00
Matthieu ffed224979 feat(core) : add rbac role and permission entities with user relations 2026-06-19 16:56:07 +02:00
Matthieu fdc72573ea docs : add implementation plan for rbac fin (LST-57 / 1.2) 2026-06-19 16:47:04 +02:00
Matthieu 52de07ce23 docs : log LST-63 module core session learnings 2026-06-19 16:34:02 +02:00
Matthieu 117c2ff2e3 feat(core) : add core front layer with login and profile pages 2026-06-19 16:31:42 +02:00
Matthieu a98ea3df37 feat(core) : activate core module in modules registry 2026-06-19 16:27:10 +02:00
Matthieu f1a9b42930 feat(core) : move notification into core and expose notifier contract 2026-06-19 16:25:03 +02:00
Matthieu 0b4874e94d refactor(core) : move user repository/providers to core and migrate all consumers off App\Entity\User 2026-06-19 16:16:44 +02:00
Matthieu d70925b812 refactor(core) : point user relations to the shared contract via resolve_target_entities 2026-06-19 16:04:14 +02:00
Matthieu f8fc4d6bd9 feat(core) : move user entity into core module and repoint security/doctrine (temp legacy alias) 2026-06-19 16:03:52 +02:00
Matthieu 6ca91cbd3b feat(core) : add CoreModule, user repository contract, notifier contract and enriched user contract 2026-06-19 15:53:38 +02:00
Matthieu 8865bf51e6 docs : add implementation plan for module core (LST-63 / 1.1) 2026-06-19 15:50:32 +02:00
Matthieu d1a980d1c2 docs : log LST-62 socle front session learnings 2026-06-19 15:37:03 +02:00
Matthieu fdcf8df518 feat(front) : add sidebar i18n labels 2026-06-19 15:33:59 +02:00
Matthieu 977e74f669 feat(front) : render dynamic sidebar from /api/sidebar in default layout 2026-06-19 15:32:23 +02:00
Matthieu a620833550 feat(front) : load sidebar/modules after login and redirect disabled routes 2026-06-19 15:28:16 +02:00
Matthieu fcfb16fc5b docs : correct LST-62 front verification gate (typecheck is not green on this stack) 2026-06-19 15:25:39 +02:00
Matthieu b00e92bdd3 feat(front) : modular nuxt config with app/ shell dirs and modules/* layer auto-detection 2026-06-19 15:24:57 +02:00
Matthieu 1aa43a5356 refactor(front) : move useApi and shared stores (auth, ui) to shared/ 2026-06-19 15:06:50 +02:00
Matthieu 51de96c797 feat(front) : add shared useModules/useSidebar composables and sidebar types 2026-06-19 15:05:35 +02:00
Matthieu 0ee82c8b62 feat(sidebar) : add role gate to sidebar provider and global nav config 2026-06-19 15:03:45 +02:00
Matthieu 111f37a0c9 docs : add implementation plan for socle front (LST-62 / 0.2) 2026-06-19 15:00:23 +02:00
Matthieu 5fbdda1983 docs : log LST-56 socle back session learnings 2026-06-19 15:00:17 +02:00
Matthieu b301c543bb feat(shared) : add column comments catalog helper for migrations 2026-06-19 14:38:40 +02:00
Matthieu 3053c09522 feat(shared) : add timestampable/blamable trait and doctrine subscriber 2026-06-19 14:37:28 +02:00
Matthieu 52399b35d9 feat(sidebar) : expose GET /api/sidebar filtered by active modules 2026-06-19 14:35:17 +02:00
Matthieu 748289b61a feat(modules) : expose GET /api/modules and module registry 2026-06-19 14:33:53 +02:00
Matthieu 2d0e9de155 docs : add implementation plan for socle back (LST-56 / 0.1)
Plan TDD en 4 tâches : endpoints /api/modules et /api/sidebar, garde-fou Timestampable/Blamable, helper ColumnCommentsCatalog.
2026-06-19 10:56:27 +02:00
Matthieu a510b2ca73 docs : add modular monolith migration roadmap and socle design
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).
2026-06-19 10:50:14 +02:00
gitea-actions d0a49322e1 chore: bump version to v0.4.30
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 2m21s
2026-06-19 07:21:59 +00:00
matthieu 3e26c12052 Merge pull request 'fix(prod) : droits www-data sur le volume de logs' (#11) from fix/prod-logs-volume-permissions into develop
Auto Tag Develop / tag (push) Successful in 7s
Reviewed-on: #11
2026-06-19 07:21:52 +00:00
Matthieu 6c32110288 fix(prod) : créer var/log dans l'image pour que le volume de logs hérite des droits www-data
Le volume nommé lesstime_logs est monté sur /var/www/html/var/log, mais ce
dossier n'existe pas dans l'image. Au premier montage d'un volume vide, Docker
crée le point de montage en root:root, ce qui empêche www-data d'écrire les
logs et fait crasher l'application. En créant var/log avant le chown -R, tout
volume de logs neuf hérite des droits www-data automatiquement.
2026-06-19 09:07:14 +02:00
gitea-actions da8beb2b2d chore: bump version to v0.4.29
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 28s
2026-06-15 09:52:11 +00:00
matthieu 16748cce40 Merge pull request 'feat(notification) : recâbler les notifications sur les événements de tâche' (#10) from feat/task-notifications into develop
Auto Tag Develop / tag (push) Successful in 9s
Reviewed-on: #10
2026-06-15 09:52:01 +00:00
344 changed files with 12554 additions and 1002 deletions
+108
View File
@@ -54,8 +54,116 @@
- **Pattern**: Retirer de composer.json + bundles.php + supprimer config YAML + templates - **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 - **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.
## Session 2026-06-19 (LST-62 / 0.2 — Socle front : shell + auto-détection layers Nuxt)
### Contexte
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-62-socle-front.md`), 7 tasks. Exécution en 3 sous-agents (Task 1 back ; Tasks 2-4 fondations front ; Tasks 5-7 middlewares/layout/i18n), pilotage chrono/MCP/vérif sur la session principale.
- 7 commits + 1 commit doc de correction du plan. Back : 115 tests verts (110 + 5 nouveaux cas gate rôle).
### Patterns
- **Gate de rôle additif dans la sidebar** : clé `roles` optionnelle sur section/item dans `config/sidebar.php` ; `SidebarFilter::filter($sections, $activeModuleIds, $activeRoles = [])` masque sans polluer `disabledRoutes` (réservé au filtrage par module). `SidebarProvider` injecte `Symfony\Bundle\SecurityBundle\Security` et passe `array_values($user->getRoles())`. ROLE_ADMIN seulement (pas le RBAC fin, qui viendra en 1.1/1.2).
- **Layout front aligné Starseed** (vérifié dans le code Starseed) : `srcDir: '.'`, `dir.layouts/middleware → app/`, code transverse auto-importé sous `shared/{composables,stores,utils}` via `imports.dirs` EXPLICITE, scan `readdirSync('modules/')``extends` + dossiers `modules/*/composables` ajoutés dynamiquement à `imports.dirs`. `useApi`/`auth`/`ui` déplacés par `git mv` (historique préservé) ; `timer.ts`/`mail.ts` restent dans `stores/` (métier non migré).
- **Singletons module-level** : `useSidebar`/`useModules` portent leur état en `ref` au niveau module ; reset explicite au logout depuis `auth.global.ts` (l'approche Starseed via callback `onAuthSessionCleared()` est une alternative non retenue ici).
### Gotchas
- **`nuxt typecheck` n'est PAS un gate vert sur ce stack** : le baseline Lesstime est rouge (~230 lignes `error TS`) et la RÉFÉRENCE Starseed (même Nuxt 4.3.1, même layout) ship en prod avec **325 erreurs**. Classes structurelles tolérées : `Cannot find name 'ref'/'useApi'/'useRoute'/'navigateTo'/'defineStore'…` dans `shared/` (Nuxt 4 type `shared/` sous un `tsconfig.shared.json` isolé sans les globals d'auto-import, alors que `imports.dirs` les expose au RUNTIME — vérifié dans `.nuxt/imports.d.ts`), erreurs `nuxt.config.ts` (`node:fs`/`process`/`__dirname`, pas de `@types/node`, compilé au runtime par Nuxt), `useApi.ts` 'Property url'. **Le vrai gate** = zéro `Cannot find module '~/shared/…'` (= vrai import cassé) + auto-imports présents dans `.nuxt/imports.d.ts` + smoke runtime. Un sous-agent consciencieux s'est arrêté à tort sur ces erreurs ("bloqueur irréductible") → toujours vérifier le gate contre la réf Starseed avant de conclure à un blocage.
- **Vérif backend live > typecheck front** : le gate de rôle a été prouvé via curl réel (`/api/login_check` → cookie BEARER → `GET /api/sidebar`) : `alice` (ROLE_USER) n'a que la section générale, `admin` (ROLE_ADMIN) a Administration, non-auth = 401. Plus fiable que le typecheck sur ce stack.
- **i18n `fr.json`** : une clé racine `sidebar` préexistait (avec un `myTasks` orphelin) → fusionner les sous-namespaces plutôt que dupliquer la clé racine (JSON invalide sinon).
### Statut / time tracking
- Ticket laissé en **"En attente de validation" (4)**, pas "Terminé" : smoke visuel front (dev server + navigateur) et sign-off du **délta cosmétique d'ordre de sidebar** (décision 3 du plan) relèvent du PO. Implémentation + AC API validés.
- Time-tracking 100 % sur la session principale cette fois (consigne des sous-agents : ne jamais toucher aux outils `mcp__lesstime__*`) — respecté.
## Session 2026-06-19 (LST-63 / 1.1 — Module Core : identité User/Auth/JWT + Notifications + layer front)
### Contexte
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-63-module-core.md`, 7 tasks / 6 phases A→F). Exécution : Phases A/B (1 sous-agent combiné), C (1 sous-agent), D (1 sous-agent), E + F faites en direct par la session principale (tâches courtes). Pilotage chrono/MCP/vérif + re-vérif login après chaque phase touchant l'auth sur la session principale.
- 5 commits impl (`6ca91cb` A, `f8fc4d6`+`d70925b` B, `0b4874e` C, `f1a9b42` D, `a98ea3d` E, `117c2ff` F) + plan `8865bf5`. Tests : 110→120 verts. Timer impl 1012 = 43 min.
### Patterns
- **Move d'entité « strangler » sans migration** : `git mv` `src/Entity/User.php``src/Module/Core/Domain/Entity/User.php` (table + colonnes + backticks VERBATIM) ; mapping Doctrine `Core` ajouté (dir `src/Module/Core/Domain/Entity`, prefix `App\Module\Core\Domain\Entity`) à côté de `App` ; `resolve_target_entities: UserInterface → Core\User`. `migrations:diff` reste vide (hors dérive préexistante `messenger_messages`) → AUCUNE migration. Idem Notification en Phase D.
- **Alias temporaire pour découpler le move des relations** : Phase B pose un `class_alias(App\Entity\User::class → Core\User)` (fichier `_compat_user_alias.php` en `autoload.files`, exclu de l'autowiring `App\:` via `exclude` services.yaml + `notPath` php-cs-fixer). Permet de relier d'abord les 8 relations d'entités au CONTRAT `UserInterface::class` (resolver propre) ; l'alias n'est qu'un pont de type-hint PHP. Phase C retire l'alias EN DERNIER, seulement quand `grep App\Entity\User` est vide.
- **Règle contrat-vs-concret pour migrer les consommateurs** (Phase C, ~50 fichiers) : type-hint `App\Shared\Domain\Contract\UserInterface` si le fichier n'appelle que les méthodes de lecture du contrat / instanceof / type DQL ; FQCN concret `App\Module\Core\Domain\Entity\User` si besoin de getters HR, `apiToken`, `avatarFileName`, setters, `new User()`. Les deux éliminent `App\Entity\User`. Collision de nom avec `Symfony\...\UserInterface` → aliaser en `SharedUserInterface`.
- **Notifier (Phase D)** : `NotifierInterface` (Shared) = API publique inter-modules ; impl `Notifier` (Core) persiste + flush. `TaskNotificationListener` appelle `notify()` UNIQUEMENT en `postFlush` (jamais `onFlush` — le flush interne y est dangereux). Comportement identique conservé.
- **Layer front d'un module (Phase F)** : `frontend/modules/core/nuxt.config.ts` (`export default defineNuxtConfig({})`) + `git mv` des pages d'identité sous `modules/core/pages/`. Les imports `~/...` (alias srcDir) survivent au déplacement ; seuls les imports relatifs/par chemin casseraient. Les URLs (`/login`, `/profile`) restent identiques (fusion auto des `pages/` de layers).
### Gotchas
- **`admin.vue` = shell admin MULTI-domaines** (onglets clients/workflows/efforts/gitea/zimbra/mail/absences + 1 onglet `AdminUserTab`) : NE PAS le déplacer entier dans Core (il porterait les admins d'autres modules pas encore extraits). Conformément au plan, en cas de doute on déplace seulement login + profile, on documente. La décomposition de `admin.vue` viendra avec les modules respectifs.
- **Vérifier la résolution des routes d'un layer Nuxt en SPA** : `ssr:false` → le dev server renvoie 200 pour N'IMPORTE QUEL chemin (shell SPA, routing client) — un `curl /login` = 200 ne prouve RIEN (testé : `/route-bidon-xyz` = 200 aussi). `nuxt prepare` ne génère pas le manifeste de routes. **Preuve déterministe** = `npx nuxt build` puis `grep 'name:"login"\|name:"profile"' .output/server/chunks/build/client.precomputed.mjs` (+ chunk CSS `profile.*.css` généré). Ne pas perturber un dev server déjà lancé (config `extends`/`imports.dirs` figée au démarrage avant création du layer) → lancer un dev frais sur un port libre pour smoke.
- **Aligner le contrat sur la réalité de l'entité, pas l'inverse** : `User::getUsername()` est `?string` (pas `string`) et la méthode réelle est `getIsEmployee(): bool` (pas `isEmployee()`). Le plan écrivait `isEmployee()` — le contrat existant était déjà correct, aucun changement. Toujours lire l'entité avant de figer une signature de contrat.
- **Tests fonctionnels qui persistent réellement** (pas de rollback transactionnel ici) : un `NotifierTest` qui crée une notif échoue au 2e run (`2 != 1`) → rendre les données uniques (`uniqid()` sur le titre) pour l'idempotence.
## Session 2026-06-19 (LST-57 / 1.2 — RBAC fin : portage Starseed)
### Contexte
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-57-rbac-fin.md`, 7 phases A→G). Source de vérité = **implémentation RBAC de Starseed** (le brief attaché au ticket était inaccessible en local — fichier non synchronisé sur le stockage ; cartographié via un agent Explore sur `/home/matthieu/dev_malio/Starseed`). 1 sous-agent par phase, pilotage chrono/MCP/vérif/push sur la session principale.
- 7 commits impl (A `ffed224`, B `ac662e7`, C `5060fb6`, D `48c67a5`, E `1a9eba9`, F `544d4cf`, G `511353c`) + plan `fdc7257`. Tests 131→**147 verts**. Timer impl 1014.
### Décision d'architecture majeure (actée, à valider PO)
- **RBAC additif, `ROLE_ADMIN` = bypass, PAS de colonne `is_admin`** — divergence assumée vs Starseed (qui a supprimé la colonne JSON `roles` au profit de `is_admin`). Lesstime garde `roles` JSON + `getRoles()` (login/JWT/MCP/sidebar #62 reposent dessus) ; le `PermissionVoter` bypass si `in_array('ROLE_ADMIN', $user->getRoles())`. Réécrire l'auth aurait été une régression à haut risque pour zéro bénéfice AC. Migration future vers `is_admin` possible.
### Patterns
- **RBAC = Role + Permission (M2M) + relations User** : `Role`(code snake_case immuable, label, description, isSystem, ManyToMany permissions EAGER), `Permission`(code `module.resource.action` unique, label, module, orphan), `User` reçoit `rbacRoles` (table `user_role`) + `directPermissions` (table `user_permission`), `getEffectivePermissions()` = union triée dédupliquée. Migration **100% additive** (5 CREATE TABLE, zéro DROP/ALTER sur `user`).
- **Permissions déclaratives par module** : `ModuleInterface::permissions(): list<array{code,label}>`, agrégées par `ModuleRegistry::permissions($activeClasses)` (injecte `module=id()`, valide le préfixe). `app:sync-permissions` upsert (revive orphan / updateMetadata / create) + markOrphan des absentes. `app:seed-rbac` seede les rôles système (`admin`/`user`, isSystem) — **sans matrice métier** tant qu'aucune permission métier n'existe (les modules 2.x ajouteront leurs permissions + rôles).
- **Voter pur + bypass applicatif** : `PermissionVoter` (regex `/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/` pour `supports`, donc abstient sur `ROLE_*`/`IS_AUTHENTICATED_*`). Le bypass admin de la **sidebar** est dans `SidebarProvider` (si ROLE_ADMIN → injecte le catalogue complet `ModuleRegistry::permissions()`), pas dans `SidebarFilter` qui reste un filtre pur (`permissionSatisfied()`). Le seed n'attachant aucune permission, sans ce bypass l'admin ne verrait rien.
- **Front** : `usePermissions()` (`can/canAny/canAll/isAdmin`) dans `modules/core/composables/` (auto-importé) ; type `UserData` enrichi de `effectivePermissions` ; onglet `AdminRoleTab`+`RoleDrawer` dans `frontend/components/admin/` (le scan `components` Nuxt ne couvre que `~/components`, PAS les layers `modules/*` → les composants vont dans `components/`, le composable/services dans `modules/core/`).
### Gotchas
- **`Symfony\Component\Serializer\Annotation\Groups` N'EXISTE PLUS en Symfony 8** — seul `Attribute\Groups` existe. Un import `Annotation\Groups` rend tous les `#[Groups]` **no-op silencieux** (sérialisation cassée, POST en 400 car le constructeur n'est pas alimenté). Bug latent introduit en Phase A, révélé seulement par les tests fonctionnels de Phase D (TDD). Toujours utiliser `Attribute\Groups`. Vérifier la cohérence sur TOUTES les entités.
- **`isSystem` exposé sous la clé `system`** : PropertyInfo strippe le préfixe `is`. Mettre `#[Groups]` + `#[SerializedName('isSystem')]` sur le getter pour conserver `isSystem` côté API.
- **`options: ['comment' => ...]` sur les colonnes des entités** : sans le mapping `options.comment`, les `COMMENT ON COLUMN` de la migration créent une dérive `migrations:diff` perpétuelle (Doctrine veut les remettre à `''`). Aligner le mapping entité sur le COMMENT de la migration.
- **`make db-reset` détruit `lesstime_test`** (`docker compose down -v` supprime le volume) — les tests tournent sur la base suffixée `_test`. Après un db-reset, recréer la base de test : `doctrine:database:create --env=test --if-not-exists` + `migrations:migrate -n --env=test` + `fixtures:load -n --env=test`. Ne jamais lancer `make db-reset` depuis un sous-agent de phase.
- **Signature `Voter::voteOnAttribute`** : la version Symfony installée impose `voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool` (4e param). Sans lui : « Declaration must be compatible » fatal.
### MR / Git
- **MR empilées sur Gitea** (`tea pr create --base <branche-précédente>`) reflètent la chaîne de dépendances (#56→develop, #62#56, #63#62, #57#63) avec des diffs propres ; Gitea re-cible la base à chaque merge. `tea pr` n'a pas d'`edit` → pour sortir une MR du brouillon (retrait `WIP:`), PATCH API Gitea `/repos/{o}/{r}/pulls/{n}` avec le token de `~/.config/tea/config.yml`.
- **WIP en cours** : pousser la branche d'un ticket en cours + ouvrir la MR en brouillon (titre `WIP:`) sauvegarde le travail sans signaler « prêt à merger » ; re-pousser à chaque phase. Le push ne lock pas l'index → aucune contention avec un sous-agent qui committe en parallèle.
## Meta-learnings ## Meta-learnings
- **Parallélisation**: Les tickets touchant des fichiers indépendants peuvent tourner en parallèle sans problème - **Parallélisation**: Les tickets touchant des fichiers indépendants peuvent tourner en parallèle sans problème
- **Commits concurrents**: NE PAS lancer deux sous-agents qui committent sur le même repo en parallèle (collision `.git/index.lock`) — séquencer.
- **Gate de vérif fourni par le plan**: si un plan fixe un seuil (ex "typecheck 0 erreur"), le confronter à la réalité du projet/réf AVANT de bloquer dessus ; corriger le plan si le seuil est faux.
- **MCP status**: Toujours mettre "En cours" AVANT de commencer, "Terminé" APRÈS validation - **MCP status**: Toujours mettre "En cours" AVANT de commencer, "Terminé" APRÈS validation
- **PostgreSQL gotchas**: Tester les queries SQL avec agrégation + locking sur PostgreSQL, pas MySQL - **PostgreSQL gotchas**: Tester les queries SQL avec agrégation + locking sur PostgreSQL, pas MySQL
- **Agents**: Les agents simples (1-3 fichiers) terminent en ~30s, les complexes (22 fichiers) en ~8min - **Agents**: Les agents simples (1-3 fichiers) terminent en ~30s, les complexes (22 fichiers) en ~8min
## Session 2026-06-19 (LST-61 / 1.3 — Audit log : #[Auditable], audit_log, AuditListener, resource)
### Contexte
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-61-audit-log.md`, Tasks A→F). Exécution : 1 sous-agent par task (A, B, C, D, E) en séquence, vérif + smoke par la session principale entre chaque ; Task F (validation finale + correctif front + learnings + push + statut) en direct.
- Infra portée VERBATIM depuis Starseed (réf canonique `/home/matthieu/dev_malio/Starseed`) : `AuditListener` byte-identique (`diff -q` OK), + 6 fichiers API (DTO/paginator/providers/resources) copiés tels quels — namespaces `App\Module\Core\...` et `App\Shared\Domain\Attribute\...` DÉJÀ alignés entre les deux projets, zéro adaptation.
- 6 commits impl (`934cf08` A, `d8553f0` B, `8c3699a` C, `90b8ca1` D, `e7af415` E, `9b26b43` fix front) + plan `fda03bd`. Tests : 147→157 verts. Branche `feat/lst-61-audit-log` empilée sur `feat/lst-57-rbac-fin`.
### Patterns
- **Audit en 4 couches additives** : (1) marquage déclaratif `#[Auditable]`(TARGET_CLASS) / `#[AuditIgnore]`(TARGET_PROPERTY) dans `src/Shared/Domain/Attribute/` (Shared, pas Core → aucun module n'a de dépendance circulaire) ; (2) capture `AuditListener` Doctrine sur `onFlush` (lit `UnitOfWork` : insertions/updates/deletions + `getScheduledCollectionUpdates/Deletions` pour le M2M) puis `postFlush` (écrit, swap-and-clear anti-réentrance) ; (3) écriture `AuditLogWriter` sur connexion DBAL dédiée `audit` (hors transaction ORM → survit aux rollbacks) ; (4) lecture `AuditLogProvider` DBAL (pas d'entité ORM) + `DbalPaginator implements PaginatorInterface` (API Platform génère `hydra:view` seul).
- **Connexion DBAL dédiée + `schema_filter`** : restructurer `doctrine.yaml` de connexion unique → `connections: {default, audit}` (même DSN), `default_connection: default`, `schema_filter: '~^(?!audit_log$).+~'` sur `default` (la table n'a PAS d'entité → exclue de `migrations:diff`/`schema:validate`). Le bloc `orm` reste INCHANGÉ (l'EM par défaut se lie à `default_connection`). En `when@test`, propager `dbname_suffix` aux DEUX connexions (sinon `audit` écrit en base dev pendant que l'ORM écrit en test).
- **Table append-only hors ORM** : créée par migration manuelle (squelette via `doctrine:migrations:generate` puis contenu écrit à la main — JAMAIS `migrations:diff`, qui ne voit pas la table). `id uuid` natif PG, `changes JSONB`, `performed_at TIMESTAMP(6) WITH TIME ZONE`. UUID v7 (writer, tri monotone) / v4 (requestId par requête HTTP). `entity_type` au format `module.Entity` (regex `App\Module\<module>\...\<Entity>``core.User`).
- **Marquage scope = entités migrées** : `#[Auditable]` posé sur User/Role/Permission (Core) uniquement ; `#[AuditIgnore]` sur `User.password` ET `User.apiToken` (Lesstime n'a pas de `plainPassword`). Défense en profondeur : `AuditLogWriter::SENSITIVE_KEYS` strippe aussi `password/plainPassword/apiToken/token/secret`. Les entités métier legacy (`src/Entity/*`) seront marquées à leur migration en modules (2.x).
### Gotchas
- **Tests fonctionnels Lesstime SANS rollback transactionnel** (pas de DAMADoctrineTestBundle) : les entités persistées survivent d'un run à l'autre → violation d'unicité `username`. Convention projet : `uniqid()` OU nettoyage explicite en `setUp()` (`DELETE FROM "user" WHERE username LIKE 'audit\_%'`). Les données d'audit de test se seedent directement via `doctrine.dbal.audit_connection` (DELETE + inserts UUID v7) pour du déterministe.
- **`migrations:diff` génère un fichier jetable** même quand on ne veut que vérifier : toujours supprimer le `Version<ts>.php` non suivi créé après un diff de contrôle (`git ls-files --others migrations/`). Une dérive préexistante `messenger_messages` (DROP) pollue le diff — sans rapport, ne pas committer.
- **`/audit-log-entity-types` = ressource item unique, pas une collection** : `Get` API Platform avec `uriTemplate` fixe sans `{id}` → renvoie `{ entityTypes: string[] }` (PAS d'enveloppe hydra `member`). Le service front ne doit PAS passer par `extractHydraMembers` ici (bug livré par le sous-agent E, corrigé en `9b26b43`). `/audit-logs` en revanche est bien une collection paginée hydra.
- **Login en curl = `/login_check` (POST), pas `/api/login`** ; le JWT json_login est capricieux en curl pur (405/cookie). La preuve d'auth faisant autorité reste le test fonctionnel (client `loginUser()`), pas un smoke curl.
### Time-tracking / orchestration
- **Interdire explicitement aux sous-agents de toucher au MCP lesstime** (timer + statut ticket) : un sous-agent a spontanément créé/stoppé une time entry (1016) alors que le chrono est piloté par la session principale. Ajouter la consigne « NE TOUCHE PAS au time-tracking » dans chaque prompt de sous-agent. Pas de conflit ici (il avait stoppé l'actif avant), mais découpage involontaire.
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
/*
* Liste ordonnée des modules actifs (classes implémentant App\Shared\Domain\Module\ModuleInterface).
* Activer/désactiver un module = ajouter/commenter sa ligne. Exposé par GET /api/modules.
*/
use App\Module\Core\CoreModule;
use App\Module\ProjectManagement\ProjectManagementModule;
use App\Module\TimeTracking\TimeTrackingModule;
return [
CoreModule::class,
TimeTrackingModule::class,
ProjectManagementModule::class,
];
+39 -6
View File
@@ -1,18 +1,31 @@
doctrine: doctrine:
dbal: dbal:
default_connection: default
connections:
# ORM uses `default`; AuditLogWriter uses `audit` (same DSN, separate
# service) to write outside the ORM transaction so audit rows survive
# an application-side rollback and avoid transactional entanglement.
default:
url: '%env(resolve:DATABASE_URL)%' url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%' profiling_collect_backtrace: '%kernel.debug%'
# audit_log has no ORM entity (written via raw DBAL). Exclude it
# from schema comparison so migrations:diff / schema:validate stay
# clean. Creation/teardown stay driven by migrations.
schema_filter: '~^(?!audit_log$).+~'
audit:
url: '%env(resolve:DATABASE_URL)%'
orm: orm:
validate_xml_mapping: true validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
identity_generation_preferences: identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true auto_mapping: true
resolve_target_entities:
App\Shared\Domain\Contract\UserInterface: App\Module\Core\Domain\Entity\User
App\Shared\Domain\Contract\ProjectInterface: App\Module\ProjectManagement\Domain\Entity\Project
App\Shared\Domain\Contract\TaskInterface: App\Module\ProjectManagement\Domain\Entity\Task
App\Shared\Domain\Contract\TaskTagInterface: App\Module\ProjectManagement\Domain\Entity\TaskTag
App\Shared\Domain\Contract\ClientInterface: App\Entity\Client
mappings: mappings:
App: App:
type: attribute type: attribute
@@ -20,13 +33,33 @@ doctrine:
dir: '%kernel.project_dir%/src/Entity' dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity' prefix: 'App\Entity'
alias: App alias: App
Core:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
prefix: 'App\Module\Core\Domain\Entity'
TimeTracking:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/TimeTracking/Domain/Entity'
prefix: 'App\Module\TimeTracking\Domain\Entity'
ProjectManagement:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity'
prefix: 'App\Module\ProjectManagement\Domain\Entity'
controller_resolver: controller_resolver:
auto_mapping: false auto_mapping: false
when@test: when@test:
doctrine: doctrine:
dbal: dbal:
# "TEST_TOKEN" is typically set by ParaTest # Propagate the _test suffix to BOTH connections: the audit
# connection must write to the test DB, not the dev DB.
connections:
default:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
audit:
dbname_suffix: '_test%env(default::TEST_TOKEN)%' dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod: when@prod:
+3 -1
View File
@@ -10,7 +10,7 @@ security:
providers: providers:
app_user_provider: app_user_provider:
entity: entity:
class: App\Entity\User class: App\Module\Core\Domain\Entity\User
property: username property: username
firewalls: firewalls:
@@ -62,6 +62,8 @@ security:
- { path: ^/api/docs, roles: PUBLIC_ACCESS } - { path: ^/api/docs, roles: PUBLIC_ACCESS }
# Version de l'application en public # Version de l'application en public
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] } - { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
# Liste des modules actifs en public (consommée au boot du front)
- { path: ^/api/modules, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] } - { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY } - { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
# Mail : requiert authentification (le check ROLE_USER est dans MailAccessChecker) # Mail : requiert authentification (le check ROLE_USER est dans MailAccessChecker)
+33 -5
View File
@@ -31,25 +31,25 @@ services:
# add more service definitions when explicit configuration is needed # add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones # please note that last definitions always *replace* previous ones
App\EventListener\TaskDocumentListener: App\Module\ProjectManagement\Infrastructure\EventListener\TaskDocumentListener:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
tags: tags:
- { name: doctrine.orm.entity_listener } - { name: doctrine.orm.entity_listener }
App\State\TaskDocumentProcessor: App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProcessor:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Controller\TaskDocumentDownloadController: App\Module\ProjectManagement\Infrastructure\Controller\TaskDocumentDownloadController:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Mcp\Tool\Task\AddTaskDocumentTool: App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\AddTaskDocumentTool:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Mcp\Tool\Task\UpdateTaskDocumentTool: App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\UpdateTaskDocumentTool:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
@@ -66,3 +66,31 @@ services:
$uploadDir: '%absence_justification_upload_dir%' $uploadDir: '%absence_justification_upload_dir%'
App\Service\Share\FileSource: '@App\Service\Share\SmbFileSource' App\Service\Share\FileSource: '@App\Service\Share\SmbFileSource'
App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository'
App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository'
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface: '@App\Module\TimeTracking\Infrastructure\Doctrine\DoctrineTimeEntryRepository'
App\Module\ProjectManagement\Domain\Repository\ProjectRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineProjectRepository'
App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRepository'
App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineWorkflowRepository'
App\Module\ProjectManagement\Domain\Repository\TaskStatusRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskStatusRepository'
App\Module\ProjectManagement\Domain\Repository\TaskGroupRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskGroupRepository'
App\Module\ProjectManagement\Domain\Repository\TaskEffortRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskEffortRepository'
App\Module\ProjectManagement\Domain\Repository\TaskPriorityRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskPriorityRepository'
App\Module\ProjectManagement\Domain\Repository\TaskTagRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskTagRepository'
App\Module\ProjectManagement\Domain\Repository\TaskRecurrenceRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRecurrenceRepository'
App\Shared\Domain\Contract\NotifierInterface: '@App\Module\Core\Infrastructure\Notifier'
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* Définition de la sidebar (sections + items) — navigation GLOBALE uniquement.
* Filtrée par SidebarFilter :
* - `module` : route ajoutée à disabledRoutes si module inactif ;
* - `roles` : section ou item masqué si l'utilisateur n'a aucun des rôles listés (gate minimal) ;
* - `permission` : section ou item masqué si la permission effective absente (RBAC fin —
* `User::getEffectivePermissions()` ; ROLE_ADMIN bypasse via le voter, mais la
* sidebar évalue les permissions effectives réelles — combiner avec `roles` au besoin).
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail) et user-flag
* (Mes absences) restent rendus côté layout, hors de cet endpoint.
* Les labels sont des clés i18n (sidebar.<domaine>.<item>).
*/
return [
[
'label' => 'sidebar.general.section',
'icon' => 'mdi:view-dashboard-outline',
'items' => [
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management'],
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management'],
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking'],
],
],
[
'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', 'permission' => 'core.users.view'],
],
],
];
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.4.28' app.version: '0.4.30'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,706 @@
# LST-61 (1.3) · Audit log — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Porter l'infrastructure d'audit de Starseed dans Lesstime : tracer create/update/delete des entités `#[Auditable]` dans une table append-only `audit_log`, exposée en lecture seule via `GET /api/audit-logs` (paginé + filtrable), avec une page de consultation front gated RBAC.
**Architecture:** 4 couches indépendantes, additives (strangler) — (1) **marquage** déclaratif `#[Auditable]`/`#[AuditIgnore]` dans `src/Shared/Domain/Attribute/` ; (2) **capture** par un `AuditListener` Doctrine sur `onFlush`/`postFlush` (capture en mémoire puis écriture déphasée) ; (3) **écriture** via `AuditLogWriter` sur une connexion DBAL dédiée `audit` (hors transaction ORM, survit aux rollbacks) ; (4) **lecture API** via `AuditLogProvider` DBAL (pas d'entité ORM) + `DbalPaginator`. Front Nuxt : service + page consultation gated `core.audit_log.view`.
**Tech Stack:** Symfony 8, API Platform 4, Doctrine ORM/DBAL, PostgreSQL 16, PHP 8.4, PHPUnit, symfony/uid (vendoré), Nuxt 4 / Vue 3 / Pinia / @nuxtjs/i18n.
## Global Constraints
- **Aucune mention de Claude/Anthropic/IA** dans les écritures Git (commits, trailers, descriptions MR, merge). Messages factuels et techniques.
- **Additif uniquement** : aucune migration destructive (pas de DROP/ALTER sur tables existantes en `up()`).
- **PostgreSQL** : noms de colonnes toujours en minuscules snake_case dans le SQL brut.
- **Code** : `declare(strict_types=1)`, PSR-12, patterns API Platform / Doctrine existants. Variables et commentaires en anglais.
- **`config/reference.php`** auto-généré — NE JAMAIS committer.
- Toujours lire un fichier avant de le modifier ; reproduire le style existant.
- Branche : `feat/lst-61-audit-log` (empilée sur `feat/lst-57-rbac-fin`).
- Tests Docker : `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`.
---
## File Structure
**Créés :**
- `src/Shared/Domain/Attribute/Auditable.php` — marqueur classe
- `src/Shared/Domain/Attribute/AuditIgnore.php` — marqueur propriété
- `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php` — écriture DBAL `audit`
- `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php` — UUID par requête
- `src/Module/Core/Infrastructure/Doctrine/AuditListener.php` — capture onFlush/postFlush
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php`
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php`
- `src/Module/Core/Application/DTO/AuditLogOutput.php`
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php`
- `src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php`
- `migrations/Version20260619XXXXXX.php` — table `audit_log`
- `tests/Functional/Module/Core/AuditListenerTest.php`
- `tests/Functional/Module/Core/AuditLogApiTest.php`
- `frontend/modules/core/services/audit-logs.ts`
- `frontend/components/admin/AdminAuditTab.vue`
**Modifiés :**
- `config/packages/doctrine.yaml` — connexion `audit` + `schema_filter` audit_log
- `src/Module/Core/CoreModule.php` — permission `core.audit_log.view`
- `src/Module/Core/Domain/Entity/User.php``#[Auditable]` + `#[AuditIgnore]` password/apiToken
- `src/Module/Core/Domain/Entity/Role.php``#[Auditable]`
- `src/Module/Core/Domain/Entity/Permission.php``#[Auditable]`
- `tests/Unit/Module/Core/CoreModuleTest.php` — assert nouvelle permission
- `frontend/pages/admin.vue` — onglet Audit gated `core.audit_log.view`
- `frontend/i18n/locales/fr.json` — clés `admin.audit.*` + `audit.entity.*`
---
## Task A: Marquage + table + connexion DBAL audit
**Files:**
- Create: `src/Shared/Domain/Attribute/Auditable.php`, `src/Shared/Domain/Attribute/AuditIgnore.php`
- Create: `migrations/Version20260619XXXXXX.php`
- Modify: `config/packages/doctrine.yaml`
**Interfaces produced:** `App\Shared\Domain\Attribute\Auditable` (TARGET_CLASS), `App\Shared\Domain\Attribute\AuditIgnore` (TARGET_PROPERTY) ; service DBAL `doctrine.dbal.audit_connection` ; table `audit_log`.
- [ ] **Step A1: Attributs** — créer les deux fichiers :
```php
<?php
// src/Shared/Domain/Attribute/Auditable.php
declare(strict_types=1);
namespace App\Shared\Domain\Attribute;
use Attribute;
/**
* Marker placed on a Doctrine entity to enable audit tracking.
*
* Located in Shared (not Core) so every module can use it without a
* circular dependency on Core. Any migrated business entity that should be
* traced carries this attribute, with #[AuditIgnore] on sensitive fields.
*/
#[Attribute(Attribute::TARGET_CLASS)]
final class Auditable
{
}
```
```php
<?php
// src/Shared/Domain/Attribute/AuditIgnore.php
declare(strict_types=1);
namespace App\Shared\Domain\Attribute;
use Attribute;
/**
* Marker placed on an entity property to exclude it from audit tracking.
*
* Typical use: sensitive fields (password, apiToken). The AuditLogWriter also
* carries an exact-match blacklist on the most dangerous names as
* defense-in-depth, but the base rule is to annotate explicitly here.
*/
#[Attribute(Attribute::TARGET_PROPERTY)]
final class AuditIgnore
{
}
```
- [ ] **Step A2: Migration** — créer `migrations/Version20260619XXXXXX.php` (timestamp réel via `php bin/console make:migration` puis remplacer le contenu, OU horodatage manuel cohérent > 20260619145109) :
```php
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Audit log (LST-61) : append-only `audit_log` table.
*
* Not managed by Doctrine ORM (no entity). Written via raw DBAL by the
* AuditLogWriter on a dedicated `audit` connection to avoid re-entrant
* flushes from the Doctrine listener. Columns are lowercase snake_case.
* Additive only — no DROP/ALTER on existing tables.
*/
final class Version20260619XXXXXX extends AbstractMigration
{
public function getDescription(): string
{
return 'Audit log: create append-only audit_log table + indexes (additive)';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE audit_log (
id uuid NOT NULL,
entity_type VARCHAR(100) NOT NULL,
entity_id VARCHAR(64) NOT NULL,
action VARCHAR(10) NOT NULL,
changes JSONB NOT NULL DEFAULT '{}'::jsonb,
performed_by VARCHAR(100) NOT NULL,
performed_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
ip_address VARCHAR(45) DEFAULT NULL,
request_id VARCHAR(36) DEFAULT NULL,
PRIMARY KEY(id)
)
SQL);
$this->addSql('CREATE INDEX idx_audit_entity_time ON audit_log (entity_type, entity_id, performed_at)');
$this->addSql('CREATE INDEX idx_audit_performer ON audit_log (performed_by, performed_at)');
$this->addSql('CREATE INDEX idx_audit_time ON audit_log (performed_at)');
$this->addSql("COMMENT ON COLUMN audit_log.entity_type IS 'Audited entity type, format module.Entity (e.g. core.User)'");
$this->addSql("COMMENT ON COLUMN audit_log.entity_id IS 'Audited entity identifier (int or composite key serialized)'");
$this->addSql("COMMENT ON COLUMN audit_log.action IS 'create|update|delete'");
$this->addSql("COMMENT ON COLUMN audit_log.changes IS 'JSON diff: {field:{old,new}} for update, full snapshot for create/delete'");
$this->addSql("COMMENT ON COLUMN audit_log.performed_by IS 'User identifier or system'");
$this->addSql("COMMENT ON COLUMN audit_log.request_id IS 'UUID shared by all audit rows of a single HTTP request (null in CLI)'");
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE audit_log');
}
}
```
- [ ] **Step A3: Connexion DBAL `audit`** — restructurer `config/packages/doctrine.yaml`. Remplacer le bloc `dbal` racine (connexion unique) par des connexions nommées, et propager le `dbname_suffix` de test aux deux connexions. **Le bloc `orm` reste inchangé** (l'EM par défaut se lie à `default_connection`).
Remplacer :
```yaml
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
```
par :
```yaml
dbal:
default_connection: default
connections:
# ORM uses `default`; AuditLogWriter uses `audit` (same DSN, separate
# service) to write outside the ORM transaction so audit rows survive
# an application-side rollback and avoid transactional entanglement.
default:
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
# audit_log has no ORM entity (written via raw DBAL). Exclude it
# from schema comparison so migrations:diff / schema:validate stay
# clean. Creation/teardown stay driven by migrations.
schema_filter: '~^(?!audit_log$).+~'
audit:
url: '%env(resolve:DATABASE_URL)%'
```
Et remplacer le bloc `when@test` :
```yaml
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
```
par :
```yaml
when@test:
doctrine:
dbal:
# Propagate the _test suffix to BOTH connections: the audit
# connection must write to the test DB, not the dev DB.
connections:
default:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
audit:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
```
- [ ] **Step A4: Vérifier la non-régression** — la restructuration des connexions est le point sensible. Lancer la suite existante :
```bash
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
```
Expected: 147 tests toujours verts (aucune régression liée au changement de connexions).
- [ ] **Step A5: Appliquer la migration (dev + test)** :
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate -n
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate -n --env=test
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --env=test 2>&1 | grep -i "audit_log" || echo "OK: audit_log absent du diff (schema_filter actif)"
```
Expected: table créée, `audit_log` absente de tout diff généré.
- [ ] **Step A6: Commit**
```bash
git add src/Shared/Domain/Attribute config/packages/doctrine.yaml migrations/
git commit -m "feat(core) : add audit attributes, audit_log table and dedicated dbal connection"
```
---
## Task B: AuditLogWriter + RequestIdProvider
**Files:**
- Create: `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php`
- Create: `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php`
**Interfaces produced:** `AuditLogWriter::log(string $entityType, string $entityId, string $action, array $changes): void` ; `RequestIdProvider::getRequestId(): ?string`.
- [ ] **Step B1: RequestIdProvider** (verbatim Starseed) :
```php
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Audit;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Uid\Uuid;
/**
* Provides an HTTP request identifier (UUID v4) shared by every audit row
* produced during a single main request. Null in CLI (fixtures, batch).
*/
final class RequestIdProvider
{
private ?string $requestId = null;
#[AsEventListener(event: 'kernel.request')]
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$this->requestId = Uuid::v4()->toRfc4122();
}
public function getRequestId(): ?string
{
return $this->requestId;
}
}
```
- [ ] **Step B2: AuditLogWriter** (verbatim Starseed, connexion `audit`) :
```php
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Audit;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Types\Types;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Uid\Uuid;
/**
* Low-level service responsible for writing into the `audit_log` table.
*
* Uses a dedicated `audit` DBAL connection (same DSN as `default`) to write
* outside the ORM transaction: audit rows survive an application-side
* rollback and avoid transactional entanglement in batch (fixtures).
*
* Sensitive keys are stripped in defense-in-depth even when entities already
* declare those properties #[AuditIgnore]. SQL failures are swallowed by the
* caller (AuditListener wraps log() in try/catch) — audit must never crash a
* business flow.
*/
final class AuditLogWriter
{
/** @var list<string> keys always stripped from the `changes` payload */
private const array SENSITIVE_KEYS = ['password', 'plainPassword', 'apiToken', 'token', 'secret'];
public function __construct(
#[Autowire(service: 'doctrine.dbal.audit_connection')]
private readonly Connection $connection,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly RequestIdProvider $requestIdProvider,
) {
}
/**
* @param string $entityType Format "module.Entity" (e.g. "core.User")
* @param string $entityId Entity id (int or serialized UUID)
* @param string $action create|update|delete
* @param array<string, mixed> $changes JSON payload (sensitive keys stripped)
*/
public function log(
string $entityType,
string $entityId,
string $action,
array $changes,
): void {
$filteredChanges = $this->stripSensitive($changes);
$this->connection->insert('audit_log', [
'id' => Uuid::v7()->toRfc4122(),
'entity_type' => $entityType,
'entity_id' => $entityId,
'action' => $action,
'changes' => $filteredChanges,
'performed_by' => $this->security->getUser()?->getUserIdentifier() ?? 'system',
'performed_at' => new DateTimeImmutable('now', new DateTimeZone('UTC')),
'ip_address' => $this->requestStack->getCurrentRequest()?->getClientIp(),
'request_id' => $this->requestIdProvider->getRequestId(),
], [
'id' => Types::GUID,
'changes' => Types::JSON,
'performed_at' => Types::DATETIMETZ_IMMUTABLE,
]);
}
/**
* Recursively removes sensitive keys from the payload.
*
* @param array<string, mixed> $data
*
* @return array<string, mixed>
*/
private function stripSensitive(array $data): array
{
foreach ($data as $key => $value) {
if (in_array($key, self::SENSITIVE_KEYS, true)) {
unset($data[$key]);
continue;
}
if (is_array($value)) {
$data[$key] = $this->stripSensitive($value);
}
}
return $data;
}
}
```
- [ ] **Step B3: Vérifier le câblage** (autowiring) :
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console debug:container App\\Module\\Core\\Infrastructure\\Audit\\AuditLogWriter 2>&1 | head -20
```
Expected: service trouvé, injection `doctrine.dbal.audit_connection` résolue.
- [ ] **Step B4: Commit**
```bash
git add src/Module/Core/Infrastructure/Audit/
git commit -m "feat(core) : add audit log writer and request id provider"
```
---
## Task C: AuditListener + marquage des entités Core
**Files:**
- Create: `src/Module/Core/Infrastructure/Doctrine/AuditListener.php`
- Modify: `src/Module/Core/Domain/Entity/User.php`, `Role.php`, `Permission.php`
- Test: `tests/Functional/Module/Core/AuditListenerTest.php`
**Interfaces consumed:** `AuditLogWriter`, attributs `Auditable`/`AuditIgnore`.
- [ ] **Step C1: Écrire le test fonctionnel (échec attendu)**`tests/Functional/Module/Core/AuditListenerTest.php`. Le test crée/modifie/supprime un User via l'EntityManager dans le kernel de test, puis lit `audit_log` via la connexion `audit`. (S'inspirer du style des tests fonctionnels existants — `RoleApiTest`, `UserRbacApiTest`.)
```php
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Core;
use App\Module\Core\Domain\Entity\User;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*/
final class AuditListenerTest extends KernelTestCase
{
private EntityManagerInterface $em;
private Connection $auditConnection;
protected function setUp(): void
{
self::bootKernel();
$container = self::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->auditConnection = $container->get('doctrine.dbal.audit_connection');
// Clean slate for deterministic assertions.
$this->auditConnection->executeStatement('DELETE FROM audit_log');
}
public function testCreateUserIsAudited(): void
{
$user = $this->makeUser('audit_create_user');
$this->em->persist($user);
$this->em->flush();
$rows = $this->fetchLogs('core.User', (string) $user->getId());
self::assertCount(1, $rows);
self::assertSame('create', $rows[0]['action']);
$changes = json_decode((string) $rows[0]['changes'], true);
self::assertArrayHasKey('username', $changes);
self::assertArrayNotHasKey('password', $changes, 'password must be excluded via #[AuditIgnore]');
self::assertArrayNotHasKey('apiToken', $changes, 'apiToken must be excluded via #[AuditIgnore]');
}
public function testUpdateUserIsAuditedWithDiff(): void
{
$user = $this->makeUser('audit_update_user');
$this->em->persist($user);
$this->em->flush();
$this->auditConnection->executeStatement('DELETE FROM audit_log');
$user->setFirstName('Changed');
$this->em->flush();
$rows = $this->fetchLogs('core.User', (string) $user->getId());
self::assertCount(1, $rows);
self::assertSame('update', $rows[0]['action']);
$changes = json_decode((string) $rows[0]['changes'], true);
self::assertArrayHasKey('firstName', $changes);
self::assertSame('Changed', $changes['firstName']['new']);
}
public function testDeleteUserIsAudited(): void
{
$user = $this->makeUser('audit_delete_user');
$this->em->persist($user);
$this->em->flush();
$id = (string) $user->getId();
$this->auditConnection->executeStatement('DELETE FROM audit_log');
$this->em->remove($user);
$this->em->flush();
$rows = $this->fetchLogs('core.User', $id);
self::assertCount(1, $rows);
self::assertSame('delete', $rows[0]['action']);
}
private function makeUser(string $username): User
{
$user = new User();
$user->setUsername($username);
$user->setPassword('hashed-secret');
$user->setRoles(['ROLE_USER']);
return $user;
}
/**
* @return list<array<string, mixed>>
*/
private function fetchLogs(string $entityType, string $entityId): array
{
return $this->auditConnection->fetchAllAssociative(
'SELECT action, changes FROM audit_log WHERE entity_type = :t AND entity_id = :id ORDER BY performed_at ASC',
['t' => $entityType, 'id' => $entityId],
);
}
protected function tearDown(): void
{
parent::tearDown();
unset($this->em, $this->auditConnection);
}
}
```
> **Note adaptation :** vérifier la signature réelle de `User` (setters disponibles : `setUsername`, `setPassword`, `setRoles`, `setFirstName`). Ajuster `makeUser()` aux champs NOT NULL réels de la table `user`. Si `User` exige d'autres champs obligatoires (ex. `createdAt` initialisé au constructeur — déjà le cas), ne rien ajouter.
- [ ] **Step C2: Run le test → échec** (listener absent, entités non marquées) :
```bash
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditListenerTest.php
```
Expected: FAIL.
- [ ] **Step C3: Créer `AuditListener`** (verbatim Starseed, namespace `App\Module\Core\Infrastructure\Doctrine`). Copier intégralement le listener fourni dans le rapport Starseed (onFlush capture + postFlush écriture, swap-and-clear, gestion collections, snapshot create/delete, buildUpdateChanges, formatEntityType regex `App\Module\<module>\...\<Entity>`, caches Auditable/AuditIgnore). **Ne rien simplifier.**
- [ ] **Step C4: Marquer les entités Core.**
`src/Module/Core/Domain/Entity/User.php` — ajouter import + attribut classe + `#[AuditIgnore]` sur `password` et `apiToken` :
```php
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
```
```php
#[Auditable]
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements ...
```
Sur la propriété `password` (ligne ~89-90) et `apiToken` (ligne ~99-100), ajouter `#[AuditIgnore]` au-dessus de la ligne `private ?string $password = null;` / `private ?string $apiToken = null;`.
`src/Module/Core/Domain/Entity/Role.php` — ajouter `use App\Shared\Domain\Attribute\Auditable;` et `#[Auditable]` au-dessus de `#[ORM\Entity...]`.
`src/Module/Core/Domain/Entity/Permission.php` — idem `#[Auditable]`.
- [ ] **Step C5: Run le test → succès** :
```bash
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditListenerTest.php
```
Expected: PASS (3 tests).
- [ ] **Step C6: Suite complète + cs-fixer** :
```bash
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
make php-cs-fixer-allow-risky
```
Expected: tout vert.
- [ ] **Step C7: Commit**
```bash
git add src/Module/Core/Infrastructure/Doctrine/AuditListener.php src/Module/Core/Domain/Entity/ tests/Functional/Module/Core/AuditListenerTest.php
git commit -m "feat(core) : add doctrine audit listener and mark core entities auditable"
```
---
## Task D: API de lecture `/api/audit-logs` + permission
**Files:**
- Create: `AuditLogOutput.php`, `DbalPaginator.php`, `AuditLogProvider.php`, `AuditLogResource.php`, `AuditLogEntityTypesResource.php`, `AuditLogEntityTypesProvider.php`
- Modify: `src/Module/Core/CoreModule.php` (permission), `tests/Unit/Module/Core/CoreModuleTest.php`
- Test: `tests/Functional/Module/Core/AuditLogApiTest.php`
**Interfaces consumed:** table `audit_log`, connexion `doctrine.dbal.default_connection`, permission `core.audit_log.view`.
- [ ] **Step D1: Permission** — ajouter dans `CoreModule::permissions()` :
```php
['code' => 'core.audit_log.view', 'label' => 'Consulter le journal d\'audit'],
```
Mettre à jour `tests/Unit/Module/Core/CoreModuleTest.php` pour asserter la présence de ce code (la liste passe à 6 permissions).
- [ ] **Step D2: DTO + Paginator + Providers + Resources** — créer les 6 fichiers verbatim depuis le rapport Starseed :
- `src/Module/Core/Application/DTO/AuditLogOutput.php`
- `src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php`
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php`
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php`
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php`
**Adaptation pagination :** Lesstime n'a pas de `itemsPerPage`/`maximum_items_per_page` explicite dans `api_platform.yaml`. Le provider utilise `Pagination::getPage()`/`getLimit()` (défauts API Platform : 30/page). C'est acceptable. Conserver le clamp `max(1, page)`.
- [ ] **Step D3: Écrire le test API (échec attendu)**`tests/Functional/Module/Core/AuditLogApiTest.php`. S'aligner sur le helper d'auth des tests existants (login admin/admin via cookie JWT, cf. `RoleApiTest`). Tests :
- admin authentifié : `GET /api/audit-logs` → 200, structure hydra paginée.
- filtre `?action=update` → ne renvoie que des updates.
- filtre `?entity_type=core.User`.
- `?action=bogus` → 400.
- utilisateur sans permission (alice) : 403.
- non authentifié : 401.
Préparer des données : créer/modifier un User via l'EM avant les assertions (le listener écrit), OU insérer directement des lignes via la connexion `audit`.
- [ ] **Step D4: Run → échec, puis vérifier la route** :
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console debug:router 2>&1 | grep -i audit
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditLogApiTest.php
```
Expected: routes `/api/audit-logs`, `/api/audit-logs/{id}`, `/api/audit-log-entity-types` présentes ; test passe une fois les providers branchés.
- [ ] **Step D5: sync-permissions** (enregistre `core.audit_log.view` en base dev + test) :
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console app:sync-permissions
docker exec -t -u www-data php-lesstime-fpm php bin/console app:sync-permissions --env=test
```
- [ ] **Step D6: Suite complète + cs-fixer**
```bash
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
make php-cs-fixer-allow-risky
```
- [ ] **Step D7: Commit**
```bash
git add src/Module/Core/ tests/
git commit -m "feat(core) : expose read-only audit-logs api with dbal provider and pagination"
```
---
## Task E: Front — page consultation gated RBAC
**Files:**
- Create: `frontend/modules/core/services/audit-logs.ts`, `frontend/components/admin/AdminAuditTab.vue`
- Modify: `frontend/pages/admin.vue`, `frontend/i18n/locales/fr.json`
**Interfaces consumed:** `GET /api/audit-logs`, composable `usePermissions` (livré en 1.2), pattern onglet admin (cf. `AdminRoleTab.vue` créé en 1.2).
- [ ] **Step E1: Service**`frontend/modules/core/services/audit-logs.ts` : fonction `fetchAuditLogs(params)` via `useApi()` (suivre `roles.ts`/`permissions.ts` créés en 1.2). Types : `AuditLogItem { id, entityType, entityId, action, changes, performedBy, performedAt, ipAddress, requestId }`.
- [ ] **Step E2: Composant onglet**`frontend/components/admin/AdminAuditTab.vue` : tableau paginé (colonnes date, utilisateur, type d'entité, action, id), filtre par `entityType` et `action`. Labels via i18n `audit.entity.*` et `audit.action.*`. Reproduire le style de `AdminRoleTab.vue`.
- [ ] **Step E3: Onglet dans admin.vue** — ajouter un onglet « Audit » gated `can('core.audit_log.view')` (suivre le gating de l'onglet rôles ajouté en 1.2).
- [ ] **Step E4: i18n**`frontend/i18n/locales/fr.json` : ajouter `admin.audit.*` (titre, colonnes, filtres) et `audit.entity.core.User` = « Utilisateur », `audit.entity.core.Role` = « Rôle », `audit.entity.core.Permission` = « Permission » ; `audit.action.create/update/delete`.
- [ ] **Step E5: Vérifier la route déterministe (SPA)** :
```bash
cd frontend && npx nuxt build 2>&1 | tail -5
grep -o 'name:"admin"' .output/server/chunks/build/client.precomputed.mjs | head -1
```
Expected: build OK (la page admin reste enregistrée).
- [ ] **Step E6: Commit**
```bash
git add frontend/
git commit -m "feat(core) : add audit log consultation tab in admin gated by permission"
```
---
## Task F: Validation finale + statut
- [ ] **Step F1: Suite complète verte + login fumée**
```bash
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
```
Vérifier login admin → 204 + `GET /api/me` 200 + `GET /api/audit-logs` 200 (cURL ou via test).
- [ ] **Step F2: migrations:diff propre** (audit_log absente du diff) :
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --env=test 2>&1 | grep -ci audit_log
```
Expected: 0.
- [ ] **Step F3: Learnings** — append session #61 à `.claude/skills/ticket-executor/LEARNINGS.md`, commit `docs : log LST-61 audit log session learnings`.
- [ ] **Step F4: Push branche + MR empilée sur #57** (Gitea, base `feat/lst-57-rbac-fin`), draft puis un-draft via API si voulu.
- [ ] **Step F5: Ticket #61 (id 647) → « En attente de validation » (statut 4)**, stopper le timer, informer l'utilisateur.
---
## Self-Review (couverture spec)
| Critère d'acceptation | Tâche |
|---|---|
| CRUD des entités `#[Auditable]` tracé | C (listener + test create/update/delete) |
| Endpoint `/api/audit-logs` paginé/filtrable | D (provider DBAL + DbalPaginator + filtres) |
| `make test` vert, aucune migration destructive | A (migration additive), C/D/F (suite) |
| `#[Auditable]`/`#[AuditIgnore]` dans Shared | A1 |
| Table `audit_log` (qui/quoi/quand/diff/requestId) + COMMENT | A2 |
| `#[AuditIgnore]` champs sensibles (password, apiToken) | C4 + B2 blacklist |
| Front consultation + i18n `audit.entity.*` gated RBAC | E |
**Décision de scope :** `#[Auditable]` posé sur les **entités migrées** (User, Role, Permission) conformément au libellé du ticket. Les entités métier legacy (`src/Entity/*`) ne sont pas marquées ici — elles le seront lors de leur migration en modules (phases 2.x+). L'infra est prête à les auditer sans modification dès qu'elles portent l'attribut.
@@ -0,0 +1,976 @@
# 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/<x>/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 `<type>(<scope>) : <message>` (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 (en LECTURE différentielle, cf. ci-dessous) + 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.
> **⚠️ `nuxt typecheck` n'est PAS un gate vert sur ce projet (constat 2026-06-19).** Le baseline Lesstime est déjà rouge (~230 lignes `error TS`), et le projet de référence **Starseed (même Nuxt 4.3.1, même layout `shared/` + `srcDir: '.'`) ship en prod avec 325 erreurs `error TS`**. Ces erreurs sont des classes structurelles attendues, pas des régressions :
> - dans `shared/composables/*` et `shared/stores/*` : `Cannot find name 'ref'/'useApi'/'useRoute'/'navigateTo'/'defineStore'/'useToast'/'useNuxtApp'…` — Nuxt 4 type le dossier `shared/` sous un `tsconfig.shared.json` isolé sans les globals d'auto-import, alors que `imports.dirs` les rend bien disponibles au RUNTIME (vérifié dans `.nuxt/imports.d.ts`). Starseed a exactement ces 15 erreurs et fonctionne.
> - dans `nuxt.config.ts` : `node:fs`/`node:path`/`__dirname`/`process` (pas de `@types/node` — comme Starseed) ; ce fichier est compilé par Nuxt au runtime, pas par `tsc`.
> - dans `useApi.ts` : `Property 'url' does not exist…` (préexistant, code forké de Starseed).
>
> **Le vrai gate front** = (1) **ZÉRO erreur `Cannot find module '~/shared/…'` / chemin cassé** (sinon un import a vraiment été cassé par un déplacement) ; (2) les auto-imports attendus présents dans `.nuxt/imports.d.ts` ; (3) smoke runtime sur le dev server. Ne JAMAIS s'arrêter sur les classes d'erreurs structurelles ci-dessus — elles sont identiques à la référence Starseed.
---
### 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
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Sidebar;
use App\Shared\Domain\Sidebar\SidebarFilter;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class SidebarFilterTest extends TestCase
{
public function testItemWithoutModuleIsAlwaysVisible(): void
{
$sections = [
['label' => 'sidebar.core.section', 'icon' => 'mdi:home', 'items' => [
['label' => 'sidebar.core.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard'],
]],
];
$result = SidebarFilter::filter($sections, [], ['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
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Sidebar;
final class SidebarFilter
{
/**
* @param list<array{label:string, icon:string, roles?:list<string>, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>}>}> $sections
* @param list<string> $activeModuleIds
* @param list<string> $activeRoles
*
* @return array{sections: list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string}>}>, disabledRoutes: list<string>}
*/
public static function filter(array $sections, array $activeModuleIds, array $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<string>|null $required
* @param list<string> $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
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Shared\Domain\Module\ModuleRegistry;
use App\Shared\Domain\Sidebar\SidebarFilter;
use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class SidebarProvider implements ProviderInterface
{
public function __construct(
#[Autowire('%kernel.project_dir%')]
private string $projectDir,
private Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): SidebarResource
{
/** @var list<class-string> $moduleClasses */
$moduleClasses = require $this->projectDir.'/config/modules.php';
/** @var list<array{label:string, icon:string, roles?:list<string>, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>}>}> $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
<?php
declare(strict_types=1);
/*
* Définition de la sidebar (sections + items) — navigation GLOBALE uniquement.
* Filtrée par SidebarFilter : `module` (route ajoutée à disabledRoutes si module inactif),
* `roles` (section ou item masqué si l'utilisateur n'a aucun des rôles listés ; gate minimal,
* le RBAC fin par permission arrive en #1.2).
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail) et user-flag
* (Mes absences) restent rendus côté layout, hors de cet endpoint.
* Les labels sont des clés i18n (sidebar.<domaine>.<item>).
*/
return [
[
'label' => 'sidebar.general.section',
'icon' => 'mdi:view-dashboard-outline',
'items' => [
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi: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
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Shared;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
*/
final class SidebarEndpointTest extends WebTestCase
{
public function testSidebarRequiresAuthentication(): void
{
$client = self::createClient();
$client->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<string[]>, loaded: Ref<boolean>, loadModules(): Promise<void>, isModuleActive(id: string): boolean, resetModules(): void }`
- `useSidebar(): { sections: Ref<SidebarSection[]>, disabledRoutes: Ref<string[]>, loaded: Ref<boolean>, loadSidebar(): Promise<void>, 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<string[]>([])
const loaded = ref(false)
export function useModules() {
async function loadModules(): Promise<void> {
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<T>(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<SidebarSection[]>([])
const disabledRoutes = ref<string[]>([])
const loaded = ref(false)
export function useSidebar() {
async function loadSidebar(): Promise<void> {
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 `<script setup>` non lié à la sidebar (timer, drawer, head, mail polling, refData) est conservé à l'identique.
- [ ] **Step 1: Réécrire le bloc `<nav>` et l'en-tête du `<script setup>` de `frontend/app/layouts/default.vue`**
Dans le `<template>`, remplace le contenu de `<nav class="flex-1 overflow-hidden" …>…</nav>` (lignes ~40-167 de l'original) par :
```vue
<nav class="flex-1 overflow-hidden" :class="sidebarIsCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
<!-- Sections dynamiques (/api/sidebar) : navigation globale + sections gated par rôle -->
<template v-for="(section, sIndex) in translatedSections" :key="section.label">
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
{{ section.label }}
</p>
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
<SidebarLink
v-for="item in section.items"
:key="item.to"
:to="item.to"
:icon="item.icon"
:label="item.label"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
<!-- Items conservés côté client, insérés après la 1re section (cf. décision 3) -->
<template v-if="sIndex === 0">
<!-- Contextuel projet -->
<template v-if="currentProjectId">
<SidebarLink :to="`/projects/${currentProjectId}`" icon="mdi:view-column-outline" label="Kanban" :collapsed="sidebarIsCollapsed" sub exact @click="ui.closeMobileSidebar()" />
<SidebarLink :to="`/projects/${currentProjectId}/groups`" icon="mdi:tag-multiple-outline" label="Groupes" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
<SidebarLink :to="`/projects/${currentProjectId}/archives`" icon="mdi:archive-outline" label="Archives" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
</template>
<!-- Feature-flag : Documents -->
<SidebarLink v-if="isDocumentsVisible" to="/documents" icon="mdi:folder-network-outline" :label="$t('sharedFiles.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
<!-- Feature-flag : Mail + badge -->
<div v-if="isMailVisible" class="relative">
<SidebarLink to="/mail" icon="mdi:email-outline" :label="$t('mail.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
<span
v-if="mailStore.globalUnreadCount > 0"
class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
:class="{ 'right-1 top-1 translate-y-0': sidebarIsCollapsed }"
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
>
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
</span>
</div>
<!-- User-flag : Mes absences (isEmployee non couvert par le gate rôle) -->
<SidebarLink v-if="isEmployee" to="/absences" icon="mdi:umbrella-beach-outline" label="Mes absences" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
</template>
</template>
</nav>
```
Dans le `<script setup lang="ts">`, **ajoute** en tête (après les `useXxxStore()` existants) :
```ts
const { t } = useI18n()
const { sections } = useSidebar()
const translatedSections = computed(() =>
sections.value.map((section) => ({
label: t(section.label),
icon: section.icon,
items: section.items.map((item) => ({
label: t(item.label),
to: item.to,
icon: item.icon,
})),
})),
)
```
**Conserve** tout le reste du `<script setup>` (`isAdmin`, `isEmployee`, `isMailVisible`, `isDocumentsVisible`, `currentProjectId`, `sidebarIsCollapsed`, timer/drawer/head/mail/refData…) et le `<style scoped>` à l'identique. `isAdmin`/`isAbsenceSectionVisible` deviennent inutilisés pour la sidebar (l'admin est gated côté serveur) — si le typecheck signale une variable inutilisée, retirer `isAbsenceSectionVisible` ; garder `isAdmin` s'il sert ailleurs, sinon le retirer aussi.
- [ ] **Step 2: Typecheck**
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck`
Expected: 0 erreur (corriger toute variable / tout import inutilisé signalé).
- [ ] **Step 3: Smoke test visuel — non-régression de navigation**
Dev server. Se connecter successivement :
- `alice` (ROLE_USER) : sidebar affiche Tableau de bord / Mes tâches / Projets / Suivi de temps (dynamiques), + Documents/Mail si visibles, + Mes absences si employé ; **PAS** de section Administration ni Absences équipe.
- `admin` (ROLE_ADMIN) : en plus, section **Administration** avec Absences équipe + Administration.
- Entrer dans un projet (`/projects/<id>`) : Kanban/Groupes/Archives apparaissent (contextuel conservé).
Expected: tous les liens d'avant atteignables ; gating admin respecté. Noter tout délta visuel (ordre) pour validation PO (cf. décision 3).
- [ ] **Step 4: Commit**
```bash
git add frontend/app/layouts/default.vue
git commit -m "feat(front) : render dynamic sidebar from /api/sidebar in default layout"
```
---
### Task 7: Frontend — clés i18n `sidebar.*` + vérification bout-en-bout
**Files:**
- Modify: `frontend/i18n/locales/fr.json` (ajouter le namespace `sidebar`)
**Interfaces:**
- Consumes : les labels renvoyés par `/api/sidebar` (`sidebar.general.*`, `sidebar.admin.*`) traduits par `t()` dans `translatedSections`.
- [ ] **Step 1: Repérer la structure du fichier i18n**
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && head -20 i18n/locales/fr.json`
Objectif : connaître l'indentation et confirmer que c'est un objet JSON imbriqué (ajouter une clé racine `sidebar`).
- [ ] **Step 2: Ajouter le namespace `sidebar`**
Ajoute (à la racine de l'objet JSON, en respectant l'indentation existante) :
```json
"sidebar": {
"general": {
"section": "Gestion de projet",
"dashboard": "Tableau de bord",
"myTasks": "Mes tâches",
"projects": "Projets",
"timeTracking": "Suivi de temps"
},
"admin": {
"section": "Administration",
"teamAbsences": "Absences équipe",
"administration": "Administration"
}
}
```
> Les libellés reprennent ceux du layout actuel. `sidebar.general.section` = « Gestion de projet » (regroupe désormais le Tableau de bord — délta cosmétique acté, décision 3).
- [ ] **Step 3: Typecheck + smoke i18n**
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck`
Dev server : confirmer que les en-têtes/labels de sidebar s'affichent **traduits** (pas les clés brutes `sidebar.general.*`).
Expected: libellés FR corrects.
- [ ] **Step 4: Vérification bout-en-bout de l'activation/désactivation (AC)**
Test manuel documenté (aucun module réel en 0.2) :
1. Ajouter TEMPORAIREMENT dans `config/sidebar.php` un item avec `'module' => 'demo'`, `'to' => '/projects'` (route existante) dans une section visible.
2. `config/modules.php` reste vide (module `demo` inactif) → `GET /api/sidebar` doit lister `/projects` dans `disabledRoutes` et masquer l'item ; naviguer vers `/projects` doit rediriger vers `/`.
3. Ajouter une classe `DemoModule implements ModuleInterface { id()='demo' … }` + `config/modules.php` = `[DemoModule::class]` → l'item réapparaît, `/projects` n'est plus dans `disabledRoutes`, la navigation fonctionne.
4. **Tout retirer** (item démo + DemoModule + entrée modules.php). Confirmer l'état initial.
Documenter le résultat dans le message de fin. **Ne rien committer de ce stub.**
- [ ] **Step 5: Suite back + cs-fixer (non-régression globale) + commit**
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
Expected: vert (inchangé vs Task 1).
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck` → 0 erreur.
```bash
git add frontend/i18n/locales/fr.json
git commit -m "feat(front) : add sidebar i18n labels"
```
---
## Acceptance check (après toutes les tasks)
- [ ] `frontend/app/{layouts,middleware}`, `frontend/shared/{composables,stores,types}`, `frontend/modules/` (vide) en place ; `nuxt.config.ts` scanne `modules/*/`.
- [ ] Sidebar **dynamique** alimentée par `/api/sidebar` pour la nav globale ; gate ROLE_ADMIN effectif (admin-only invisible pour `alice`).
- [ ] Route d'un module désactivé → **redirigée** vers `/` (vérifié via le stub démo).
- [ ] **Aucune page métier déplacée** ; `frontend/pages/` intact ; tous les liens actuels atteignables.
- [ ] `npx nuxt typecheck` = 0 erreur ; suite PHPUnit verte ; aucune migration BDD.
- [ ] Délta cosmétique d'ordre de sidebar présenté au PO pour validation.
## Notes pour le ticket suivant (1.1 — Module Core)
Le 1.1 migrera `User`/Auth dans `src/Module/Core/`, re-pointera `resolve_target_entities` vers `Module\Core\User`, déclarera `CoreModule` (REQUIRED) dans `config/modules.php`, et créera le premier vrai layer front `frontend/modules/core/` (login, profile, admin users) — c'est là que le scan de layers et `useModules`/`useSidebar` prennent tout leur sens (premier item de sidebar avec une clé `module` réelle).
@@ -0,0 +1,732 @@
# LST-63 (1.1) — Module Core : Identité (User/Auth/JWT) & Notifications — 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:** Migrer l'identité (`User` + Auth/JWT + password hashing + `MeProvider`) et les notifications dans `src/Module/Core/`, exposer le contrat `UserInterface` enrichi + `NotifierInterface`, déclarer `CoreModule` (REQUIRED), et créer le premier vrai layer front `modules/core/`**sans aucune migration destructive et sans casser le login à aucune étape**.
**Architecture:** Strangler 100 % additif, phasé. On déplace physiquement la classe `User` vers `App\Module\Core\Domain\Entity\User` (table `user` inchangée → zéro migration), on re-pointe `resolve_target_entities` et le provider de sécurité, puis on bascule les 8 relations d'entités et les 26 consommateurs du concret `App\Entity\User` vers le **contrat** `App\Shared\Domain\Contract\UserInterface` (enrichi des accessors réellement utilisés). Les notifications passent par un `NotifierInterface` (impl Core). Chaque phase laisse `make test` vert ET le login JWT fonctionnel (re-vérifié par curl).
**Tech Stack:** PHP 8.4 / Symfony 8 / API Platform 4 / Doctrine ORM / lexik/jwt-authentication / PostgreSQL 16 / PHPUnit 13 — front Nuxt 4.3 / Vue 3.5 / Pinia 3.
## Global Constraints
- **`declare(strict_types=1);`** en tête de chaque fichier PHP.
- **Zéro migration destructive** : le déplacement de namespace ne change ni la table (`user`) ni les colonnes → `doctrine:migrations:diff` doit produire un diff VIDE. Si un diff non vide apparaît, c'est un bug (mapping mal recopié) — corriger, ne pas générer la migration.
- **Login JWT fonctionnel à chaque phase** : vérif curl obligatoire (voir « Vérification login » ci-dessous) après toute phase touchant `User`/sécurité.
- **AC ticket** : (1) login/JWT OK via le module ; (2) aucun `use App\Entity\User;` hors `src/Module/Core/` ; (3) `make test` vert, aucune migration destructive.
- **Commits** : `<type>(<scope>) : <message>` (espaces autour du `:`). **Jamais** de mention IA/Claude/Anthropic.
- **`config/reference.php`** : auto-généré, **jamais committé** (apparaît modifié dans `git status`).
- **Tests** : `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`. Baseline avant ce ticket : **115 tests, 227 assertions** (16 PHPUnit Notices préexistantes, non bloquantes).
- **Front** : `nuxt typecheck` n'est PAS un gate vert sur ce stack (cf. plan LST-62) — gate front = zéro `Cannot find module`, auto-imports présents dans `.nuxt/imports.d.ts`, smoke runtime.
- **PostgreSQL** : noms de colonnes en minuscules dans le SQL brut.
## Vérification login (à exécuter après chaque phase back touchant User/sécurité)
```bash
# Doit renvoyer http=204 (cookie BEARER posé) puis le profil courant
curl -s -c /tmp/cj.txt -X POST http://localhost:8082/api/login_check \
-H "Content-Type: application/json" -d '{"username":"alice","password":"alice"}' \
-o /dev/null -w "login http=%{http_code}\n"
curl -s -b /tmp/cj.txt http://localhost:8082/api/me -w "\nme http=%{http_code}\n" | head -c 400
# MCP apiToken (ApiTokenAuthenticator) — admin
curl -s -X POST http://localhost:8082/_mcp -H "Authorization: Bearer dev-mcp-token-for-testing-only-do-not-use-in-production" \
-H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"ping"}' -o /dev/null -w "mcp http=%{http_code}\n"
```
Attendu : `login http=204`, `me http=200` avec le JSON de l'utilisateur (`username`, `roles`), MCP répond (200). **Si l'un casse, arrêter la phase et corriger avant de committer.**
## Décisions de conception (actées, à valider PO a posteriori)
1. **`UserInterface` enrichi (contrat de lecture)** — plutôt que de garder `App\Entity\User` partout, on enrichit `App\Shared\Domain\Contract\UserInterface` des accessors **réellement consommés** hors Core (lecture). Les setters/écriture restent sur le concret (Core uniquement). Cela permet de typer les 8 relations et les 26 consommateurs sur le contrat sans casse.
2. **Move physique, table inchangée**`User` change de namespace mais garde `#[ORM\Table(name: '`user`')]` et toutes ses colonnes → aucune migration. La classe reste une entité Doctrine mappée (nouveau dir de mapping `Core`).
3. **Relations via le contrat** — les 8 entités passent à `targetEntity: UserInterface::class` + type `?UserInterface`, résolu par `resolve_target_entities → Core\User`. C'est le pattern Starseed.
4. **Notification dans Core + `NotifierInterface`**`Notification` migre dans Core (couplée à l'identité) ; la création de notif passe par `NotifierInterface` (impl Core), `TaskNotificationListener` (qui reste legacy en Phase D) en dépend par contrat. L'API REST `/api/notifications` est préservée à l'identique.
5. **Front layer `modules/core/`** — login, profile, admin users **déplacés** de `frontend/pages/` vers `frontend/modules/core/pages/` (premier layer réel ; le scan `readdirSync('modules/')` de LST-62 l'enregistre automatiquement). Le routage Nuxt est préservé (mêmes chemins d'URL).
---
## Phase A — Squelette Core + contrats (100 % additif, app inchangée)
### Task 1: `CoreModule` + `UserRepositoryInterface` + `NotifierInterface` + contrat `UserInterface` enrichi
**Files:**
- Create: `src/Module/Core/CoreModule.php`
- Create: `src/Module/Core/Domain/Repository/UserRepositoryInterface.php`
- Create: `src/Shared/Domain/Contract/NotifierInterface.php`
- Modify: `src/Shared/Domain/Contract/UserInterface.php` (enrichir)
- Create: `tests/Unit/Module/Core/CoreModuleTest.php`
**Interfaces:**
- Produces :
- `App\Module\Core\CoreModule implements ModuleInterface` : `id()='core'`, `label()='Core'`, `isRequired()=true`, `permissions()` (stub pour 1.2, voir code).
- `App\Module\Core\Domain\Repository\UserRepositoryInterface` : `findByRole(string $role): array`, `findActiveEmployees(\DateTimeInterface $date): array`, `findOneByUsername(string $username): ?UserInterface`.
- `App\Shared\Domain\Contract\NotifierInterface` : `notify(UserInterface $user, string $type, string $title, string $message): void`.
- `UserInterface` enrichi (lecture) : `getId(): ?int`, `getUserIdentifier(): string`, `getUsername(): string`, `getRoles(): array`, `getFirstName(): ?string`, `getLastName(): ?string`, `getAvatarUrl(): ?string`, `isEmployee(): bool`.
- Consumes : `App\Shared\Domain\Module\ModuleInterface` (existant).
- [ ] **Step 1: Écrire le test unitaire `CoreModule`**
`tests/Unit/Module/Core/CoreModuleTest.php` :
```php
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Module\Core;
use App\Module\Core\CoreModule;
use App\Shared\Domain\Module\ModuleInterface;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class CoreModuleTest extends TestCase
{
public function testItIsAModule(): void
{
self::assertInstanceOf(ModuleInterface::class, new CoreModule());
}
public function testIdentity(): void
{
self::assertSame('core', CoreModule::id());
self::assertTrue(CoreModule::isRequired());
self::assertNotSame('', CoreModule::label());
}
public function testPermissionsAreWellFormed(): void
{
foreach (CoreModule::permissions() as $permission) {
self::assertArrayHasKey('code', $permission);
self::assertArrayHasKey('label', $permission);
}
}
}
```
- [ ] **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/Module/Core/CoreModuleTest.php`
Expected: FAIL (classe `CoreModule` inexistante).
- [ ] **Step 3: Créer `CoreModule`**
`src/Module/Core/CoreModule.php` :
```php
<?php
declare(strict_types=1);
namespace App\Module\Core;
use App\Shared\Domain\Module\ModuleInterface;
final class CoreModule implements ModuleInterface
{
public static function id(): string
{
return 'core';
}
public static function label(): string
{
return 'Core';
}
public static function isRequired(): bool
{
return true;
}
/**
* Permissions posées pour le RBAC fin (1.2). Inertes tant que 1.2 n'est pas livré.
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'core.user.read', 'label' => 'Consulter les utilisateurs'],
['code' => 'core.user.manage', 'label' => 'Gérer les utilisateurs'],
['code' => 'core.notification.read', 'label' => 'Consulter ses notifications'],
];
}
}
```
> ⚠️ Confirmer la signature EXACTE de `ModuleInterface` (`src/Shared/Domain/Module/ModuleInterface.php`) avant d'écrire : la cartographie indique `id()`, `label()`, `isRequired()`, `permissions()` statiques. Si une méthode diffère (ex. non statique), aligner `CoreModule` ET le test dessus.
- [ ] **Step 4: Lancer le test, vérifier le vert**
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Module/Core/CoreModuleTest.php`
Expected: PASS (3 tests).
- [ ] **Step 5: Enrichir le contrat `UserInterface`**
Remplace `src/Shared/Domain/Contract/UserInterface.php` par :
```php
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat de LECTURE de l'identité, consommé hors du module Core.
* Les écritures (setPassword, setters HR…) restent sur le concret Core\Domain\Entity\User.
*/
interface UserInterface
{
public function getId(): ?int;
public function getUserIdentifier(): string;
public function getUsername(): string;
/** @return list<string> */
public function getRoles(): array;
public function getFirstName(): ?string;
public function getLastName(): ?string;
public function getAvatarUrl(): ?string;
public function isEmployee(): bool;
}
```
> ⚠️ Cet enrichissement DOIT correspondre à des méthodes existantes de l'entité `User` (la cartographie confirme `getId`, `getUserIdentifier`, `getUsername`, `getRoles`, `getFirstName`, `getLastName`, `getAvatarUrl`, `isEmployee`). Si une signature diffère (ex. `getAvatarUrl(): string` non-nullable), aligner le contrat sur le réel. Ne PAS ajouter au contrat une méthode absente de `User`.
- [ ] **Step 6: Créer `UserRepositoryInterface`**
`src/Module/Core/Domain/Repository/UserRepositoryInterface.php` :
```php
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Shared\Domain\Contract\UserInterface;
interface UserRepositoryInterface
{
/**
* @return list<UserInterface>
*/
public function findByRole(string $role): array;
/**
* @return list<UserInterface>
*/
public function findActiveEmployees(\DateTimeInterface $date): array;
public function findOneByUsername(string $username): ?UserInterface;
}
```
- [ ] **Step 7: Créer `NotifierInterface`**
`src/Shared/Domain/Contract/NotifierInterface.php` :
```php
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
interface NotifierInterface
{
public function notify(UserInterface $user, string $type, string $title, string $message): void;
}
```
- [ ] **Step 8: Suite complète + commit**
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
Expected: PASS (115 + 3 = 118 tests). L'enrichissement du contrat ne casse rien (l'entité `User` implémente déjà ces méthodes ; `resolve_target_entities` pointe encore `App\Entity\User`).
Run: `make php-cs-fixer-allow-risky`
```bash
git add src/Module/Core/CoreModule.php src/Module/Core/Domain/Repository/UserRepositoryInterface.php src/Shared/Domain/Contract/NotifierInterface.php src/Shared/Domain/Contract/UserInterface.php tests/Unit/Module/Core/CoreModuleTest.php
git commit -m "feat(core) : add CoreModule, user repository contract, notifier contract and enriched user contract"
```
---
## Phase B — Déplacer `User` + Auth dans Core (re-pointage, zéro migration)
### Task 2: Déplacer la classe `User` vers Core + mapping Doctrine + provider sécurité
**Files:**
- Move: `src/Entity/User.php``src/Module/Core/Domain/Entity/User.php` (namespace `App\Module\Core\Domain\Entity`)
- Modify: `config/packages/doctrine.yaml` (mapping `Core` + `resolve_target_entities`)
- Modify: `config/packages/security.yaml` (`app_user_provider.entity.class`)
- Modify: `config/packages/api_platform.yaml` (mapping paths : ajouter le dir entité Core)
**Interfaces:**
- Produces : entité `App\Module\Core\Domain\Entity\User` (table `user` inchangée), résolue par `resolve_target_entities`.
- [ ] **Step 1: Déplacer le fichier (git mv) et changer le namespace**
```bash
cd /home/matthieu/dev_malio/Lesstime
mkdir -p src/Module/Core/Domain/Entity
git mv src/Entity/User.php src/Module/Core/Domain/Entity/User.php
```
Puis éditer `src/Module/Core/Domain/Entity/User.php` :
- `namespace App\Entity;``namespace App\Module\Core\Domain\Entity;`
- Adapter les `use` internes devenus nécessaires (l'entité référençait `UserRepository`, `MeProvider`, `UserPasswordHasherProcessor`, l'enum `ContractType`, le contrat `UserInterface as SharedUserInterface`). Mettre les `use` complets vers leurs emplacements ACTUELS (la plupart bougent en Tasks 3/4 ; pour cette task, pointer encore vers `App\Repository\UserRepository`, `App\State\MeProvider`, `App\State\UserPasswordHasherProcessor`, `App\Entity\Enum\ContractType` ou l'emplacement réel — vérifier les `use` d'origine et les conserver tels quels tant que ces classes n'ont pas bougé).
- Garder VERBATIM : tous les attributs `#[ORM\...]` (dont `#[ORM\Table(name: '`user`')]`), `#[ApiResource(...)]`, `#[ApiProperty(...)]`, toutes les propriétés/méthodes, `implements UserInterface, PasswordAuthenticatedUserInterface, SharedUserInterface`.
> ⚠️ Lire le fichier d'origine en entier AVANT de déplacer pour relever tous les `use`. Ne changer QUE le `namespace` et, si besoin, garder les `use` pointant vers les emplacements actuels des classes non encore déplacées.
- [ ] **Step 2: Mapping Doctrine + resolve_target_entities**
Dans `config/packages/doctrine.yaml`, sous `orm:` :
- `resolve_target_entities` :
```yaml
resolve_target_entities:
App\Shared\Domain\Contract\UserInterface: App\Module\Core\Domain\Entity\User
```
- Ajouter un mapping pour les entités Core (en plus du mapping `App` existant qui scanne `src/Entity`) :
```yaml
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
Core:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
prefix: 'App\Module\Core\Domain\Entity'
```
> Le mapping `App` (src/Entity) ne contient plus `User.php` (déplacé) → cohérent. Aucune entité orpheline.
- [ ] **Step 3: Provider de sécurité**
Dans `config/packages/security.yaml` :
```yaml
providers:
app_user_provider:
entity:
class: App\Module\Core\Domain\Entity\User
property: username
```
- [ ] **Step 4: API Platform mapping paths**
Dans `config/packages/api_platform.yaml`, ajouter au `mapping.paths` le dossier entité Core (l'`#[ApiResource]` est porté par l'entité `User` déplacée) :
```yaml
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
```
> Conserver tous les paths existants. Si `api_platform.yaml` n'a pas de `mapping.paths` explicite (auto-discovery), vérifier que les Resources sous `src/Module/...` sont bien découvertes (comme `src/Shared/...` l'a été en #56 — cf. LEARNINGS : API Platform 4 auto-découvre). Si la découverte auto suffit, NE PAS ajouter de path ; sinon ajouter celui ci-dessus.
- [ ] **Step 5: Vider le cache + vérifier qu'AUCUNE migration n'est nécessaire**
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction 2>&1 | tail -20
```
Expected : schema VALID (mapping ok, sync DB ok). Le `diff` doit annoncer **« No changes detected »** (table/colonnes identiques). **Si une migration est générée, la SUPPRIMER** (`git status` → retirer le fichier sous `migrations/`) : un diff non vide = mapping mal recopié, corriger l'entité.
- [ ] **Step 6: Vérif login + suite complète**
Exécuter le bloc « Vérification login » (curl) → `login http=204`, `me http=200`, MCP 200.
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
Expected: PASS (118). Les consommateurs importent encore `App\Entity\User`**ERREUR attendue** : la classe n'existe plus à cet emplacement. ⇒ Cette task NE PASSE PAS seule ; elle est indissociable de la Task 3 (rewire). **Voir note ci-dessous.**
> 🔴 **Note d'ordonnancement** : déplacer `User` casse les 26 `use App\Entity\User;`. Pour garder l'app bootable entre Task 2 et Task 3, **ajouter un alias de compatibilité TEMPORAIRE** au tout début de Task 2 et le retirer en fin de Task 3 :
> Créer `src/Module/Core/_compat_user_alias.php` (chargé via `composer.json` `autoload.files`) :
> ```php
> <?php
> declare(strict_types=1);
> if (!class_exists(\App\Entity\User::class, false)) {
> class_alias(\App\Module\Core\Domain\Entity\User::class, \App\Entity\User::class);
> }
> ```
> Ajouter `"files": ["src/Module/Core/_compat_user_alias.php"]` sous `autoload` dans `composer.json`, puis `composer dump-autoload`. Cela garde les 26 consommateurs fonctionnels (et Doctrine `targetEntity: User::class` résolu via l'alias) le temps de la Task 3. **L'alias est SUPPRIMÉ en Task 3 Step final** (avec le retrait du fichier, l'entrée composer et un nouveau `dump-autoload`) une fois tous les consommateurs basculés sur le contrat. La verif login de cette Step utilise donc l'alias — c'est attendu.
- [ ] **Step 7: php-cs-fixer + commit (Phase B, avec alias temporaire)**
Run: `make php-cs-fixer-allow-risky`
```bash
git add src/Module/Core/Domain/Entity/User.php src/Module/Core/_compat_user_alias.php composer.json composer.lock config/packages/doctrine.yaml config/packages/security.yaml config/packages/api_platform.yaml
git commit -m "feat(core) : move user entity into core module and repoint security/doctrine (temp legacy alias)"
```
---
## Phase C — Basculer relations + consommateurs sur le contrat, retirer l'alias
### Task 3: Relations d'entités → `UserInterface::class`
**Files (8 entités):**
- Modify: `src/Entity/Task.php` (assignee ManyToOne, collaborators ManyToMany)
- Modify: `src/Entity/TimeEntry.php` (user)
- Modify: `src/Entity/AbsenceRequest.php` (user)
- Modify: `src/Entity/AbsenceBalance.php` (user)
- Modify: `src/Entity/TaskDocument.php` (user)
- Modify: `src/Entity/TaskMailLink.php` (user)
- Modify: `src/Module/Core/Domain/Entity/Notification.php` (user) — **après son déplacement en Phase D** ; en Phase C, `Notification` est encore `src/Entity/Notification.php`, la traiter ici aussi.
> Pour CHAQUE relation vers User : remplacer `use App\Entity\User;` par `use App\Shared\Domain\Contract\UserInterface;`, le `targetEntity: User::class` par `targetEntity: UserInterface::class`, et le type de propriété/param `?User` → `?UserInterface` (idem getters/setters). Doctrine résout via `resolve_target_entities`. La colonne FK et son nom restent identiques → **aucune migration**.
- [ ] **Step 1: Modifier les relations (entité par entité)**
Pour chaque fichier ci-dessus, lire puis appliquer le remplacement décrit. Exemple `Task.php` (assignee) :
```php
// avant
use App\Entity\User;
#[ORM\ManyToOne(targetEntity: User::class)]
private ?User $assignee = null;
public function getAssignee(): ?User { return $this->assignee; }
public function setAssignee(?User $assignee): static { $this->assignee = $assignee; return $this; }
// collaborators
#[ORM\ManyToMany(targetEntity: User::class)]
private Collection $collaborators;
// après
use App\Shared\Domain\Contract\UserInterface;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
private ?UserInterface $assignee = null;
public function getAssignee(): ?UserInterface { return $this->assignee; }
public function setAssignee(?UserInterface $assignee): static { $this->assignee = $assignee; return $this; }
#[ORM\ManyToMany(targetEntity: UserInterface::class)]
private Collection $collaborators;
```
> ⚠️ Conserver tous les autres attributs de relation (`inversedBy`, `joinTable`, `joinColumn`, `nullable`, `onDelete`, Groups…) VERBATIM. Ne changer que le type et `targetEntity`.
- [ ] **Step 2: Valider le schéma (toujours zéro migration)**
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction 2>&1 | tail -5
```
Expected : « No changes detected ». Sinon corriger (un `joinColumn`/`onDelete` a été perdu).
### Task 4: Consommateurs (26 fichiers) → contrat + repository interface, MeProvider/Processor dans Core, retrait alias
**Files:** les 26 fichiers listés dans la cartographie (Controllers, Repositories, State, Services, EventListener, Security, DataFixtures, Mcp). Déplacements vers Core :
- Move: `src/Repository/UserRepository.php``src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php` (implémente `UserRepositoryInterface`, namespace `App\Module\Core\Infrastructure\Doctrine`)
- Move: `src/State/MeProvider.php``src/Module/Core/Infrastructure/ApiPlatform/State/MeProvider.php`
- Move: `src/State/UserPasswordHasherProcessor.php``src/Module/Core/Infrastructure/ApiPlatform/State/UserPasswordHasherProcessor.php`
- Modify: l'`#[ApiResource]` de l'entité `User` (les `provider:`/`processor:` pointent vers les nouveaux FQCN Core).
- Delete (en fin de task): `src/Module/Core/_compat_user_alias.php` + entrée `composer.json`.
- [ ] **Step 1: Déplacer le repository et l'aligner sur l'interface**
```bash
mkdir -p src/Module/Core/Infrastructure/Doctrine src/Module/Core/Infrastructure/ApiPlatform/State
git mv src/Repository/UserRepository.php src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php
git mv src/State/MeProvider.php src/Module/Core/Infrastructure/ApiPlatform/State/MeProvider.php
git mv src/State/UserPasswordHasherProcessor.php src/Module/Core/Infrastructure/ApiPlatform/State/UserPasswordHasherProcessor.php
```
Éditer `DoctrineUserRepository.php` : `namespace App\Module\Core\Infrastructure\Doctrine;`, `class DoctrineUserRepository extends ServiceEntityRepository implements UserRepositoryInterface`, `use App\Module\Core\Domain\Entity\User;`, `use App\Module\Core\Domain\Repository\UserRepositoryInterface;`, et passer `User::class` au constructeur parent. Ajouter `findOneByUsername()` si absent (`return $this->findOneBy(['username' => $username]);`). Conserver `findByRole()` (SQL natif `roles::text LIKE`) et `findActiveEmployees()`.
Éditer `User.php` : `#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]` avec le bon `use`.
Éditer `MeProvider.php` / `UserPasswordHasherProcessor.php` : nouveaux namespaces ; `use App\Module\Core\Domain\Entity\User;` (le processor manipule le concret — c'est dans Core, autorisé).
Mettre à jour les `provider:`/`processor:` dans l'`#[ApiResource]` de `User` vers les nouveaux FQCN.
- [ ] **Step 2: Lier l'interface repository au service Doctrine**
Dans `config/services.yaml`, alias pour l'injection par interface :
```yaml
App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository'
```
- [ ] **Step 3: Basculer les 25 autres consommateurs sur le contrat**
Pour chaque fichier important `App\Entity\User` (hors Core), remplacer `use App\Entity\User;` par `use App\Shared\Domain\Contract\UserInterface;` et le type-hint `User` par `UserInterface` (params, retours, propriétés, `@var`, expressions). Cas particuliers :
- `src/Repository/{Notification,AbsenceBalance,AbsenceRequest,TimeEntry}Repository.php` : les signatures `countUnreadByUser(User $user)` etc. → `UserInterface`. Ne pas changer la logique DQL (`n.user = :user` fonctionne avec l'instance).
- `src/State/Absence*`, `TaskDocumentProvider`, `src/Service/AbsenceBalanceService`, `src/Security/MailAccessChecker`, `src/EventListener/TaskNotificationListener` (sera retravaillé en Phase D mais peut déjà passer au contrat ici), `src/Controller/*` (7), `src/Mcp/Tool/Absence/ReviewAbsenceRequestTool`, `src/Mcp/Tool/Serializer` : remplacer le type-hint.
- `src/DataFixtures/AppFixtures.php` : **garde le concret** `App\Module\Core\Domain\Entity\User` (les fixtures INSTANCIENT `new User()` et appellent des setters d'écriture — c'est légitime ; importer le concret Core, pas le contrat). C'est hors `src/Module/Core/` mais c'est de l'écriture d'identité → exception documentée (les fixtures sont un cas d'amorçage, pas un consommateur métier).
> Liste de contrôle : après cette step, `grep -rn "use App\\\\Entity\\\\User;" src/` ne doit retourner QUE `src/DataFixtures/AppFixtures.php` (qui importe désormais le FQCN Core, donc 0 occurrence de `App\Entity\User`). Viser **0 occurrence de `App\Entity\User`** dans tout `src/`.
- [ ] **Step 4: Retirer l'alias de compatibilité**
```bash
git rm src/Module/Core/_compat_user_alias.php
```
Retirer l'entrée `"files": [...]` ajoutée sous `autoload` dans `composer.json` (Task 2), puis :
```bash
docker exec -t -u www-data php-lesstime-fpm composer dump-autoload
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
```
- [ ] **Step 5: `grep` de garde (AC 2) + schéma + tests + login**
```bash
grep -rn "App\\\\Entity\\\\User" src/ config/ ; echo "(doit être VIDE)"
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction 2>&1 | tail -5
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
```
Expected : grep VIDE, schéma valide, « No changes detected », **118 tests verts**. Puis bloc « Vérification login » (login 204, me 200, MCP 200).
- [ ] **Step 6: php-cs-fixer + commit (Phase C)**
Run: `make php-cs-fixer-allow-risky`
```bash
git add -A -- src config composer.json composer.lock
git commit -m "refactor(core) : wire user relations and consumers to the shared contract, drop legacy alias"
```
> ⚠️ NE PAS `git add config/reference.php`. Vérifier `git status` avant le commit ; si `reference.php` est listé, l'exclure du `git add` (stager explicitement les fichiers voulus).
---
## Phase D — Notifications via `NotifierInterface` (impl Core)
### Task 5: Déplacer `Notification` dans Core + `Notifier` (impl) + recâbler le listener
**Files:**
- Move: `src/Entity/Notification.php``src/Module/Core/Domain/Entity/Notification.php`
- Move: `src/Repository/NotificationRepository.php``src/Module/Core/Infrastructure/Doctrine/DoctrineNotificationRepository.php`
- Move: `src/State/NotificationProvider.php``src/Module/Core/Infrastructure/ApiPlatform/State/NotificationProvider.php`
- Create: `src/Module/Core/Infrastructure/Notifier.php` (implements `NotifierInterface`)
- Modify: `src/EventListener/TaskNotificationListener.php` (dépend de `NotifierInterface`)
- Modify: `config/packages/doctrine.yaml` (le mapping `Core` couvre déjà `Domain/Entity` → Notification incluse automatiquement)
- Modify: `tests/` — ajouter `tests/Unit/Module/Core/NotifierTest.php` (ou Functional) si testable unitairement.
- [ ] **Step 1: Écrire un test du `Notifier`**
`tests/Functional/Module/Core/NotifierTest.php` (crée une notif et vérifie la persistance) :
```php
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Core;
use App\Module\Core\Domain\Entity\User;
use App\Shared\Domain\Contract\NotifierInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*/
final class NotifierTest extends KernelTestCase
{
public function testNotifyPersistsANotificationForTheUser(): void
{
self::bootKernel();
$em = self::getContainer()->get(EntityManagerInterface::class);
$notifier = self::getContainer()->get(NotifierInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
self::assertNotNull($user);
$notifier->notify($user, 'task_assigned', 'Titre', 'Message');
$count = (int) $em->createQuery(
'SELECT COUNT(n.id) FROM App\\Module\\Core\\Domain\\Entity\\Notification n WHERE n.user = :u AND n.title = :t'
)->setParameter('u', $user)->setParameter('t', 'Titre')->getSingleScalarResult();
self::assertSame(1, $count);
}
}
```
- [ ] **Step 2: Lancer, vérifier l'échec**`NotifierInterface` non instanciable / `Notification` introuvable au nouveau namespace.
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/NotifierTest.php`
Expected: FAIL.
- [ ] **Step 3: Déplacer `Notification` + repository + provider**
```bash
git mv src/Entity/Notification.php src/Module/Core/Domain/Entity/Notification.php
git mv src/Repository/NotificationRepository.php src/Module/Core/Infrastructure/Doctrine/DoctrineNotificationRepository.php
git mv src/State/NotificationProvider.php src/Module/Core/Infrastructure/ApiPlatform/State/NotificationProvider.php
```
- `Notification.php` : `namespace App\Module\Core\Domain\Entity;`, `use App\Shared\Domain\Contract\UserInterface;`, relation `user``targetEntity: UserInterface::class` + type `?UserInterface`, `repositoryClass: DoctrineNotificationRepository::class`, conserver `#[ORM\Table(name:'notification')]` + index VERBATIM, ApiResource (provider → nouveau FQCN). **Table/colonnes inchangées.**
- `DoctrineNotificationRepository.php` : namespace Core, `use App\Module\Core\Domain\Entity\Notification;`, signatures `UserInterface`.
- `NotificationProvider.php` : namespace Core, mêmes dépendances.
- [ ] **Step 4: Implémenter `Notifier`**
`src/Module/Core/Infrastructure/Notifier.php` :
```php
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure;
use App\Module\Core\Domain\Entity\Notification;
use App\Shared\Domain\Contract\NotifierInterface;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\ORM\EntityManagerInterface;
final readonly class Notifier implements NotifierInterface
{
public function __construct(private EntityManagerInterface $em) {}
public function notify(UserInterface $user, string $type, string $title, string $message): void
{
$notification = new Notification();
$notification->setUser($user);
$notification->setType($type);
$notification->setTitle($title);
$notification->setMessage($message);
$this->em->persist($notification);
$this->em->flush();
}
}
```
> ⚠️ Aligner sur les setters réels de `Notification` (la cartographie indique `user`, `type`, `title`, `message`, `isRead` default false, `createdAt`). Si `createdAt` n'est pas auto (prePersist), le poser ici. Si `setUser` attend le concret, accepter `UserInterface` (resolve_target_entities) — vérifier le type du setter.
- [ ] **Step 5: Recâbler `TaskNotificationListener` sur `NotifierInterface`**
Lire le listener ; remplacer la création directe de `Notification` (`new Notification()` + persist) par l'injection et l'appel de `NotifierInterface::notify(...)`. **Attention** : le listener tourne sur `onFlush`/`postFlush` — un `flush()` dans `notify()` pendant un `onFlush` est dangereux. Conserver le pattern existant (accumulation en `onFlush`, écriture en `postFlush`). Si `notify()` flush, l'appeler UNIQUEMENT en `postFlush` (jamais pendant `onFlush`). Préserver le comportement exact (mêmes types `task_assigned`/`task_collaborator_added`, mêmes destinataires). Adapter le test existant du listener s'il y en a un.
> Si l'intrication onFlush/postFlush rend `NotifierInterface` inadapté (flush imbriqué), documenter et garder le listener en écriture directe via le repository Core, mais TOUJOURS dépendre du contrat pour le type User. Le but AC est « Notification exposée via NotifierInterface » : `NotifierInterface` doit exister et être l'API publique pour les autres modules ; le listener interne Core peut écrire directement.
- [ ] **Step 6: Tests + login + endpoints notifications**
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
Expected: PASS (118 + 1 = 119). Vérifier `doctrine:migrations:diff` → « No changes detected ». Bloc login. Puis curl notifications :
```bash
curl -s -b /tmp/cj.txt "http://localhost:8082/api/notifications" -w "\nnotif http=%{http_code}\n" | head -c 200
curl -s -b /tmp/cj.txt "http://localhost:8082/api/notifications/unread-count" -w "\nunread http=%{http_code}\n"
```
Expected : 200 sur les deux.
- [ ] **Step 7: php-cs-fixer + commit**
Run: `make php-cs-fixer-allow-risky`
```bash
git add -A -- src config tests
git commit -m "feat(core) : move notification into core and expose notifier contract"
```
---
## Phase E — Déclarer `CoreModule` actif
### Task 6: Enregistrer Core dans `config/modules.php`
**Files:**
- Modify: `config/modules.php`
- Modify: `tests/Functional/Shared/ModulesEndpointTest.php` (ou équivalent — adapter l'assertion à la présence de `core`)
- [ ] **Step 1: Adapter/écrire le test de l'endpoint modules**
Vérifier le test existant de `/api/modules` (cartographie : `ModulesProvider`/`ModulesResource` créés en #56). Ajouter une assertion :
```php
public function testCoreModuleIsActive(): void
{
$client = self::createClient();
// /api/modules est public (GET) d'après security.yaml
$client->request('GET', '/api/modules');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertContains('core', $data['modules']);
}
```
> Adapter le nom de classe/fichier de test à l'existant (#56). Si aucun test fonctionnel modules n'existe, créer `tests/Functional/Shared/ModulesEndpointTest.php`.
- [ ] **Step 2: Lancer, vérifier l'échec** (modules.php retourne `[]`).
- [ ] **Step 3: Activer Core**
`config/modules.php` :
```php
<?php
declare(strict_types=1);
use App\Module\Core\CoreModule;
return [
CoreModule::class,
];
```
- [ ] **Step 4: Tests + curl**
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
Expected: PASS. Curl :
```bash
curl -s http://localhost:8082/api/modules | head -c 200 # doit contenir "core"
```
- [ ] **Step 5: commit**
```bash
git add config/modules.php tests/
git commit -m "feat(core) : activate core module in modules registry"
```
---
## Phase F — Layer front `modules/core/`
### Task 7: Déplacer login / profile / admin users dans `frontend/modules/core/`
**Files:**
- Create: `frontend/modules/core/nuxt.config.ts` (`export default defineNuxtConfig({})`)
- Move: `frontend/pages/login.vue``frontend/modules/core/pages/login.vue`
- Move: `frontend/pages/profile.vue``frontend/modules/core/pages/profile.vue`
- Move: `frontend/pages/admin/**` (gestion users) → `frontend/modules/core/pages/admin/**`
- Move (si pertinent): composants/services liés à l'identité (ex. `frontend/components/user/**`, `frontend/components/admin/**`, `frontend/services/user.ts`) → `frontend/modules/core/{components,services}/**`
> ⚠️ AVANT de déplacer, LIRE `frontend/pages/` et `frontend/components/` pour identifier précisément les pages/compos d'identité. Le scan `readdirSync('modules/')` (LST-62) ajoute `./modules/core` à `extends` et `modules/core/composables`/`stores` à `imports.dirs`. Les `pages/` d'un layer Nuxt sont fusionnées automatiquement → **les URLs (`/login`, `/profile`, `/admin/...`) restent identiques**. Vérifier qu'aucune page déplacée n'utilise un import PAR CHEMIN cassé (auto-import sinon).
- [ ] **Step 1: Créer le layer + déplacer les pages d'identité**
```bash
cd /home/matthieu/dev_malio/Lesstime/frontend
mkdir -p modules/core/pages
printf 'export default defineNuxtConfig({})\n' > modules/core/nuxt.config.ts
git mv pages/login.vue modules/core/pages/login.vue
git mv pages/profile.vue modules/core/pages/profile.vue
# admin users : adapter au réel (git mv pages/admin/... modules/core/pages/admin/...)
```
> Lister `frontend/pages/admin/` d'abord ; déplacer UNIQUEMENT les pages de gestion des utilisateurs (pas les pages admin d'autres domaines). En cas de doute, déplacer seulement login + profile en 1.1 et laisser admin users (documenter).
- [ ] **Step 2: Corriger les imports par chemin éventuels**
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && grep -rn "pages/login\|pages/profile\|~/pages" --include=*.ts --include=*.vue . | grep -v node_modules`
Corriger toute référence cassée (les redirections `navigateTo('/login')` restent valides — c'est une URL, pas un chemin de fichier).
- [ ] **Step 3: Gate front (cf. LST-62) + smoke**
Run: `cd frontend && npx nuxt typecheck 2>&1 | grep "Cannot find module" | grep -E "modules/core|login|profile"` → doit être VIDE.
Run: `grep -E "login|profile" frontend/.nuxt/routes.* 2>/dev/null` ou démarrer `make dev-nuxt` et confirmer que `/login`, `/profile` répondent (la fusion des pages du layer est effective).
> Smoke runtime (login via navigateur) : laisser au PO si pas de navigateur côté exécutant.
- [ ] **Step 4: commit**
```bash
git add -A -- frontend
git commit -m "feat(core) : add core front layer with login, profile and admin users pages"
```
---
## Acceptance check (après toutes les phases)
- [ ] **AC1** Login/JWT OK via le module : `login http=204`, `/api/me` 200, MCP apiToken 200, `/api/notifications` 200.
- [ ] **AC2** `grep -rn "App\\Entity\\User" src/ config/`**VIDE** (User vit dans `src/Module/Core/Domain/Entity/`, consommé via contrat ; fixtures importent le FQCN Core).
- [ ] **AC3** `make test` vert (≈119 tests), `doctrine:schema:validate` OK, `doctrine:migrations:diff` = « No changes detected » (**aucune migration destructive ni même additive**).
- [ ] `/api/modules` renvoie `core` ; `CoreModule::isRequired() === true`.
- [ ] `resolve_target_entities: UserInterface → App\Module\Core\Domain\Entity\User`.
- [ ] Front : layer `modules/core/` détecté ; `/login`, `/profile` (+ admin users) accessibles aux mêmes URLs ; aucun `Cannot find module`.
- [ ] `config/reference.php` jamais committé.
## Notes pour le ticket suivant (1.2 — RBAC fin)
`CoreModule::permissions()` est déjà posé (stub). 1.2 ajoutera `Role`/`Permission`, `app:sync-permissions`, `PermissionVoter`, et fera filtrer `SidebarProvider` **par permission** (en plus du module actif + du gate rôle minimal posé en 0.2). Le contrat `UserInterface` enrichi est prêt à recevoir `getPermissions()` si besoin.
@@ -0,0 +1,82 @@
# LST-65 (2.2) — Module ProjectManagement : plan de migration
> Migration strangler du cœur métier Projets/Tâches vers `src/Module/ProjectManagement/`.
> Additive, sans régression API. Exécution en 4 tranches **incrémentalement vertes**
> (chaque tranche compile + `phpunit` vert + commit ; aucun état cassé committé).
**Branche** : `integration/modular-monolith-0.1-1.3` (empilement phase 2).
**Vérif container** : `docker exec -u www-data php-lesstime-fpm php bin/console cache:clear`
**Tests** : `docker exec -u www-data php-lesstime-fpm php vendor/bin/phpunit` (baseline = 159 verts).
**Style** : `make php-cs-fixer-allow-risky`. PHP `declare(strict_types=1)`. SQL colonnes minuscules.
## Périmètre (10 entités + écosystème)
Entités : Project, Task, Workflow, TaskStatus, TaskGroup, TaskEffort, TaskPriority, TaskTag, TaskRecurrence, TaskDocument.
Enums : StatusCategory, RecurrenceType.
Repos (9), State (7), MCP (38), Controller (1), Services (2 : CalDavService, RecurrenceCalculator), Listeners (3), ApiResource (SwitchWorkflowOutput), fixtures, tests.
## Décisions d'architecture (figées)
1. **Contrats inter-modules uniquement** (`src/Shared/Domain/Contract/`), surface minimale :
- `ProjectInterface` : `getId(): ?int`, `getCode(): ?string`, `getName(): ?string`
- `TaskInterface` : `getId(): ?int`, `getNumber(): ?int`, `getTitle(): ?string`
- `TaskTagInterface` : `getId(): ?int`, `getLabel(): ?string`, `getColor(): ?string`
- `ClientInterface` : `getId(): ?int`, `getName(): ?string`
- PAS de WorkflowInterface (Workflow est intra-module PM).
2. **Consommateur contractuel** : seul le module **TimeTracking** (`TimeEntry`) bascule Project/Task/TaskTag → interfaces. **Project** (PM) bascule client → `ClientInterface`.
3. **Legacy non modularisé** (Gitea/BookStack/Mail : `src/Controller/Mail/*`, `src/State/Gitea*`, `src/State/BookStack*`, `src/Service/GiteaApiService.php`, `src/ApiResource/BookStack*`, `src/Entity/TaskMailLink.php`, `src/Entity/TaskBookStackLink.php`), **Serializer MCP partagé** (`src/Mcp/Tool/Serializer.php`), fixtures, tests : bascule du **FQCN concret** `App\Entity\X``App\Module\ProjectManagement\Domain\Entity\X`. Couplage transitoire legacy→module, nettoyé en 2.4/2.5/2.6.
4. **Repos** : pattern Core/TimeTracking — interface `Domain/Repository/XxxRepositoryInterface` + `Infrastructure/Doctrine/DoctrineXxxRepository extends ServiceEntityRepository implements …` + binding `services.yaml`. Conserver les méthodes métier (`findMaxNumberByProjectForUpdate`, `findFirstNonFinal`, `findDefault`).
5. **Services CalDavService + RecurrenceCalculator**`Infrastructure/` du module (dépendance résiduelle ZimbraConfiguration legacy tolérée jusqu'à 2.6).
6. **Serializer.php** reste à `src/Mcp/Tool/` (helper multi-domaines), import concret PM.
7. **Timestampable additif** : sur **Task** et **Project** uniquement (agrégats), pas les référentiels. Migration additive (4 colonnes nullable + FK SET NULL + COMMENT).
8. **Table inchangée** (naming strategy → mêmes tables). Aucune migration destructive.
9. **resolve_target_entities** final :
```
UserInterface -> App\Module\Core\Domain\Entity\User (existant)
ProjectInterface -> App\Module\ProjectManagement\Domain\Entity\Project
TaskInterface -> App\Module\ProjectManagement\Domain\Entity\Task
TaskTagInterface -> App\Module\ProjectManagement\Domain\Entity\TaskTag
ClientInterface -> App\Entity\Client (Client legacy jusqu'à 2.4)
```
---
## Tranche 1 — Découplage EN PLACE (entités non déplacées)
But : créer les contrats et basculer les consommateurs inter-modules, **sans déplacer** les entités → diff minimal, isole le risque architectural.
1. Créer les 4 interfaces dans `src/Shared/Domain/Contract/` (signatures ci-dessus).
2. `src/Entity/Project.php` `implements ProjectInterface` ; `Task.php` `implements TaskInterface` ; `TaskTag.php` `implements TaskTagInterface` ; `Client.php` `implements ClientInterface`. (Méthodes déjà présentes — juste `implements` + `use`.)
3. `Project.php` : `client` → type `?ClientInterface` (`targetEntity: ClientInterface::class`, import, getter/setter).
4. `src/Module/TimeTracking/Domain/Entity/TimeEntry.php` : `project`→`?ProjectInterface`, `task`→`?TaskInterface`, `tags`→`Collection<TaskTagInterface>` (`targetEntity` = interfaces, imports, getters/setters/addTag/removeTag). MAJ `TimeEntryRepositoryInterface`/`DoctrineTimeEntryRepository`/`ActiveTimeEntryProvider`/`TimeEntryExportController` si typage Project/Task.
5. `config/packages/doctrine.yaml` : ajouter les 4 lignes `resolve_target_entities` (cibles = `App\Entity\Project/Task/TaskTag` + `App\Entity\Client` — encore legacy à ce stade).
6. Vérif : `cache:clear` OK + `phpunit` vert. Commit `refactor(project-management) : introduce Project/Task/TaskTag/Client contracts, decouple TimeTracking`.
## Tranche 2 — Move mécanique vers le module
But : déplacer entités + écosystème, bascule namespaces, sans changement de comportement.
1. `git mv` entités → `src/Module/ProjectManagement/Domain/Entity/` (namespace `App\Module\ProjectManagement\Domain\Entity`). Relations intra-module = concret ; client=`ClientInterface` ; assignee/collaborators/uploadedBy=`UserInterface` (inchangé). `repositoryClass` → `DoctrineXxxRepository::class`.
2. `git mv` enums → `src/Module/ProjectManagement/Domain/Enum/` (namespace adapté).
3. Repos → `Infrastructure/Doctrine/DoctrineXxxRepository.php` + interfaces `Domain/Repository/XxxRepositoryInterface.php` (méthodes métier dans l'interface). Bindings `services.yaml` (9).
4. State (7), MCP (38), Controller (1), Services (2), Listeners (3), ApiResource SwitchWorkflowOutput → sous-dossiers `Infrastructure/…` du module, namespaces adaptés, **injecter les interfaces de repo**. `services.yaml` : repointer `App\State\TaskDocumentProcessor`, `App\Controller\TaskDocumentDownloadController`, `App\Mcp\Tool\Task\AddTaskDocumentTool`, `App\Mcp\Tool\Task\UpdateTaskDocumentTool`, `App\EventListener\TaskDocumentListener` vers les nouveaux FQCN (garder `$uploadDir` + tag `doctrine.orm.entity_listener`).
5. `resolve_target_entities` : repointer ProjectInterface/TaskInterface/TaskTagInterface vers les FQCN module. (ClientInterface reste `App\Entity\Client`.)
6. **Swap FQCN concret legacy** : remplacer `App\Entity\{Task,Project,Workflow,TaskStatus,TaskGroup,TaskEffort,TaskPriority,TaskTag,TaskRecurrence,TaskDocument}` → `App\Module\ProjectManagement\Domain\Entity\…` et `App\Enum\{StatusCategory,RecurrenceType}` → `App\Module\ProjectManagement\Domain\Enum\…` et `App\Repository\Xxx` → interfaces/Doctrine, dans : Serializer.php, Controller/Mail/*, State/Gitea*, State/BookStack*, ApiResource/BookStack*, Service/GiteaApiService.php, Entity/TaskMailLink.php, Entity/TaskBookStackLink.php, DataFixtures/AppFixtures.php, tests/*. (NE PAS toucher `App\Entity\Client`.)
7. `config/modules.php` : ajouter `ProjectManagementModule` (id `project-management`, label `Projets & Tâches`, isRequired false, permissions `project-management.projects.view/manage`, `project-management.tasks.view/manage` — non recâblées, additif).
8. `config/packages/doctrine.yaml` : mapping `ProjectManagement` (dir `src/Module/ProjectManagement/Domain/Entity`).
9. `config/sidebar.php` : `'module' => 'project-management'` sur items `my-tasks` et `projects`.
10. Vérif : `cache:clear` OK + `doctrine:schema:validate` mapping OK + `phpunit` vert + cs-fixer. Commit `feat(project-management) : migrate core Projects/Tasks domain into module (back)`.
## Tranche 3 — Timestampable additif (Task + Project)
1. Ajouter `TimestampableBlamableTrait` + interfaces à `Task` et `Project`.
2. Migration **additive** manuscrite : `created_at/updated_at` (TIMESTAMP(0) null), `created_by/updated_by` (INT null, FK `"user"` ON DELETE SET NULL) + index + COMMENT, sur `task` et `project`. `down()` = DROP des ajouts.
3. Champs hors groupes API existants (le trait porte ses propres groupes).
4. Vérif : `migrations:migrate -n` (dev+test) + `phpunit` vert. Commit `feat(project-management) : add timestampable/blamable to Task and Project (additive)`.
## Tranche 4 — Front layer project-management
1. `git mv` vers `frontend/modules/project-management/` : pages (my-tasks, projects/index, projects/[id]/{index,groups,archives}), components/{project,task}/*, services (projects, tasks, workflows, task-statuses, task-priorities, task-efforts, task-tags, task-groups, task-documents, task-recurrences) + services/dto/* correspondants. `nuxt.config.ts` = `export default defineNuxtConfig({})`.
2. Réécrire imports explicites `~/services/<x>` + `~/services/dto/<x>` → `~/modules/project-management/...` dans : les fichiers déplacés, `components/admin/{AdminEffortTab,AdminPriorityTab,AdminTagTab,AdminWorkflowTab,WorkflowDrawer}.vue`, `components/mail/{MailCreateTaskModal,MailLinkTaskModal}.vue`, `pages/index.vue`, `pages/mail.vue`, `app/layouts/default.vue`, **et `frontend/modules/time-tracking/`** (dto/time-entry, stores/timer, pages/time-tracking, components/TimeEntryDrawer importent project/task/task-tag dto). `clients.ts` reste racine.
3. Préserver routes `/my-tasks`, `/projects`, `/projects/:id`, `/projects/:id/groups`, `/projects/:id/archives`. i18n global inchangé.
4. Vérif : `cd frontend && npx nuxt build` OK + routes présentes. Commit `feat(project-management) : extract Projects/Tasks front into Nuxt module layer`.
## Critères d'acceptation (ticket)
- [ ] Cœur Projets/Tâches en module sans régression API (opérations/securities/uriTemplates conservés).
- [ ] Aucun import direct inter-modules **établis** (contrats) — legacy en transit toléré.
- [ ] `make test` vert, aucune migration destructive.
- [ ] Toggle module project-management (sidebar + routes) prouvé.
@@ -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/<X>/{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.
@@ -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 |
@@ -38,84 +38,35 @@
</button> </button>
</div> </div>
<nav class="flex-1 overflow-hidden" :class="sidebarIsCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'"> <nav class="flex-1 overflow-hidden" :class="sidebarIsCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
<SidebarLink <!-- Sections dynamiques (/api/sidebar) : navigation globale + sections gated par rôle -->
to="/" <template v-for="(section, sIndex) in translatedSections" :key="section.label">
icon="mdi:view-dashboard-outline"
label="Tableau de bord"
:collapsed="sidebarIsCollapsed"
:class="sidebarIsCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
@click="ui.closeMobileSidebar()"
/>
<!-- Section : Gestion de projet -->
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"> <p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
Gestion de projet {{ section.label }}
</p> </p>
<div v-else class="mx-2 my-3 border-t border-secondary-500" /> <div v-else class="mx-2 my-3 border-t border-secondary-500" />
<SidebarLink <SidebarLink
to="/my-tasks" v-for="item in section.items"
icon="mdi:clipboard-check-outline" :key="item.to"
label="Mes tâches" :to="item.to"
:collapsed="sidebarIsCollapsed" :icon="item.icon"
@click="ui.closeMobileSidebar()" :label="item.label"
/>
<SidebarLink
to="/projects"
icon="mdi:folder-outline"
label="Projets"
:collapsed="sidebarIsCollapsed" :collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()" @click="ui.closeMobileSidebar()"
/> />
<!-- Items conservés côté client, insérés après la 1re section (cf. décision 3) -->
<template v-if="sIndex === 0">
<!-- Contextuel projet -->
<template v-if="currentProjectId"> <template v-if="currentProjectId">
<SidebarLink <SidebarLink :to="`/projects/${currentProjectId}`" icon="mdi:view-column-outline" label="Kanban" :collapsed="sidebarIsCollapsed" sub exact @click="ui.closeMobileSidebar()" />
:to="`/projects/${currentProjectId}`" <SidebarLink :to="`/projects/${currentProjectId}/groups`" icon="mdi:tag-multiple-outline" label="Groupes" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
icon="mdi:view-column-outline" <SidebarLink :to="`/projects/${currentProjectId}/archives`" icon="mdi:archive-outline" label="Archives" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
label="Kanban"
:collapsed="sidebarIsCollapsed"
sub
exact
@click="ui.closeMobileSidebar()"
/>
<SidebarLink
:to="`/projects/${currentProjectId}/groups`"
icon="mdi:tag-multiple-outline"
label="Groupes"
:collapsed="sidebarIsCollapsed"
sub
@click="ui.closeMobileSidebar()"
/>
<SidebarLink
:to="`/projects/${currentProjectId}/archives`"
icon="mdi:archive-outline"
label="Archives"
:collapsed="sidebarIsCollapsed"
sub
@click="ui.closeMobileSidebar()"
/>
</template> </template>
<SidebarLink <!-- Feature-flag : Documents -->
to="/time-tracking" <SidebarLink v-if="isDocumentsVisible" to="/documents" icon="mdi:folder-network-outline" :label="$t('sharedFiles.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
icon="mdi:calendar-edit-outline" <!-- Feature-flag : Mail + badge -->
label="Suivi de temps"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
<SidebarLink
v-if="isDocumentsVisible"
to="/documents"
icon="mdi:folder-network-outline"
:label="$t('sharedFiles.sidebar.title')"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
<div v-if="isMailVisible" class="relative"> <div v-if="isMailVisible" class="relative">
<SidebarLink <SidebarLink to="/mail" icon="mdi:email-outline" :label="$t('mail.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
to="/mail"
icon="mdi:email-outline"
:label="$t('mail.sidebar.title')"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
<span <span
v-if="mailStore.globalUnreadCount > 0" v-if="mailStore.globalUnreadCount > 0"
class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white" class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
@@ -125,44 +76,9 @@
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }} {{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
</span> </span>
</div> </div>
<!-- User-flag : Mes absences (isEmployee non couvert par le gate rôle) -->
<!-- Section : Absences --> <SidebarLink v-if="isEmployee" to="/absences" icon="mdi:umbrella-beach-outline" label="Mes absences" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
<template v-if="isAbsenceSectionVisible">
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
Absences
</p>
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
</template> </template>
<SidebarLink
v-if="isEmployee"
to="/absences"
icon="mdi:umbrella-beach-outline"
label="Mes absences"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
<SidebarLink
v-if="isAdmin"
to="/team-absences"
icon="mdi:calendar-account-outline"
label="Absences équipe"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
<!-- Section : Administration (admin only) -->
<template v-if="isAdmin">
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
Administration
</p>
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
<SidebarLink
to="/admin"
icon="mdi:cog-outline"
label="Administration"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
</template> </template>
</nav> </nav>
@@ -209,8 +125,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag' import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import { useAppVersion } from '~/composables/useAppVersion' import { useAppVersion } from '~/composables/useAppVersion'
import type { HydraCollection } from '~/utils/api' import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api' import { extractHydraMembers } from '~/utils/api'
@@ -220,10 +136,22 @@ const ui = useUiStore()
const mailStore = useMailStore() const mailStore = useMailStore()
const {version} = useAppVersion() const {version} = useAppVersion()
const route = useRoute() const route = useRoute()
const { t } = useI18n()
const { sections } = useSidebar()
const translatedSections = computed(() =>
sections.value.map((section) => ({
label: t(section.label),
icon: section.icon,
items: section.items.map((item) => ({
label: t(item.label),
to: item.to,
icon: item.icon,
})),
})),
)
const isAdmin = computed(() => (auth.user?.roles ?? []).includes('ROLE_ADMIN'))
const isEmployee = computed(() => Boolean(auth.user?.isEmployee)) const isEmployee = computed(() => Boolean(auth.user?.isEmployee))
const isAbsenceSectionVisible = computed(() => isEmployee.value || isAdmin.value)
const isMailVisible = computed(() => { const isMailVisible = computed(() => {
const roles: string[] = auth.user?.roles ?? [] const roles: string[] = auth.user?.roles ?? []
+30
View File
@@ -0,0 +1,30 @@
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()
}
})
+15
View File
@@ -0,0 +1,15 @@
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('/')
}
})
+160
View File
@@ -0,0 +1,160 @@
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">{{ $t('admin.audit.title') }}</h2>
</div>
<div class="mt-4 flex flex-wrap gap-4">
<MalioSelect
v-model="entityTypeFilter"
:options="entityTypeOptions"
:label="$t('admin.audit.filterEntityType')"
:empty-option-label="$t('admin.audit.filterEntityTypeAll')"
group-class="w-64"
/>
<MalioSelect
v-model="actionFilter"
:options="actionOptions"
:label="$t('admin.audit.filterAction')"
:empty-option-label="$t('admin.audit.filterActionAll')"
group-class="w-64"
/>
</div>
<DataTable
:columns="columns"
:items="rows"
:loading="isLoading"
:empty-message="$t('admin.audit.empty')"
>
<template #cell-performedAt="{ item }">
{{ formatDate(item.performedAt) }}
</template>
<template #cell-entityType="{ item }">
{{ entityTypeLabel(item.entityType) }}
</template>
<template #cell-action="{ item }">
{{ actionLabel(item.action) }}
</template>
</DataTable>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-neutral-500">{{ $t('admin.audit.page', { page }) }}</span>
<div class="flex gap-2">
<MalioButton
variant="secondary"
button-class="w-auto px-4"
:label="$t('admin.audit.previous')"
:disabled="page <= 1 || isLoading"
@click="goToPage(page - 1)"
/>
<MalioButton
variant="secondary"
button-class="w-auto px-4"
:label="$t('admin.audit.next')"
:disabled="!hasNextPage || isLoading"
@click="goToPage(page + 1)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { AuditLogAction, AuditLogItem } from '~/modules/core/services/audit-logs'
import { useAuditLogService } from '~/modules/core/services/audit-logs'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const { t, te } = useI18n()
const PAGE_SIZE = 30
const columns = computed<DataTableColumn[]>(() => [
{ key: 'performedAt', label: t('admin.audit.date'), primary: true },
{ key: 'performedBy', label: t('admin.audit.performedBy') },
{ key: 'entityType', label: t('admin.audit.entityType') },
{ key: 'action', label: t('admin.audit.action') },
{ key: 'entityId', label: t('admin.audit.entityId') },
])
const actionOptions = computed<{ value: AuditLogAction, label: string }[]>(() => [
{ value: 'create', label: t('audit.action.create') },
{ value: 'update', label: t('audit.action.update') },
{ value: 'delete', label: t('audit.action.delete') },
])
const auditLogService = useAuditLogService()
const rows = ref<AuditLogItem[]>([])
const entityTypes = ref<string[]>([])
const totalItems = ref(0)
const page = ref(1)
const isLoading = ref(true)
const entityTypeFilter = ref<string | null>(null)
const actionFilter = ref<AuditLogAction | null>(null)
const entityTypeOptions = computed<{ value: string, label: string }[]>(() =>
entityTypes.value.map((value) => ({ value, label: entityTypeLabel(value) })),
)
// PAGE_SIZE must match the API default page size. The full-page guard keeps the
// "next" button accurate even on the last (partial) page.
const hasNextPage = computed(() => rows.value.length >= PAGE_SIZE && page.value * PAGE_SIZE < totalItems.value)
function entityTypeLabel(value: string): string {
const key = `audit.entity.${value}`
return te(key) ? t(key) : value
}
function actionLabel(action: AuditLogAction): string {
return t(`audit.action.${action}`)
}
function formatDate(value: string): string {
return new Date(value).toLocaleString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
async function loadItems() {
isLoading.value = true
try {
const result = await auditLogService.list({
page: page.value,
entityType: entityTypeFilter.value ?? undefined,
action: actionFilter.value ?? undefined,
})
rows.value = result.items
totalItems.value = result.totalItems
} finally {
isLoading.value = false
}
}
async function loadEntityTypes() {
entityTypes.value = await auditLogService.entityTypes()
}
function goToPage(target: number) {
if (target < 1) {
return
}
page.value = target
loadItems()
}
watch([entityTypeFilter, actionFilter], () => {
page.value = 1
loadItems()
})
onMounted(() => {
loadItems()
loadEntityTypes()
})
</script>
+2 -2
View File
@@ -30,8 +30,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskEffort } from '~/services/dto/task-effort' import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
import { useTaskEffortService } from '~/services/task-efforts' import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
@@ -37,8 +37,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskPriority } from '~/services/dto/task-priority' import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
import { useTaskPriorityService } from '~/services/task-priorities' import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
+116
View File
@@ -0,0 +1,116 @@
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">{{ $t('admin.roles.title') }}</h2>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('admin.roles.addRole')"
@click="openCreate"
/>
</div>
<DataTable
:columns="columns"
:items="items"
:loading="isLoading"
:empty-message="$t('admin.roles.empty')"
@row-click="openEdit"
>
<template #cell-isSystem="{ item }">
<span
v-if="item.isSystem"
class="rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-600"
>
{{ $t('admin.roles.system') }}
</span>
</template>
<template #cell-permissions="{ item }">
<span class="text-neutral-600">{{ item.permissions.length }}</span>
</template>
<template #actions="{ item }">
<MalioButtonIcon
v-if="!item.isSystem"
icon="mdi:delete-outline"
:aria-label="$t('common.delete')"
variant="ghost"
icon-size="20"
button-class="text-neutral-400 hover:text-red-500"
@click.stop="handleDelete(item.id)"
/>
</template>
</DataTable>
<RoleDrawer
v-model="drawerOpen"
:item="selectedItem"
:permissions="permissions"
@saved="onSaved"
/>
</div>
</template>
<script setup lang="ts">
import type { Role } from '~/modules/core/services/roles'
import { useRoleService } from '~/modules/core/services/roles'
import type { Permission } from '~/modules/core/services/permissions'
import { usePermissionService } from '~/modules/core/services/permissions'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const { t } = useI18n()
const columns = computed<DataTableColumn[]>(() => [
{ key: 'label', label: t('admin.roles.label'), primary: true },
{ key: 'code', label: t('admin.roles.code') },
{ key: 'permissions', label: t('admin.roles.permissions') },
{ key: 'isSystem', label: '' },
])
const roleService = useRoleService()
const permissionService = usePermissionService()
const items = ref<Role[]>([])
const permissions = ref<Permission[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedItem = ref<Role | null>(null)
async function loadItems() {
isLoading.value = true
try {
items.value = await roleService.list()
} finally {
isLoading.value = false
}
}
async function loadPermissions() {
permissions.value = await permissionService.list()
}
function openCreate() {
selectedItem.value = null
drawerOpen.value = true
}
function openEdit(item: Role) {
selectedItem.value = item
drawerOpen.value = true
}
async function handleDelete(id: number) {
await roleService.remove(id)
await loadItems()
}
async function onSaved() {
await loadItems()
}
onMounted(() => {
loadItems()
loadPermissions()
})
</script>
+2 -2
View File
@@ -37,8 +37,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskTag } from '~/services/dto/task-tag' import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import { useTaskTagService } from '~/services/task-tags' import { useTaskTagService } from '~/modules/project-management/services/task-tags'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
@@ -42,8 +42,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Workflow } from '~/services/dto/workflow' import type { Workflow } from '~/modules/project-management/services/dto/workflow'
import { useWorkflowService } from '~/services/workflows' import { useWorkflowService } from '~/modules/project-management/services/workflows'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const { t } = useI18n() const { t } = useI18n()
+186
View File
@@ -0,0 +1,186 @@
<template>
<MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">
{{ isEditing ? $t('admin.roles.editRole') : $t('admin.roles.addRole') }}
</h2>
</template>
<form class="flex flex-col gap-3" @submit.prevent="handleSubmit">
<MalioInputText
v-model="form.code"
:label="$t('admin.roles.code')"
input-class="w-full"
:disabled="isEditing"
:hint="isEditing ? $t('admin.roles.codeImmutable') : $t('admin.roles.codeHint')"
:error="touched.code && !codeValid ? $t('admin.roles.codeInvalid') : ''"
@blur="touched.code = true"
/>
<MalioInputText
v-model="form.label"
:label="$t('admin.roles.label')"
input-class="w-full"
:error="touched.label && !form.label.trim() ? $t('admin.roles.labelRequired') : ''"
@blur="touched.label = true"
/>
<MalioInputTextArea
v-model="form.description"
:label="$t('admin.roles.description')"
input-class="w-full"
/>
<div class="mt-2">
<label class="text-sm font-semibold text-neutral-700">
{{ $t('admin.roles.permissions') }}
</label>
<p v-if="permissions.length === 0" class="mt-2 text-xs text-neutral-400">
{{ $t('admin.roles.noPermissions') }}
</p>
<div
v-for="group in groupedPermissions"
:key="group.module"
class="mt-3 rounded-lg border border-neutral-200 p-3"
>
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-neutral-500">
{{ group.module }}
</p>
<div class="flex flex-col gap-2">
<label
v-for="perm in group.permissions"
:key="perm.id"
class="flex items-start gap-2 text-sm text-neutral-700"
>
<input
v-model="form.permissions"
type="checkbox"
:value="perm['@id']"
class="mt-0.5 rounded border-neutral-300"
/>
<span>
{{ perm.label }}
<span class="block text-xs text-neutral-400">{{ perm.code }}</span>
</span>
</label>
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<MalioButton
:label="$t('common.save')"
button-class="w-auto px-6"
:disabled="isSubmitting"
@click="handleSubmit"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Role, RoleWrite } from '~/modules/core/services/roles'
import { useRoleService } from '~/modules/core/services/roles'
import type { Permission } from '~/modules/core/services/permissions'
const props = defineProps<{
modelValue: boolean
item: Role | null
permissions: Permission[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const isEditing = computed(() => !!props.item)
const isSubmitting = ref(false)
const form = reactive({
code: '',
label: '',
description: '',
permissions: [] as string[],
})
const touched = reactive({
code: false,
label: false,
})
const codeValid = computed(() => /^[a-z][a-z0-9_]*$/.test(form.code))
const groupedPermissions = computed(() => {
const byModule = new Map<string, Permission[]>()
for (const perm of props.permissions) {
const list = byModule.get(perm.module) ?? []
list.push(perm)
byModule.set(perm.module, list)
}
return [...byModule.entries()]
.map(([module, permissions]) => ({ module, permissions }))
.sort((a, b) => a.module.localeCompare(b.module))
})
watch(() => props.modelValue, (open) => {
if (open) {
if (props.item) {
form.code = props.item.code
form.label = props.item.label
form.description = props.item.description ?? ''
form.permissions = props.item.permissions
.map((p) => p['@id'])
.filter((iri): iri is string => !!iri)
} else {
form.code = ''
form.label = ''
form.description = ''
form.permissions = []
}
touched.code = false
touched.label = false
}
})
const { create, update } = useRoleService()
async function handleSubmit() {
touched.code = true
touched.label = true
if (!form.label.trim()) {
return
}
if (!isEditing.value && !codeValid.value) {
return
}
isSubmitting.value = true
try {
if (isEditing.value && props.item) {
const payload: Partial<RoleWrite> = {
label: form.label.trim(),
description: form.description.trim() || null,
permissions: form.permissions,
}
await update(props.item.id, payload)
} else {
const payload: RoleWrite = {
code: form.code.trim(),
label: form.label.trim(),
description: form.description.trim() || null,
permissions: form.permissions,
}
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>
+5 -5
View File
@@ -96,11 +96,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Workflow, StatusCategory } from '~/services/dto/workflow' import type { Workflow, StatusCategory } from '~/modules/project-management/services/dto/workflow'
import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow' import { STATUS_CATEGORY_COLOR } from '~/modules/project-management/services/dto/workflow'
import type { TaskStatusWrite } from '~/services/dto/task-status' import type { TaskStatusWrite } from '~/modules/project-management/services/dto/task-status'
import { useWorkflowService } from '~/services/workflows' import { useWorkflowService } from '~/modules/project-management/services/workflows'
import { useTaskStatusService } from '~/services/task-statuses' import { useTaskStatusService } from '~/modules/project-management/services/task-statuses'
const { t } = useI18n() const { t } = useI18n()
@@ -1,14 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MailMessageDetailDto } from '~/services/dto/mail' import type { MailMessageDetailDto } from '~/services/dto/mail'
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
import type { TaskGroup } from '~/services/dto/task-group' import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import { useMailService } from '~/services/mail' import { useMailService } from '~/services/mail'
import { useProjectService } from '~/services/projects' import { useProjectService } from '~/modules/project-management/services/projects'
import { useTaskGroupService } from '~/services/task-groups' import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
import { useUserService } from '~/services/users' import { useUserService } from '~/services/users'
import { useAuthStore } from '~/stores/auth' import { useAuthStore } from '~/shared/stores/auth'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
import { useMailService } from '~/services/mail' import { useMailService } from '~/services/mail'
import { useTaskService } from '~/services/tasks' import { useTaskService } from '~/modules/project-management/services/tasks'
import { useProjectService } from '~/services/projects' import { useProjectService } from '~/modules/project-management/services/projects'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
+64 -1
View File
@@ -195,6 +195,57 @@
"addUser": "Ajouter un utilisateur", "addUser": "Ajouter un utilisateur",
"editUser": "Modifier 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."
},
"audit": {
"title": "Audit",
"empty": "Aucune entrée d'audit trouvée.",
"date": "Date",
"performedBy": "Utilisateur",
"entityType": "Type d'entité",
"action": "Action",
"entityId": "Identifiant",
"filterEntityType": "Type d'entité",
"filterEntityTypeAll": "Tous les types",
"filterAction": "Action",
"filterActionAll": "Toutes les actions",
"previous": "Précédent",
"next": "Suivant",
"page": "Page {page}"
}
},
"audit": {
"entity": {
"core": {
"User": "Utilisateur",
"Role": "Rôle",
"Permission": "Permission"
}
},
"action": {
"create": "Création",
"update": "Modification",
"delete": "Suppression"
}
},
"timeEntries": { "timeEntries": {
"created": "Temps enregistré", "created": "Temps enregistré",
"updated": "Temps modifié", "updated": "Temps modifié",
@@ -296,7 +347,19 @@
} }
}, },
"sidebar": { "sidebar": {
"myTasks": "Mes tâches" "myTasks": "Mes tâches",
"general": {
"section": "Gestion de projet",
"dashboard": "Tableau de bord",
"myTasks": "Mes tâches",
"projects": "Projets",
"timeTracking": "Suivi de temps"
},
"admin": {
"section": "Administration",
"teamAbsences": "Absences équipe",
"administration": "Administration"
}
}, },
"common": { "common": {
"cancel": "Annuler", "cancel": "Annuler",
-16
View File
@@ -1,16 +0,0 @@
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('/')
}
})
View File
@@ -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 }
}
+1
View File
@@ -0,0 +1 @@
export default defineNuxtConfig({})
@@ -0,0 +1,65 @@
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export type AuditLogAction = 'create' | 'update' | 'delete'
export type AuditLogItem = {
id: string
'@id'?: string
entityType: string
entityId: string
action: AuditLogAction
changes: Record<string, unknown>
performedBy: string
performedAt: string
ipAddress: string | null
requestId: string | null
}
export type AuditLogQuery = {
page?: number
entityType?: string
action?: AuditLogAction
}
export type AuditLogPage = {
items: AuditLogItem[]
totalItems: number
}
export type AuditLogEntityTypes = {
'@id'?: string
entityTypes: string[]
}
export function useAuditLogService() {
const api = useApi()
async function list(params: AuditLogQuery = {}): Promise<AuditLogPage> {
const query: Record<string, unknown> = {}
if (params.page !== undefined) {
query.page = params.page
}
if (params.entityType) {
query.entity_type = params.entityType
}
if (params.action) {
query.action = params.action
}
const data = await api.get<HydraCollection<AuditLogItem>>('/audit-logs', query)
return {
items: extractHydraMembers(data),
totalItems: data['hydra:totalItems'] ?? data['totalItems'] ?? 0,
}
}
async function entityTypes(): Promise<string[]> {
// `/audit-log-entity-types` is a single API Platform item resource
// (not a hydra collection): it returns `{ entityTypes: string[] }`.
const data = await api.get<AuditLogEntityTypes>('/audit-log-entity-types')
return data.entityTypes ?? []
}
return { list, entityTypes }
}
@@ -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<Permission[]> {
const data = await api.get<HydraCollection<Permission>>('/permissions')
return extractHydraMembers(data)
}
return { list }
}
+50
View File
@@ -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<Role[]> {
const data = await api.get<HydraCollection<Role>>('/roles')
return extractHydraMembers(data)
}
async function create(payload: RoleWrite): Promise<Role> {
return api.post<Role>('/roles', payload as Record<string, unknown>, {
toastSuccessKey: 'admin.roles.created',
})
}
async function update(id: number, payload: Partial<RoleWrite>): Promise<Role> {
return api.patch<Role>(`/roles/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'admin.roles.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/roles/${id}`, {}, {
toastSuccessKey: 'admin.roles.deleted',
})
}
return { list, create, update, remove }
}
@@ -123,11 +123,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Project, ProjectWrite } from '~/services/dto/project' import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project'
import type { Client } from '~/services/dto/client' import type { Client } from '~/services/dto/client'
import type { GiteaRepository } from '~/services/dto/gitea' import type { GiteaRepository } from '~/services/dto/gitea'
import type { BookStackShelf } from '~/services/dto/bookstack' import type { BookStackShelf } from '~/services/dto/bookstack'
import { useProjectService } from '~/services/projects' import { useProjectService } from '~/modules/project-management/services/projects'
import { useGiteaService } from '~/services/gitea' import { useGiteaService } from '~/services/gitea'
import { useBookStackService } from '~/services/bookstack' import { useBookStackService } from '~/services/bookstack'
@@ -67,10 +67,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskGroup } from '~/services/dto/task-group' import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
import { useTaskGroupService } from '~/services/task-groups' import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
import { useTaskService } from '~/services/tasks' import { useTaskService } from '~/modules/project-management/services/tasks'
import { stripRichText } from '~/utils/format' import { stripRichText } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
@@ -82,12 +82,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
import type { Workflow } from '~/services/dto/workflow' import type { Workflow } from '~/modules/project-management/services/dto/workflow'
import type { TaskStatus } from '~/services/dto/task-status' import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
import { useWorkflowService } from '~/services/workflows' import { useWorkflowService } from '~/modules/project-management/services/workflows'
import { useTaskService } from '~/services/tasks' import { useTaskService } from '~/modules/project-management/services/tasks'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TaskStatus } from '~/services/dto/task-status' import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
defineProps<{ defineProps<{
statuses: TaskStatus[] statuses: TaskStatus[]
@@ -104,13 +104,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status' import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort' import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority' import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
import type { TaskGroup } from '~/services/dto/task-group' import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
selectedCount: number selectedCount: number
@@ -102,7 +102,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
task: Task task: Task
@@ -60,8 +60,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskDocument } from '~/services/dto/task-document' import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents' import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
import { formatFileSize } from '~/utils/format' import { formatFileSize } from '~/utils/format'
defineProps<{ defineProps<{
@@ -121,8 +121,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskDocument } from '~/services/dto/task-document' import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents' import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
import { formatFileSize } from '~/utils/format' import { formatFileSize } from '~/utils/format'
import { copyToClipboard } from '~/utils/clipboard' import { copyToClipboard } from '~/utils/clipboard'
@@ -58,7 +58,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Breadcrumb, FileEntry } from '~/services/dto/share' import type { Breadcrumb, FileEntry } from '~/services/dto/share'
import { useShareService } from '~/services/share' import { useShareService } from '~/services/share'
import { useTaskDocumentService } from '~/services/task-documents' import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
import { formatFileSize } from '~/utils/format' import { formatFileSize } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
@@ -46,7 +46,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useTaskDocumentService } from '~/services/task-documents' import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
const props = defineProps<{ const props = defineProps<{
taskId?: number taskId?: number
@@ -25,8 +25,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskEffort, TaskEffortWrite } from '~/services/dto/task-effort' import type { TaskEffort, TaskEffortWrite } from '~/modules/project-management/services/dto/task-effort'
import { useTaskEffortService } from '~/services/task-efforts' import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -226,7 +226,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea' import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
import { useGiteaService } from '~/services/gitea' import { useGiteaService } from '~/services/gitea'
import { copyToClipboard } from '~/utils/clipboard' import { copyToClipboard } from '~/utils/clipboard'
@@ -56,10 +56,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskGroup, TaskGroupWrite } from '~/services/dto/task-group' import type { TaskGroup, TaskGroupWrite } from '~/modules/project-management/services/dto/task-group'
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
import { useTaskGroupService } from '~/services/task-groups' import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
import { useTaskService } from '~/services/tasks' import { useTaskService } from '~/modules/project-management/services/tasks'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -110,7 +110,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
task: Task task: Task
@@ -536,21 +536,21 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Task, TaskWrite } from '~/services/dto/task' import type { Task, TaskWrite } from '~/modules/project-management/services/dto/task'
import type { TaskDocument } from '~/services/dto/task-document' import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
import { useGiteaService } from '~/services/gitea' import { useGiteaService } from '~/services/gitea'
import { useTaskDocumentService } from '~/services/task-documents' import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue' import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
import type { TaskStatus } from '~/services/dto/task-status' import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort' import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority' import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
import type { TaskTag } from '~/services/dto/task-tag' import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group' import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/services/tasks' import { useTaskService } from '~/modules/project-management/services/tasks'
import { useTaskRecurrenceService } from '~/services/task-recurrences' import { useTaskRecurrenceService } from '~/modules/project-management/services/task-recurrences'
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
import { useMailService } from '~/services/mail' import { useMailService } from '~/services/mail'
import type { MailMessageHeaderDto } from '~/services/dto/mail' import type { MailMessageHeaderDto } from '~/services/dto/mail'
@@ -28,8 +28,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskPriority, TaskPriorityWrite } from '~/services/dto/task-priority' import type { TaskPriority, TaskPriorityWrite } from '~/modules/project-management/services/dto/task-priority'
import { useTaskPriorityService } from '~/services/task-priorities' import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -28,8 +28,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskTag, TaskTagWrite } from '~/services/dto/task-tag' import type { TaskTag, TaskTagWrite } from '~/modules/project-management/services/dto/task-tag'
import { useTaskTagService } from '~/services/task-tags' import { useTaskTagService } from '~/modules/project-management/services/task-tags'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -0,0 +1 @@
export default defineNuxtConfig({})
@@ -1,22 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status' import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort' import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority' import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
import type { TaskTag } from '~/services/dto/task-tag' import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group' import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
import type { StatusCategory } from '~/services/dto/workflow' import type { StatusCategory } from '~/modules/project-management/services/dto/workflow'
import { STATUS_CATEGORY_LABEL, STATUS_CATEGORY_COLOR, contrastText } from '~/services/dto/workflow' import { STATUS_CATEGORY_LABEL, STATUS_CATEGORY_COLOR, contrastText } from '~/modules/project-management/services/dto/workflow'
import { useTaskService } from '~/services/tasks' import { useTaskService } from '~/modules/project-management/services/tasks'
import { useTaskStatusService } from '~/services/task-statuses' import { useTaskStatusService } from '~/modules/project-management/services/task-statuses'
import { useTaskEffortService } from '~/services/task-efforts' import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
import { useTaskPriorityService } from '~/services/task-priorities' import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
import { useTaskTagService } from '~/services/task-tags' import { useTaskTagService } from '~/modules/project-management/services/task-tags'
import { useTaskGroupService } from '~/services/task-groups' import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
import { useUserService } from '~/services/users' import { useUserService } from '~/services/users'
import { useProjectService } from '~/services/projects' import { useProjectService } from '~/modules/project-management/services/projects'
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
@@ -72,20 +72,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status' import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort' import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority' import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
import type { TaskTag } from '~/services/dto/task-tag' import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group' import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import { useProjectService } from '~/services/projects' import { useProjectService } from '~/modules/project-management/services/projects'
import { useTaskService } from '~/services/tasks' import { useTaskService } from '~/modules/project-management/services/tasks'
import { useTaskEffortService } from '~/services/task-efforts' import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
import { useTaskPriorityService } from '~/services/task-priorities' import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
import { useTaskTagService } from '~/services/task-tags' import { useTaskTagService } from '~/modules/project-management/services/task-tags'
import { useTaskGroupService } from '~/services/task-groups' import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
import { useUserService } from '~/services/users' import { useUserService } from '~/services/users'
const route = useRoute() const route = useRoute()
@@ -13,8 +13,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
import { useProjectService } from '~/services/projects' import { useProjectService } from '~/modules/project-management/services/projects'
const route = useRoute() const route = useRoute()
const projectId = computed(() => Number(route.params.id)) const projectId = computed(() => Number(route.params.id))
@@ -207,22 +207,22 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status' import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort' import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority' import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
import type { TaskTag } from '~/services/dto/task-tag' import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group' import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import type { Client } from '~/services/dto/client' import type { Client } from '~/services/dto/client'
import { useProjectService } from '~/services/projects' import { useProjectService } from '~/modules/project-management/services/projects'
import { useClientService } from '~/services/clients' import { useClientService } from '~/services/clients'
import { useTaskService } from '~/services/tasks' import { useTaskService } from '~/modules/project-management/services/tasks'
import { useTaskEffortService } from '~/services/task-efforts' import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
import { useTaskPriorityService } from '~/services/task-priorities' import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
import { useTaskTagService } from '~/services/task-tags' import { useTaskTagService } from '~/modules/project-management/services/task-tags'
import { useTaskGroupService } from '~/services/task-groups' import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
import { useUserService } from '~/services/users' import { useUserService } from '~/services/users'
const route = useRoute() const route = useRoute()
@@ -76,9 +76,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
import type { Client } from '~/services/dto/client' import type { Client } from '~/services/dto/client'
import { useProjectService } from '~/services/projects' import { useProjectService } from '~/modules/project-management/services/projects'
import { useClientService } from '~/services/clients' import { useClientService } from '~/services/clients'
useHead({ title: 'Projets' }) useHead({ title: 'Projets' })
@@ -1,4 +1,4 @@
import type { Client } from './client' import type { Client } from '~/services/dto/client'
import type { Workflow } from './workflow' import type { Workflow } from './workflow'
export type Project = { export type Project = {
@@ -1,4 +1,4 @@
import type { UserData } from './user-data' import type { UserData } from '~/services/dto/user-data'
export type TaskDocument = { export type TaskDocument = {
'@id'?: string '@id'?: string
@@ -3,7 +3,7 @@ import type { TaskEffort } from './task-effort'
import type { TaskPriority } from './task-priority' import type { TaskPriority } from './task-priority'
import type { TaskTag } from './task-tag' import type { TaskTag } from './task-tag'
import type { TaskGroup } from './task-group' import type { TaskGroup } from './task-group'
import type { UserData } from './user-data' import type { UserData } from '~/services/dto/user-data'
import type { Project } from './project' import type { Project } from './project'
import type { TaskDocument } from './task-document' import type { TaskDocument } from './task-document'
@@ -64,7 +64,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry' import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
const props = defineProps<{ const props = defineProps<{
entry: TimeEntry entry: TimeEntry
@@ -35,7 +35,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry' import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
const props = defineProps<{ const props = defineProps<{
visible: boolean visible: boolean
@@ -124,11 +124,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry, TimeEntryWrite } from '~/services/dto/time-entry' import type { TimeEntry, TimeEntryWrite } from '~/modules/time-tracking/services/dto/time-entry'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag' import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import { useTimeEntryService } from '~/services/time-entries' import { useTimeEntryService } from '~/modules/time-tracking/services/time-entries'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -67,7 +67,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry' import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
import { stripRichText } from '~/utils/format' import { stripRichText } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
@@ -150,7 +150,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry' import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
import { useAbsenceService } from '~/services/absences' import { useAbsenceService } from '~/services/absences'
const { t } = useI18n() const { t } = useI18n()
@@ -108,8 +108,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag' import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import type { Client } from '~/services/dto/client' import type { Client } from '~/services/dto/client'
const props = defineProps<{ const props = defineProps<{
@@ -0,0 +1 @@
export default defineNuxtConfig({})
@@ -150,12 +150,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry' import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag' import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import type { Client } from '~/services/dto/client' import type { Client } from '~/services/dto/client'
import { useTimeEntryService } from '~/services/time-entries' import { useTimeEntryService } from '~/modules/time-tracking/services/time-entries'
import type { HydraCollection } from '~/utils/api' import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api' import { extractHydraMembers } from '~/utils/api'
@@ -1,7 +1,7 @@
import type { UserData } from './user-data' import type { UserData } from '~/services/dto/user-data'
import type { Project } from './project' import type { Project } from '~/modules/project-management/services/dto/project'
import type { Task } from './task' import type { Task } from '~/modules/project-management/services/dto/task'
import type { TaskTag } from './task-tag' import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
export type TimeEntry = { export type TimeEntry = {
id: number id: number
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import type { TimeEntry } from '~/services/dto/time-entry' import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
import { useTimeEntryService } from '~/services/time-entries' import { useTimeEntryService } from '~/modules/time-tracking/services/time-entries'
export const useTimerStore = defineStore('timer', () => { export const useTimerStore = defineStore('timer', () => {
const activeEntry = ref<TimeEntry | null>(null) const activeEntry = ref<TimeEntry | null>(null)
+43 -10
View File
@@ -1,14 +1,32 @@
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({ export default defineNuxtConfig({
compatibilityDate: '2025-07-15', compatibilityDate: '2025-07-15',
devtools: { enabled: false }, devtools: { enabled: false },
ssr: false, ssr: false,
srcDir: '.',
css: ['~/assets/css/app.css', '~/assets/css/dark.css'], css: ['~/assets/css/app.css', '~/assets/css/dark.css'],
app: { app: {
baseURL: process.env.NODE_ENV === 'production' baseURL: process.env.NODE_ENV === 'production'
? (process.env.NUXT_PUBLIC_APP_BASE || '/') ? (process.env.NUXT_PUBLIC_APP_BASE || '/')
: '/' : '/',
}, },
extends: ['@malio/layer-ui'], extends: ['@malio/layer-ui', ...moduleLayers],
modules: [ modules: [
'@nuxtjs/tailwindcss', '@nuxtjs/tailwindcss',
'@pinia/nuxt', '@pinia/nuxt',
@@ -16,10 +34,29 @@ export default defineNuxtConfig({
'@nuxtjs/i18n', '@nuxtjs/i18n',
'@nuxt/icon', '@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: { runtimeConfig: {
public: { public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE apiBase: process.env.NUXT_PUBLIC_API_BASE,
} },
}, },
devServer: { devServer: {
port: 3002, port: 3002,
@@ -56,10 +93,6 @@ export default defineNuxtConfig({
{code: 'fr', file: 'fr.json', name: 'Français'} {code: 'fr', file: 'fr.json', name: 'Français'}
], ],
}, },
typescript: { typescript: { strict: true },
strict: true build: { transpile: ['@vuepic/vue-datepicker'] },
},
build: {
transpile: ['@vuepic/vue-datepicker']
}
}) })
+15 -1
View File
@@ -6,7 +6,7 @@
<div class="mt-6 border-b border-neutral-200 overflow-x-auto"> <div class="mt-6 border-b border-neutral-200 overflow-x-auto">
<nav class="flex gap-4 sm:gap-6"> <nav class="flex gap-4 sm:gap-6">
<button <button
v-for="tab in tabs" v-for="tab in visibleTabs"
:key="tab.key" :key="tab.key"
class="whitespace-nowrap px-1 pb-3 text-sm font-semibold transition" class="whitespace-nowrap px-1 pb-3 text-sm font-semibold transition"
:class="activeTab === tab.key :class="activeTab === tab.key
@@ -27,6 +27,8 @@
<AdminPriorityTab v-if="activeTab === 'priorities'" /> <AdminPriorityTab v-if="activeTab === 'priorities'" />
<AdminTagTab v-if="activeTab === 'tags'" /> <AdminTagTab v-if="activeTab === 'tags'" />
<AdminUserTab v-if="activeTab === 'users'" /> <AdminUserTab v-if="activeTab === 'users'" />
<AdminRoleTab v-if="activeTab === 'roles' && canViewRoles" />
<AdminAuditTab v-if="activeTab === 'audit' && canViewAudit" />
<AdminGiteaTab v-if="activeTab === 'gitea'" /> <AdminGiteaTab v-if="activeTab === 'gitea'" />
<AdminBookStackTab v-if="activeTab === 'bookstack'" /> <AdminBookStackTab v-if="activeTab === 'bookstack'" />
<AdminZimbraTab v-if="activeTab === 'zimbra'" /> <AdminZimbraTab v-if="activeTab === 'zimbra'" />
@@ -41,6 +43,12 @@
definePageMeta({ middleware: ['admin'] }) definePageMeta({ middleware: ['admin'] })
useHead({ title: 'Administration' }) useHead({ title: 'Administration' })
const { can } = usePermissions()
const { t } = useI18n()
const canViewRoles = computed(() => can('core.roles.view'))
const canViewAudit = computed(() => can('core.audit_log.view'))
const tabs = [ const tabs = [
{ key: 'clients', label: 'Clients' }, { key: 'clients', label: 'Clients' },
{ key: 'workflows', label: 'Workflows' }, { key: 'workflows', label: 'Workflows' },
@@ -48,6 +56,8 @@ const tabs = [
{ key: 'priorities', label: 'Priorités' }, { key: 'priorities', label: 'Priorités' },
{ key: 'tags', label: 'Tags' }, { key: 'tags', label: 'Tags' },
{ key: 'users', label: 'Utilisateurs' }, { key: 'users', label: 'Utilisateurs' },
{ key: 'roles', label: t('admin.roles.title'), permission: 'core.roles.view' },
{ key: 'audit', label: t('admin.audit.title'), permission: 'core.audit_log.view' },
{ key: 'gitea', label: 'Gitea' }, { key: 'gitea', label: 'Gitea' },
{ key: 'bookstack', label: 'BookStack' }, { key: 'bookstack', label: 'BookStack' },
{ key: 'zimbra', label: 'Zimbra' }, { key: 'zimbra', label: 'Zimbra' },
@@ -58,5 +68,9 @@ const tabs = [
type TabKey = typeof tabs[number]['key'] type TabKey = typeof tabs[number]['key']
const visibleTabs = computed(() =>
tabs.filter((tab) => !('permission' in tab) || can(tab.permission)),
)
const activeTab = ref<TabKey>('clients') const activeTab = ref<TabKey>('clients')
</script> </script>
+10 -10
View File
@@ -1,16 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { Doughnut, Bar, Line } from 'vue-chartjs' import { Doughnut, Bar, Line } from 'vue-chartjs'
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status' import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
import type { TaskPriority } from '~/services/dto/task-priority' import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
import type { TimeEntry } from '~/services/dto/time-entry' import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/services/tasks' import { useTaskService } from '~/modules/project-management/services/tasks'
import { useTaskStatusService } from '~/services/task-statuses' import { useTaskStatusService } from '~/modules/project-management/services/task-statuses'
import { useTaskPriorityService } from '~/services/task-priorities' import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
import { useTimeEntryService } from '~/services/time-entries' import { useTimeEntryService } from '~/modules/time-tracking/services/time-entries'
import { useProjectService } from '~/services/projects' import { useProjectService } from '~/modules/project-management/services/projects'
import { useUserService } from '~/services/users' import { useUserService } from '~/services/users'
const { t } = useI18n() const { t } = useI18n()
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
import { useMailStore } from '~/stores/mail' import { useMailStore } from '~/stores/mail'
const { t } = useI18n() const { t } = useI18n()

Some files were not shown because too many files have changed in this diff Show More