## Migration modular monolith DDD — Lesstime (0.1 → 3.3) Cette MR regroupe l'intégralité de la refonte en monolithe modulaire (strangler progressif, additif). Elle remplace les MR stackées de Phase 1 (#12–#16), désormais incluses ici. **Ne pas merger avant validation fonctionnelle** : branche destinée à être testée telle quelle. ### Périmètre — 9 modules sous `src/Module/` | Phase | Module | Contenu | |------|--------|---------| | 0.1 | (socle) | infrastructure modulaire, `ModuleInterface`, mapping Doctrine par module | | 0.2 | (socle front) | auto-détection des layers Nuxt sous `frontend/modules/*` | | 1.1 | **Core** | Identité (User/Auth), Notifications, Notifier | | 1.2 | Core | RBAC fin (permissions `module.resource.action`, sidebar gated) | | 1.3 | Core | Audit log (`#[Auditable]`, listener, provider DBAL) | | 2.1 | **TimeTracking** | TimeEntry + MCP + export | | 2.2 | **ProjectManagement** | cœur métier Projets/Tâches + 38 MCP tools | | 2.3 | **Absence** | demandes, soldes, policies, justificatifs | | 2.4 | **Directory** | Clients (migrés) + **Prospects** (nouveau, conversion → Client) | | 2.5 | **Mail** | intégration IMAP OVH + liens tâches | | 2.6 | **Integration** | Gitea / BookStack / Zimbra / Share | | 3.1 | **Reporting** | rapports transverses (DBAL read-only, 0 import inter-module) | | 3.2 | **ClientPortal** | portail client (ROLE_CLIENT cloisonné, tickets, notifications) | | 3.3 | (finition) | nettoyage legacy — `src/Entity` vide, app 100% modulaire | ### Architecture - Découplage inter-modules par **contrats** (`UserInterface`, `ProjectInterface`, `TaskInterface`, `TaskTagInterface`, `ClientInterface`, `ClientTicketInterface`, `LeaveProfileInterface`) + `resolve_target_entities` 100% modulaire (aucune cible legacy). - Repositories : interface `Domain/Repository` + implémentation `Infrastructure/Doctrine`, bindées. - Reporting en DBAL read-only pur (aucun import d'entité d'un autre module). - Chaque migration de module : déplacement à comportement préservé (API publique et noms d'outils MCP inchangés), migrations **additives** uniquement (zéro destructif). ### Sécurité - ROLE_CLIENT cloisonné : un utilisateur client n'accède qu'à `/portal` et à ses propres tickets (filtrés par `allowedProjects`), interdit sur toute l'API interne. - Correctif : interdiction pour un client de créer un lien vers le partage SMB (upload uniquement). ### QA non-régression (branche reconstruite from scratch) - Migrations from scratch + fixtures : OK. - Compilation dev + prod : OK. - **180 tests PHPUnit verts**, php-cs-fixer clean, ~96 routes, **66 outils MCP** tous sous `App\Module\*`. - Smoke test runtime multi-rôles (admin / ROLE_USER / ROLE_CLIENT) : 44 vérifications HTTP, **0 écart**, cloisonnement client étanche. - Build Nuxt OK, 9 layers, 0 import legacy résiduel. ### Points à arbitrer (hors périmètre de cette migration) - Durcissement MCP/IDOR pré-existant (`userId` explicite sans scoping sur certains tools TimeTracking/Absence/TaskDocument) — ticket dédié recommandé. - Validation fonctionnelle de **Prospect** et **ClientPortal** (conçus depuis les specs disque). - **Harmonisation visuelle Malio finale** (3.3) — finition esthétique inter-modules laissée au PO. --- ## ⚠️ Déploiement / migration des données — à ne pas oublier ### 1. Resynchroniser les séquences PostgreSQL après tout import/restore de dump Si la prod (ou tout environnement) est **montée depuis un dump** (`pg_restore` / `COPY`), les lignes sont chargées avec leurs `id` explicites **sans avancer les séquences** → au premier `INSERT` : `duplicate key value violates unique constraint "..._pkey"` (constaté en local sur `notification`, `task`, `time_entry`…). À lancer **juste après chaque restore/import** : ```sql DO $$ DECLARE r RECORD; maxid BIGINT; seq TEXT; BEGIN FOR r IN SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public' LOOP seq := pg_get_serial_sequence(quote_ident(r.table_name), r.column_name); IF seq IS NOT NULL THEN EXECUTE format('SELECT COALESCE(MAX(%I),0) FROM %I', r.column_name, r.table_name) INTO maxid; PERFORM setval(seq, GREATEST(maxid,1), maxid > 0); END IF; END LOOP; END $$; ``` > Ne concerne **pas** une prod qui tourne déjà (séquences avancées organiquement) — uniquement le cas restore/import. Idempotent, sans risque. ### 2. Fix dénormalisation des collections typées-contrat (code, inclus dans la branche) Les relations **to-many** typées par une interface `Shared\Domain\Contract\*` (`TimeEntry::tags` → `TaskTagInterface`, `Task::collaborators` → `UserInterface`) étaient **indénormalisables par API Platform** (mono-valué OK via IRI, collection KO) → **tout POST/PATCH portant une telle collection renvoyait 400/500**. Corrigé par un dénormaliseur générique `ContractRelationDenormalizer` (réutilise `resolve_target_entities`, zéro couplage par-entité) + test fonctionnel de non-régression. --------- Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #17
16 KiB
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), etStarseed/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 etmake testvert à chaque étape. - Prod = Docker, BDD peuplée → uniquement des migrations additives et nullable (aucun
DROP, aucunNOT NULLré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/Contractpour 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 :
TimeEntryreste legacy en #56 (domaine Time tracking séparé). Le lienTask ↔ TimeEntryest porté côtéTimeEntry(FK nullable vers la tabletask) ; aucune contrainte ne casse car la tabletaskne change pas de nom.
4. Câblage des dépendances (zéro import inter-modules)
- Interfaces dans
src/Shared/Domain/Contract/:UserInterface(id + identifiants nécessaires aux entités du module : assignee, collaborators, createdBy/updatedBy),ClientInterface(id + nom, pourProject.client),UserResolverInterface(résoudre un user par id, pour les State/MCP du module),NotifierInterface(créer une notification — impl legacy).
- Les entités du module type-hintent les interfaces, jamais
App\Entity\*. config/packages/doctrine.yaml → orm.resolve_target_entities:resolve_target_entities: App\Shared\Domain\Contract\UserInterface: App\Entity\User App\Shared\Domain\Contract\ClientInterface: App\Entity\ClientApp\Entity\Userimplements UserInterface,App\Entity\Clientimplements ClientInterface(legacy modifié à minima, additif).- Notifications :
App\Module\ProjectManagement\…appelleNotifierInterface; implApp\…\LegacyNotifier(wrappe leNotificationServiceactuel). LeTaskNotificationListenerest déplacé/adapté pour passer par le contrat.
5. Config backend (toutes additives)
doctrine.yaml— ajouter un mapping module (garderApp → src/Entity) :Les entités déplacées gardent leurmappings: 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'#[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 (garderDoctrineMigrations) :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 :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 globApp\: '../src/'continue d'autowire les classes déplacées.
6. Garde-fous portés dans #56
- TimestampableBlamable : trait
Shared/Domain/Trait/TimestampableBlamableTrait(4 colonnescreated_at,updated_at,created_by,updated_by— toutes nullable), rempli parTimestampableBlamableSubscriber(prePersist/preUpdate). Appliqué aux entités du pilote → 1 migration additive par table concernée, avecCOMMENT ON COLUMNviaColumnCommentsCatalog::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+ModulesProviderlisantconfig/modules.php(renvoie{ modules: ["project_management", …] }).GET /api/sidebar(auth) —SidebarResource+SidebarProviderlisantconfig/sidebar.php; filtrage par module actif (itemmoduleabsent de la liste active → masqué + route ajoutée àdisabledRoutes) ; gate de section optionnelROLE_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 versShared/).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 » avecmodule => '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 layersmodules/*/(scanreaddirSynccomme 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 dansauth.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/projectssont déplacés et retirés defrontend/pages/pour éviter le doublon.
9. Plan strangler (ordre d'exécution — app verte à chaque palier)
- Shared/ + garde-fous : trait, subscriber,
ColumnCommentsCatalog. Neutre (rien ne les consomme encore). - Endpoints modules/sidebar +
config/modules.php+config/sidebar.php(toutes entrées legacy sansmodule→ rien masqué). Additif. - Contrats
Shared/Domain/Contract+resolve_target_entities+User/Clientimplements …Interface. Neutre. - 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 testvert. - Migration additive Timestampable sur les tables du pilote (+
COMMENT ON COLUMN). - Front shell :
app/+shared/+ middlewares + auto-détectionnuxt.config.ts. App encore en pages plates. - Déplacement front du pilote vers
modules/project-management/(pages/components/services), retrait des doublons defrontend/pages/. - Vérification bout-en-bout : commenter
ProjectManagementModule::classdansconfig/modules.php→/api/modulesne le liste plus,/api/sidebarmasque ses entrées + peupledisabledRoutes, 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/sidebarfonctionnels ;/api/versionaligné.- Aucun import direct
App\Entity\User/Clientdepuis le module (contrats +resolve_target_entities). - Front : layers
frontend/modules/*/auto-détectés ;useSidebar/useModules+auth.global.ts/modules.global.tsopé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 testvert ; 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_bynon 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\Tasketc. → 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 queMcpSchemaGeneratorPassles redécouvre (scansrc/). auto_mapping: true: valider que l'ajout d'un mapping explicite ne perturbe pas la résolution (sinon désactiverauto_mappinget 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.